🎉 Initial commit: AudiOhm - Alternative à Spotify avec streaming YouTube

Features:
- Frontend Flutter avec thème néon cyberpunk
- Backend FastAPI avec streaming YouTube
- Base de données PostgreSQL + Redis
- Authentification JWT complète
- Recherche multi-source (DB + YouTube)
- Playlists CRUD avec drag & drop
- Queue management
- Settings avec audio quality
- Interface adaptative (Desktop + Mobile)

Tech Stack:
- Frontend: Flutter 3.2+, Riverpod
- Backend: Python 3.11+, FastAPI
- Database: PostgreSQL 15+
- Cache: Redis 7+
- Streaming: yt-dlp + FFmpeg

🚀 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
feldenr
2026-01-18 17:08:59 +01:00
commit 9c504d2c3d
128 changed files with 22638 additions and 0 deletions
@@ -0,0 +1,420 @@
/// Album Details Page - Desktop Layout
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/colors.dart';
import '../../providers/album_provider.dart';
import '../../providers/music_provider.dart';
import '../../../domain/entities/track.dart';
import '../../widgets/album/album_widgets.dart';
import '../common/cached_network_image_with_fallback.dart';
class AlbumDesktopPage extends ConsumerStatefulWidget {
final String albumId;
const AlbumDesktopPage({
required this.albumId,
super.key,
});
@override
ConsumerState<AlbumDesktopPage> createState() => _AlbumDesktopPageState();
}
class _AlbumDesktopPageState extends ConsumerState<AlbumDesktopPage> {
@override
void initState() {
super.initState();
// Load album data
Future.microtask(() {
ref.read(albumProvider.notifier).loadAlbum(widget.albumId);
});
}
@override
Widget build(BuildContext context) {
final albumState = ref.watch(albumProvider);
if (albumState.isLoading && albumState.album == null) {
return _buildLoadingState();
}
if (albumState.error != null && albumState.album == null) {
return _buildErrorState(albumState.error!);
}
if (albumState.album == null) {
return _buildEmptyState();
}
return Scaffold(
backgroundColor: AppColors.primary,
body: Row(
children: [
// Left panel - Album art and info
Expanded(
flex: 4,
child: _buildLeftPanel(albumState),
),
const VerticalDivider(width: 1, color: AppColors.surfaceVariant),
// Right panel - Tracklist
Expanded(
flex: 6,
child: _buildRightPanel(albumState),
),
],
),
);
}
Widget _buildLoadingState() {
return const Center(
child: CircularProgressIndicator(color: AppColors.cyan),
);
}
Widget _buildErrorState(String error) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 64,
color: AppColors.error,
),
const SizedBox(height: 16),
const Text(
'Something went wrong',
style: TextStyle(
fontSize: 18,
color: AppColors.error,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
error,
style: const TextStyle(
fontSize: 14,
color: AppColors.muted,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () {
ref.read(albumProvider.notifier).loadAlbum(widget.albumId);
},
child: const Text('Retry'),
),
],
),
),
);
}
Widget _buildEmptyState() {
return const Center(
child: CircularProgressIndicator(color: AppColors.cyan),
);
}
Widget _buildLeftPanel(albumState) {
final album = albumState.album!;
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.violet.withOpacity(0.2),
AppColors.primary,
],
),
),
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Album art
Hero(
tag: 'album_art_${album.id}',
child: Container(
decoration: BoxDecoration(
boxShadow: AppColors.cyanGlow,
borderRadius: BorderRadius.circular(12),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: CachedNetworkImageWithFallback(
imageUrl: album.imageUrl,
fallbackIcon: Icons.album,
progressColor: AppColors.cyan,
width: 320,
height: 320,
fit: BoxFit.cover,
),
),
),
),
const SizedBox(height: 32),
// Album title
Text(
album.title,
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: AppColors.onBackground,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
// Artist name and year
if (album.artist != null)
Text(
'${album.artist!.name}${album.releaseDate != null ? '${album.releaseDate!.year}' : ''}',
style: const TextStyle(
fontSize: 17,
color: AppColors.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
// Action buttons
_buildActionButtons(albumState),
const SizedBox(height: 24),
// Album info chips
_buildAlbumInfo(albumState),
],
),
),
),
);
}
Widget _buildActionButtons(albumState) {
final tracks = albumState.tracks;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Play All button
SizedBox(
width: 180,
height: 52,
child: ElevatedButton.icon(
onPressed: tracks.isNotEmpty
? () {
final albumNotifier = ref.read(albumProvider.notifier);
final playerNotifier = ref.read(playerProvider.notifier);
albumNotifier.playAll(playerNotifier);
}
: null,
icon: const Icon(Icons.play_arrow, size: 26),
label: const Text('Play All',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.cyan,
foregroundColor: AppColors.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(26),
),
elevation: 0,
),
),
),
const SizedBox(width: 16),
// Shuffle button
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.violet, AppColors.rose],
),
boxShadow: AppColors.violetGlow,
),
child: IconButton(
icon: const Icon(Icons.shuffle, size: 24),
color: AppColors.onBackground,
onPressed: tracks.isNotEmpty
? () {
final albumNotifier = ref.read(albumProvider.notifier);
final playerNotifier = ref.read(playerProvider.notifier);
albumNotifier.shuffle(playerNotifier);
}
: null,
iconSize: 52,
),
),
],
);
}
Widget _buildAlbumInfo(albumState) {
final album = albumState.album!;
final tracks = albumState.tracks;
return Wrap(
alignment: WrapAlignment.center,
spacing: 12,
runSpacing: 8,
children: [
if (album.totalTracks > 0)
_buildInfoChip('${album.totalTracks} ${album.totalTracks == 1 ? 'track' : 'tracks'}'),
if (album.totalTracks > 0 && albumState.totalDuration > 0)
_buildInfoChip(albumState.formattedTotalDuration),
if (album.genre != null)
_buildInfoChip(album.genre!, isGenre: true),
],
);
}
Widget _buildInfoChip(String text, {bool isGenre = false}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: isGenre
? AppColors.violet.withOpacity(0.2)
: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isGenre
? AppColors.violet.withOpacity(0.5)
: AppColors.cyan.withOpacity(0.2),
),
),
child: Text(
text,
style: TextStyle(
color: isGenre ? AppColors.violet : AppColors.onSurfaceVariant,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
);
}
Widget _buildRightPanel(albumState) {
final tracks = albumState.tracks;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppColors.surface.withOpacity(0.5),
border: Border(
bottom: BorderSide(
color: AppColors.surfaceVariant,
width: 1,
),
),
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: AppColors.onBackground),
onPressed: () => Navigator.of(context).pop(),
),
const SizedBox(width: 16),
const Text(
'Tracklist',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: AppColors.onBackground,
),
),
const Spacer(),
if (tracks.isNotEmpty)
Text(
'${tracks.length} tracks',
style: const TextStyle(
fontSize: 14,
color: AppColors.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
],
),
),
// Tracklist
Expanded(
child: tracks.isEmpty
? _buildEmptyTracklistState()
: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 16),
itemCount: tracks.length,
itemBuilder: (context, index) {
final track = tracks[index];
return AlbumTrackTile(
track: track,
index: index,
onTap: () => _playTrack(track, index),
);
},
),
),
],
);
}
Widget _buildEmptyTracklistState() {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.music_note,
size: 64,
color: AppColors.muted,
),
SizedBox(height: 16),
Text(
'No tracks available',
style: TextStyle(
fontSize: 16,
color: AppColors.muted,
),
),
],
),
);
}
void _playTrack(Track track, int index) {
final albumNotifier = ref.read(albumProvider.notifier);
final playerNotifier = ref.read(playerProvider.notifier);
albumNotifier.playTrack(playerNotifier, track);
}
}
@@ -0,0 +1,28 @@
/// Album Details Page - Adaptive layout
library;
import 'package:flutter/material.dart';
import 'album_mobile_page.dart';
import 'album_desktop_page.dart';
class AlbumDetailsPage extends StatelessWidget {
final String albumId;
const AlbumDetailsPage({
required this.albumId,
super.key,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth >= 800) {
return AlbumDesktopPage(albumId: albumId);
} else {
return AlbumMobilePage(albumId: albumId);
}
},
);
}
}
@@ -0,0 +1,395 @@
/// Album Details Page - Mobile Layout
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/colors.dart';
import '../../providers/album_provider.dart';
import '../../providers/music_provider.dart';
import '../../../domain/entities/track.dart';
import '../../widgets/album/album_widgets.dart';
import '../common/cached_network_image_with_fallback.dart';
class AlbumMobilePage extends ConsumerStatefulWidget {
final String albumId;
const AlbumMobilePage({
required this.albumId,
super.key,
});
@override
ConsumerState<AlbumMobilePage> createState() => _AlbumMobilePageState();
}
class _AlbumMobilePageState extends ConsumerState<AlbumMobilePage> {
@override
void initState() {
super.initState();
// Load album data
Future.microtask(() {
ref.read(albumProvider.notifier).loadAlbum(widget.albumId);
});
}
@override
Widget build(BuildContext context) {
final albumState = ref.watch(albumProvider);
if (albumState.isLoading && albumState.album == null) {
return _buildLoadingState();
}
if (albumState.error != null && albumState.album == null) {
return _buildErrorState(albumState.error!);
}
if (albumState.album == null) {
return _buildEmptyState();
}
return CustomScrollView(
slivers: [
// Hero header
_buildHeroHeader(albumState.album!),
// Action buttons
_buildActionButtons(albumState.tracks),
// Album info
_buildAlbumInfo(albumState),
// Tracklist
if (albumState.tracks.isNotEmpty)
_buildTracklistSection(albumState.tracks),
// Bottom spacing
const SliverToBoxAdapter(
child: SizedBox(height: 100),
),
],
);
}
Widget _buildLoadingState() {
return const Center(
child: CircularProgressIndicator(color: AppColors.cyan),
);
}
Widget _buildErrorState(String error) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 64,
color: AppColors.error,
),
const SizedBox(height: 16),
const Text(
'Something went wrong',
style: TextStyle(
fontSize: 18,
color: AppColors.error,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
error,
style: const TextStyle(
fontSize: 14,
color: AppColors.muted,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () {
ref.read(albumProvider.notifier).loadAlbum(widget.albumId);
},
child: const Text('Retry'),
),
],
),
),
);
}
Widget _buildEmptyState() {
return const Center(
child: CircularProgressIndicator(color: AppColors.cyan),
);
}
Widget _buildHeroHeader(album) {
return SliverToBoxAdapter(
child: Stack(
children: [
// Background gradient
Container(
height: 400,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.violet.withOpacity(0.3),
AppColors.primary,
],
),
),
),
// Album art background (blurred)
if (album.imageUrl != null)
Positioned.fill(
child: Opacity(
opacity: 0.15,
child: Image.network(
album.imageUrl!,
fit: BoxFit.cover,
),
),
),
// Content
SizedBox(
height: 400,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Back button
IconButton(
icon: const Icon(Icons.arrow_back,
color: AppColors.onBackground),
onPressed: () => Navigator.of(context).pop(),
),
const Spacer(),
// Album art
Center(
child: Hero(
tag: 'album_art_${album.id}',
child: Container(
decoration: BoxDecoration(
boxShadow: AppColors.cyanGlow,
borderRadius: BorderRadius.circular(8),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: CachedNetworkImageWithFallback(
imageUrl: album.imageUrl,
fallbackIcon: Icons.album,
progressColor: AppColors.cyan,
width: 240,
height: 240,
fit: BoxFit.cover,
),
),
),
),
),
const SizedBox(height: 24),
// Album title
Text(
album.title,
style: const TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: AppColors.onBackground,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
// Artist name and year
if (album.artist != null)
Text(
'${album.artist!.name}${album.releaseDate != null ? '${album.releaseDate!.year}' : ''}',
style: const TextStyle(
fontSize: 15,
color: AppColors.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
],
),
),
),
),
],
),
);
}
Widget _buildActionButtons(List<Track> tracks) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// Play All button
Expanded(
child: SizedBox(
height: 48,
child: ElevatedButton.icon(
onPressed: tracks.isNotEmpty
? () {
final albumNotifier = ref.read(albumProvider.notifier);
final playerNotifier = ref.read(playerProvider.notifier);
albumNotifier.playAll(playerNotifier);
}
: null,
icon: const Icon(Icons.play_arrow, size: 24),
label: const Text('Play All',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.cyan,
foregroundColor: AppColors.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
elevation: 0,
),
),
),
),
const SizedBox(width: 12),
// Shuffle button
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.violet, AppColors.rose],
),
boxShadow: AppColors.violetGlow,
),
child: IconButton(
icon: const Icon(Icons.shuffle, size: 20),
color: AppColors.onBackground,
onPressed: tracks.isNotEmpty
? () {
final albumNotifier = ref.read(albumProvider.notifier);
final playerNotifier = ref.read(playerProvider.notifier);
albumNotifier.shuffle(playerNotifier);
}
: null,
),
),
],
),
),
);
}
Widget _buildAlbumInfo(albumState) {
final album = albumState.album!;
final tracks = albumState.tracks;
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
if (album.totalTracks > 0)
_buildInfoChip('${album.totalTracks} ${album.totalTracks == 1 ? 'track' : 'tracks'}'),
if (album.totalTracks > 0 && albumState.totalDuration > 0)
const SizedBox(width: 8),
if (albumState.totalDuration > 0)
_buildInfoChip(albumState.formattedTotalDuration),
if (album.genre != null) ...[
const SizedBox(width: 8),
_buildInfoChip(album.genre!, isGenre: true),
],
],
),
),
);
}
Widget _buildInfoChip(String text, {bool isGenre = false}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isGenre
? AppColors.violet.withOpacity(0.2)
: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isGenre
? AppColors.violet.withOpacity(0.5)
: AppColors.cyan.withOpacity(0.2),
),
),
child: Text(
text,
style: TextStyle(
color: isGenre ? AppColors.violet : AppColors.onSurfaceVariant,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
);
}
Widget _buildTracklistSection(List<Track> tracks) {
return SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 24, 16, 12),
child: Text(
'Tracklist',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: AppColors.onBackground,
),
),
),
...tracks.asMap().entries.map((entry) {
final index = entry.key;
final track = entry.value;
return AlbumTrackTile(
track: track,
index: index,
onTap: () => _playTrack(track, index),
);
}),
const SizedBox(height: 24),
],
),
);
}
void _playTrack(Track track, int index) {
final albumNotifier = ref.read(albumProvider.notifier);
final playerNotifier = ref.read(playerProvider.notifier);
albumNotifier.playTrack(playerNotifier, track);
}
}
@@ -0,0 +1,456 @@
/// Artist Details Page - Desktop Layout
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/colors.dart';
import '../../providers/artist_provider.dart';
import '../../providers/music_provider.dart';
import '../../../domain/entities/track.dart';
import '../../widgets/artist/artist_track_tile.dart';
import '../../widgets/artist/artist_album_card.dart';
class ArtistDesktopPage extends ConsumerStatefulWidget {
final String artistId;
const ArtistDesktopPage({
required this.artistId,
super.key,
});
@override
ConsumerState<ArtistDesktopPage> createState() => _ArtistDesktopPageState();
}
class _ArtistDesktopPageState extends ConsumerState<ArtistDesktopPage> {
@override
void initState() {
super.initState();
// Load artist data
Future.microtask(() {
ref.read(artistProvider.notifier).loadAllArtistData(widget.artistId);
});
}
@override
Widget build(BuildContext context) {
final artistState = ref.watch(artistProvider);
if (artistState.isLoading && artistState.artist == null) {
return _buildLoadingState();
}
if (artistState.error != null && artistState.artist == null) {
return _buildErrorState(artistState.error!);
}
if (artistState.artist == null) {
return _buildEmptyState();
}
return CustomScrollView(
slivers: [
// Hero header
_buildHeroHeader(artistState.artist!),
// Main content
SliverToBoxAdapter(
child: _buildMainContent(artistState),
),
// Bottom spacing
const SliverToBoxAdapter(
child: SizedBox(height: 100),
),
],
);
}
Widget _buildLoadingState() {
return const Center(
child: CircularProgressIndicator(color: AppColors.cyan),
);
}
Widget _buildErrorState(String error) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 64,
color: AppColors.error,
),
const SizedBox(height: 16),
const Text(
'Something went wrong',
style: TextStyle(
fontSize: 18,
color: AppColors.error,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
error,
style: const TextStyle(
fontSize: 14,
color: AppColors.muted,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () {
ref.read(artistProvider.notifier).loadAllArtistData(widget.artistId);
},
child: const Text('Retry'),
),
],
),
),
);
}
Widget _buildEmptyState() {
return const Center(
child: CircularProgressIndicator(color: AppColors.cyan),
);
}
Widget _buildHeroHeader(artist) {
return SliverToBoxAdapter(
child: Stack(
children: [
// Background gradient
Container(
height: 350,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.violet.withOpacity(0.2),
AppColors.primary,
],
),
),
),
// Artist image background
if (artist.imageUrl != null)
Positioned.fill(
child: Opacity(
opacity: 0.15,
child: Image.network(
artist.imageUrl!,
fit: BoxFit.cover,
),
),
),
// Content
SizedBox(
height: 350,
child: Row(
children: [
const SizedBox(width: 48),
// Back button
Padding(
padding: const EdgeInsets.only(top: 24),
child: IconButton(
icon: const Icon(Icons.arrow_back, color: AppColors.onBackground),
onPressed: () => Navigator.of(context).pop(),
),
),
const Spacer(),
// Artist image and info
Expanded(
flex: 2,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 48),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Spacer(),
// Artist image
if (artist.imageUrl != null)
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.network(
artist.imageUrl!,
width: 220,
height: 220,
fit: BoxFit.cover,
),
),
const SizedBox(height: 20),
// Artist name
Text(
artist.name,
style: const TextStyle(
fontSize: 36,
fontWeight: FontWeight.bold,
color: AppColors.onBackground,
letterSpacing: -0.5,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
// Genres
if (artist.genres.isNotEmpty)
Wrap(
spacing: 12,
alignment: WrapAlignment.center,
children: artist.genres.take(4).map((genre) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.cyan.withOpacity(0.3),
),
),
child: Text(
genre,
style: const TextStyle(
color: AppColors.cyan,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
);
}).toList(),
),
const Spacer(),
],
),
),
),
const Spacer(flex: 3),
],
),
),
],
),
);
}
Widget _buildMainContent(artistState) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 48, vertical: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Play All button
_buildPlayAllButton(artistState.topTracks),
const SizedBox(height: 32),
// Two column layout
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Left column - Popular tracks
Expanded(
child: _buildPopularTracksSection(artistState.topTracks),
),
const SizedBox(width: 48),
// Right column - Albums and Related
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Albums section
if (artistState.albums.isNotEmpty)
_buildAlbumsSection(artistState.albums),
const SizedBox(height: 32),
// Related tracks section
if (artistState.relatedTracks.isNotEmpty)
_buildRelatedTracksSection(artistState.relatedTracks),
],
),
),
],
),
],
),
);
}
Widget _buildPlayAllButton(List<Track> tracks) {
return SizedBox(
width: 200,
child: ElevatedButton.icon(
onPressed: tracks.isNotEmpty
? () {
final playerNotifier = ref.read(playerProvider.notifier);
playerNotifier.setQueue(tracks, startIndex: 0);
playerNotifier.loadTrack(tracks.first);
playerNotifier.play();
}
: null,
icon: const Icon(Icons.play_arrow, size: 28),
label: const Text('Play All', style: TextStyle(fontSize: 16)),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: AppColors.cyan,
foregroundColor: AppColors.primary,
),
),
);
}
Widget _buildPopularTracksSection(List<Track> tracks) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Popular Tracks',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: AppColors.onBackground,
),
),
const SizedBox(height: 16),
Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppColors.cyan.withOpacity(0.1),
),
),
child: Column(
children: tracks.asMap().entries.map((entry) {
final index = entry.key;
final track = entry.value;
return ArtistTrackTile(
track: track,
index: index,
onTap: () => _playTrack(track),
);
}).toList(),
),
),
],
);
}
Widget _buildAlbumsSection(albums) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Albums',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: AppColors.onBackground,
),
),
const SizedBox(height: 16),
// Grid of albums - 2 per row on desktop
Column(
children: [
for (int i = 0; i < albums.length; i += 2)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
children: [
Expanded(
child: ArtistAlbumCard(
album: albums[i],
onTap: () {
// TODO: Navigate to album details
},
),
),
if (i + 1 < albums.length) ...[
const SizedBox(width: 16),
Expanded(
child: ArtistAlbumCard(
album: albums[i + 1],
onTap: () {
// TODO: Navigate to album details
},
),
),
],
],
),
),
],
),
],
);
}
Widget _buildRelatedTracksSection(List<Track> tracks) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Related Tracks',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: AppColors.onBackground,
),
),
const SizedBox(height: 16),
Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppColors.violet.withOpacity(0.1),
),
),
child: Column(
children: tracks.take(5).toList().asMap().entries.map((entry) {
final index = entry.key;
final track = entry.value;
return ArtistTrackTile(
track: track,
index: index,
onTap: () => _playTrack(track),
);
}).toList(),
),
),
],
);
}
void _playTrack(Track track) {
final playerNotifier = ref.read(playerProvider.notifier);
final artistState = ref.read(artistProvider);
playerNotifier.setQueue(artistState.topTracks, startIndex: 0);
playerNotifier.loadTrack(track);
playerNotifier.play();
}
}
@@ -0,0 +1,28 @@
/// Artist Details Page - Adaptive layout
library;
import 'package:flutter/material.dart';
import 'artist_mobile_page.dart';
import 'artist_desktop_page.dart';
class ArtistDetailsPage extends StatelessWidget {
final String artistId;
const ArtistDetailsPage({
required this.artistId,
super.key,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth >= 800) {
return ArtistDesktopPage(artistId: artistId);
} else {
return ArtistMobilePage(artistId: artistId);
}
},
);
}
}
@@ -0,0 +1,387 @@
/// Artist Details Page - Mobile Layout
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/colors.dart';
import '../../providers/artist_provider.dart';
import '../../providers/music_provider.dart';
import '../../../domain/entities/track.dart';
import '../../widgets/artist/artist_track_tile.dart';
import '../../widgets/artist/artist_album_card.dart';
class ArtistMobilePage extends ConsumerStatefulWidget {
final String artistId;
const ArtistMobilePage({
required this.artistId,
super.key,
});
@override
ConsumerState<ArtistMobilePage> createState() => _ArtistMobilePageState();
}
class _ArtistMobilePageState extends ConsumerState<ArtistMobilePage> {
@override
void initState() {
super.initState();
// Load artist data
Future.microtask(() {
ref.read(artistProvider.notifier).loadAllArtistData(widget.artistId);
});
}
@override
Widget build(BuildContext context) {
final artistState = ref.watch(artistProvider);
if (artistState.isLoading && artistState.artist == null) {
return _buildLoadingState();
}
if (artistState.error != null && artistState.artist == null) {
return _buildErrorState(artistState.error!);
}
if (artistState.artist == null) {
return _buildEmptyState();
}
return CustomScrollView(
slivers: [
// Hero header
_buildHeroHeader(artistState.artist!),
// Play All button
_buildPlayAllButton(artistState.topTracks),
// Popular tracks section
if (artistState.topTracks.isNotEmpty)
_buildPopularTracksSection(artistState.topTracks),
// Albums section
if (artistState.albums.isNotEmpty)
_buildAlbumsSection(artistState.albums),
// Related tracks section
if (artistState.relatedTracks.isNotEmpty)
_buildRelatedTracksSection(artistState.relatedTracks),
// Bottom spacing
const SliverToBoxAdapter(
child: SizedBox(height: 100),
),
],
);
}
Widget _buildLoadingState() {
return const Center(
child: CircularProgressIndicator(color: AppColors.cyan),
);
}
Widget _buildErrorState(String error) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 64,
color: AppColors.error,
),
const SizedBox(height: 16),
const Text(
'Something went wrong',
style: TextStyle(
fontSize: 18,
color: AppColors.error,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Text(
error,
style: const TextStyle(
fontSize: 14,
color: AppColors.muted,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () {
ref.read(artistProvider.notifier).loadAllArtistData(widget.artistId);
},
child: const Text('Retry'),
),
],
),
),
);
}
Widget _buildEmptyState() {
return const Center(
child: CircularProgressIndicator(color: AppColors.cyan),
);
}
Widget _buildHeroHeader(artist) {
return SliverToBoxAdapter(
child: Stack(
children: [
// Background gradient
Container(
height: 280,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.violet.withOpacity(0.3),
AppColors.primary,
],
),
),
),
// Artist image
if (artist.imageUrl != null)
Positioned.fill(
child: Opacity(
opacity: 0.2,
child: Image.network(
artist.imageUrl!,
fit: BoxFit.cover,
),
),
),
// Content
SizedBox(
height: 280,
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Back button
IconButton(
icon: const Icon(Icons.arrow_back, color: AppColors.onBackground),
onPressed: () => Navigator.of(context).pop(),
),
const Spacer(),
// Artist image
if (artist.imageUrl != null)
Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
artist.imageUrl!,
width: 160,
height: 160,
fit: BoxFit.cover,
),
),
),
const SizedBox(height: 16),
// Artist name
Text(
artist.name,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: AppColors.onBackground,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
// Genres
if (artist.genres.isNotEmpty)
Wrap(
spacing: 8,
alignment: WrapAlignment.center,
children: artist.genres.take(3).map((genre) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppColors.cyan.withOpacity(0.3),
),
),
child: Text(
genre,
style: const TextStyle(
color: AppColors.cyan,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
);
}).toList(),
),
const SizedBox(height: 16),
],
),
),
),
),
],
),
);
}
Widget _buildPlayAllButton(List<Track> tracks) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: tracks.isNotEmpty
? () {
final playerNotifier = ref.read(playerProvider.notifier);
playerNotifier.setQueue(tracks, startIndex: 0);
playerNotifier.loadTrack(tracks.first);
playerNotifier.play();
}
: null,
icon: const Icon(Icons.play_arrow, size: 28),
label: const Text('Play All', style: TextStyle(fontSize: 16)),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: AppColors.cyan,
foregroundColor: AppColors.primary,
),
),
),
),
);
}
Widget _buildPopularTracksSection(List<Track> tracks) {
return SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 24, 16, 12),
child: Text(
'Popular',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: AppColors.onBackground,
),
),
),
...tracks.asMap().entries.map((entry) {
final index = entry.key;
final track = entry.value;
return ArtistTrackTile(
track: track,
index: index,
onTap: () => _playTrack(track),
);
}),
const SizedBox(height: 24),
],
),
);
}
Widget _buildAlbumsSection(albums) {
return SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 24, 16, 12),
child: Text(
'Albums',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: AppColors.onBackground,
),
),
),
SizedBox(
height: 200,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemCount: albums.length,
itemBuilder: (context, index) {
return ArtistAlbumCard(
album: albums[index],
onTap: () {
// TODO: Navigate to album details
},
);
},
),
),
const SizedBox(height: 24),
],
),
);
}
Widget _buildRelatedTracksSection(List<Track> tracks) {
return SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 24, 16, 12),
child: Text(
'Related Tracks',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: AppColors.onBackground,
),
),
),
...tracks.asMap().entries.map((entry) {
final index = entry.key;
final track = entry.value;
return ArtistTrackTile(
track: track,
index: index,
onTap: () => _playTrack(track),
);
}),
],
),
);
}
void _playTrack(Track track) {
final playerNotifier = ref.read(playerProvider.notifier);
final artistState = ref.read(artistProvider);
playerNotifier.setQueue(artistState.topTracks, startIndex: 0);
playerNotifier.loadTrack(track);
playerNotifier.play();
}
}
@@ -0,0 +1,212 @@
/// Login Page
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/colors.dart';
import '../../providers/auth_provider.dart';
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@override
ConsumerState<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
bool _isLoginMode = true;
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
final authNotifier = ref.read(authProvider.notifier);
if (_isLoginMode) {
await authNotifier.login(
_emailController.text.trim(),
_passwordController.text,
);
} else {
await authNotifier.register(
email: _emailController.text.trim(),
username: _emailController.text.split('@')[0],
password: _passwordController.text,
);
}
if (mounted && authNotifier.hasError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(authNotifier.error ?? 'An error occurred'),
backgroundColor: AppColors.error,
),
);
}
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
// If already logged in, redirect to home
if (authState.isAuthenticated) {
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.of(context).pushReplacementNamed('/');
});
}
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppColors.primary,
AppColors.surface,
],
),
),
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Card(
elevation: 20,
shadowColor: AppColors.cyan.withOpacity(0.3),
child: Padding(
padding: const EdgeInsets.all(32),
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Logo/Title
const Text(
'AudiOhm',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
foreground: AppColors.primaryGradient,
),
),
const SizedBox(height: 8),
Text(
_isLoginMode ? 'Welcome back' : 'Create account',
style: const TextStyle(
fontSize: 14,
color: AppColors.muted,
),
),
const SizedBox(height: 32),
// Email field
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email',
prefixIcon: Icon(Icons.email_outlined),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Email is required';
}
if (!value.contains('@')) {
return 'Enter a valid email';
}
return null;
},
),
const SizedBox(height: 16),
// Password field
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock_outlined),
suffixIcon: IconButton(
icon: Icon(_obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Password is required';
}
if (value.length < 8) {
return 'Password must be at least 8 characters';
}
return null;
},
),
const SizedBox(height: 24),
// Login button
SizedBox(
width: double.infinity,
height: 50,
child: FilledButton(
onPressed: authState.isLoading ? null : _submit,
child: authState.isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.primary,
),
)
: Text(_isLoginMode ? 'Login' : 'Register'),
),
),
const SizedBox(height: 16),
// Toggle mode
TextButton(
onPressed: () {
setState(() {
_isLoginMode = !_isLoginMode;
});
},
child: Text(
_isLoginMode
? "Don't have an account? Register"
: 'Already have an account? Login',
),
),
],
),
),
),
),
),
),
),
),
),
);
}
}
@@ -0,0 +1,292 @@
import 'package:flutter/material.dart';
import '../../../core/theme/colors.dart';
/// Desktop Home Page
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
return CustomScrollView(
slivers: [
// Header
SliverAppBar(
expandedHeight: 200,
floating: false,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
title: const Text(
'Good Evening',
style: TextStyle(
color: AppColors.onBackground,
fontWeight: FontWeight.bold,
),
),
background: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppColors.surface,
AppColors.surfaceVariant,
],
),
),
),
),
),
// Content sections
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Quick picks
const _SectionTitle(title: 'Quick Picks'),
const SizedBox(height: 12),
SizedBox(
height: 80,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: 6,
itemBuilder: (context, index) {
return const _QuickPickCard();
},
),
),
const SizedBox(height: 24),
// Recently played
const _SectionTitle(title: 'Recently Played'),
const SizedBox(height: 12),
SizedBox(
height: 200,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: 10,
itemBuilder: (context, index) {
return const _AlbumCard();
},
),
),
const SizedBox(height: 24),
// Made for you
const _SectionTitle(title: 'Made For You'),
const SizedBox(height: 12),
SizedBox(
height: 200,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: 8,
itemBuilder: (context, index) {
return const _PlaylistCard();
},
),
),
],
),
),
),
],
);
}
}
class _SectionTitle extends StatelessWidget {
final String title;
const _SectionTitle({required this.title});
@override
Widget build(BuildContext context) {
return Text(
title,
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: AppColors.cyan,
),
);
}
}
class _QuickPickCard extends StatelessWidget {
const _QuickPickCard();
@override
Widget build(BuildContext context) {
return Container(
width: 280,
margin: const EdgeInsets.only(right: 12),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
AppColors.surface,
AppColors.surfaceVariant,
],
),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColors.cyan.withOpacity(0.2),
width: 1,
),
),
child: Row(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
gradient: AppColors.primaryGradient,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(7),
bottomLeft: Radius.circular(7),
),
),
child: const Icon(
Icons.music_note,
color: AppColors.onBackground,
),
),
const Expanded(
child: Padding(
padding: EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Playlist Name',
style: TextStyle(
fontWeight: FontWeight.w500,
color: AppColors.onSurface,
),
),
SizedBox(height: 4),
Text(
'Description',
style: TextStyle(
fontSize: 12,
color: AppColors.muted,
),
),
],
),
),
),
],
),
);
}
}
class _AlbumCard extends StatelessWidget {
const _AlbumCard();
@override
Widget build(BuildContext context) {
return Container(
width: 160,
margin: const EdgeInsets.only(right: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Album art
Container(
width: 160,
height: 160,
decoration: BoxDecoration(
gradient: AppColors.accentGradient,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.album,
size: 64,
color: AppColors.onBackground,
),
),
const SizedBox(height: 8),
// Album info
const Text(
'Album Name',
style: TextStyle(
fontWeight: FontWeight.w500,
color: AppColors.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const Text(
'Artist Name',
style: TextStyle(
fontSize: 12,
color: AppColors.muted,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
}
class _PlaylistCard extends StatelessWidget {
const _PlaylistCard();
@override
Widget build(BuildContext context) {
return Container(
width: 160,
margin: const EdgeInsets.only(right: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Playlist art
Container(
width: 160,
height: 160,
decoration: BoxDecoration(
gradient: AppColors.fullGradient,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.playlist_play,
size: 64,
color: AppColors.onBackground,
),
),
const SizedBox(height: 8),
// Playlist info
const Text(
'Playlist Name',
style: TextStyle(
fontWeight: FontWeight.w500,
color: AppColors.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const Text(
'Description',
style: TextStyle(
fontSize: 12,
color: AppColors.muted,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
}
@@ -0,0 +1,542 @@
/// Library Page - Desktop Layout
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/colors.dart';
import '../../providers/library_provider.dart';
import '../../widgets/library/playlist_tile.dart';
import '../../../domain/entities/track.dart';
import '../../../domain/entities/album.dart';
import '../../../domain/entities/artist.dart';
class LibraryDesktopPage extends ConsumerStatefulWidget {
const LibraryDesktopPage({super.key});
@override
ConsumerState<LibraryDesktopPage> createState() =>
_LibraryDesktopPageState();
}
class _LibraryDesktopPageState extends ConsumerState<LibraryDesktopPage>
with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
// Load library on init
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(libraryProvider.notifier).loadLibrary();
});
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final libraryState = ref.watch(libraryProvider);
return Column(
children: [
// Header with tabs
_buildHeader(libraryState),
// Content based on selected tab
Expanded(
child: _buildContent(libraryState),
),
],
);
}
Widget _buildHeader(dynamic libraryState) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
decoration: BoxDecoration(
color: AppColors.surfaceVariant.withOpacity(0.5),
border: Border(
bottom: BorderSide(
color: AppColors.cyan.withOpacity(0.2),
width: 1,
),
),
),
child: Row(
children: [
const Icon(
Icons.library_music,
color: AppColors.cyan,
size: 32,
),
const SizedBox(width: 16),
const Text(
'Your Library',
style: TextStyle(
color: AppColors.onSurface,
fontSize: 24,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
if (libraryState.totalItems > 0)
IconButton(
icon: const Icon(Icons.refresh, color: AppColors.cyan),
onPressed: () {
ref.read(libraryProvider.notifier).refresh();
},
tooltip: 'Refresh',
),
],
),
);
}
Widget _buildContent(dynamic libraryState) {
if (libraryState.isLoading) {
return const Center(
child: CircularProgressIndicator(color: AppColors.cyan),
);
}
if (libraryState.error != null) {
return _buildErrorState(libraryState.error ?? 'Unknown error');
}
return TabBarView(
controller: _tabController,
children: [
_buildPlaylistsTab(libraryState.playlists),
_buildLikedSongsTab(libraryState.likedSongs),
_buildAlbumsTab(libraryState.savedAlbums),
_buildArtistsTab(libraryState.followedArtists),
],
);
}
Widget _buildPlaylistsTab(List<dynamic> playlists) {
if (playlists.isEmpty) {
return _buildEmptyState(
icon: Icons.playlist_play,
message: 'No playlists yet',
submessage: 'Create your first playlist to get started',
);
}
return GridView.builder(
padding: const EdgeInsets.all(24),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
childAspectRatio: 2.5,
),
itemCount: playlists.length,
itemBuilder: (context, index) {
final playlist = playlists[index];
return PlaylistTile(
playlist: playlist,
onTap: () => _openPlaylist(playlist),
canDelete: true,
onDelete: () => _confirmDeletePlaylist(playlist),
);
},
);
}
Widget _buildLikedSongsTab(List<dynamic> likedSongs) {
if (likedSongs.isEmpty) {
return _buildEmptyState(
icon: Icons.favorite_border,
message: 'No liked songs',
submessage: 'Like songs to see them here',
);
}
return ListView.builder(
padding: const EdgeInsets.all(24),
itemCount: likedSongs.length,
itemBuilder: (context, index) {
final track = likedSongs[index] as Track;
return _buildTrackTile(track, index);
},
);
}
Widget _buildAlbumsTab(List<dynamic> albums) {
if (albums.isEmpty) {
return _buildEmptyState(
icon: Icons.album,
message: 'No saved albums',
submessage: 'Save albums to see them here',
);
}
return GridView.builder(
padding: const EdgeInsets.all(24),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
childAspectRatio: 1,
),
itemCount: albums.length,
itemBuilder: (context, index) {
final album = albums[index] as Album;
return _buildAlbumCard(album);
},
);
}
Widget _buildArtistsTab(List<dynamic> artists) {
if (artists.isEmpty) {
return _buildEmptyState(
icon: Icons.person,
message: 'No followed artists',
submessage: 'Follow artists to see them here',
);
}
return GridView.builder(
padding: const EdgeInsets.all(24),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
childAspectRatio: 1,
),
itemCount: artists.length,
itemBuilder: (context, index) {
final artist = artists[index] as Artist;
return _buildArtistCard(artist);
},
);
}
Widget _buildTrackTile(Track track, int index) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColors.cyan.withOpacity(0.1),
),
),
child: ListTile(
leading: Text(
'${index + 1}',
style: const TextStyle(
color: AppColors.muted,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
title: Text(
track.title,
style: const TextStyle(
color: AppColors.onSurface,
fontWeight: FontWeight.w500,
),
),
subtitle: Text(
track.artist?.name ?? 'Unknown Artist',
style: const TextStyle(
color: AppColors.muted,
),
),
trailing: Text(
track.formattedDuration,
style: const TextStyle(
color: AppColors.muted,
),
),
onTap: () {
// TODO: Play track
},
),
);
}
Widget _buildAlbumCard(Album album) {
return GestureDetector(
onTap: () => _openAlbum(album),
child: Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.rose.withOpacity(0.3),
),
),
child: Column(
children: [
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
gradient: AppColors.accentGradient,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(12),
),
),
child: album.imageUrl != null
? ClipRRect(
borderRadius: const BorderRadius.vertical(
top: Radius.circular(12),
),
child: Image.network(
album.imageUrl!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.album,
size: 64,
color: AppColors.muted,
);
},
),
)
: const Icon(
Icons.album,
size: 64,
color: AppColors.muted,
),
),
),
Padding(
padding: const EdgeInsets.all(8),
child: Column(
children: [
Text(
album.title,
style: const TextStyle(
color: AppColors.onSurface,
fontWeight: FontWeight.w500,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
if (album.artist != null)
Text(
album.artist!.name,
style: const TextStyle(
color: AppColors.muted,
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
],
),
),
],
),
),
);
}
Widget _buildArtistCard(Artist artist) {
return GestureDetector(
onTap: () => _openArtist(artist),
child: Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.violet.withOpacity(0.3),
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: Center(
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: AppColors.primaryGradient,
),
child: artist.imageUrl != null
? ClipOval(
child: Image.network(
artist.imageUrl!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.person,
size: 48,
color: AppColors.muted,
);
},
),
)
: const Icon(
Icons.person,
size: 48,
color: AppColors.muted,
),
),
),
),
Padding(
padding: const EdgeInsets.all(8),
child: Text(
artist.name,
style: const TextStyle(
color: AppColors.onSurface,
fontWeight: FontWeight.w500,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
),
],
),
),
);
}
Widget _buildEmptyState({
required IconData icon,
required String message,
required String submessage,
}) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 64,
color: AppColors.muted,
),
const SizedBox(height: 16),
Text(
message,
style: const TextStyle(
fontSize: 18,
color: AppColors.onSurface,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Text(
submessage,
style: const TextStyle(
fontSize: 14,
color: AppColors.muted,
),
),
],
),
);
}
Widget _buildErrorState(String error) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 64,
color: AppColors.error,
),
const SizedBox(height: 16),
const Text(
'Something went wrong',
style: TextStyle(
fontSize: 18,
color: AppColors.error,
),
),
const SizedBox(height: 8),
Text(
error,
style: const TextStyle(
fontSize: 14,
color: AppColors.muted,
),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () {
ref.read(libraryProvider.notifier).refresh();
},
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.cyan,
foregroundColor: AppColors.primary,
),
),
],
),
);
}
void _openPlaylist(dynamic playlist) {
// TODO: Navigate to playlist details
print('Opening playlist: ${playlist.name}');
}
void _confirmDeletePlaylist(dynamic playlist) {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: AppColors.surfaceVariant,
title: const Text(
'Delete Playlist?',
style: TextStyle(color: AppColors.onSurface),
),
content: Text(
'Are you sure you want to delete "${playlist.name}"? This action cannot be undone.',
style: const TextStyle(color: AppColors.muted),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text(
'Cancel',
style: TextStyle(color: AppColors.muted),
),
),
TextButton(
onPressed: () {
Navigator.pop(context);
ref.read(libraryProvider.notifier).deletePlaylist(playlist.id);
},
child: const Text(
'Delete',
style: TextStyle(color: AppColors.error),
),
),
],
),
);
}
void _openAlbum(Album album) {
// TODO: Navigate to album details
print('Opening album: ${album.title}');
}
void _openArtist(Artist artist) {
// TODO: Navigate to artist details
print('Opening artist: ${artist.name}');
}
}
@@ -0,0 +1,580 @@
/// Library Page - Mobile Layout
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/colors.dart';
import '../../providers/library_provider.dart';
import '../../widgets/library/playlist_tile.dart';
import '../../../domain/entities/track.dart';
import '../../../domain/entities/album.dart';
import '../../../domain/entities/artist.dart';
class LibraryMobilePage extends ConsumerStatefulWidget {
const LibraryMobilePage({super.key});
@override
ConsumerState<LibraryMobilePage> createState() =>
_LibraryMobilePageState();
}
class _LibraryMobilePageState extends ConsumerState<LibraryMobilePage>
with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
// Load library on init
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(libraryProvider.notifier).loadLibrary();
});
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final libraryState = ref.watch(libraryProvider);
return Column(
children: [
// Header with title
_buildHeader(libraryState),
// Tab bar
_buildTabBar(),
// Content based on selected tab
Expanded(
child: _buildContent(libraryState),
),
],
);
}
Widget _buildHeader(dynamic libraryState) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppColors.surfaceVariant.withOpacity(0.5),
border: Border(
bottom: BorderSide(
color: AppColors.cyan.withOpacity(0.2),
width: 1,
),
),
),
child: Row(
children: [
const Icon(
Icons.library_music,
color: AppColors.cyan,
size: 24,
),
const SizedBox(width: 12),
const Text(
'Your Library',
style: TextStyle(
color: AppColors.onSurface,
fontSize: 20,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
if (libraryState.totalItems > 0)
IconButton(
icon: const Icon(Icons.refresh, color: AppColors.cyan),
onPressed: () {
ref.read(libraryProvider.notifier).refresh();
},
tooltip: 'Refresh',
iconSize: 20,
),
],
),
);
}
Widget _buildTabBar() {
return Container(
decoration: BoxDecoration(
color: AppColors.surface,
border: Border(
bottom: BorderSide(
color: AppColors.cyan.withOpacity(0.2),
width: 1,
),
),
),
child: TabBar(
controller: _tabController,
indicatorColor: AppColors.cyan,
labelColor: AppColors.cyan,
unselectedLabelColor: AppColors.muted,
labelStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
tabs: const [
Tab(text: 'Playlists'),
Tab(text: 'Songs'),
Tab(text: 'Albums'),
Tab(text: 'Artists'),
],
),
);
}
Widget _buildContent(dynamic libraryState) {
if (libraryState.isLoading) {
return const Center(
child: CircularProgressIndicator(color: AppColors.cyan),
);
}
if (libraryState.error != null) {
return _buildErrorState(libraryState.error ?? 'Unknown error');
}
return TabBarView(
controller: _tabController,
children: [
_buildPlaylistsTab(libraryState.playlists),
_buildLikedSongsTab(libraryState.likedSongs),
_buildAlbumsTab(libraryState.savedAlbums),
_buildArtistsTab(libraryState.followedArtists),
],
);
}
Widget _buildPlaylistsTab(List<dynamic> playlists) {
if (playlists.isEmpty) {
return _buildEmptyState(
icon: Icons.playlist_play,
message: 'No playlists yet',
submessage: 'Create your first playlist to get started',
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: playlists.length,
itemBuilder: (context, index) {
final playlist = playlists[index];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: PlaylistTile(
playlist: playlist,
onTap: () => _openPlaylist(playlist),
canDelete: true,
onDelete: () => _confirmDeletePlaylist(playlist),
),
);
},
);
}
Widget _buildLikedSongsTab(List<dynamic> likedSongs) {
if (likedSongs.isEmpty) {
return _buildEmptyState(
icon: Icons.favorite_border,
message: 'No liked songs',
submessage: 'Like songs to see them here',
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: likedSongs.length,
itemBuilder: (context, index) {
final track = likedSongs[index] as Track;
return _buildTrackTile(track, index);
},
);
}
Widget _buildAlbumsTab(List<dynamic> albums) {
if (albums.isEmpty) {
return _buildEmptyState(
icon: Icons.album,
message: 'No saved albums',
submessage: 'Save albums to see them here',
);
}
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1,
),
itemCount: albums.length,
itemBuilder: (context, index) {
final album = albums[index] as Album;
return _buildAlbumCard(album);
},
);
}
Widget _buildArtistsTab(List<dynamic> artists) {
if (artists.isEmpty) {
return _buildEmptyState(
icon: Icons.person,
message: 'No followed artists',
submessage: 'Follow artists to see them here',
);
}
return GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1,
),
itemCount: artists.length,
itemBuilder: (context, index) {
final artist = artists[index] as Artist;
return _buildArtistCard(artist);
},
);
}
Widget _buildTrackTile(Track track, int index) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColors.cyan.withOpacity(0.1),
),
),
child: ListTile(
leading: Text(
'${index + 1}',
style: const TextStyle(
color: AppColors.muted,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
title: Text(
track.title,
style: const TextStyle(
color: AppColors.onSurface,
fontWeight: FontWeight.w500,
fontSize: 14,
),
),
subtitle: Text(
track.artist?.name ?? 'Unknown Artist',
style: const TextStyle(
color: AppColors.muted,
fontSize: 12,
),
),
trailing: Text(
track.formattedDuration,
style: const TextStyle(
color: AppColors.muted,
fontSize: 12,
),
),
onTap: () {
// TODO: Play track
},
),
);
}
Widget _buildAlbumCard(Album album) {
return GestureDetector(
onTap: () => _openAlbum(album),
child: Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.rose.withOpacity(0.3),
),
),
child: Column(
children: [
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
gradient: AppColors.accentGradient,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(12),
),
),
child: album.imageUrl != null
? ClipRRect(
borderRadius: const BorderRadius.vertical(
top: Radius.circular(12),
),
child: Image.network(
album.imageUrl!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.album,
size: 48,
color: AppColors.muted,
);
},
),
)
: const Icon(
Icons.album,
size: 48,
color: AppColors.muted,
),
),
),
Padding(
padding: const EdgeInsets.all(8),
child: Column(
children: [
Text(
album.title,
style: const TextStyle(
color: AppColors.onSurface,
fontWeight: FontWeight.w500,
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
if (album.artist != null)
Text(
album.artist!.name,
style: const TextStyle(
color: AppColors.muted,
fontSize: 11,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
],
),
),
],
),
),
);
}
Widget _buildArtistCard(Artist artist) {
return GestureDetector(
onTap: () => _openArtist(artist),
child: Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.violet.withOpacity(0.3),
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: Center(
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: AppColors.primaryGradient,
),
child: artist.imageUrl != null
? ClipOval(
child: Image.network(
artist.imageUrl!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.person,
size: 36,
color: AppColors.muted,
);
},
),
)
: const Icon(
Icons.person,
size: 36,
color: AppColors.muted,
),
),
),
),
Padding(
padding: const EdgeInsets.all(8),
child: Text(
artist.name,
style: const TextStyle(
color: AppColors.onSurface,
fontWeight: FontWeight.w500,
fontSize: 12,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
),
],
),
),
);
}
Widget _buildEmptyState({
required IconData icon,
required String message,
required String submessage,
}) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
icon,
size: 48,
color: AppColors.muted,
),
const SizedBox(height: 12),
Text(
message,
style: const TextStyle(
fontSize: 16,
color: AppColors.onSurface,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Text(
submessage,
style: const TextStyle(
fontSize: 12,
color: AppColors.muted,
),
),
],
),
);
}
Widget _buildErrorState(String error) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 48,
color: AppColors.error,
),
const SizedBox(height: 12),
const Text(
'Something went wrong',
style: TextStyle(
fontSize: 16,
color: AppColors.error,
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Text(
error,
style: const TextStyle(
fontSize: 12,
color: AppColors.muted,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () {
ref.read(libraryProvider.notifier).refresh();
},
icon: const Icon(Icons.refresh, size: 16),
label: const Text('Retry'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.cyan,
foregroundColor: AppColors.primary,
),
),
],
),
);
}
void _openPlaylist(dynamic playlist) {
// TODO: Navigate to playlist details
print('Opening playlist: ${playlist.name}');
}
void _confirmDeletePlaylist(dynamic playlist) {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: AppColors.surfaceVariant,
title: const Text(
'Delete Playlist?',
style: TextStyle(color: AppColors.onSurface),
),
content: Text(
'Are you sure you want to delete "${playlist.name}"? This action cannot be undone.',
style: const TextStyle(color: AppColors.muted),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text(
'Cancel',
style: TextStyle(color: AppColors.muted),
),
),
TextButton(
onPressed: () {
Navigator.pop(context);
ref.read(libraryProvider.notifier).deletePlaylist(playlist.id);
},
child: const Text(
'Delete',
style: TextStyle(color: AppColors.error),
),
),
],
),
);
}
void _openAlbum(Album album) {
// TODO: Navigate to album details
print('Opening album: ${album.title}');
}
void _openArtist(Artist artist) {
// TODO: Navigate to artist details
print('Opening artist: ${artist.name}');
}
}
@@ -0,0 +1,23 @@
/// Library Page - Adaptive layout
library;
import 'package:flutter/material.dart';
import 'library_desktop_page.dart';
import 'library_mobile_page.dart';
class LibraryPage extends StatelessWidget {
const LibraryPage({super.key});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth >= 800) {
return const LibraryDesktopPage();
} else {
return const LibraryMobilePage();
}
},
);
}
}
@@ -0,0 +1,297 @@
import 'package:flutter/material.dart';
import '../../../core/theme/colors.dart';
/// Mobile Home Page
class MobileHomePage extends StatelessWidget {
const MobileHomePage({super.key});
@override
Widget build(BuildContext context) {
return CustomScrollView(
slivers: [
// Header
SliverAppBar(
expandedHeight: 180,
floating: false,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
title: const Text(
'Good Evening',
style: TextStyle(
color: AppColors.onBackground,
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
background: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppColors.surface,
AppColors.surfaceVariant,
],
),
),
),
),
),
// Content sections
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Quick picks grid
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 2.5,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: 6,
itemBuilder: (context, index) {
return const _QuickPickCard();
},
),
const SizedBox(height: 24),
// Recently played
const _SectionTitle(title: 'Recently Played'),
const SizedBox(height: 12),
SizedBox(
height: 160,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: 10,
itemBuilder: (context, index) {
return const _AlbumCard();
},
),
),
const SizedBox(height: 24),
// Made for you
const _SectionTitle(title: 'Made For You'),
const SizedBox(height: 12),
SizedBox(
height: 160,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: 8,
itemBuilder: (context, index) {
return const _PlaylistCard();
},
),
),
],
),
),
),
],
);
}
}
class _SectionTitle extends StatelessWidget {
final String title;
const _SectionTitle({required this.title});
@override
Widget build(BuildContext context) {
return Text(
title,
style: Theme.of(context).textTheme.displaySmall?.copyWith(
color: AppColors.cyan,
fontSize: 20,
),
);
}
}
class _QuickPickCard extends StatelessWidget {
const _QuickPickCard();
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
AppColors.surface,
AppColors.surfaceVariant,
],
),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColors.cyan.withOpacity(0.2),
width: 1,
),
),
child: Row(
children: [
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
gradient: AppColors.primaryGradient,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(7),
bottomLeft: Radius.circular(7),
),
),
child: const Icon(
Icons.music_note,
color: AppColors.onBackground,
size: 20,
),
),
const Expanded(
child: Padding(
padding: EdgeInsets.all(8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Playlist',
style: TextStyle(
fontWeight: FontWeight.w500,
color: AppColors.onSurface,
fontSize: 13,
),
),
Text(
'Description',
style: TextStyle(
fontSize: 10,
color: AppColors.muted,
),
),
],
),
),
),
],
),
);
}
}
class _AlbumCard extends StatelessWidget {
const _AlbumCard();
@override
Widget build(BuildContext context) {
return Container(
width: 120,
margin: const EdgeInsets.only(right: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Album art
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
gradient: AppColors.accentGradient,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.album,
size: 48,
color: AppColors.onBackground,
),
),
const SizedBox(height: 6),
// Album info
const Text(
'Album Name',
style: TextStyle(
fontWeight: FontWeight.w500,
color: AppColors.onSurface,
fontSize: 13,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const Text(
'Artist',
style: TextStyle(
fontSize: 11,
color: AppColors.muted,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
}
class _PlaylistCard extends StatelessWidget {
const _PlaylistCard();
@override
Widget build(BuildContext context) {
return Container(
width: 120,
margin: const EdgeInsets.only(right: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Playlist art
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
gradient: AppColors.fullGradient,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.playlist_play,
size: 48,
color: AppColors.onBackground,
),
),
const SizedBox(height: 6),
// Playlist info
const Text(
'Playlist',
style: TextStyle(
fontWeight: FontWeight.w500,
color: AppColors.onSurface,
fontSize: 13,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const Text(
'Description',
style: TextStyle(
fontSize: 11,
color: AppColors.muted,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
}
@@ -0,0 +1,8 @@
/// Pages Export
library;
export 'desktop/home_page.dart';
export 'mobile/mobile_home_page.dart';
export 'auth/login_page.dart';
export 'search/search_page.dart';
export 'library/library_page.dart';
@@ -0,0 +1,662 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/colors.dart';
import '../../../domain/entities/track.dart';
import '../../providers/music_provider.dart';
import '../../widgets/player/queue_track_tile.dart';
/// Queue View Page
///
/// Complete queue management interface with:
/// - Now Playing section (top)
/// - Queue list section (bottom)
/// - Swipe to remove
/// - Drag to reorder
/// - Clear queue functionality
class QueueViewPage extends ConsumerStatefulWidget {
const QueueViewPage({super.key});
@override
ConsumerState<QueueViewPage> createState() => _QueueViewPageState();
}
class _QueueViewPageState extends ConsumerState<QueueViewPage> {
@override
Widget build(BuildContext context) {
final queueData = ref.watch(queueProvider);
return Scaffold(
backgroundColor: AppColors.primary,
body: SafeArea(
child: Column(
children: [
// Header
_buildHeader(queueData),
// Content
Expanded(
child: queueData.hasQueue
? Column(
children: [
// Now Playing Section
_buildNowPlayingSection(queueData),
const SizedBox(height: 8),
// Queue Section
Expanded(
child: _buildQueueSection(queueData),
),
],
)
: _buildEmptyQueue(),
),
],
),
),
);
}
Widget _buildHeader(QueueViewData queueData) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppColors.surface,
border: Border(
bottom: BorderSide(
color: AppColors.cyan.withOpacity(0.2),
width: 1,
),
),
boxShadow: [
BoxShadow(
color: AppColors.cyan.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 2),
),
],
),
child: Row(
children: [
// Back button
IconButton(
icon: const Icon(Icons.arrow_back),
color: AppColors.onSurface,
onPressed: () => Navigator.of(context).pop(),
),
const SizedBox(width: 8),
// Title
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Queue',
style: TextStyle(
color: AppColors.onSurface,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
if (queueData.hasQueue)
Text(
'${queueData.queueCount} tracks',
style: const TextStyle(
color: AppColors.muted,
fontSize: 12,
),
),
],
),
),
// Clear queue button
if (queueData.hasNextTracks)
TextButton.icon(
onPressed: () => _showClearQueueDialog(queueData),
icon: const Icon(
Icons.clear_all,
size: 18,
color: AppColors.rouge,
),
label: const Text(
'Clear',
style: TextStyle(
color: AppColors.rouge,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
backgroundColor: AppColors.rouge.withOpacity(0.1),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
],
),
);
}
Widget _buildNowPlayingSection(QueueViewData queueData) {
final currentTrack = queueData.currentTrack;
if (currentTrack == null) {
return const SizedBox.shrink();
}
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppColors.surfaceVariant,
AppColors.surfaceElevated,
],
),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppColors.cyan.withOpacity(0.3),
width: 1,
),
boxShadow: [
BoxShadow(
color: AppColors.cyan.withOpacity(0.1),
blurRadius: 20,
spreadRadius: 2,
),
],
),
child: Column(
children: [
// Section label
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: AppColors.cyan.withOpacity(0.2),
borderRadius: BorderRadius.circular(6),
),
child: const Text(
'NOW PLAYING',
style: TextStyle(
color: AppColors.cyan,
fontSize: 10,
fontWeight: FontWeight.w600,
letterSpacing: 1,
),
),
),
const Spacer(),
_buildPlayingIndicator(queueData.isPlaying),
],
),
const SizedBox(height: 16),
// Track info
Row(
children: [
// Album art
_buildLargeAlbumArt(currentTrack),
const SizedBox(width: 16),
// Track details
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
currentTrack.title,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 16,
fontWeight: FontWeight.w600,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
currentTrack.artist?.name ?? 'Unknown Artist',
style: const TextStyle(
color: AppColors.muted,
fontSize: 14,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
Row(
children: [
_buildControlButton(
icon: Icons.skip_previous,
onTap: () => _playPrevious(),
),
const SizedBox(width: 12),
_buildPlayPauseButton(queueData.isPlaying),
const SizedBox(width: 12),
_buildControlButton(
icon: Icons.skip_next,
onTap: () => _playNext(),
),
],
),
],
),
),
],
),
],
),
);
}
Widget _buildLargeAlbumArt(Track track) {
return Container(
width: 80,
height: 80,
decoration: BoxDecoration(
gradient: AppColors.accentGradient,
borderRadius: BorderRadius.circular(12),
boxShadow: AppColors.violetGlow,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: track.imageUrl != null
? Image.network(
track.imageUrl!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) {
return const Icon(
Icons.music_note,
color: AppColors.onBackground,
size: 40,
);
},
)
: const Icon(
Icons.music_note,
color: AppColors.onBackground,
size: 40,
),
),
);
}
Widget _buildPlayingIndicator(bool isPlaying) {
if (!isPlaying) {
return const SizedBox.shrink();
}
return Row(
children: [
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: AppColors.vert,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: AppColors.vert,
blurRadius: 8,
spreadRadius: 2,
),
],
),
),
const SizedBox(width: 4),
const Text(
'Playing',
style: TextStyle(
color: AppColors.vert,
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
],
);
}
Widget _buildControlButton({
required IconData icon,
required VoidCallback onTap,
}) {
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.surfaceElevated,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
icon,
color: AppColors.onSurface,
size: 24,
),
),
),
);
}
Widget _buildPlayPauseButton(bool isPlaying) {
return MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () => _togglePlayPause(isPlaying),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
gradient: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(12),
boxShadow: AppColors.cyanGlow,
),
child: Icon(
isPlaying ? Icons.pause : Icons.play_arrow,
color: AppColors.primary,
size: 28,
),
),
),
);
}
Widget _buildQueueSection(QueueViewData queueData) {
if (!queueData.hasNextTracks) {
return const SizedBox.shrink();
}
return Column(
children: [
// Queue header
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: AppColors.violet.withOpacity(0.2),
borderRadius: BorderRadius.circular(6),
),
child: const Text(
'NEXT UP',
style: TextStyle(
color: AppColors.violet,
fontSize: 10,
fontWeight: FontWeight.w600,
letterSpacing: 1,
),
),
),
const SizedBox(width: 8),
Text(
'${queueData.nextTracks.length} tracks',
style: const TextStyle(
color: AppColors.muted,
fontSize: 12,
),
),
],
),
),
const SizedBox(height: 8),
// Queue list
Expanded(
child: ReorderableListView.builder(
padding: const EdgeInsets.only(bottom: 16),
itemCount: queueData.nextTracks.length,
onReorder: (oldIndex, newIndex) {
_reorderQueue(oldIndex, newIndex, queueData);
},
itemBuilder: (context, index) {
final track = queueData.nextTracks[index];
final actualIndex = queueData.currentIndex + 1 + index;
return Dismissible(
key: Key('queue_${track.id}_$index'),
direction: DismissDirection.endToStart,
onDismissed: (direction) {
_removeFromQueue(actualIndex);
},
background: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
color: AppColors.rouge,
borderRadius: BorderRadius.circular(12),
),
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(
Icons.delete,
color: AppColors.primary,
),
),
child: QueueTrackTile(
key: Key('queue_${track.id}_$index'),
track: track,
index: index,
onTap: () => _playTrack(actualIndex),
onRemove: () => _removeFromQueue(actualIndex),
),
);
},
),
),
],
);
}
Widget _buildEmptyQueue() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
AppColors.cyan.withOpacity(0.2),
AppColors.violet.withOpacity(0.2),
],
),
borderRadius: BorderRadius.circular(30),
),
child: const Icon(
Icons.queue_music,
color: AppColors.muted,
size: 60,
),
),
const SizedBox(height: 24),
const Text(
'Queue is empty',
style: TextStyle(
color: AppColors.onSurface,
fontSize: 20,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
const Text(
'Add tracks to build your queue',
style: TextStyle(
color: AppColors.muted,
fontSize: 14,
),
),
],
),
);
}
void _togglePlayPause(bool isPlaying) {
final notifier = ref.read(playerProvider.notifier);
if (isPlaying) {
notifier.pause();
} else {
notifier.play();
}
}
void _playNext() {
ref.read(playerProvider.notifier).next();
}
void _playPrevious() {
ref.read(playerProvider.notifier).previous();
}
void _playTrack(int index) {
final queueData = ref.read(queueProvider);
final track = queueData.queue[index];
ref.read(playerProvider.notifier).loadTrack(track).then((_) {
ref.read(playerProvider.notifier).play();
});
}
void _removeFromQueue(int index) {
ref.read(playerProvider.notifier).removeFromQueue(index);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: const Text('Removed from queue'),
backgroundColor: AppColors.surfaceElevated,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
duration: const Duration(seconds: 2),
),
);
}
void _reorderQueue(int oldIndex, int newIndex, QueueViewData queueData) {
if (oldIndex == newIndex) return;
// Adjust for the actual queue position
final actualOldIndex = queueData.currentIndex + 1 + oldIndex;
int actualNewIndex = queueData.currentIndex + 1 + newIndex;
if (newIndex > oldIndex) {
actualNewIndex--;
}
final notifier = ref.read(playerProvider.notifier);
final queue = List<Track>.from(queueData.queue);
final item = queue.removeAt(actualOldIndex);
queue.insert(actualNewIndex, item);
notifier.setQueue(queue, startIndex: queueData.currentIndex);
}
void _showClearQueueDialog(QueueViewData queueData) {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: AppColors.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: const Text(
'Clear Queue?',
style: TextStyle(
color: AppColors.onSurface,
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
content: Text(
'Remove all ${queueData.nextTracks.length} upcoming tracks from the queue?',
style: const TextStyle(
color: AppColors.muted,
fontSize: 14,
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text(
'Cancel',
style: TextStyle(
color: AppColors.muted,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
TextButton(
onPressed: () {
_clearQueue(queueData);
Navigator.of(context).pop();
},
style: TextButton.styleFrom(
backgroundColor: AppColors.rouge.withOpacity(0.2),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text(
'Clear',
style: TextStyle(
color: AppColors.rouge,
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
void _clearQueue(QueueViewData queueData) {
if (queueData.currentTrack == null) return;
// Keep only the current track
ref.read(playerProvider.notifier).setQueue(
[queueData.currentTrack!],
startIndex: 0,
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Queue cleared'),
backgroundColor: AppColors.surfaceElevated,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
duration: Duration(seconds: 2),
),
);
}
}
@@ -0,0 +1,537 @@
/// Playlist Details Page - Desktop layout
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../domain/entities/playlist.dart';
import '../../../../domain/entities/track.dart';
import '../../../../core/theme/colors.dart';
import '../../../providers/playlist_provider.dart';
import '../../../providers/music_provider.dart';
import '../../../providers/auth_provider.dart';
import '../../widgets/playlist/playlist_track_tile.dart';
import '../../widgets/common/cached_network_image_with_fallback.dart';
class PlaylistDesktopPage extends ConsumerStatefulWidget {
final String playlistId;
const PlaylistDesktopPage({
required this.playlistId,
super.key,
});
@override
ConsumerState<PlaylistDesktopPage> createState() => _PlaylistDesktopPageState();
}
class _PlaylistDesktopPageState extends ConsumerState<PlaylistDesktopPage> {
final TextEditingController _nameController = TextEditingController();
final TextEditingController _descriptionController = TextEditingController();
bool _isEditing = false;
@override
void dispose() {
_nameController.dispose();
_descriptionController.dispose();
super.dispose();
}
void _startEditing(Playlist playlist) {
_nameController.text = playlist.name;
_descriptionController.text = playlist.description ?? '';
setState(() => _isEditing = true);
}
Future<void> _saveEdit() async {
final notifier = ref.read(playlistProvider(widget.playlistId).notifier);
await notifier.updatePlaylist(
name: _nameController.text.trim(),
description: _descriptionController.text.trim().isEmpty
? null
: _descriptionController.text.trim(),
);
setState(() => _isEditing = false);
}
void _cancelEdit() {
setState(() => _isEditing = false);
}
Future<void> _showDeleteDialog(Playlist playlist) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
backgroundColor: AppColors.surfaceElevated,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: AppColors.rose.withOpacity(0.3)),
),
title: Text(
'Delete Playlist',
style: TextStyle(color: AppColors.rose, fontSize: 20),
),
content: Text(
'Are you sure you want to delete "${playlist.name}"? This action cannot be undone.',
style: TextStyle(color: AppColors.onBackground),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(
'Cancel',
style: TextStyle(color: AppColors.onSurfaceVariant),
),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rose,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text('Delete'),
),
],
),
);
if (confirmed == true && mounted) {
final notifier = ref.read(playlistProvider(widget.playlistId).notifier);
await notifier.deletePlaylist();
if (mounted) Navigator.pop(context);
}
}
@override
Widget build(BuildContext context) {
final playlistState = ref.watch(playlistProvider(widget.playlistId));
final authState = ref.watch(authProvider);
final playerNotifier = ref.read(playerProvider.notifier);
final playlist = playlistState.playlist;
final tracks = playlistState.tracks;
final isOwner = authState.user?.id == playlist?.userId;
if (playlistState.isLoading && playlist == null) {
return Scaffold(
backgroundColor: AppColors.primary,
body: Center(
child: CircularProgressIndicator(color: AppColors.cyan),
),
);
}
if (playlistState.error != null && playlist == null) {
return Scaffold(
backgroundColor: AppColors.primary,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, color: AppColors.rose, size: 64),
const SizedBox(height: 16),
Text(
playlistState.error!,
style: const TextStyle(color: AppColors.rose),
textAlign: TextAlign.center,
),
],
),
),
);
}
if (playlist == null) {
return Scaffold(
backgroundColor: AppColors.primary,
body: Center(
child: Text(
'Playlist not found',
style: TextStyle(color: AppColors.onBackground, fontSize: 20),
),
),
);
}
return Scaffold(
backgroundColor: AppColors.primary,
body: CustomScrollView(
slivers: [
// Header with gradient background
SliverAppBar(
expandedHeight: 300,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
background: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.violet.withOpacity(0.3),
AppColors.primary,
],
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Row(
children: [
// Cover image
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: 200,
height: 200,
child: CachedNetworkImageWithFallback(
imageUrl: playlist.imageUrl,
fallbackIcon: Icons.playlist_play,
progressColor: AppColors.cyan,
fit: BoxFit.cover,
),
),
),
const SizedBox(width: 32),
// Playlist info
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (_isEditing) ...[
// Edit mode
TextField(
controller: _nameController,
style: const TextStyle(
color: AppColors.onBackground,
fontSize: 32,
fontWeight: FontWeight.bold,
),
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: AppColors.cyan.withOpacity(0.5),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: AppColors.cyan,
width: 2,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
),
const SizedBox(height: 12),
TextField(
controller: _descriptionController,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 14,
),
maxLines: 3,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: AppColors.cyan.withOpacity(0.5),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: AppColors.cyan,
width: 2,
),
),
hintText: 'Add a description...',
hintStyle: TextStyle(
color: AppColors.onSurfaceVariant,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
),
const SizedBox(height: 16),
Row(
children: [
ElevatedButton(
onPressed: _saveEdit,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.cyan,
foregroundColor: AppColors.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
child: const Text('Save'),
),
const SizedBox(width: 12),
TextButton(
onPressed: _cancelEdit,
child: Text(
'Cancel',
style: TextStyle(
color: AppColors.onSurfaceVariant,
),
),
),
],
),
] else ...[
// View mode
Text(
playlist.name,
style: const TextStyle(
color: AppColors.onBackground,
fontSize: 32,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
if (playlist.description != null) ...[
Text(
playlist.description!,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 14,
),
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 12),
],
Row(
children: [
Icon(
playlist.isPublic
? Icons.public
: Icons.lock,
color: AppColors.onSurfaceVariant,
size: 16,
),
const SizedBox(width: 8),
Text(
'${playlist.trackCount} songs',
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 14,
),
),
const SizedBox(width: 16),
Text(
playlistState.formattedTotalDuration,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 14,
),
),
],
),
],
],
),
),
],
),
),
),
),
),
),
// Action buttons
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24),
child: Row(
children: [
// Play button
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: AppColors.primaryGradient,
boxShadow: AppColors.cyanGlow,
),
child: IconButton(
icon: const Icon(Icons.play_arrow, size: 32),
color: AppColors.primary,
onPressed: () {
ref
.read(playlistProvider(widget.playlistId).notifier)
.playPlaylist(playerNotifier);
},
),
),
const SizedBox(width: 16),
// Shuffle button
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.surfaceElevated,
border: Border.all(
color: AppColors.cyan.withOpacity(0.3),
),
),
child: IconButton(
icon: const Icon(Icons.shuffle),
color: AppColors.cyan,
onPressed: () {
ref
.read(playlistProvider(widget.playlistId).notifier)
.shufflePlaylist(playerNotifier);
},
),
),
const SizedBox(width: 16),
// Download button
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.surfaceElevated,
border: Border.all(
color: AppColors.cyan.withOpacity(0.3),
),
),
child: IconButton(
icon: const Icon(Icons.download),
color: AppColors.cyan,
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Download feature coming soon',
style: TextStyle(color: AppColors.primary),
),
backgroundColor: AppColors.cyan,
),
);
},
),
),
const Spacer(),
// Edit button (for owner)
if (isOwner && !_isEditing) ...[
ElevatedButton.icon(
onPressed: () => _startEditing(playlist),
icon: const Icon(Icons.edit),
label: const Text('Edit'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.surfaceElevated,
foregroundColor: AppColors.cyan,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
side: BorderSide(
color: AppColors.cyan.withOpacity(0.3),
),
),
),
),
const SizedBox(width: 12),
IconButton(
icon: Icon(Icons.delete, color: AppColors.rose),
onPressed: () => _showDeleteDialog(playlist),
tooltip: 'Delete playlist',
),
],
],
),
),
),
// Tracks list
if (tracks.isEmpty)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.music_note,
color: AppColors.onSurfaceVariant,
size: 64,
),
const SizedBox(height: 16),
Text(
'No tracks in this playlist',
style: TextStyle(
color: AppColors.onSurfaceVariant,
fontSize: 18,
),
),
],
),
),
)
else
SliverPadding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final track = tracks[index];
return PlaylistTrackTile(
track: track,
position: index,
isOwner: isOwner,
onTap: () {
// Play this track
playerNotifier.setQueue(tracks, startIndex: index);
},
onRemove: isOwner
? () {
ref
.read(playlistProvider(widget.playlistId).notifier)
.removeTrack(track.id);
}
: null,
onAddToQueue: () {
playerNotifier.addToQueue(track);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Added to queue',
style: TextStyle(color: AppColors.primary),
),
backgroundColor: AppColors.vert,
),
);
},
);
},
childCount: tracks.length,
),
),
),
// Loading indicator for reordering
if (playlistState.isReordering)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: CircularProgressIndicator(color: AppColors.cyan),
),
),
),
],
),
);
}
}
@@ -0,0 +1,28 @@
/// Playlist Details Page - Adaptive layout
library;
import 'package:flutter/material.dart';
import 'playlist_desktop_page.dart';
import 'playlist_mobile_page.dart';
class PlaylistDetailsPage extends StatelessWidget {
final String playlistId;
const PlaylistDetailsPage({
required this.playlistId,
super.key,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth >= 800) {
return PlaylistDesktopPage(playlistId: playlistId);
} else {
return PlaylistMobilePage(playlistId: playlistId);
}
},
);
}
}
@@ -0,0 +1,565 @@
/// Playlist Details Page - Mobile layout
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../domain/entities/playlist.dart';
import '../../../../core/theme/colors.dart';
import '../../../providers/playlist_provider.dart';
import '../../../providers/music_provider.dart';
import '../../../providers/auth_provider.dart';
import '../../widgets/playlist/playlist_track_tile.dart';
import '../../widgets/common/cached_network_image_with_fallback.dart';
class PlaylistMobilePage extends ConsumerStatefulWidget {
final String playlistId;
const PlaylistMobilePage({
required this.playlistId,
super.key,
});
@override
ConsumerState<PlaylistMobilePage> createState() => _PlaylistMobilePageState();
}
class _PlaylistMobilePageState extends ConsumerState<PlaylistMobilePage> {
final TextEditingController _nameController = TextEditingController();
final TextEditingController _descriptionController = TextEditingController();
bool _isEditing = false;
@override
void dispose() {
_nameController.dispose();
_descriptionController.dispose();
super.dispose();
}
void _startEditing(Playlist playlist) {
_nameController.text = playlist.name;
_descriptionController.text = playlist.description ?? '';
setState(() => _isEditing = true);
}
Future<void> _saveEdit() async {
final notifier = ref.read(playlistProvider(widget.playlistId).notifier);
await notifier.updatePlaylist(
name: _nameController.text.trim(),
description: _descriptionController.text.trim().isEmpty
? null
: _descriptionController.text.trim(),
);
setState(() => _isEditing = false);
}
void _cancelEdit() {
setState(() => _isEditing = false);
}
Future<void> _showDeleteDialog(Playlist playlist) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
backgroundColor: AppColors.surfaceElevated,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: AppColors.rose.withOpacity(0.3)),
),
title: Text(
'Delete Playlist',
style: TextStyle(color: AppColors.rose, fontSize: 20),
),
content: Text(
'Are you sure you want to delete "${playlist.name}"?',
style: TextStyle(color: AppColors.onBackground),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: Text(
'Cancel',
style: TextStyle(color: AppColors.onSurfaceVariant),
),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rose,
foregroundColor: Colors.white,
),
child: const Text('Delete'),
),
],
),
);
if (confirmed == true && mounted) {
final notifier = ref.read(playlistProvider(widget.playlistId).notifier);
await notifier.deletePlaylist();
if (mounted) Navigator.pop(context);
}
}
@override
Widget build(BuildContext context) {
final playlistState = ref.watch(playlistProvider(widget.playlistId));
final authState = ref.watch(authProvider);
final playerNotifier = ref.read(playerProvider.notifier);
final playlist = playlistState.playlist;
final tracks = playlistState.tracks;
final isOwner = authState.user?.id == playlist?.userId;
if (playlistState.isLoading && playlist == null) {
return Scaffold(
backgroundColor: AppColors.primary,
body: Center(
child: CircularProgressIndicator(color: AppColors.cyan),
),
);
}
if (playlistState.error != null && playlist == null) {
return Scaffold(
backgroundColor: AppColors.primary,
appBar: AppBar(
backgroundColor: AppColors.primary,
elevation: 0,
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, color: AppColors.rose, size: 64),
const SizedBox(height: 16),
Text(
playlistState.error!,
style: const TextStyle(color: AppColors.rose),
textAlign: TextAlign.center,
),
],
),
),
);
}
if (playlist == null) {
return Scaffold(
backgroundColor: AppColors.primary,
appBar: AppBar(
backgroundColor: AppColors.primary,
elevation: 0,
),
body: Center(
child: Text(
'Playlist not found',
style: TextStyle(color: AppColors.onBackground, fontSize: 18),
),
),
);
}
return Scaffold(
backgroundColor: AppColors.primary,
body: CustomScrollView(
slivers: [
// App bar
SliverAppBar(
backgroundColor: AppColors.primary,
expandedHeight: 320,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
background: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.violet.withOpacity(0.3),
AppColors.primary,
],
),
),
child: Column(
children: [
const SizedBox(height: kToolbarHeight * 2),
// Cover image
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: SizedBox(
width: 200,
height: 200,
child: CachedNetworkImageWithFallback(
imageUrl: playlist.imageUrl,
fallbackIcon: Icons.playlist_play,
progressColor: AppColors.cyan,
fit: BoxFit.cover,
),
),
),
],
),
),
),
),
// Playlist info and actions
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Title and edit
if (_isEditing) ...[
TextField(
controller: _nameController,
style: const TextStyle(
color: AppColors.onBackground,
fontSize: 24,
fontWeight: FontWeight.bold,
),
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: AppColors.cyan.withOpacity(0.5),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: AppColors.cyan,
width: 2,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
),
const SizedBox(height: 12),
TextField(
controller: _descriptionController,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 14,
),
maxLines: 3,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: AppColors.cyan.withOpacity(0.5),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: AppColors.cyan,
width: 2,
),
),
hintText: 'Add a description...',
hintStyle: TextStyle(
color: AppColors.onSurfaceVariant,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
),
const SizedBox(height: 16),
Row(
children: [
ElevatedButton(
onPressed: _saveEdit,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.cyan,
foregroundColor: AppColors.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
),
child: const Text('Save'),
),
const SizedBox(width: 12),
TextButton(
onPressed: _cancelEdit,
child: Text(
'Cancel',
style: TextStyle(
color: AppColors.onSurfaceVariant,
),
),
),
],
),
] else ...[
Text(
playlist.name,
style: const TextStyle(
color: AppColors.onBackground,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
if (playlist.description != null) ...[
Text(
playlist.description!,
style: const TextStyle(
color: AppColors.onSurface,
fontSize: 14,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
],
Row(
children: [
Icon(
playlist.isPublic ? Icons.public : Icons.lock,
color: AppColors.onSurfaceVariant,
size: 14,
),
const SizedBox(width: 4),
Text(
'${playlist.trackCount} songs • ${playlistState.formattedTotalDuration}',
style: const TextStyle(
color: AppColors.onSurfaceVariant,
fontSize: 13,
),
),
],
),
],
const SizedBox(height: 16),
// Action buttons
Row(
children: [
// Play button
Expanded(
child: Container(
decoration: BoxDecoration(
gradient: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(24),
boxShadow: AppColors.cyanGlow,
),
child: ElevatedButton.icon(
onPressed: () {
ref
.read(
playlistProvider(widget.playlistId).notifier)
.playPlaylist(playerNotifier);
},
icon: const Icon(Icons.play_arrow),
label: const Text('Play'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.transparent,
foregroundColor: AppColors.primary,
shadowColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
),
),
),
const SizedBox(width: 12),
// Shuffle button
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.surfaceElevated,
border: Border.all(
color: AppColors.cyan.withOpacity(0.3),
),
),
child: IconButton(
icon: const Icon(Icons.shuffle),
color: AppColors.cyan,
onPressed: () {
ref
.read(
playlistProvider(widget.playlistId).notifier)
.shufflePlaylist(playerNotifier);
},
),
),
// Download button
const SizedBox(width: 8),
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.surfaceElevated,
border: Border.all(
color: AppColors.cyan.withOpacity(0.3),
),
),
child: IconButton(
icon: const Icon(Icons.download),
color: AppColors.cyan,
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Download feature coming soon',
style: TextStyle(color: AppColors.primary),
),
backgroundColor: AppColors.cyan,
),
);
},
),
),
const Spacer(),
// Edit button (for owner)
if (isOwner && !_isEditing) ...[
IconButton(
icon: Icon(Icons.edit, color: AppColors.cyan),
onPressed: () => _startEditing(playlist),
),
IconButton(
icon: Icon(Icons.delete, color: AppColors.rose),
onPressed: () => _showDeleteDialog(playlist),
),
],
],
),
const SizedBox(height: 16),
// Divider
Container(
height: 1,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.transparent,
AppColors.cyan.withOpacity(0.3),
Colors.transparent,
],
),
),
),
],
),
),
),
// Tracks list
if (tracks.isEmpty)
SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.music_note,
color: AppColors.onSurfaceVariant,
size: 64,
),
const SizedBox(height: 16),
Text(
'No tracks in this playlist',
style: TextStyle(
color: AppColors.onSurfaceVariant,
fontSize: 16,
),
),
],
),
),
)
else
SliverReorderableList(
delegate: ReorderableChildBuilderDelegate(
childCount: tracks.length,
(context, index) {
final track = tracks[index];
return Dismissible(
key: ValueKey(track.id),
direction: isOwner
? DismissDirection.endToStart
: DismissDirection.none,
onDismissed: (_) {
if (isOwner) {
ref
.read(playlistProvider(widget.playlistId).notifier)
.removeTrack(track.id);
}
},
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
decoration: BoxDecoration(
color: AppColors.rose.withOpacity(0.3),
borderRadius: BorderRadius.circular(8),
),
child: Icon(Icons.delete, color: AppColors.rose),
),
child: PlaylistTrackTile(
track: track,
position: index,
isOwner: isOwner,
onTap: () {
playerNotifier.setQueue(tracks, startIndex: index);
},
onRemove: isOwner
? () {
ref
.read(playlistProvider(widget.playlistId).notifier)
.removeTrack(track.id);
}
: null,
onAddToQueue: () {
playerNotifier.addToQueue(track);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Added to queue',
style: TextStyle(color: AppColors.primary),
),
backgroundColor: AppColors.vert,
),
);
},
),
);
},
),
onReorder: isOwner
? (oldIndex, newIndex) {
ref
.read(playlistProvider(widget.playlistId).notifier)
.reorderTracks(oldIndex, newIndex);
}
: (_, __) {},
),
// Loading indicator for reordering
if (playlistState.isReordering)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: CircularProgressIndicator(color: AppColors.cyan),
),
),
),
],
),
);
}
}
@@ -0,0 +1,288 @@
/// Search Page - Desktop Layout
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/colors.dart';
import '../../providers/search_provider.dart';
import '../../providers/music_provider.dart';
import '../../../domain/entities/track.dart';
import '../../../domain/entities/artist.dart';
import '../../../domain/entities/album.dart';
import '../../widgets/search/search_track_card.dart';
import '../../widgets/search/search_artist_card.dart';
import '../../widgets/search/search_album_card.dart';
class SearchDesktopPage extends ConsumerStatefulWidget {
const SearchDesktopPage({super.key});
@override
ConsumerState<SearchDesktopPage> createState() => _SearchDesktopPageState();
}
class _SearchDesktopPageState extends ConsumerState<SearchDesktopPage> {
final _controller = TextEditingController();
final _focusNode = FocusNode();
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
// Search bar
Padding(
padding: const EdgeInsets.all(24),
child: _buildSearchBar(),
),
// Results
Expanded(
child: _buildResults(),
),
],
);
}
Widget _buildSearchBar() {
return Container(
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: AppColors.cyan.withOpacity(0.2),
width: 2,
),
),
child: TextField(
controller: _controller,
focusNode: _focusNode,
style: const TextStyle(color: AppColors.onSurface, fontSize: 16),
decoration: InputDecoration(
hintText: 'What do you want to listen to?',
hintStyle: const TextStyle(color: AppColors.muted),
prefixIcon: const Icon(Icons.search, color: AppColors.cyan),
suffixIcon: _controller.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, color: AppColors.muted),
onPressed: () {
_controller.clear();
ref.read(searchProvider.notifier).clear();
},
)
: null,
border: InputBorder.none,
contentPadding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
),
onChanged: (value) {
ref.read(searchProvider.notifier).search(value);
setState(() {});
},
),
);
}
Widget _buildResults() {
final searchState = ref.watch(searchProvider);
if (searchState.query.isEmpty) {
return _buildEmptyState();
}
if (searchState.isSearching) {
return const Center(
child: CircularProgressIndicator(color: AppColors.cyan),
);
}
if (searchState.error != null) {
return _buildErrorState(searchState.error ?? 'Unknown error');
}
if (searchState.totalResults == 0) {
return _buildNoResultsState();
}
return CustomScrollView(
slivers: [
// Tracks section
if (searchState.tracks.isNotEmpty)
SliverToBoxAdapter(
child: _buildSection('Tracks', searchState.tracks),
),
// Artists section
if (searchState.artists.isNotEmpty)
SliverToBoxAdapter(
child: _buildSection('Artists', searchState.artists),
),
// Albums section
if (searchState.albums.isNotEmpty)
SliverToBoxAdapter(
child: _buildSection('Albums', searchState.albums),
),
],
);
}
Widget _buildSection(String title, List<dynamic> items) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 12),
child: Text(
title,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: AppColors.cyan,
),
),
),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 24),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
childAspectRatio: 1,
),
itemCount: items.length,
itemBuilder: (context, index) {
return _buildResultCard(items[index]);
},
),
],
);
}
Widget _buildResultCard(dynamic item) {
final playerNotifier = ref.read(playerProvider.notifier);
if (item is Track) {
// It's a track - play on tap
return SearchTrackCard(
track: item,
onTap: () => _playTrack(item, playerNotifier),
);
} else if (item is Artist) {
// It's an artist - show details (TODO: navigate)
return SearchArtistCard(
artist: item,
onTap: () => _showArtistDetails(item),
);
} else if (item is Album) {
// It's an album - show details (TODO: navigate)
return SearchAlbumCard(
album: item,
onTap: () => _showAlbumDetails(item),
);
}
return const SizedBox.shrink();
}
void _playTrack(Track track, PlayerNotifier playerNotifier) {
// Set as queue and play
playerNotifier.setQueue([track], startIndex: 0);
playerNotifier.loadTrack(track);
playerNotifier.play();
}
void _showArtistDetails(Artist artist) {
// TODO: Navigate to artist details page
print('Show artist: ${artist.name}');
}
void _showAlbumDetails(Album album) {
// TODO: Navigate to album details page
print('Show album: ${album.title}');
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search,
size: 64,
color: AppColors.muted,
),
const SizedBox(height: 16),
const Text(
'Search for your favorite music',
style: TextStyle(
fontSize: 18,
color: AppColors.muted,
),
),
],
),
);
}
Widget _buildErrorState(String error) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: AppColors.error,
),
const SizedBox(height: 16),
const Text(
'Something went wrong',
style: TextStyle(
fontSize: 18,
color: AppColors.error,
),
),
const SizedBox(height: 8),
Text(
error,
style: const TextStyle(
fontSize: 14,
color: AppColors.muted,
),
),
],
),
);
}
Widget _buildNoResultsState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.music_off,
size: 64,
color: AppColors.muted,
),
const SizedBox(height: 16),
const Text(
'No results found',
style: TextStyle(
fontSize: 18,
color: AppColors.muted,
),
),
],
),
);
}
}
@@ -0,0 +1,279 @@
/// Search Page - Mobile Layout
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/theme/colors.dart';
import '../../providers/search_provider.dart';
import '../../providers/music_provider.dart';
import '../../../domain/entities/track.dart';
import '../../../domain/entities/artist.dart';
import '../../../domain/entities/album.dart';
import '../../widgets/search/search_track_card.dart';
import '../../widgets/search/search_artist_card.dart';
import '../../widgets/search/search_album_card.dart';
class SearchMobilePage extends ConsumerStatefulWidget {
const SearchMobilePage({super.key});
@override
ConsumerState<SearchMobilePage> createState() => _SearchMobilePageState();
}
class _SearchMobilePageState extends ConsumerState<SearchMobilePage> {
final _controller = TextEditingController();
final _focusNode = FocusNode();
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
// Search bar
Padding(
padding: const EdgeInsets.all(16),
child: _buildSearchBar(),
),
// Results (same as desktop but different layout)
Expanded(
child: _buildResults(),
),
],
);
}
Widget _buildSearchBar() {
// Similar to desktop but smaller
return Container(
decoration: BoxDecoration(
color: AppColors.surfaceVariant,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: AppColors.cyan.withOpacity(0.2),
width: 2,
),
),
child: TextField(
controller: _controller,
focusNode: _focusNode,
onChanged: (value) {
ref.read(searchProvider.notifier).search(value);
setState(() {});
},
decoration: const InputDecoration(
hintText: 'Search...',
hintStyle: TextStyle(color: AppColors.muted),
prefixIcon: Icon(Icons.search, color: AppColors.cyan),
border: InputBorder.none,
contentPadding: EdgeInsets.symmetric(horizontal: 20, vertical: 14),
),
),
);
}
Widget _buildResults() {
// Reuse desktop logic but with 2-column grid
final searchState = ref.watch(searchProvider);
if (searchState.query.isEmpty) {
return _buildEmptyState();
}
if (searchState.isSearching) {
return const Center(
child: CircularProgressIndicator(color: AppColors.cyan),
);
}
if (searchState.error != null) {
return _buildErrorState(searchState.error ?? 'Unknown error');
}
if (searchState.totalResults == 0) {
return _buildNoResultsState();
}
return CustomScrollView(
slivers: [
// Tracks section
if (searchState.tracks.isNotEmpty)
SliverToBoxAdapter(
child: _buildSection('Tracks', searchState.tracks),
),
// Artists section
if (searchState.artists.isNotEmpty)
SliverToBoxAdapter(
child: _buildSection('Artists', searchState.artists),
),
// Albums section
if (searchState.albums.isNotEmpty)
SliverToBoxAdapter(
child: _buildSection('Albums', searchState.albums),
),
],
);
}
Widget _buildSection(String title, List<dynamic> items) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.cyan,
),
),
),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1,
),
itemCount: items.length,
itemBuilder: (context, index) {
return _buildResultCard(items[index]);
},
),
],
);
}
Widget _buildResultCard(dynamic item) {
final playerNotifier = ref.read(playerProvider.notifier);
if (item is Track) {
// It's a track - play on tap
return SearchTrackCard(
track: item,
onTap: () => _playTrack(item, playerNotifier),
);
} else if (item is Artist) {
// It's an artist - show details (TODO: navigate)
return SearchArtistCard(
artist: item,
onTap: () => _showArtistDetails(item),
);
} else if (item is Album) {
// It's an album - show details (TODO: navigate)
return SearchAlbumCard(
album: item,
onTap: () => _showAlbumDetails(item),
);
}
return const SizedBox.shrink();
}
void _playTrack(Track track, PlayerNotifier playerNotifier) {
// Set as queue and play
playerNotifier.setQueue([track], startIndex: 0);
playerNotifier.loadTrack(track);
playerNotifier.play();
}
void _showArtistDetails(Artist artist) {
// TODO: Navigate to artist details page
print('Show artist: ${artist.name}');
}
void _showAlbumDetails(Album album) {
// TODO: Navigate to album details page
print('Show album: ${album.title}');
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.search,
size: 48,
color: AppColors.muted,
),
const SizedBox(height: 12),
const Text(
'Search for your favorite music',
style: TextStyle(
fontSize: 16,
color: AppColors.muted,
),
),
],
),
);
}
Widget _buildErrorState(String error) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 48,
color: AppColors.error,
),
const SizedBox(height: 12),
const Text(
'Something went wrong',
style: TextStyle(
fontSize: 16,
color: AppColors.error,
),
),
const SizedBox(height: 8),
Text(
error,
style: const TextStyle(
fontSize: 12,
color: AppColors.muted,
),
),
],
),
);
}
Widget _buildNoResultsState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.music_off,
size: 48,
color: AppColors.muted,
),
const SizedBox(height: 12),
const Text(
'No results found',
style: TextStyle(
fontSize: 16,
color: AppColors.muted,
),
),
],
),
);
}
}
@@ -0,0 +1,23 @@
/// Search Page - Adaptive layout
library;
import 'package:flutter/material.dart';
import 'search_desktop_page.dart';
import 'search_mobile_page.dart';
class SearchPage extends StatelessWidget {
const SearchPage({super.key});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth >= 800) {
return const SearchDesktopPage();
} else {
return const SearchMobilePage();
}
},
);
}
}
@@ -0,0 +1,261 @@
# Settings Page - Visual Preview & Features
## Visual Design
### Overall Theme
- **Background**: Deep dark blue (#0A0E27) with neon cyan accents
- **Cards**: Semi-transparent surfaces with cyan glow borders
- **Typography**: Outfit font family with neon color highlights
- **Effects**: Subtle gradients, glow shadows, smooth transitions
## Section Breakdown
### 1. Profile Section (Top)
```
┌─────────────────────────────────────────────────┐
│ ┌────┐ John Doe [PREMIUM] │
│ │ 👤 │ john.doe@email.com │
│ └────┘ @johndoe │
│ │
│ [ Edit Profile ] │
└─────────────────────────────────────────────────┘
```
Features:
- Circular avatar with gradient glow
- Premium badge with violet/rose gradient
- Display name, email, username
- Edit Profile button (cyan outlined)
### 2. Audio Quality Section
```
┌─────────────────────────────────────────────────┐
│ AUDIO │
├─────────────────────────────────────────────────┤
│ 🎵 Audio Quality │
│ Higher quality uses more data │
│ ──────────────────────────────────────────── │
│ Low [96 kbps] Best for... │
│ Medium [160 kbps] Good... │
│ High [320 kbps] Best... ✓ │
│ Lossless [FLAC] Requires... [🔒]│
└─────────────────────────────────────────────────┘
```
Features:
- Radio-style selection
- Bitrate badges
- Quality descriptions
- Premium lock on Lossless
- Selection indicator (cyan checkmark)
### 3. Playback Section
```
┌─────────────────────────────────────────────────┐
│ PLAYBACK │
├─────────────────────────────────────────────────┤
│ 🎚️ Crossfade [○] │
│ Smooth transition between tracks │
│ │
│ Crossfade Duration: 5s │
│ ━━━━━━━━●━━━━━━━━━━━━━━━━━━━━━ │
│ │
│ ──────────────────────────────────────────── │
│ ♾️ Gapless Playback [●] │
│ No gap between tracks │
│ │
│ ──────────────────────────────────────────── │
│ 🔊 Normalize Volume [○] │
│ Set same volume for all tracks │
└─────────────────────────────────────────────────┘
```
Features:
- Toggle switches with cyan active color
- Crossfade duration slider (1-12 seconds)
- Descriptive subtitles
- Icon indicators
### 4. Downloads Section
```
┌─────────────────────────────────────────────────┐
│ DOWNLOADS │
├─────────────────────────────────────────────────┤
│ 📥 Download on Mobile Data [○] │
│ May use extra data │
│ │
│ ──────────────────────────────────────────── │
│ 🔞 Show Explicit Content [●] │
│ Display explicit content in search │
└─────────────────────────────────────────────────┘
```
### 5. Storage Section
```
┌─────────────────────────────────────────────────┐
│ 💾 Storage │
│ Cache and offline data │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ Cache Size 📁 │ │
│ │ 245.3 MB │ │
│ └─────────────────────────────────────────┘ │
│ │
│ [ Clear Cache ] │
└─────────────────────────────────────────────────┘
```
Features:
- Large cache size display (cyan)
- Folder icon
- Clear cache button (rose outlined)
- Confirmation dialog
### 6. About Section
```
┌─────────────────────────────────────────────────┐
│ ABOUT │
├─────────────────────────────────────────────────┤
️ App Version │
│ 1.0.0+1 │
│ │
│ ──────────────────────────────────────────── │
│ 📄 Licenses │
│ Open source licenses │
└─────────────────────────────────────────────────┘
```
### 7. Logout Button
```
┌─────────────────────────────────────────────────┐
│ [ 🚪 Log Out ] │
└─────────────────────────────────────────────────┘
```
Features:
- Rose outlined button
- Confirmation dialog
- Logout icon
## Color Palette
### Primary Colors
- **Cyan**: #00F0FF (primary accent)
- **Violet**: #BF00FF (secondary accent)
- **Rose**: #FF006E (error/danger)
- **Green**: #39FF14 (success)
### Backgrounds
- **Primary**: #0A0E27 (main background)
- **Surface**: #1A1F3A (cards)
- **Surface Variant**: #252B4A (elevated)
### Text Colors
- **On Background**: #E0E6FF (primary text)
- **On Surface**: #B0B8D4 (secondary text)
- **Muted**: #6A7294 (disabled/hints)
## Interactive Elements
### Toggle Switches
- Active: Cyan with glow
- Inactive: Grey
- Smooth animations
### Buttons
- **Elevated**: Cyan gradient with glow shadow
- **Outlined**: Cyan/rose border with transparent bg
- **Text**: Cyan with underline effect
### Cards
- 1px cyan border (15% opacity)
- Subtle glow shadow
- 16px border radius
- Smooth hover effects
## Animations
### Page Transitions
- Smooth slide-in from right
- Fade-in for content
- Staggered section animations
### Micro-interactions
- Ripple effects on taps
- Scale animations on buttons
- Color transitions on toggles
- Slide-up dialogs
## Dialogs
### Edit Profile Dialog
- Centered, rounded corners
- Avatar with camera overlay
- Text input with cyan border
- Save/Cancel buttons
### Clear Cache Dialog
- Warning icon (rose)
- Confirmation text
- Cancel/Clear buttons
### Logout Dialog
- Logout icon (rose)
- Confirmation message
- Cancel/Logout buttons
## Snackbar Notifications
### Success
- Green background
- White text
- Checkmark icon
- 3 second duration
### Error
- Red background
- White text
- Error icon
- Auto-dismiss
### Info
- Cyan background
- White text
- Info icon
- Extended duration for tips
## Responsive Design
### Mobile (< 600px)
- Full-width cards
- 16px horizontal padding
- Bottom navigation or drawer
- Compact spacing
### Tablet (600-900px)
- Centered content (max 600px)
- Larger touch targets
- Side navigation optional
### Desktop (> 900px)
- Centered column (max 800px)
- Larger fonts
- Side navigation
- More spacing
## Accessibility
- High contrast ratios (WCAG AA)
- Large touch targets (44px min)
- Clear visual hierarchy
- Screen reader labels
- Keyboard navigation support
- Focus indicators
## Performance
- Lazy loading for images
- Efficient state management
- Optimized rebuilds with Riverpod
- Smooth 60fps animations
- Minimal memory usage
@@ -0,0 +1,358 @@
/// Settings Page
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../../../core/theme/colors.dart';
import '../../../core/theme/text_styles.dart';
import '../../providers/settings_provider.dart';
import '../../providers/auth_provider.dart';
import '../../widgets/settings/profile_section.dart';
import '../../widgets/settings/audio_quality_selector.dart';
import '../../widgets/settings/cache_management_tile.dart';
import '../../widgets/settings/settings_tile.dart';
/// Settings page
class SettingsPage extends ConsumerStatefulWidget {
const SettingsPage({super.key});
@override
ConsumerState<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends ConsumerState<SettingsPage> {
String _appVersion = '1.0.0';
@override
void initState() {
super.initState();
_loadAppVersion();
// Load settings on init
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(settingsProvider.notifier).loadSettings();
});
}
Future<void> _loadAppVersion() async {
final info = await PackageInfo.fromPlatform();
setState(() {
_appVersion = '${info.version}+${info.buildNumber}';
});
}
@override
Widget build(BuildContext context) {
final settingsState = ref.watch(settingsProvider);
final authState = ref.watch(authProvider);
return Scaffold(
body: CustomScrollView(
slivers: [
// App Bar
SliverAppBar(
floating: true,
pinned: true,
elevation: 0,
backgroundColor: AppColors.primary.withOpacity(0.8),
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new_rounded),
onPressed: () => Navigator.pop(context),
color: AppColors.onBackground,
),
title: Text(
'Settings',
style: AppTextStyles.h2.copyWith(
color: AppColors.onBackground,
fontWeight: FontWeight.w700,
),
),
),
// Content
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 8),
// Profile Section
const ProfileSection(),
const SizedBox(height: 24),
// Audio Quality Section
const SettingsSectionHeader(title: 'Audio'),
const AudioQualitySelector(),
// Playback Section
const SettingsSectionHeader(title: 'Playback'),
SettingsCard(
children: [
SettingsToggleTile(
title: 'Crossfade',
subtitle: 'Smooth transition between tracks',
leading: const Icon(Icons.fade_out),
value: settingsState.crossfadeEnabled,
onChanged: (value) {
ref
.read(settingsProvider.notifier)
.toggleCrossfade(value);
},
),
if (settingsState.crossfadeEnabled)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Crossfade Duration: ${settingsState.crossfadeDuration.toInt()}s',
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.muted,
),
),
Slider(
value: settingsState.crossfadeDuration,
min: 1,
max: 12,
divisions: 11,
activeColor: AppColors.cyan,
onChanged: (value) {
ref
.read(settingsProvider.notifier)
.setCrossfadeDuration(value);
},
),
],
),
),
const Divider(height: 1, color: AppColors.surfaceVariant),
SettingsToggleTile(
title: 'Gapless Playback',
subtitle: 'No gap between tracks',
leading: const Icon(Icons.all_inclusive),
value: settingsState.gaplessPlayback,
onChanged: (value) {
ref
.read(settingsProvider.notifier)
.toggleGaplessPlayback(value);
},
),
const Divider(height: 1, color: AppColors.surfaceVariant),
SettingsToggleTile(
title: 'Normalize Volume',
subtitle: 'Set same volume level for all tracks',
leading: const Icon(Icons.volume_up),
value: settingsState.normalizeVolume,
onChanged: (value) {
ref
.read(settingsProvider.notifier)
.toggleNormalizeVolume(value);
},
),
],
),
// Downloads Section
const SettingsSectionHeader(title: 'Downloads'),
SettingsCard(
children: [
SettingsToggleTile(
title: 'Download on Mobile Data',
subtitle: 'May use extra data',
leading: const Icon(Icons.download_done),
value: settingsState.downloadOnMobileData,
onChanged: (value) {
ref
.read(settingsProvider.notifier)
.toggleDownloadOnMobileData(value);
},
),
const Divider(height: 1, color: AppColors.surfaceVariant),
SettingsToggleTile(
title: 'Show Explicit Content',
subtitle: 'Display explicit content in search',
leading: const Icon(Icons.explicit),
value: settingsState.showExplicitContent,
onChanged: (value) {
ref
.read(settingsProvider.notifier)
.toggleShowExplicitContent(value);
},
),
],
),
const SizedBox(height: 8),
// Cache Management
const CacheManagementTile(),
const SizedBox(height: 24),
// About Section
const SettingsSectionHeader(title: 'About'),
SettingsCard(
children: [
SettingsTile(
title: 'App Version',
subtitle: _appVersion,
leading: const Icon(Icons.info_outline),
),
const Divider(height: 1, color: AppColors.surfaceVariant),
SettingsTile(
title: 'Licenses',
subtitle: 'Open source licenses',
leading: const Icon(Icons.description_outlined),
onTap: () {
showLicensePage(
context: context,
applicationName: 'Spotify Le 2',
applicationVersion: _appVersion,
applicationLegalese: '© 2025 Spotify Le 2',
);
},
),
],
),
const SizedBox(height: 24),
// Logout Button
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () => _showLogoutDialog(context, ref),
icon: const Icon(Icons.logout, size: 18),
label: const Text('Log Out'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
side: BorderSide(
color: AppColors.rose.withOpacity(0.5),
width: 1.5,
),
foregroundColor: AppColors.rose,
),
),
),
),
const SizedBox(height: 32),
// Error message
if (settingsState.error != null)
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.error.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.error.withOpacity(0.3),
),
),
child: Row(
children: [
const Icon(
Icons.error_outline,
color: AppColors.error,
),
const SizedBox(width: 12),
Expanded(
child: Text(
settingsState.error!,
style: AppTextStyles.bodySmall.copyWith(
color: AppColors.error,
),
),
),
IconButton(
onPressed: () {
ref
.read(settingsProvider.notifier)
.copyWith(error: null);
},
icon: const Icon(Icons.close, size: 20),
color: AppColors.error,
),
],
),
),
const SizedBox(height: 24),
],
),
),
],
),
);
}
void _showLogoutDialog(BuildContext context, WidgetRef ref) {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: AppColors.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: AppColors.cyan.withOpacity(0.2),
width: 1,
),
),
title: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppColors.rose.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.logout,
color: AppColors.rose,
size: 20,
),
),
const SizedBox(width: 12),
Text(
'Log Out',
style: AppTextStyles.h3.copyWith(
color: AppColors.onBackground,
),
),
],
),
content: Text(
'Are you sure you want to log out?',
style: AppTextStyles.body.copyWith(
color: AppColors.onSurface,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(
'Cancel',
style: AppTextStyles.button.copyWith(
color: AppColors.muted,
),
),
),
ElevatedButton(
onPressed: () async {
Navigator.pop(context);
await ref.read(authProvider.notifier).logout();
if (context.mounted) {
Navigator.of(context).pushNamedAndRemoveUntil(
'/login',
(route) => false,
);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rose,
foregroundColor: Colors.white,
),
child: Text(
'Log Out',
style: AppTextStyles.button,
),
),
],
),
);
}
}
@@ -0,0 +1,144 @@
/// Example: How to integrate Settings Page into your app
import 'package:flutter/material.dart';
import 'package:spotify_le_2/presentation/pages/settings/settings_page.dart';
// Example 1: Navigate from home page
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Home'),
actions: [
IconButton(
icon: const Icon(Icons.settings),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SettingsPage(),
),
);
},
),
],
),
body: const Center(child: Text('Home Page')),
);
}
}
// Example 2: Using Go Router
/*
In your router configuration:
import 'package:go_router/go_router.dart';
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomePage(),
),
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsPage(),
),
],
);
// Then navigate:
context.push('/settings');
*/
// Example 3: Bottom navigation bar
class MainNavigation extends StatefulWidget {
const MainNavigation({super.key});
@override
State<MainNavigation> createState() => _MainNavigationState();
}
class _MainNavigationState extends State<MainNavigation> {
int _currentIndex = 0;
final List<Widget> _pages = [
const HomePage(),
const SearchPage(),
const LibraryPage(),
const SettingsPage(), // Settings as a main tab
];
@override
Widget build(BuildContext context) {
return Scaffold(
body: _pages[_currentIndex],
bottomNavigationBar: NavigationBar(
currentIndex: _currentIndex,
onDestinationSelected: (index) {
setState(() {
_currentIndex = index;
});
},
destinations: const [
NavigationDestination(
icon: Icon(Icons.home),
label: 'Home',
),
NavigationDestination(
icon: Icon(Icons.search),
label: 'Search',
),
NavigationDestination(
icon: Icon(Icons.library_music),
label: 'Library',
),
NavigationDestination(
icon: Icon(Icons.settings),
label: 'Settings',
),
],
),
);
}
}
// Example 4: From a profile button in player widget
class PlayerWidget extends StatelessWidget {
const PlayerWidget({super.key});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => _navigateToSettings(context),
child: const CircleAvatar(
child: Icon(Icons.person),
),
);
}
void _navigateToSettings(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SettingsPage(),
),
);
}
}
// Placeholder classes for example
class SearchPage extends StatelessWidget {
const SearchPage({super.key});
@override
Widget build(BuildContext context) => const Scaffold(body: Center(child: Text('Search')));
}
class LibraryPage extends StatelessWidget {
const LibraryPage({super.key});
@override
Widget build(BuildContext context) => const Scaffold(body: Center(child: Text('Library')));
}