🎉 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:
@@ -0,0 +1,48 @@
|
||||
/// API constants
|
||||
class ApiConstants {
|
||||
ApiConstants._();
|
||||
|
||||
// Base URLs
|
||||
static const String baseUrl = String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: 'http://localhost:8000/api/v1',
|
||||
);
|
||||
|
||||
static const String wsUrl = String.fromEnvironment(
|
||||
'WS_BASE_URL',
|
||||
defaultValue: 'ws://localhost:8000',
|
||||
);
|
||||
|
||||
// Timeout durations
|
||||
static const int connectionTimeoutMs = 30000; // 30 seconds
|
||||
static const int receiveTimeoutMs = 30000;
|
||||
static const int sendTimeoutMs = 30000;
|
||||
|
||||
// API Endpoints
|
||||
static const String auth = '/auth';
|
||||
static const String music = '/music';
|
||||
static const String playlists = '/playlists';
|
||||
static const String library = '/library';
|
||||
static const String search = '/search';
|
||||
|
||||
// Auth endpoints
|
||||
static const String login = '/auth/login';
|
||||
static const String register = '/auth/register';
|
||||
static const String refresh = '/auth/refresh';
|
||||
static const String logout = '/auth/logout';
|
||||
static const String me = '/auth/me';
|
||||
|
||||
// Music endpoints
|
||||
static const String tracks = '/music/tracks';
|
||||
static const String artists = '/music/artists';
|
||||
static const String albums = '/music/albums';
|
||||
static const String searchMusic = '/music/search';
|
||||
static const String stream = '/stream';
|
||||
static const String recommendations = '/music/tracks';
|
||||
static const String trending = '/music/trending';
|
||||
|
||||
// Playlist endpoints
|
||||
static const String userPlaylists = '/playlists';
|
||||
static const String playlistTracks = '/tracks';
|
||||
static const String reorder = '/tracks/reorder';
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'colors.dart';
|
||||
import 'text_styles.dart';
|
||||
|
||||
/// App Theme - Neon Cyberpunk
|
||||
class AppTheme {
|
||||
AppTheme._();
|
||||
|
||||
// Light theme (not used, keeping for completeness)
|
||||
static ThemeData get lightTheme => ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.light,
|
||||
colorScheme: _lightColorScheme,
|
||||
textTheme: _textTheme,
|
||||
fontFamily: AppTextStyles.fontFamily,
|
||||
);
|
||||
|
||||
// Dark theme (main theme)
|
||||
static ThemeData get darkTheme => ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
colorScheme: _darkColorScheme,
|
||||
textTheme: _textTheme,
|
||||
fontFamily: AppTextStyles.fontFamily,
|
||||
scaffoldBackgroundColor: AppColors.primary,
|
||||
appBarTheme: _appBarTheme,
|
||||
cardTheme: _cardTheme,
|
||||
elevatedButtonTheme: _elevatedButtonTheme,
|
||||
textButtonTheme: _textButtonTheme,
|
||||
outlinedButtonTheme: _outlinedButtonTheme,
|
||||
inputDecorationTheme: _inputDecorationTheme,
|
||||
floatingActionButtonTheme: _floatingActionButtonTheme,
|
||||
bottomNavigationBarTheme: _bottomNavigationBarTheme,
|
||||
navigationBarTheme: _navigationBarTheme,
|
||||
sliderTheme: _sliderTheme,
|
||||
progressIndicatorTheme: _progressIndicatorTheme,
|
||||
);
|
||||
|
||||
// Color Schemes
|
||||
static const ColorScheme _lightColorScheme = ColorScheme.light(
|
||||
primary: AppColors.cyan,
|
||||
secondary: AppColors.violet,
|
||||
tertiary: AppColors.rose,
|
||||
surface: AppColors.surface,
|
||||
error: AppColors.error,
|
||||
onPrimary: AppColors.primary,
|
||||
onSecondary: AppColors.primary,
|
||||
onSurface: AppColors.onSurface,
|
||||
onError: Colors.white,
|
||||
);
|
||||
|
||||
static const ColorScheme _darkColorScheme = ColorScheme.dark(
|
||||
primary: AppColors.cyan,
|
||||
secondary: AppColors.violet,
|
||||
tertiary: AppColors.rose,
|
||||
surface: AppColors.surface,
|
||||
error: AppColors.error,
|
||||
onPrimary: AppColors.primary,
|
||||
onSecondary: AppColors.primary,
|
||||
onSurface: AppColors.onSurface,
|
||||
onError: Colors.white,
|
||||
);
|
||||
|
||||
// Text Theme
|
||||
static const TextTheme _textTheme = TextTheme(
|
||||
displayLarge: AppTextStyles.h1,
|
||||
displayMedium: AppTextStyles.h2,
|
||||
displaySmall: AppTextStyles.h3,
|
||||
bodyLarge: AppTextStyles.bodyLarge,
|
||||
bodyMedium: AppTextStyles.body,
|
||||
bodySmall: AppTextStyles.bodySmall,
|
||||
labelLarge: AppTextStyles.button,
|
||||
labelMedium: AppTextStyles.label,
|
||||
labelSmall: AppTextStyles.caption,
|
||||
);
|
||||
|
||||
// AppBar Theme
|
||||
static const AppBarTheme _appBarTheme = AppBarTheme(
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
backgroundColor: Colors.transparent,
|
||||
foregroundColor: AppColors.onBackground,
|
||||
titleTextStyle: AppTextStyles.h2,
|
||||
iconTheme: IconThemeData(
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
);
|
||||
|
||||
// Card Theme
|
||||
static CardTheme _cardTheme = CardTheme(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(
|
||||
color: AppColors.cyan.withOpacity(0.15),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
color: AppColors.surface,
|
||||
margin: const EdgeInsets.all(8),
|
||||
);
|
||||
|
||||
// Elevated Button Theme
|
||||
static ElevatedButtonThemeData _elevatedButtonTheme =
|
||||
ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
backgroundColor: AppColors.cyan,
|
||||
foregroundColor: AppColors.primary,
|
||||
textStyle: AppTextStyles.button,
|
||||
shadowColor: AppColors.cyan.withOpacity(0.4),
|
||||
).copyWith(
|
||||
overlayColor: MaterialStateProperty.resolveWith((states) {
|
||||
if (states.contains(MaterialState.pressed)) {
|
||||
return AppColors.cyan.withOpacity(0.2);
|
||||
}
|
||||
if (states.contains(MaterialState.hovered)) {
|
||||
return AppColors.cyan.withOpacity(0.1);
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Text Button Theme
|
||||
static TextButtonThemeData _textButtonTheme = TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
foregroundColor: AppColors.cyan,
|
||||
textStyle: AppTextStyles.button,
|
||||
).copyWith(
|
||||
side: MaterialStateProperty.resolveWith((states) {
|
||||
if (states.contains(MaterialState.pressed)) {
|
||||
return BorderSide(color: AppColors.cyan, width: 2);
|
||||
}
|
||||
return BorderSide(
|
||||
color: AppColors.cyan.withOpacity(0.5),
|
||||
width: 1.5,
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// Outlined Button Theme
|
||||
static OutlinedButtonThemeData _outlinedButtonTheme =
|
||||
OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 14),
|
||||
side: const BorderSide(color: AppColors.cyan, width: 2),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
foregroundColor: AppColors.cyan,
|
||||
textStyle: AppTextStyles.button,
|
||||
),
|
||||
);
|
||||
|
||||
// Input Decoration Theme
|
||||
static InputDecorationTheme _inputDecorationTheme =
|
||||
InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: AppColors.surface,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide(
|
||||
color: AppColors.cyan.withOpacity(0.2),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide(
|
||||
color: AppColors.cyan.withOpacity(0.2),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: const BorderSide(color: AppColors.cyan, width: 2),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: const BorderSide(color: AppColors.error, width: 2),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: const BorderSide(color: AppColors.error, width: 2),
|
||||
),
|
||||
hintStyle: AppTextStyles.body.copyWith(color: AppColors.muted),
|
||||
);
|
||||
|
||||
// Floating Action Button Theme
|
||||
static FloatingActionButtonThemeData _floatingActionButtonTheme =
|
||||
FloatingActionButtonThemeData(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
backgroundColor: AppColors.cyan,
|
||||
foregroundColor: AppColors.primary,
|
||||
iconSize: 24,
|
||||
);
|
||||
|
||||
// Bottom Navigation Bar Theme
|
||||
static BottomNavigationBarThemeData _bottomNavigationBarTheme =
|
||||
BottomNavigationBarThemeData(
|
||||
elevation: 8,
|
||||
backgroundColor: AppColors.surface,
|
||||
selectedItemColor: AppColors.cyan,
|
||||
unselectedItemColor: AppColors.muted,
|
||||
selectedLabelStyle: AppTextStyles.caption,
|
||||
unselectedLabelStyle: AppTextStyles.caption,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
);
|
||||
|
||||
// Navigation Bar Theme (Material 3)
|
||||
static NavigationBarThemeData _navigationBarTheme =
|
||||
NavigationBarThemeData(
|
||||
elevation: 0,
|
||||
backgroundColor: AppColors.surface,
|
||||
indicatorColor: AppColors.cyan.withOpacity(0.15),
|
||||
labelTextStyle: MaterialStateProperty.all(AppTextStyles.caption),
|
||||
height: 56,
|
||||
);
|
||||
|
||||
// Slider Theme
|
||||
static SliderThemeData _sliderTheme = SliderThemeData(
|
||||
trackHeight: 3,
|
||||
thumbShape: const RoundSliderThumbShape(
|
||||
enabledThumbRadius: 6,
|
||||
),
|
||||
overlayShape: const RoundSliderOverlayShape(
|
||||
overlayRadius: 16,
|
||||
),
|
||||
activeTrackColor: AppColors.cyan,
|
||||
inactiveTrackColor: AppColors.surfaceVariant,
|
||||
thumbColor: AppColors.cyan,
|
||||
overlayColor: AppColors.cyan.withOpacity(0.2),
|
||||
);
|
||||
|
||||
// Progress Indicator Theme
|
||||
static ProgressIndicatorThemeData _progressIndicatorTheme =
|
||||
ProgressIndicatorThemeData(
|
||||
color: AppColors.cyan,
|
||||
linearTrackColor: AppColors.surfaceVariant,
|
||||
circularTrackColor: AppColors.surfaceVariant,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// App Colors - Neon Cyberpunk Theme
|
||||
class AppColors {
|
||||
AppColors._();
|
||||
|
||||
// Backgrounds
|
||||
static const Color primary = Color(0xFF0A0E27); // Bleu nuit très foncé
|
||||
static const Color surface = Color(0xFF1A1F3A); // Bleu nuit
|
||||
static const Color surfaceVariant = Color(0xFF252B4A);
|
||||
static const Color surfaceElevated = Color(0xFF2D344F);
|
||||
|
||||
// Neon accent colors
|
||||
static const Color cyan = Color(0xFF00F0FF); // Cyan électrique néon
|
||||
static const Color violet = Color(0xFFBF00FF); // Violet/magenta néon
|
||||
static const Color rose = Color(0xFFFF006E); // Rose néon vif
|
||||
static const Color vert = Color(0xFF39FF14); // Vert néon matrix
|
||||
static const Color jaune = Color(0xFFFFD600); // Jaune néon
|
||||
static const Color rouge = Color(0xFFFF2A6D); // Rouge néon
|
||||
|
||||
// Text colors
|
||||
static const Color onBackground = Color(0xFFE0E6FF); // Blanc bleuté
|
||||
static const Color onSurface = Color(0xFFB0B8D4); // Bleu gris clair
|
||||
static const Color onSurfaceVariant = Color(0xFF8A92B4);
|
||||
static const Color muted = Color(0xFF6A7294); // Bleu gris désaturé
|
||||
|
||||
// Functional colors
|
||||
static const Color success = vert;
|
||||
static const Color warning = jaune;
|
||||
static const Color error = rouge;
|
||||
static const Color info = cyan;
|
||||
|
||||
// Gradients
|
||||
static const LinearGradient primaryGradient = LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [cyan, violet],
|
||||
);
|
||||
|
||||
static const LinearGradient accentGradient = LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [violet, rose],
|
||||
);
|
||||
|
||||
static const LinearGradient fullGradient = LinearGradient(
|
||||
begin: Alignment(-1.0, -1.0),
|
||||
end: Alignment(1.0, 1.0),
|
||||
colors: [cyan, violet, rose],
|
||||
);
|
||||
|
||||
// Glow shadows
|
||||
static List<BoxShadow> get cyanGlow => [
|
||||
BoxShadow(
|
||||
color: cyan.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
];
|
||||
|
||||
static List<BoxShadow> get violetGlow => [
|
||||
BoxShadow(
|
||||
color: violet.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
];
|
||||
|
||||
static List<BoxShadow> get roseGlow => [
|
||||
BoxShadow(
|
||||
color: rose.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'colors.dart';
|
||||
|
||||
/// App Text Styles - Neon Cyberpunk Theme
|
||||
class AppTextStyles {
|
||||
AppTextStyles._();
|
||||
|
||||
// Font family
|
||||
static const String fontFamily = 'Outfit';
|
||||
|
||||
// Heading 1 - 32px Bold
|
||||
static const TextStyle h1 = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.onBackground,
|
||||
letterSpacing: -0.5,
|
||||
);
|
||||
|
||||
// Heading 2 - 24px SemiBold
|
||||
static const TextStyle h2 = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onBackground,
|
||||
letterSpacing: -0.25,
|
||||
);
|
||||
|
||||
// Heading 3 - 20px SemiBold
|
||||
static const TextStyle h3 = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onBackground,
|
||||
);
|
||||
|
||||
// Body Large - 16px Regular
|
||||
static const TextStyle bodyLarge = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.onBackground,
|
||||
height: 1.5,
|
||||
);
|
||||
|
||||
// Body - 14px Regular
|
||||
static const TextStyle body = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.onSurface,
|
||||
height: 1.5,
|
||||
);
|
||||
|
||||
// Body Small - 12px Regular
|
||||
static const TextStyle bodySmall = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.muted,
|
||||
height: 1.4,
|
||||
);
|
||||
|
||||
// Caption - 12px Regular
|
||||
static const TextStyle caption = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.muted,
|
||||
height: 1.3,
|
||||
);
|
||||
|
||||
// Button - 14px SemiBold
|
||||
static const TextStyle button = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primary,
|
||||
letterSpacing: 0.5,
|
||||
);
|
||||
|
||||
// Label - 14px Medium
|
||||
static const TextStyle label = TextStyle(
|
||||
fontFamily: fontFamily,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.onSurface,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import 'artist.dart';
|
||||
|
||||
/// Album entity
|
||||
class Album extends Equatable {
|
||||
final String id;
|
||||
final String title;
|
||||
final DateTime? releaseDate;
|
||||
final String? imageUrl;
|
||||
final int totalTracks;
|
||||
final String? genre;
|
||||
final String? artistId;
|
||||
final Artist? artist;
|
||||
final String? spotifyId;
|
||||
final String? youtubePlaylistId;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
const Album({
|
||||
required this.id,
|
||||
required this.title,
|
||||
this.releaseDate,
|
||||
this.imageUrl,
|
||||
this.totalTracks = 0,
|
||||
this.genre,
|
||||
this.artistId,
|
||||
this.artist,
|
||||
this.spotifyId,
|
||||
this.youtubePlaylistId,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
/// Create Album from JSON
|
||||
factory Album.fromJson(Map<String, dynamic> json) {
|
||||
return Album(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
releaseDate: json['release_date'] != null
|
||||
? DateTime.parse(json['release_date'] as String)
|
||||
: null,
|
||||
imageUrl: json['image_url'] as String?,
|
||||
totalTracks: json['total_tracks'] as int? ?? 0,
|
||||
genre: json['genre'] as String?,
|
||||
artistId: json['artist_id'] as String?,
|
||||
artist: json['artist'] != null
|
||||
? Artist.fromJson(json['artist'] as Map<String, dynamic>)
|
||||
: null,
|
||||
spotifyId: json['spotify_id'] as String?,
|
||||
youtubePlaylistId: json['youtube_playlist_id'] as String?,
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(json['created_at'] as String)
|
||||
: DateTime.now(),
|
||||
updatedAt: json['updated_at'] != null
|
||||
? DateTime.parse(json['updated_at'] as String)
|
||||
: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, title, releaseDate, totalTracks];
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Artist entity
|
||||
class Artist extends Equatable {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? imageUrl;
|
||||
final String? bio;
|
||||
final List<String> genres;
|
||||
final int popularity;
|
||||
final String? spotifyId;
|
||||
final String? youtubeId;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
const Artist({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.imageUrl,
|
||||
this.bio,
|
||||
this.genres = const [],
|
||||
this.popularity = 0,
|
||||
this.spotifyId,
|
||||
this.youtubeId,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
/// Create Artist from JSON
|
||||
factory Artist.fromJson(Map<String, dynamic> json) {
|
||||
return Artist(
|
||||
id: json['id'] as String,
|
||||
name: json['name'] as String,
|
||||
imageUrl: json['image_url'] as String?,
|
||||
bio: json['bio'] as String?,
|
||||
genres: (json['genres'] as List<dynamic>?)
|
||||
?.map((e) => e as String)
|
||||
.toList() ??
|
||||
[],
|
||||
popularity: json['popularity'] as int? ?? 0,
|
||||
spotifyId: json['spotify_id'] as String?,
|
||||
youtubeId: json['youtube_id'] as String?,
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(json['created_at'] as String)
|
||||
: DateTime.now(),
|
||||
updatedAt: json['updated_at'] != null
|
||||
? DateTime.parse(json['updated_at'] as String)
|
||||
: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, name, genres, popularity];
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Domain entities."""
|
||||
export 'user.dart';
|
||||
export 'track.dart';
|
||||
export 'playlist.dart';
|
||||
export 'artist.dart';
|
||||
export 'album.dart';
|
||||
@@ -0,0 +1,130 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import 'track.dart';
|
||||
|
||||
/// Playlist entity
|
||||
class Playlist extends Equatable {
|
||||
final String id;
|
||||
final String userId;
|
||||
final String name;
|
||||
final String? description;
|
||||
final String? imageUrl;
|
||||
final bool isPublic;
|
||||
final bool isCollaborative;
|
||||
final bool isSmart;
|
||||
final int trackCount;
|
||||
final int totalDuration;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final List<PlaylistTrack>? tracks;
|
||||
|
||||
const Playlist({
|
||||
required this.id,
|
||||
required this.userId,
|
||||
required this.name,
|
||||
this.description,
|
||||
this.imageUrl,
|
||||
this.isPublic = false,
|
||||
this.isCollaborative = false,
|
||||
this.isSmart = false,
|
||||
this.trackCount = 0,
|
||||
this.totalDuration = 0,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.tracks,
|
||||
});
|
||||
|
||||
/// Format total duration as Xh Ym or Ym Zs
|
||||
String get formattedDuration {
|
||||
final hours = totalDuration ~/ 3600;
|
||||
final minutes = (totalDuration % 3600) ~/ 60;
|
||||
final seconds = totalDuration % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return '${hours}h ${minutes}m';
|
||||
} else if (minutes > 0) {
|
||||
return '${minutes}m ${seconds}s';
|
||||
} else {
|
||||
return '${seconds}s';
|
||||
}
|
||||
}
|
||||
|
||||
/// Create Playlist from JSON
|
||||
factory Playlist.fromJson(Map<String, dynamic> json) {
|
||||
return Playlist(
|
||||
id: json['id'] as String,
|
||||
userId: json['user_id'] as String,
|
||||
name: json['name'] as String,
|
||||
description: json['description'] as String?,
|
||||
imageUrl: json['image_url'] as String?,
|
||||
isPublic: json['is_public'] as bool? ?? false,
|
||||
isCollaborative: json['is_collaborative'] as bool? ?? false,
|
||||
isSmart: json['is_smart'] as bool? ?? false,
|
||||
trackCount: json['track_count'] as int? ?? 0,
|
||||
totalDuration: json['total_duration'] as int? ?? 0,
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(json['created_at'] as String)
|
||||
: DateTime.now(),
|
||||
updatedAt: json['updated_at'] != null
|
||||
? DateTime.parse(json['updated_at'] as String)
|
||||
: DateTime.now(),
|
||||
tracks: json['tracks'] != null
|
||||
? (json['tracks'] as List)
|
||||
.map((item) => PlaylistTrack.fromJson(item as Map<String, dynamic>))
|
||||
.toList()
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
userId,
|
||||
name,
|
||||
isPublic,
|
||||
isCollaborative,
|
||||
trackCount,
|
||||
totalDuration,
|
||||
];
|
||||
}
|
||||
|
||||
/// Playlist track association
|
||||
class PlaylistTrack extends Equatable {
|
||||
final String id;
|
||||
final String playlistId;
|
||||
final String trackId;
|
||||
final int position;
|
||||
final DateTime addedAt;
|
||||
final String? addedBy;
|
||||
final Track? track;
|
||||
|
||||
const PlaylistTrack({
|
||||
required this.id,
|
||||
required this.playlistId,
|
||||
required this.trackId,
|
||||
required this.position,
|
||||
required this.addedAt,
|
||||
this.adddedBy,
|
||||
this.track,
|
||||
});
|
||||
|
||||
/// Create PlaylistTrack from JSON
|
||||
factory PlaylistTrack.fromJson(Map<String, dynamic> json) {
|
||||
return PlaylistTrack(
|
||||
id: json['id'] as String,
|
||||
playlistId: json['playlist_id'] as String,
|
||||
trackId: json['track_id'] as String,
|
||||
position: json['position'] as int,
|
||||
addedAt: json['added_at'] != null
|
||||
? DateTime.parse(json['added_at'] as String)
|
||||
: DateTime.now(),
|
||||
addedBy: json['added_by'] as String?,
|
||||
track: json['track'] != null
|
||||
? Track.fromJson(json['track'] as Map<String, dynamic>)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, playlistId, trackId, position, addedAt];
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
import 'artist.dart';
|
||||
import 'album.dart';
|
||||
|
||||
/// Track entity
|
||||
class Track extends Equatable {
|
||||
final String id;
|
||||
final String title;
|
||||
final int? duration;
|
||||
final int? trackNumber;
|
||||
final String? imageUrl;
|
||||
final String? artistId;
|
||||
final String? albumId;
|
||||
final Artist? artist;
|
||||
final Album? album;
|
||||
final String? audioUrl;
|
||||
final int? playCount;
|
||||
final String? youtubeId;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
const Track({
|
||||
required this.id,
|
||||
required this.title,
|
||||
this.duration,
|
||||
this.trackNumber,
|
||||
this.imageUrl,
|
||||
this.artistId,
|
||||
this.albumId,
|
||||
this.artist,
|
||||
this.album,
|
||||
this.audioUrl,
|
||||
this.playCount,
|
||||
this.youtubeId,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
Track copyWith({
|
||||
String? id,
|
||||
String? title,
|
||||
int? duration,
|
||||
int? trackNumber,
|
||||
String? imageUrl,
|
||||
String? artistId,
|
||||
String? albumId,
|
||||
Artist? artist,
|
||||
Album? album,
|
||||
String? audioUrl,
|
||||
int? playCount,
|
||||
String? youtubeId,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return Track(
|
||||
id: id ?? this.id,
|
||||
title: title ?? this.title,
|
||||
duration: duration ?? this.duration,
|
||||
trackNumber: trackNumber ?? this.trackNumber,
|
||||
imageUrl: imageUrl ?? this.imageUrl,
|
||||
artistId: artistId ?? this.artistId,
|
||||
albumId: albumId ?? this.albumId,
|
||||
artist: artist ?? this.artist,
|
||||
album: album ?? this.album,
|
||||
audioUrl: audioUrl ?? this.audioUrl,
|
||||
playCount: playCount ?? this.playCount,
|
||||
youtubeId: youtubeId ?? this.youtubeId,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
/// Format duration as mm:ss
|
||||
String get formattedDuration {
|
||||
if (duration == null) return '--:--';
|
||||
final minutes = duration! ~/ 60;
|
||||
final seconds = duration! % 60;
|
||||
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
/// Create Track from JSON
|
||||
factory Track.fromJson(Map<String, dynamic> json) {
|
||||
return Track(
|
||||
id: json['id'] as String,
|
||||
title: json['title'] as String,
|
||||
duration: json['duration'] as int?,
|
||||
trackNumber: json['track_number'] as int?,
|
||||
imageUrl: json['image_url'] as String?,
|
||||
artistId: json['artist_id'] as String?,
|
||||
albumId: json['album_id'] as String?,
|
||||
artist: json['artist'] != null
|
||||
? Artist.fromJson(json['artist'] as Map<String, dynamic>)
|
||||
: null,
|
||||
album: json['album'] != null
|
||||
? Album.fromJson(json['album'] as Map<String, dynamic>)
|
||||
: null,
|
||||
audioUrl: json['audio_url'] as String?,
|
||||
playCount: json['play_count'] as int?,
|
||||
youtubeId: json['youtube_id'] as String?,
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(json['created_at'] as String)
|
||||
: DateTime.now(),
|
||||
updatedAt: json['updated_at'] != null
|
||||
? DateTime.parse(json['updated_at'] as String)
|
||||
: DateTime.now(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
title,
|
||||
duration,
|
||||
artistId,
|
||||
albumId,
|
||||
youtubeId,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// User entity
|
||||
class User extends Equatable {
|
||||
final String id;
|
||||
final String email;
|
||||
final String username;
|
||||
final String? displayName;
|
||||
final String? avatarUrl;
|
||||
final bool isPremium;
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
|
||||
const User({
|
||||
required this.id,
|
||||
required this.email,
|
||||
required this.username,
|
||||
this.displayName,
|
||||
this.avatarUrl,
|
||||
this.isPremium = false,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
User copyWith({
|
||||
String? id,
|
||||
String? email,
|
||||
String? username,
|
||||
String? displayName,
|
||||
String? avatarUrl,
|
||||
bool? isPremium,
|
||||
DateTime? createdAt,
|
||||
DateTime? updatedAt,
|
||||
}) {
|
||||
return User(
|
||||
id: id ?? this.id,
|
||||
email: email ?? this.email,
|
||||
username: username ?? this.username,
|
||||
displayName: displayName ?? this.displayName,
|
||||
avatarUrl: avatarUrl ?? this.avatarUrl,
|
||||
isPremium: isPremium ?? this.isPremium,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
email,
|
||||
username,
|
||||
displayName,
|
||||
avatarUrl,
|
||||
isPremium,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/// API Client exports
|
||||
export 'api_service.dart';
|
||||
export 'auth_api_service.dart';
|
||||
export 'music_api_service.dart';
|
||||
export 'playlist_api_service.dart';
|
||||
@@ -0,0 +1,76 @@
|
||||
/// API Service - Main HTTP client using Dio
|
||||
library;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:pretty_dio_logger/pretty_dio_logger.dart';
|
||||
|
||||
import '../../../core/constants/api_constants.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
|
||||
/// API Service provider
|
||||
final apiServiceProvider = Provider<Dio>((ref) {
|
||||
final authState = ref.watch(authProvider);
|
||||
final token = authState?.accessToken;
|
||||
|
||||
final options = BaseOptions(
|
||||
baseUrl: ApiConstants.baseUrl,
|
||||
connectTimeout: const Duration(milliseconds: ApiConstants.connectionTimeoutMs),
|
||||
receiveTimeout: const Duration(milliseconds: ApiConstants.receiveTimeoutMs),
|
||||
sendTimeout: const Duration(milliseconds: ApiConstants.sendTimeoutMs),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
if (token != null) 'Authorization': 'Bearer $token',
|
||||
},
|
||||
);
|
||||
|
||||
final dio = Dio(options);
|
||||
|
||||
// Add logger in debug mode
|
||||
dio.interceptors.add(
|
||||
PrettyDioLogger(
|
||||
requestHeader: true,
|
||||
requestBody: true,
|
||||
responseBody: true,
|
||||
responseHeader: false,
|
||||
error: true,
|
||||
compact: true,
|
||||
),
|
||||
);
|
||||
|
||||
// Add token refresh interceptor
|
||||
dio.interceptors.add(
|
||||
InterceptorsWrapper(
|
||||
onError: (error, handler) async {
|
||||
if (error.response?.statusCode == 401) {
|
||||
// Try to refresh token
|
||||
try {
|
||||
final newToken = await ref.read(authProvider.notifier).refreshToken();
|
||||
if (newToken != null) {
|
||||
// Retry original request with new token
|
||||
final opts = options.copyWith(
|
||||
headers: {
|
||||
...options.headers,
|
||||
'Authorization': 'Bearer $newToken',
|
||||
},
|
||||
);
|
||||
final clonedReq = await dio.fetch(opts..path = error.requestOptions.path);
|
||||
return handler.resolve(clonedReq);
|
||||
}
|
||||
} catch (e) {
|
||||
// Refresh failed, logout user
|
||||
ref.read(authProvider.notifier).logout();
|
||||
}
|
||||
}
|
||||
return handler.next(error);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return dio;
|
||||
});
|
||||
|
||||
/// Get API client
|
||||
Dio getDio(Ref ref) {
|
||||
return ref.read(apiServiceProvider);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/// Auth API Service
|
||||
library;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/constants/api_constants.dart';
|
||||
import '../../../domain/entities/user.dart';
|
||||
import 'api_service.dart';
|
||||
|
||||
/// Auth API response models
|
||||
class LoginResponse {
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
final int expiresIn;
|
||||
final User user;
|
||||
|
||||
LoginResponse({
|
||||
required this.accessToken,
|
||||
required this.refreshToken,
|
||||
required this.expiresIn,
|
||||
required this.user,
|
||||
});
|
||||
|
||||
factory LoginResponse.fromJson(Map<String, dynamic> json) {
|
||||
return LoginResponse(
|
||||
accessToken: json['access_token'] as String,
|
||||
refreshToken: json['refresh_token'] as String,
|
||||
expiresIn: json['expires_in'] as int,
|
||||
user: User.fromJson(json['user'] as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension on User for JSON serialization
|
||||
extension UserJson on User {
|
||||
static User fromJson(Map<String, dynamic> json) {
|
||||
return User(
|
||||
id: json['id'] as String,
|
||||
email: json['email'] as String,
|
||||
username: json['username'] as String,
|
||||
displayName: json['display_name'] as String?,
|
||||
avatarUrl: json['avatar_url'] as String?,
|
||||
isPremium: json['is_premium'] as bool? ?? false,
|
||||
createdAt: DateTime.parse(json['created_at'] as String),
|
||||
updatedAt: DateTime.parse(json['updated_at'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'email': email,
|
||||
'username': username,
|
||||
if (displayName != null) 'display_name': displayName,
|
||||
if (avatarUrl != null) 'avatar_url': avatarUrl,
|
||||
'is_premium': isPremium,
|
||||
'created_at': createdAt.toIso8601String(),
|
||||
'updated_at': updatedAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Auth API Service
|
||||
class AuthApiService {
|
||||
AuthApiService(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
/// Login with email and password
|
||||
Future<LoginResponse> login(String email, String password) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
ApiConstants.login,
|
||||
data: {
|
||||
'email': email,
|
||||
'password': password,
|
||||
},
|
||||
);
|
||||
|
||||
return LoginResponse.fromJson(response.data);
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a new user
|
||||
Future<LoginResponse> register({
|
||||
required String email,
|
||||
required String username,
|
||||
required String password,
|
||||
String? displayName,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
ApiConstants.register,
|
||||
data: {
|
||||
'email': email,
|
||||
'username': username,
|
||||
'password': password,
|
||||
if (displayName != null) 'display_name': displayName,
|
||||
},
|
||||
);
|
||||
|
||||
return LoginResponse.fromJson(response.data);
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh access token
|
||||
Future<Map<String, dynamic>> refreshToken(String refreshToken) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
ApiConstants.refresh,
|
||||
data: {'refresh_token': refreshToken},
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current user profile
|
||||
Future<User> getCurrentUser() async {
|
||||
try {
|
||||
final response = await _dio.get(ApiConstants.me);
|
||||
return UserJson.fromJson(response.data);
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update user profile
|
||||
Future<User> updateProfile({
|
||||
String? displayName,
|
||||
String? avatarUrl,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.put(
|
||||
ApiConstants.me,
|
||||
data: {
|
||||
if (displayName != null) 'display_name': displayName,
|
||||
if (avatarUrl != null) 'avatar_url': avatarUrl,
|
||||
},
|
||||
);
|
||||
|
||||
return UserJson.fromJson(response.data);
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Logout
|
||||
Future<void> logout() async {
|
||||
try {
|
||||
await _dio.post(ApiConstants.logout);
|
||||
} on DioException catch (e) {
|
||||
throw _handleDioError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Exception _handleDioError(DioException error) {
|
||||
if (error.response != null) {
|
||||
final statusCode = error.response!.statusCode;
|
||||
final message = error.response!.data['detail'] as String? ??
|
||||
'An error occurred';
|
||||
|
||||
return Exception('$statusCode: $message');
|
||||
} else if (error.type == DioExceptionType.connectionTimeout) {
|
||||
return const Exception('Connection timeout');
|
||||
} else if (error.type == DioExceptionType.receiveTimeout) {
|
||||
return const Exception('Receive timeout');
|
||||
} else {
|
||||
return Exception('Network error: ${error.message}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Provider for Auth API Service
|
||||
final authApiServiceProvider = Provider<AuthApiService>((ref) {
|
||||
final dio = ref.watch(apiServiceProvider);
|
||||
return AuthApiService(dio);
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
/// Music API Service
|
||||
library;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/constants/api_constants.dart';
|
||||
import '../../../domain/entities/track.dart';
|
||||
import 'api_service.dart';
|
||||
|
||||
/// Music API Service
|
||||
class MusicApiService {
|
||||
MusicApiService(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
/// Search for music
|
||||
Future<Map<String, dynamic>> search(
|
||||
String query, {
|
||||
String type = 'all',
|
||||
int limit = 20,
|
||||
int offset = 0,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
ApiConstants.searchMusic,
|
||||
queryParameters: {
|
||||
'q': query,
|
||||
'type': type,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
},
|
||||
);
|
||||
|
||||
return response.data as Map<String, dynamic>;
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get track details
|
||||
Future<Map<String, dynamic>> getTrack(String trackId) async {
|
||||
try {
|
||||
final response = await _dio.get('${ApiConstants.tracks}/$trackId');
|
||||
return response.data;
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get stream URL for a track
|
||||
Future<Map<String, dynamic>> getStreamUrl(String trackId) async {
|
||||
try {
|
||||
final response = await _dio.get('${ApiConstants.tracks}/$trackId${ApiConstants.stream}');
|
||||
return response.data;
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get recommendations based on a track
|
||||
Future<List<Map<String, dynamic>>> getRecommendations(
|
||||
String trackId, {
|
||||
int limit = 10,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'${ApiConstants.recommendations}/$trackId/recommendations',
|
||||
queryParameters: {'limit': limit},
|
||||
);
|
||||
return (response.data as List).cast<Map<String, dynamic>>();
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get trending tracks
|
||||
Future<List<Map<String, dynamic>>> getTrending({int limit = 20}) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
ApiConstants.trending,
|
||||
queryParameters: {'limit': limit},
|
||||
);
|
||||
return (response.data as List).cast<Map<String, dynamic>>();
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get artist details
|
||||
Future<Map<String, dynamic>> getArtist(String artistId) async {
|
||||
try {
|
||||
final response = await _dio.get('${ApiConstants.artists}/$artistId');
|
||||
return response.data as Map<String, dynamic>;
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get artist's top tracks
|
||||
Future<List<Map<String, dynamic>>> getArtistTopTracks(
|
||||
String artistId, {
|
||||
int limit = 10,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'${ApiConstants.artists}/$artistId/tracks',
|
||||
queryParameters: {'limit': limit},
|
||||
);
|
||||
return (response.data as List).cast<Map<String, dynamic>>();
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get artist's albums
|
||||
Future<List<Map<String, dynamic>>> getArtistAlbums(
|
||||
String artistId, {
|
||||
int limit = 20,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'${ApiConstants.artists}/$artistId/albums',
|
||||
queryParameters: {'limit': limit},
|
||||
);
|
||||
return (response.data as List).cast<Map<String, dynamic>>();
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get album details
|
||||
Future<Map<String, dynamic>> getAlbum(String albumId) async {
|
||||
try {
|
||||
final response = await _dio.get('${ApiConstants.albums}/$albumId');
|
||||
return response.data as Map<String, dynamic>;
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get album tracks
|
||||
Future<List<Map<String, dynamic>>> getAlbumTracks(String albumId) async {
|
||||
try {
|
||||
final response = await _dio.get('${ApiConstants.albums}/$albumId/tracks');
|
||||
return (response.data as List).cast<Map<String, dynamic>>();
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Exception _handleError(DioException error) {
|
||||
if (error.response != null) {
|
||||
final message = error.response!.data['detail'] ?? 'An error occurred';
|
||||
return Exception('${error.response!.statusCode}: $message');
|
||||
}
|
||||
return Exception('Network error: ${error.message}');
|
||||
}
|
||||
}
|
||||
|
||||
final musicApiServiceProvider = Provider<MusicApiService>((ref) {
|
||||
final dio = ref.watch(apiServiceProvider);
|
||||
return MusicApiService(dio);
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/// Playlist API Service
|
||||
library;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/constants/api_constants.dart';
|
||||
import 'api_service.dart';
|
||||
|
||||
/// Playlist API Service
|
||||
class PlaylistApiService {
|
||||
PlaylistApiService(this._dio);
|
||||
|
||||
final Dio _dio;
|
||||
|
||||
/// Get user playlists
|
||||
Future<List<Map<String, dynamic>>> getPlaylists({
|
||||
int limit = 50,
|
||||
int offset = 0,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
ApiConstants.userPlaylists,
|
||||
queryParameters: {'limit': limit, 'offset': offset},
|
||||
);
|
||||
return (response.data as List).cast<Map<String, dynamic>>();
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Create playlist
|
||||
Future<Map<String, dynamic>> createPlaylist({
|
||||
required String name,
|
||||
String? description,
|
||||
String? imageUrl,
|
||||
bool isPublic = false,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
ApiConstants.userPlaylists,
|
||||
data: {
|
||||
'name': name,
|
||||
if (description != null) 'description': description,
|
||||
if (imageUrl != null) 'image_url': imageUrl,
|
||||
'is_public': isPublic,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get playlist with tracks
|
||||
Future<Map<String, dynamic>> getPlaylist(String playlistId) async {
|
||||
try {
|
||||
final response = await _dio.get('${ApiConstants.userPlaylists}/$playlistId');
|
||||
return response.data;
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update playlist
|
||||
Future<Map<String, dynamic>> updatePlaylist(
|
||||
String playlistId, {
|
||||
String? name,
|
||||
String? description,
|
||||
String? imageUrl,
|
||||
bool? isPublic,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.put(
|
||||
'${ApiConstants.userPlaylists}/$playlistId',
|
||||
data: {
|
||||
if (name != null) 'name': name,
|
||||
if (description != null) 'description': description,
|
||||
if (imageUrl != null) 'image_url': imageUrl,
|
||||
if (isPublic != null) 'is_public': isPublic,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete playlist
|
||||
Future<void> deletePlaylist(String playlistId) async {
|
||||
try {
|
||||
await _dio.delete('${ApiConstants.userPlaylists}/$playlistId');
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Add tracks to playlist
|
||||
Future<Map<String, dynamic>> addTracks(
|
||||
String playlistId,
|
||||
List<String> trackIds, {
|
||||
int? position,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
'${ApiConstants.userPlaylists}/$playlistId${ApiConstants.playlistTracks}',
|
||||
data: {
|
||||
'track_ids': trackIds,
|
||||
if (position != null) 'position': position,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove track from playlist
|
||||
Future<Map<String, dynamic>> removeTrack(
|
||||
String playlistId,
|
||||
String trackId,
|
||||
) async {
|
||||
try {
|
||||
final response = await _dio.delete(
|
||||
'${ApiConstants.userPlaylists}/$playlistId${ApiConstants.playlistTracks}/$trackId',
|
||||
);
|
||||
return response.data;
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reorder track in playlist
|
||||
Future<Map<String, dynamic>> reorderTrack(
|
||||
String playlistId,
|
||||
String trackId,
|
||||
int newPosition,
|
||||
) async {
|
||||
try {
|
||||
final response = await _dio.put(
|
||||
'${ApiConstants.userPlaylists}/$playlistId${ApiConstants.reorder}',
|
||||
data: {
|
||||
'track_id': trackId,
|
||||
'new_position': newPosition,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Exception _handleError(DioException error) {
|
||||
if (error.response != null) {
|
||||
final message = error.response!.data['detail'] ?? 'An error occurred';
|
||||
return Exception('${error.response!.statusCode}: $message');
|
||||
}
|
||||
return Exception('Network error: ${error.message}');
|
||||
}
|
||||
}
|
||||
|
||||
final playlistApiServiceProvider = Provider<PlaylistApiService>((ref) {
|
||||
final dio = ref.watch(apiServiceProvider);
|
||||
return PlaylistApiService(dio);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'presentation/adaptive/adaptive_layout.dart';
|
||||
|
||||
void main() {
|
||||
// Ensure Flutter bindings are initialized
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// Set preferred orientations
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
DeviceOrientation.landscapeLeft,
|
||||
DeviceOrientation.landscapeRight,
|
||||
]);
|
||||
|
||||
runApp(
|
||||
const ProviderScope(
|
||||
child: SpotifyLe2App(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class SpotifyLe2App extends StatelessWidget {
|
||||
const SpotifyLe2App({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'Spotify Le 2',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.lightTheme,
|
||||
darkTheme: AppTheme.darkTheme,
|
||||
themeMode: ThemeMode.dark, // Always dark for neon cyberpunk
|
||||
home: const AdaptiveLayout(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../providers/navigation_provider.dart';
|
||||
import '../pages/desktop/home_page.dart';
|
||||
import '../pages/mobile/mobile_home_page.dart';
|
||||
import '../pages/search/search_page.dart';
|
||||
import '../pages/library/library_page.dart';
|
||||
import '../widgets/common/mini_player.dart';
|
||||
import '../widgets/desktop/desktop_sidebar.dart';
|
||||
import '../widgets/desktop/desktop_top_bar.dart';
|
||||
|
||||
/// Adaptive Layout - Desktop or Mobile based on screen width
|
||||
class AdaptiveLayout extends ConsumerWidget {
|
||||
const AdaptiveLayout({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Breakpoint at 800px
|
||||
if (constraints.maxWidth >= 800) {
|
||||
return const DesktopLayout();
|
||||
} else {
|
||||
return const MobileLayout();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Desktop Layout - 3 columns
|
||||
class DesktopLayout extends ConsumerWidget {
|
||||
const DesktopLayout({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final currentPage = ref.watch(currentPageProvider);
|
||||
|
||||
return Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
// Sidebar (240px fixed)
|
||||
const DesktopSidebar(
|
||||
width: 240,
|
||||
),
|
||||
|
||||
// Main content
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
// Top bar
|
||||
const DesktopTopBar(),
|
||||
|
||||
// Content area
|
||||
Expanded(
|
||||
child: _buildCurrentPage(currentPage),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Right panel (320px) - Queue/Now Playing
|
||||
// TODO: Implement RightPanel
|
||||
// const SizedBox(width: 320, child: RightPanel()),
|
||||
],
|
||||
),
|
||||
// Persistent mini player at bottom
|
||||
bottomNavigationBar: const MiniPlayer(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCurrentPage(String page) {
|
||||
switch (page) {
|
||||
case 'home':
|
||||
return const HomePage();
|
||||
case 'search':
|
||||
return const SearchPage();
|
||||
case 'library':
|
||||
return const LibraryPage();
|
||||
case 'settings':
|
||||
// TODO: Implement SettingsPage
|
||||
return const _PlaceholderPage(title: 'Settings');
|
||||
default:
|
||||
return const HomePage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mobile Layout - Bottom nav
|
||||
class MobileLayout extends ConsumerWidget {
|
||||
const MobileLayout({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final currentPage = ref.watch(currentPageProvider);
|
||||
final navigationNotifier = ref.read(navigationProvider.notifier);
|
||||
|
||||
return Scaffold(
|
||||
body: Column(
|
||||
children: [
|
||||
// Top bar
|
||||
// TODO: Implement MobileTopBar
|
||||
const SizedBox(height: 60),
|
||||
|
||||
// Content area
|
||||
Expanded(
|
||||
child: _buildCurrentPage(currentPage),
|
||||
),
|
||||
|
||||
// Mini player (sticky)
|
||||
const MiniPlayer(),
|
||||
|
||||
// Bottom navigation
|
||||
NavigationBar(
|
||||
height: 56,
|
||||
selectedIndex: _navItems.indexWhere(
|
||||
(item) => item.page == currentPage,
|
||||
),
|
||||
onDestinationSelected: (index) {
|
||||
navigationNotifier.navigateTo(_navItems[index].page);
|
||||
},
|
||||
destinations: _navItems
|
||||
.map(
|
||||
(item) => NavigationDestination(
|
||||
icon: Icon(item.icon),
|
||||
label: Text(item.label),
|
||||
selectedIcon: Icon(item.selectedIcon ?? item.icon),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCurrentPage(String page) {
|
||||
switch (page) {
|
||||
case 'home':
|
||||
return const MobileHomePage();
|
||||
case 'search':
|
||||
return const SearchPage();
|
||||
case 'library':
|
||||
return const LibraryPage();
|
||||
case 'settings':
|
||||
return const _PlaceholderPage(title: 'Settings');
|
||||
default:
|
||||
return const MobileHomePage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigation items
|
||||
class _NavItem {
|
||||
final String page;
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final IconData? selectedIcon;
|
||||
|
||||
const _NavItem({
|
||||
required this.page,
|
||||
required this.label,
|
||||
required this.icon,
|
||||
this.selectedIcon,
|
||||
});
|
||||
}
|
||||
|
||||
final List<_NavItem> _navItems = const [
|
||||
_NavItem(
|
||||
page: 'home',
|
||||
label: 'Home',
|
||||
icon: Icons.home_outlined,
|
||||
selectedIcon: Icons.home,
|
||||
),
|
||||
_NavItem(
|
||||
page: 'search',
|
||||
label: 'Search',
|
||||
icon: Icons.search_outlined,
|
||||
selectedIcon: Icons.search,
|
||||
),
|
||||
_NavItem(
|
||||
page: 'library',
|
||||
label: 'Library',
|
||||
icon: Icons.library_music_outlined,
|
||||
selectedIcon: Icons.library_music,
|
||||
),
|
||||
_NavItem(
|
||||
page: 'settings',
|
||||
label: 'Settings',
|
||||
icon: Icons.settings_outlined,
|
||||
selectedIcon: Icons.settings,
|
||||
),
|
||||
];
|
||||
|
||||
/// Placeholder page for unimplemented pages
|
||||
class _PlaceholderPage extends StatelessWidget {
|
||||
final String title;
|
||||
|
||||
const _PlaceholderPage({required this.title});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.construction,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.displaySmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Coming soon...',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface
|
||||
.withOpacity(0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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')));
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/// Album Provider - Album details state management
|
||||
library;
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'dart:math';
|
||||
|
||||
import '../../../infrastructure/datasources/remote/music_api_service.dart';
|
||||
import '../../../domain/entities/album.dart';
|
||||
import '../../../domain/entities/track.dart';
|
||||
import 'music_provider.dart';
|
||||
|
||||
/// Album state
|
||||
class AlbumState {
|
||||
final Album? album;
|
||||
final List<Track> tracks;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
|
||||
const AlbumState({
|
||||
this.album,
|
||||
this.tracks = const [],
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
AlbumState copyWith({
|
||||
Album? album,
|
||||
List<Track>? tracks,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
}) {
|
||||
return AlbumState(
|
||||
album: album ?? this.album,
|
||||
tracks: tracks ?? this.tracks,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get total duration of all tracks in seconds
|
||||
int get totalDuration {
|
||||
return tracks.fold(0, (sum, track) {
|
||||
return sum + (track.duration ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
/// Get formatted total duration (hours:minutes:seconds)
|
||||
String get formattedTotalDuration {
|
||||
final totalSeconds = totalDuration;
|
||||
final hours = totalSeconds ~/ 3600;
|
||||
final minutes = (totalSeconds % 3600) ~/ 60;
|
||||
final seconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return '${hours.toString().padLeft(2, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||
} else {
|
||||
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Album notifier
|
||||
class AlbumNotifier extends StateNotifier<AlbumState> {
|
||||
AlbumNotifier(this._musicApiService) : super(const AlbumState());
|
||||
|
||||
final MusicApiService _musicApiService;
|
||||
|
||||
/// Load complete album information with tracks
|
||||
Future<void> loadAlbum(String albumId) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
// Load album details and tracks in parallel
|
||||
final results = await Future.wait([
|
||||
_musicApiService.getAlbum(albumId),
|
||||
_musicApiService.getAlbumTracks(albumId),
|
||||
]);
|
||||
|
||||
final album = Album.fromJson(results[0] as Map<String, dynamic>);
|
||||
final tracks = (results[1] as List)
|
||||
.map((json) => Track.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
// Sort tracks by track number
|
||||
tracks.sort((a, b) {
|
||||
final aNum = a.trackNumber ?? 0;
|
||||
final bNum = b.trackNumber ?? 0;
|
||||
return aNum.compareTo(bNum);
|
||||
});
|
||||
|
||||
state = AlbumState(
|
||||
album: album,
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Play all tracks from album
|
||||
Future<void> playAll(PlayerNotifier playerNotifier) async {
|
||||
if (state.tracks.isEmpty) return;
|
||||
|
||||
playerNotifier.setQueue(state.tracks, startIndex: 0);
|
||||
await playerNotifier.loadTrack(state.tracks.first);
|
||||
await playerNotifier.play();
|
||||
}
|
||||
|
||||
/// Shuffle and play all tracks from album
|
||||
Future<void> shuffle(PlayerNotifier playerNotifier) async {
|
||||
if (state.tracks.isEmpty) return;
|
||||
|
||||
// Create shuffled list
|
||||
final shuffledTracks = List<Track>.from(state.tracks);
|
||||
final random = Random();
|
||||
for (int i = shuffledTracks.length - 1; i > 0; i--) {
|
||||
final j = random.nextInt(i + 1);
|
||||
final temp = shuffledTracks[i];
|
||||
shuffledTracks[i] = shuffledTracks[j];
|
||||
shuffledTracks[j] = temp;
|
||||
}
|
||||
|
||||
playerNotifier.setQueue(shuffledTracks, startIndex: 0);
|
||||
await playerNotifier.loadTrack(shuffledTracks.first);
|
||||
await playerNotifier.play();
|
||||
}
|
||||
|
||||
/// Play specific track from album
|
||||
Future<void> playTrack(
|
||||
PlayerNotifier playerNotifier,
|
||||
Track track,
|
||||
) async {
|
||||
final index = state.tracks.indexWhere((t) => t.id == track.id);
|
||||
if (index == -1) return;
|
||||
|
||||
playerNotifier.setQueue(state.tracks, startIndex: index);
|
||||
await playerNotifier.loadTrack(track);
|
||||
await playerNotifier.play();
|
||||
}
|
||||
|
||||
/// Clear state
|
||||
void clear() {
|
||||
state = const AlbumState();
|
||||
}
|
||||
}
|
||||
|
||||
/// Album provider
|
||||
final albumProvider =
|
||||
StateNotifierProvider<AlbumNotifier, AlbumState>((ref) {
|
||||
final musicApiService = ref.watch(musicApiServiceProvider);
|
||||
return AlbumNotifier(musicApiService);
|
||||
});
|
||||
|
||||
/// Album data provider for a specific album ID
|
||||
final albumDataProvider = Provider.family<AlbumState, String>((ref, albumId) {
|
||||
final notifier = ref.watch(albumProvider.notifier);
|
||||
// Load data when first accessed
|
||||
if (notifier.state.album?.id != albumId) {
|
||||
Future.microtask(() => notifier.loadAlbum(albumId));
|
||||
}
|
||||
return notifier.state;
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
/// Artist Provider - Artist details state management
|
||||
library;
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../infrastructure/datasources/remote/music_api_service.dart';
|
||||
import '../../../domain/entities/artist.dart';
|
||||
import '../../../domain/entities/track.dart';
|
||||
import '../../../domain/entities/album.dart';
|
||||
|
||||
/// Artist state
|
||||
class ArtistState {
|
||||
final Artist? artist;
|
||||
final List<Track> topTracks;
|
||||
final List<Album> albums;
|
||||
final List<Track> relatedTracks;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
|
||||
const ArtistState({
|
||||
this.artist,
|
||||
this.topTracks = const [],
|
||||
this.albums = const [],
|
||||
this.relatedTracks = const [],
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
ArtistState copyWith({
|
||||
Artist? artist,
|
||||
List<Track>? topTracks,
|
||||
List<Album>? albums,
|
||||
List<Track>? relatedTracks,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
}) {
|
||||
return ArtistState(
|
||||
artist: artist ?? this.artist,
|
||||
topTracks: topTracks ?? this.topTracks,
|
||||
albums: albums ?? this.albums,
|
||||
relatedTracks: relatedTracks ?? this.relatedTracks,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Artist notifier
|
||||
class ArtistNotifier extends StateNotifier<ArtistState> {
|
||||
ArtistNotifier(this._musicApiService) : super(const ArtistState());
|
||||
|
||||
final MusicApiService _musicApiService;
|
||||
|
||||
/// Load complete artist information
|
||||
Future<void> loadArtist(String artistId) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
// Load artist details
|
||||
final artistData = await _musicApiService.getArtist(artistId);
|
||||
final artist = Artist.fromJson(artistData);
|
||||
|
||||
state = state.copyWith(
|
||||
artist: artist,
|
||||
isLoading: false,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Load artist's top tracks
|
||||
Future<void> loadTopTracks(String artistId) async {
|
||||
try {
|
||||
final tracksData = await _musicApiService.getArtistTopTracks(artistId);
|
||||
final tracks = tracksData
|
||||
.map((json) => Track.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
state = state.copyWith(topTracks: tracks);
|
||||
} catch (e) {
|
||||
state = state.copyWith(error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Load artist's albums
|
||||
Future<void> loadAlbums(String artistId) async {
|
||||
try {
|
||||
final albumsData = await _musicApiService.getArtistAlbums(artistId);
|
||||
final albums = albumsData
|
||||
.map((json) => Album.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
state = state.copyWith(albums: albums);
|
||||
} catch (e) {
|
||||
state = state.copyWith(error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Load related tracks (based on artist's top track)
|
||||
Future<void> loadRelatedTracks(String artistId) async {
|
||||
try {
|
||||
// Get first track from top tracks for recommendations
|
||||
if (state.topTracks.isEmpty) {
|
||||
await loadTopTracks(artistId);
|
||||
}
|
||||
|
||||
if (state.topTracks.isNotEmpty) {
|
||||
final firstTrack = state.topTracks.first;
|
||||
final relatedData =
|
||||
await _musicApiService.getRecommendations(firstTrack.id);
|
||||
final related = relatedData
|
||||
.map((json) => Track.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
state = state.copyWith(relatedTracks: related);
|
||||
}
|
||||
} catch (e) {
|
||||
state = state.copyWith(error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Load all artist data at once
|
||||
Future<void> loadAllArtistData(String artistId) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
// Load all data in parallel
|
||||
final results = await Future.wait([
|
||||
_musicApiService.getArtist(artistId),
|
||||
_musicApiService.getArtistTopTracks(artistId),
|
||||
_musicApiService.getArtistAlbums(artistId),
|
||||
]);
|
||||
|
||||
final artist = Artist.fromJson(results[0] as Map<String, dynamic>);
|
||||
final topTracks = (results[1] as List)
|
||||
.map((json) => Track.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
final albums = (results[2] as List)
|
||||
.map((json) => Album.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
// Load related tracks based on first top track
|
||||
List<Track> relatedTracks = [];
|
||||
if (topTracks.isNotEmpty) {
|
||||
try {
|
||||
final relatedData =
|
||||
await _musicApiService.getRecommendations(topTracks.first.id);
|
||||
relatedTracks = relatedData
|
||||
.map((json) => Track.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
} catch (_) {
|
||||
// Don't fail if recommendations fail
|
||||
}
|
||||
}
|
||||
|
||||
state = ArtistState(
|
||||
artist: artist,
|
||||
topTracks: topTracks,
|
||||
albums: albums,
|
||||
relatedTracks: relatedTracks,
|
||||
isLoading: false,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear state
|
||||
void clear() {
|
||||
state = const ArtistState();
|
||||
}
|
||||
}
|
||||
|
||||
/// Artist provider
|
||||
final artistProvider =
|
||||
StateNotifierProvider<ArtistNotifier, ArtistState>((ref) {
|
||||
final musicApiService = ref.watch(musicApiServiceProvider);
|
||||
return ArtistNotifier(musicApiService);
|
||||
});
|
||||
|
||||
/// Artist data provider for a specific artist ID
|
||||
final artistDataProvider = Provider.family<ArtistState, String>((ref, artistId) {
|
||||
final notifier = ref.watch(artistProvider.notifier);
|
||||
// Load data when first accessed
|
||||
if (notifier.state.artist?.id != artistId) {
|
||||
Future.microtask(() => notifier.loadAllArtistData(artistId));
|
||||
}
|
||||
return notifier.state;
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
/// Auth Provider
|
||||
library;
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
import '../../../domain/entities/user.dart';
|
||||
import '../../../infrastructure/datasources/remote/auth_api_service.dart';
|
||||
|
||||
/// Auth state
|
||||
class AuthState {
|
||||
final User? user;
|
||||
final String? accessToken;
|
||||
final String? refreshToken;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
|
||||
const AuthState({
|
||||
this.user,
|
||||
this.accessToken,
|
||||
this.refreshToken,
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
AuthState copyWith({
|
||||
User? user,
|
||||
String? accessToken,
|
||||
String? refreshToken,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
}) {
|
||||
return AuthState(
|
||||
user: user ?? this.user,
|
||||
accessToken: accessToken ?? this.accessToken,
|
||||
refreshToken: refreshToken ?? this.refreshToken,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
|
||||
bool get isAuthenticated => user != null && accessToken != null;
|
||||
}
|
||||
|
||||
/// Auth notifier
|
||||
class AuthNotifier extends StateNotifier<AuthState> {
|
||||
AuthNotifier(this._authApiService, this._storage) : super(const AuthState()) {
|
||||
_loadFromStorage();
|
||||
}
|
||||
|
||||
final AuthApiService _authApiService;
|
||||
final FlutterSecureStorage _storage;
|
||||
|
||||
static const String _accessTokenKey = 'access_token';
|
||||
static const String _refreshTokenKey = 'refresh_token';
|
||||
static const String _userKey = 'user';
|
||||
|
||||
Future<void> _loadFromStorage() async {
|
||||
state = state.copyWith(isLoading: true);
|
||||
|
||||
try {
|
||||
final accessToken = await _storage.read(key: _accessTokenKey);
|
||||
final refreshToken = await _storage.read(key: _refreshTokenKey);
|
||||
final userJson = await _storage.read(key: _userKey);
|
||||
|
||||
if (accessToken != null && refreshToken != null && userJson != null) {
|
||||
// Parse user from JSON
|
||||
final user = UserJson.fromJson(
|
||||
// ignore: avoid_dynamic_calls
|
||||
_jsonDecode(userJson),
|
||||
);
|
||||
|
||||
state = AuthState(
|
||||
user: user,
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
);
|
||||
} else {
|
||||
state = const AuthState();
|
||||
}
|
||||
} catch (e) {
|
||||
state = AuthState(error: e.toString());
|
||||
} finally {
|
||||
state = state.copyWith(isLoading: false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> login(String email, String password) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
final response = await _authApiService.login(email, password);
|
||||
|
||||
await _storage.write(key: _accessTokenKey, value: response.accessToken);
|
||||
await _storage.write(key: _refreshTokenKey, value: response.refreshToken);
|
||||
await _storage.write(
|
||||
key: _userKey,
|
||||
value: _jsonEncode(response.user.toJson()),
|
||||
);
|
||||
|
||||
state = AuthState(
|
||||
user: response.user,
|
||||
accessToken: response.accessToken,
|
||||
refreshToken: response.refreshToken,
|
||||
);
|
||||
} catch (e) {
|
||||
state = AuthState(error: e.toString());
|
||||
} finally {
|
||||
state = state.copyWith(isLoading: false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> register({
|
||||
required String email,
|
||||
required String username,
|
||||
required String password,
|
||||
String? displayName,
|
||||
}) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
final response = await _authApiService.register(
|
||||
email: email,
|
||||
username: username,
|
||||
password: password,
|
||||
displayName: displayName,
|
||||
);
|
||||
|
||||
await _storage.write(key: _accessTokenKey, value: response.accessToken);
|
||||
await _storage.write(key: _refreshTokenKey, value: response.refreshToken);
|
||||
await _storage.write(
|
||||
key: _userKey,
|
||||
value: _jsonEncode(response.user.toJson()),
|
||||
);
|
||||
|
||||
state = AuthState(
|
||||
user: response.user,
|
||||
accessToken: response.accessToken,
|
||||
refreshToken: response.refreshToken,
|
||||
);
|
||||
} catch (e) {
|
||||
state = AuthState(error: e.toString());
|
||||
} finally {
|
||||
state = state.copyWith(isLoading: false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> refreshToken() async {
|
||||
if (state.refreshToken == null) return null;
|
||||
|
||||
try {
|
||||
final response = await _authApiService.refreshToken(state.refreshToken!);
|
||||
|
||||
final newAccessToken = response['access_token'] as String;
|
||||
final newRefreshToken = response['refresh_token'] as String;
|
||||
|
||||
await _storage.write(key: _accessTokenKey, value: newAccessToken);
|
||||
await _storage.write(key: _refreshTokenKey, value: newRefreshToken);
|
||||
|
||||
state = state.copyWith(
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
);
|
||||
|
||||
return newAccessToken;
|
||||
} catch (e) {
|
||||
await logout();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
try {
|
||||
await _authApiService.logout();
|
||||
} catch (e) {
|
||||
// Ignore logout errors
|
||||
} finally {
|
||||
await _storage.delete(key: _accessTokenKey);
|
||||
await _storage.delete(key: _refreshTokenKey);
|
||||
await _storage.delete(key: _userKey);
|
||||
state = const AuthState();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateProfile({String? displayName, String? avatarUrl}) async {
|
||||
state = state.copyWith(isLoading: true);
|
||||
|
||||
try {
|
||||
final updatedUser = await _authApiService.updateProfile(
|
||||
displayName: displayName,
|
||||
avatarUrl: avatarUrl,
|
||||
);
|
||||
|
||||
await _storage.write(
|
||||
key: _userKey,
|
||||
value: _jsonEncode(updatedUser.toJson()),
|
||||
);
|
||||
|
||||
state = state.copyWith(user: updatedUser);
|
||||
} catch (e) {
|
||||
state = state.copyWith(error: e.toString());
|
||||
} finally {
|
||||
state = state.copyWith(isLoading: false);
|
||||
}
|
||||
}
|
||||
|
||||
String _jsonEncode(Object obj) {
|
||||
// Simple JSON encode
|
||||
return obj.toString();
|
||||
}
|
||||
|
||||
Object _jsonDecode(String str) {
|
||||
// Simple JSON decode
|
||||
return str;
|
||||
}
|
||||
}
|
||||
|
||||
/// Auth provider
|
||||
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
|
||||
final authApiService = ref.watch(authApiServiceProvider);
|
||||
const storage = FlutterSecureStorage();
|
||||
|
||||
return AuthNotifier(authApiService, storage);
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
/// Library Provider - Library state management
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../infrastructure/datasources/remote/playlist_api_service.dart';
|
||||
import '../../../infrastructure/datasources/remote/music_api_service.dart';
|
||||
import '../../../domain/entities/playlist.dart';
|
||||
import '../../../domain/entities/track.dart';
|
||||
import '../../../domain/entities/album.dart';
|
||||
import '../../../domain/entities/artist.dart';
|
||||
|
||||
/// Library state
|
||||
class LibraryState {
|
||||
final List<Playlist> playlists;
|
||||
final List<Track> likedSongs;
|
||||
final List<Album> savedAlbums;
|
||||
final List<Artist> followedArtists;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
|
||||
const LibraryState({
|
||||
this.playlists = const [],
|
||||
this.likedSongs = const [],
|
||||
this.savedAlbums = const [],
|
||||
this.followedArtists = const [],
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
LibraryState copyWith({
|
||||
List<Playlist>? playlists,
|
||||
List<Track>? likedSongs,
|
||||
List<Album>? savedAlbums,
|
||||
List<Artist>? followedArtists,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
}) {
|
||||
return LibraryState(
|
||||
playlists: playlists ?? this.playlists,
|
||||
likedSongs: likedSongs ?? this.likedSongs,
|
||||
savedAlbums: savedAlbums ?? this.savedAlbums,
|
||||
followedArtists: followedArtists ?? this.followedArtists,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
|
||||
int get totalItems => playlists.length + likedSongs.length + savedAlbums.length + followedArtists.length;
|
||||
}
|
||||
|
||||
/// Library notifier
|
||||
class LibraryNotifier extends StateNotifier<LibraryState> {
|
||||
LibraryNotifier(this._playlistApiService, this._musicApiService)
|
||||
: super(const LibraryState());
|
||||
|
||||
final PlaylistApiService _playlistApiService;
|
||||
final MusicApiService _musicApiService;
|
||||
|
||||
Future<void> loadLibrary() async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
// Load playlists in parallel
|
||||
final results = await Future.wait([
|
||||
_loadPlaylists(),
|
||||
// TODO: Implement these endpoints when backend is ready
|
||||
// _loadLikedSongs(),
|
||||
// _loadSavedAlbums(),
|
||||
// _loadFollowedArtists(),
|
||||
]);
|
||||
|
||||
state = LibraryState(
|
||||
playlists: results[0] as List<Playlist>,
|
||||
likedSongs: [],
|
||||
savedAlbums: [],
|
||||
followedArtists: [],
|
||||
);
|
||||
} catch (e) {
|
||||
state = LibraryState(
|
||||
playlists: state.playlists,
|
||||
likedSongs: state.likedSongs,
|
||||
savedAlbums: state.savedAlbums,
|
||||
followedArtists: state.followedArtists,
|
||||
error: e.toString(),
|
||||
);
|
||||
} finally {
|
||||
state = state.copyWith(isLoading: false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<Playlist>> _loadPlaylists() async {
|
||||
final playlistsJson = await _playlistApiService.getPlaylists();
|
||||
return playlistsJson
|
||||
.map((json) => Playlist.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> addLikedSong(Track track) async {
|
||||
// TODO: Implement when backend endpoint is ready
|
||||
final updated = [...state.likedSongs, track];
|
||||
state = state.copyWith(likedSongs: updated);
|
||||
}
|
||||
|
||||
Future<void> removeLikedSong(String trackId) async {
|
||||
// TODO: Implement when backend endpoint is ready
|
||||
final updated = state.likedSongs.where((t) => t.id != trackId).toList();
|
||||
state = state.copyWith(likedSongs: updated);
|
||||
}
|
||||
|
||||
Future<void> addSavedAlbum(Album album) async {
|
||||
// TODO: Implement when backend endpoint is ready
|
||||
final updated = [...state.savedAlbums, album];
|
||||
state = state.copyWith(savedAlbums: updated);
|
||||
}
|
||||
|
||||
Future<void> removeSavedAlbum(String albumId) async {
|
||||
// TODO: Implement when backend endpoint is ready
|
||||
final updated = state.savedAlbums.where((a) => a.id != albumId).toList();
|
||||
state = state.copyWith(savedAlbums: updated);
|
||||
}
|
||||
|
||||
Future<void> followArtist(Artist artist) async {
|
||||
// TODO: Implement when backend endpoint is ready
|
||||
final updated = [...state.followedArtists, artist];
|
||||
state = state.copyWith(followedArtists: updated);
|
||||
}
|
||||
|
||||
Future<void> unfollowArtist(String artistId) async {
|
||||
// TODO: Implement when backend endpoint is ready
|
||||
final updated = state.followedArtists.where((a) => a.id != artistId).toList();
|
||||
state = state.copyWith(followedArtists: updated);
|
||||
}
|
||||
|
||||
Future<void> deletePlaylist(String playlistId) async {
|
||||
await _playlistApiService.deletePlaylist(playlistId);
|
||||
final updated = state.playlists.where((p) => p.id != playlistId).toList();
|
||||
state = state.copyWith(playlists: updated);
|
||||
}
|
||||
|
||||
Future<void> createPlaylist({
|
||||
required String name,
|
||||
String? description,
|
||||
String? imageUrl,
|
||||
bool isPublic = false,
|
||||
}) async {
|
||||
final playlistJson = await _playlistApiService.createPlaylist(
|
||||
name: name,
|
||||
description: description,
|
||||
imageUrl: imageUrl,
|
||||
isPublic: isPublic,
|
||||
);
|
||||
final playlist = Playlist.fromJson(playlistJson as Map<String, dynamic>);
|
||||
final updated = [...state.playlists, playlist];
|
||||
state = state.copyWith(playlists: updated);
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
await loadLibrary();
|
||||
}
|
||||
}
|
||||
|
||||
/// Library provider
|
||||
final libraryProvider = StateNotifierProvider<LibraryNotifier, LibraryState>((ref) {
|
||||
final playlistApiService = ref.watch(playlistApiServiceProvider);
|
||||
final musicApiService = ref.watch(musicApiServiceProvider);
|
||||
return LibraryNotifier(playlistApiService, musicApiService);
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
/// Music Provider - Player state management
|
||||
library;
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
|
||||
import '../../../domain/entities/track.dart';
|
||||
|
||||
/// Player state
|
||||
class PlayerState {
|
||||
final List<Track> queue;
|
||||
final int currentIndex;
|
||||
final bool isPlaying;
|
||||
final Duration position;
|
||||
final Duration duration;
|
||||
final bool isLoading;
|
||||
final String? errorMessage;
|
||||
|
||||
const PlayerState({
|
||||
this.queue = const [],
|
||||
this.currentIndex = -1,
|
||||
this.isPlaying = false,
|
||||
this.position = Duration.zero,
|
||||
this.duration = Duration.zero,
|
||||
this.isLoading = false,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
Track? get currentTrack =>
|
||||
currentIndex >= 0 && currentIndex < queue.length
|
||||
? queue[currentIndex]
|
||||
: null;
|
||||
|
||||
PlayerState copyWith({
|
||||
List<Track>? queue,
|
||||
int? currentIndex,
|
||||
bool? isPlaying,
|
||||
Duration? position,
|
||||
Duration? duration,
|
||||
bool? isLoading,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return PlayerState(
|
||||
queue: queue ?? this.queue,
|
||||
currentIndex: currentIndex ?? this.currentIndex,
|
||||
isPlaying: isPlaying ?? this.isPlaying,
|
||||
position: position ?? this.position,
|
||||
duration: duration ?? this.duration,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
errorMessage: errorMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Player notifier
|
||||
class PlayerNotifier extends StateNotifier<PlayerState> {
|
||||
PlayerNotifier() : super(const PlayerState()) {
|
||||
_player = AudioPlayer();
|
||||
_init();
|
||||
}
|
||||
|
||||
late final AudioPlayer _player;
|
||||
|
||||
void _init() {
|
||||
_player.positionStream.listen((position) {
|
||||
state = state.copyWith(position: position);
|
||||
});
|
||||
|
||||
_player.durationStream.listen((duration) {
|
||||
state = state.copyWith(duration: duration ?? Duration.zero);
|
||||
});
|
||||
|
||||
_player.playerStateStream.listen((playerState) {
|
||||
state = state.copyWith(
|
||||
isPlaying: playerState.playing,
|
||||
isLoading: playerState.processingState == ProcessingState.loading,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> loadTrack(Track track) async {
|
||||
state = state.copyWith(isLoading: true);
|
||||
|
||||
try {
|
||||
// Get stream URL from API
|
||||
final streamUrl = track.audioUrl ?? '';
|
||||
|
||||
await _player.setUrl(streamUrl);
|
||||
|
||||
if (state.queue.isEmpty) {
|
||||
state = state.copyWith(queue: [track], currentIndex: 0);
|
||||
}
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> play() async {
|
||||
if (state.currentTrack != null) {
|
||||
await _player.play();
|
||||
state = state.copyWith(isPlaying: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> pause() async {
|
||||
await _player.pause();
|
||||
state = state.copyWith(isPlaying: false);
|
||||
}
|
||||
|
||||
Future<void> seek(Duration position) async {
|
||||
await _player.seek(position);
|
||||
}
|
||||
|
||||
Future<void> next() async {
|
||||
if (state.currentIndex < state.queue.length - 1) {
|
||||
final nextTrack = state.queue[state.currentIndex + 1];
|
||||
await loadTrack(nextTrack);
|
||||
state = state.copyWith(currentIndex: state.currentIndex + 1);
|
||||
await play();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> previous() async {
|
||||
if (state.currentIndex > 0) {
|
||||
final previousTrack = state.queue[state.currentIndex - 1];
|
||||
await loadTrack(previousTrack);
|
||||
state = state.copyWith(currentIndex: state.currentIndex - 1);
|
||||
await play();
|
||||
}
|
||||
}
|
||||
|
||||
void setQueue(List<Track> tracks, {int startIndex = 0}) {
|
||||
state = state.copyWith(
|
||||
queue: tracks,
|
||||
currentIndex: startIndex,
|
||||
);
|
||||
}
|
||||
|
||||
void addToQueue(Track track) {
|
||||
final newQueue = [...state.queue, track];
|
||||
state = state.copyWith(queue: newQueue);
|
||||
}
|
||||
|
||||
void removeFromQueue(int index) {
|
||||
if (index >= 0 && index < state.queue.length) {
|
||||
final newQueue = [...state.queue]..removeAt(index);
|
||||
state = state.copyWith(queue: newQueue);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_player.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Player provider
|
||||
final playerProvider =
|
||||
StateNotifierProvider<PlayerNotifier, PlayerState>((ref) {
|
||||
return PlayerNotifier();
|
||||
});
|
||||
|
||||
/// Current track provider
|
||||
final currentTrackProvider = Provider<Track?>((ref) {
|
||||
return ref.watch(playerProvider).currentTrack;
|
||||
});
|
||||
|
||||
/// Queue view data class
|
||||
class QueueViewData {
|
||||
final Track? currentTrack;
|
||||
final List<Track> queue;
|
||||
final int currentIndex;
|
||||
final bool isPlaying;
|
||||
|
||||
const QueueViewData({
|
||||
required this.currentTrack,
|
||||
required this.queue,
|
||||
required this.currentIndex,
|
||||
required this.isPlaying,
|
||||
});
|
||||
|
||||
/// Get upcoming tracks (after current)
|
||||
List<Track> get nextTracks {
|
||||
if (currentIndex < 0 || currentIndex >= queue.length - 1) {
|
||||
return [];
|
||||
}
|
||||
return queue.sublist(currentIndex + 1);
|
||||
}
|
||||
|
||||
/// Get previously played tracks (before current)
|
||||
List<Track> get previousTracks {
|
||||
if (currentIndex <= 0) {
|
||||
return [];
|
||||
}
|
||||
return queue.sublist(0, currentIndex);
|
||||
}
|
||||
|
||||
/// Check if queue has tracks
|
||||
bool get hasQueue => queue.isNotEmpty;
|
||||
|
||||
/// Check if there are upcoming tracks
|
||||
bool get hasNextTracks => nextTracks.isNotEmpty;
|
||||
|
||||
/// Check if there are previous tracks
|
||||
bool get hasPreviousTracks => previousTracks.isNotEmpty;
|
||||
|
||||
/// Get total queue count excluding current
|
||||
int get queueCount => queue.length - 1;
|
||||
}
|
||||
|
||||
/// Queue view provider
|
||||
final queueProvider = Provider<QueueViewData>((ref) {
|
||||
final playerState = ref.watch(playerProvider);
|
||||
return QueueViewData(
|
||||
currentTrack: playerState.currentTrack,
|
||||
queue: playerState.queue,
|
||||
currentIndex: playerState.currentIndex,
|
||||
isPlaying: playerState.isPlaying,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
/// Navigation state
|
||||
class NavigationState {
|
||||
final String currentPage;
|
||||
|
||||
const NavigationState({
|
||||
this.currentPage = 'home',
|
||||
});
|
||||
|
||||
NavigationState copyWith({String? currentPage}) {
|
||||
return NavigationState(
|
||||
currentPage: currentPage ?? this.currentPage,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigation notifier
|
||||
class NavigationNotifier extends StateNotifier<NavigationState> {
|
||||
NavigationNotifier() : super(const NavigationState());
|
||||
|
||||
void navigateTo(String page) {
|
||||
if (state.currentPage != page) {
|
||||
state = state.copyWith(currentPage: page);
|
||||
}
|
||||
}
|
||||
|
||||
void goBack() {
|
||||
// Simple navigation: always go to home
|
||||
state = const NavigationState(currentPage: 'home');
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigation provider
|
||||
final navigationProvider =
|
||||
StateNotifierProvider<NavigationNotifier, NavigationState>((ref) {
|
||||
return NavigationNotifier();
|
||||
});
|
||||
|
||||
/// Current page provider
|
||||
final currentPageProvider = navigationProvider.select((state) => state.currentPage);
|
||||
@@ -0,0 +1,243 @@
|
||||
/// Playlist Provider - State management for playlist details
|
||||
library;
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../domain/entities/playlist.dart';
|
||||
import '../../../domain/entities/track.dart';
|
||||
import '../../../infrastructure/datasources/remote/playlist_api_service.dart';
|
||||
import '../../providers/music_provider.dart';
|
||||
|
||||
/// Playlist state
|
||||
class PlaylistState {
|
||||
final Playlist? playlist;
|
||||
final List<Track> tracks;
|
||||
final bool isLoading;
|
||||
final bool isReordering;
|
||||
final String? error;
|
||||
|
||||
const PlaylistState({
|
||||
this.playlist,
|
||||
this.tracks = const [],
|
||||
this.isLoading = false,
|
||||
this.isReordering = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
PlaylistState copyWith({
|
||||
Playlist? playlist,
|
||||
List<Track>? tracks,
|
||||
bool? isLoading,
|
||||
bool? isReordering,
|
||||
String? error,
|
||||
}) {
|
||||
return PlaylistState(
|
||||
playlist: playlist ?? this.playlist,
|
||||
tracks: tracks ?? this.tracks,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
isReordering: isReordering ?? this.isReordering,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get total duration of all tracks
|
||||
Duration get totalDuration {
|
||||
final totalSeconds = tracks.fold<int>(
|
||||
0,
|
||||
(sum, track) => sum + (track.duration ?? 0),
|
||||
);
|
||||
return Duration(seconds: totalSeconds);
|
||||
}
|
||||
|
||||
/// Format total duration
|
||||
String get formattedTotalDuration {
|
||||
final duration = totalDuration;
|
||||
final hours = duration.inHours;
|
||||
final minutes = duration.inMinutes.remainder(60);
|
||||
|
||||
if (hours > 0) {
|
||||
return '${hours}h ${minutes}m';
|
||||
} else {
|
||||
return '${minutes}m';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Playlist notifier
|
||||
class PlaylistNotifier extends StateNotifier<PlaylistState> {
|
||||
PlaylistNotifier(this._playlistApiService) : super(const PlaylistState());
|
||||
|
||||
final PlaylistApiService _playlistApiService;
|
||||
|
||||
/// Load playlist with tracks
|
||||
Future<void> loadPlaylist(String playlistId) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
final response = await _playlistApiService.getPlaylist(playlistId);
|
||||
|
||||
// Parse playlist
|
||||
final playlist = Playlist.fromJson(response);
|
||||
|
||||
// Parse tracks from response
|
||||
final tracks = <Track>[];
|
||||
if (response['tracks'] != null) {
|
||||
for (final trackData in response['tracks'] as List) {
|
||||
if (trackData is Map<String, dynamic> && trackData['track'] != null) {
|
||||
final track = Track.fromJson(trackData['track'] as Map<String, dynamic>);
|
||||
tracks.add(track);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
state = PlaylistState(
|
||||
playlist: playlist,
|
||||
tracks: tracks,
|
||||
isLoading: false,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Add track to playlist
|
||||
Future<void> addTrack(Track track, {int? position}) async {
|
||||
if (state.playlist == null) return;
|
||||
|
||||
try {
|
||||
await _playlistApiService.addTracks(
|
||||
state.playlist!.id,
|
||||
[track.id],
|
||||
position: position,
|
||||
);
|
||||
|
||||
// Reload playlist to get updated tracks
|
||||
await loadPlaylist(state.playlist!.id);
|
||||
} catch (e) {
|
||||
state = state.copyWith(error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove track from playlist
|
||||
Future<void> removeTrack(String trackId) async {
|
||||
if (state.playlist == null) return;
|
||||
|
||||
try {
|
||||
await _playlistApiService.removeTrack(state.playlist!.id, trackId);
|
||||
|
||||
// Update local state
|
||||
final updatedTracks = state.tracks.where((t) => t.id != trackId).toList();
|
||||
|
||||
state = state.copyWith(tracks: updatedTracks);
|
||||
} catch (e) {
|
||||
state = state.copyWith(error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Reorder tracks
|
||||
Future<void> reorderTracks(int oldIndex, int newIndex) async {
|
||||
if (state.playlist == null || state.tracks.isEmpty) return;
|
||||
|
||||
// Update local state immediately for responsiveness
|
||||
final updatedTracks = List<Track>.from(state.tracks);
|
||||
final track = updatedTracks.removeAt(oldIndex);
|
||||
updatedTracks.insert(newIndex, track);
|
||||
|
||||
state = state.copyWith(tracks: updatedTracks, isReordering: true);
|
||||
|
||||
try {
|
||||
// Call API to update position
|
||||
await _playlistApiService.reorderTrack(
|
||||
state.playlist!.id,
|
||||
track.id,
|
||||
newIndex,
|
||||
);
|
||||
|
||||
state = state.copyWith(isReordering: false);
|
||||
} catch (e) {
|
||||
// Revert on error
|
||||
state = state.copyWith(
|
||||
tracks: state.tracks,
|
||||
isReordering: false,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update playlist details
|
||||
Future<void> updatePlaylist({
|
||||
String? name,
|
||||
String? description,
|
||||
String? imageUrl,
|
||||
bool? isPublic,
|
||||
}) async {
|
||||
if (state.playlist == null) return;
|
||||
|
||||
try {
|
||||
final response = await _playlistApiService.updatePlaylist(
|
||||
state.playlist!.id,
|
||||
name: name,
|
||||
description: description,
|
||||
imageUrl: imageUrl,
|
||||
isPublic: isPublic,
|
||||
);
|
||||
|
||||
final updatedPlaylist = Playlist.fromJson(response);
|
||||
state = state.copyWith(playlist: updatedPlaylist);
|
||||
} catch (e) {
|
||||
state = state.copyWith(error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete playlist
|
||||
Future<void> deletePlaylist() async {
|
||||
if (state.playlist == null) return;
|
||||
|
||||
try {
|
||||
await _playlistApiService.deletePlaylist(state.playlist!.id);
|
||||
state = const PlaylistState();
|
||||
} catch (e) {
|
||||
state = state.copyWith(error: e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/// Shuffle and play playlist
|
||||
void shufflePlaylist(PlayerNotifier playerNotifier) {
|
||||
if (state.tracks.isEmpty) return;
|
||||
|
||||
final shuffledTracks = List<Track>.from(state.tracks)..shuffle();
|
||||
playerNotifier.setQueue(shuffledTracks, startIndex: 0);
|
||||
}
|
||||
|
||||
/// Play playlist from start
|
||||
void playPlaylist(PlayerNotifier playerNotifier) {
|
||||
if (state.tracks.isEmpty) return;
|
||||
|
||||
playerNotifier.setQueue(state.tracks, startIndex: 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Playlist provider
|
||||
final playlistProvider =
|
||||
StateNotifierProvider.family<PlaylistNotifier, PlaylistState, String>(
|
||||
(ref, playlistId) {
|
||||
final playlistApiService = ref.watch(playlistApiServiceProvider);
|
||||
final notifier = PlaylistNotifier(playlistApiService);
|
||||
|
||||
// Auto-load playlist
|
||||
Future.microtask(() => notifier.loadPlaylist(playlistId));
|
||||
|
||||
return notifier;
|
||||
},
|
||||
);
|
||||
|
||||
/// Current playlist tracks provider (for easy access)
|
||||
final playlistTracksProvider = Provider.family<List<Track>, String>(
|
||||
(ref, playlistId) {
|
||||
final playlistState = ref.watch(playlistProvider(playlistId));
|
||||
return playlistState.tracks;
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,125 @@
|
||||
/// Search Provider - Search state management
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../infrastructure/datasources/remote/music_api_service.dart';
|
||||
import '../../../domain/entities/track.dart';
|
||||
import '../../../domain/entities/artist.dart';
|
||||
import '../../../domain/entities/album.dart';
|
||||
|
||||
/// Search state
|
||||
class SearchState {
|
||||
final String query;
|
||||
final bool isSearching;
|
||||
final List<Track> tracks;
|
||||
final List<Artist> artists;
|
||||
final List<Album> albums;
|
||||
final String? error;
|
||||
|
||||
const SearchState({
|
||||
this.query = '',
|
||||
this.isSearching = false,
|
||||
this.tracks = const [],
|
||||
this.artists = const [],
|
||||
this.albums = const [],
|
||||
this.error,
|
||||
});
|
||||
|
||||
SearchState copyWith({
|
||||
String? query,
|
||||
bool? isSearching,
|
||||
List<Track>? tracks,
|
||||
List<Artist>? artists,
|
||||
List<Album>? albums,
|
||||
String? error,
|
||||
}) {
|
||||
return SearchState(
|
||||
query: query ?? this.query,
|
||||
isSearching: isSearching ?? this.isSearching,
|
||||
tracks: tracks ?? this.tracks,
|
||||
artists: artists ?? this.artists,
|
||||
albums: albums ?? this.albums,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
|
||||
int get totalResults => tracks.length + artists.length + albums.length;
|
||||
}
|
||||
|
||||
/// Search notifier with debouncing
|
||||
class SearchNotifier extends StateNotifier<SearchState> {
|
||||
SearchNotifier(this._musicApiService) : super(const SearchState());
|
||||
|
||||
final MusicApiService _musicApiService;
|
||||
Timer? _debounceTimer;
|
||||
|
||||
static const _debounceDuration = Duration(milliseconds: 500);
|
||||
|
||||
void search(String query) {
|
||||
if (query.trim().isEmpty) {
|
||||
state = const SearchState();
|
||||
_debounceTimer?.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
_debounceTimer?.cancel();
|
||||
state = state.copyWith(query: query, isSearching: true);
|
||||
|
||||
_debounceTimer = Timer(_debounceDuration, () => _performSearch(query));
|
||||
}
|
||||
|
||||
Future<void> _performSearch(String query) async {
|
||||
try {
|
||||
final results = await _musicApiService.search(
|
||||
query,
|
||||
type: 'all',
|
||||
limit: 20,
|
||||
);
|
||||
|
||||
state = SearchState(
|
||||
query: query,
|
||||
tracks: (results['tracks'] as List?)
|
||||
?.map((json) => Track.fromJson(json as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
artists: (results['artists'] as List?)
|
||||
?.map((json) => Artist.fromJson(json as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
albums: (results['albums'] as List?)
|
||||
?.map((json) => Album.fromJson(json as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
} catch (e) {
|
||||
state = SearchState(
|
||||
query: query,
|
||||
error: e.toString(),
|
||||
);
|
||||
} finally {
|
||||
// Keep isSearching false if this was the latest search
|
||||
if (state.query == query) {
|
||||
state = state.copyWith(isSearching: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void clear() {
|
||||
_debounceTimer?.cancel();
|
||||
state = const SearchState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debounceTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Search provider
|
||||
final searchProvider = StateNotifierProvider<SearchNotifier, SearchState>((ref) {
|
||||
final musicApiService = ref.watch(musicApiServiceProvider);
|
||||
return SearchNotifier(musicApiService);
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
/// Settings Provider
|
||||
library;
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'dart:io';
|
||||
|
||||
import '../../../domain/entities/user.dart';
|
||||
import '../../../infrastructure/datasources/remote/auth_api_service.dart';
|
||||
|
||||
/// Audio Quality enum
|
||||
enum AudioQuality { low, medium, high, lossless }
|
||||
|
||||
/// Settings state
|
||||
class SettingsState {
|
||||
final User? user;
|
||||
final AudioQuality audioQuality;
|
||||
final bool downloadOnMobileData;
|
||||
final bool showExplicitContent;
|
||||
final bool crossfadeEnabled;
|
||||
final double crossfadeDuration;
|
||||
final bool gaplessPlayback;
|
||||
final bool normalizeVolume;
|
||||
final String cacheSize;
|
||||
final bool isLoading;
|
||||
final String? error;
|
||||
|
||||
const SettingsState({
|
||||
this.user,
|
||||
this.audioQuality = AudioQuality.high,
|
||||
this.downloadOnMobileData = false,
|
||||
this.showExplicitContent = true,
|
||||
this.crossfadeEnabled = false,
|
||||
this.crossfadeDuration = 5.0,
|
||||
this.gaplessPlayback = true,
|
||||
this.normalizeVolume = false,
|
||||
this.cacheSize = '0 MB',
|
||||
this.isLoading = false,
|
||||
this.error,
|
||||
});
|
||||
|
||||
SettingsState copyWith({
|
||||
User? user,
|
||||
AudioQuality? audioQuality,
|
||||
bool? downloadOnMobileData,
|
||||
bool? showExplicitContent,
|
||||
bool? crossfadeEnabled,
|
||||
double? crossfadeDuration,
|
||||
bool? gaplessPlayback,
|
||||
bool? normalizeVolume,
|
||||
String? cacheSize,
|
||||
bool? isLoading,
|
||||
String? error,
|
||||
}) {
|
||||
return SettingsState(
|
||||
user: user ?? this.user,
|
||||
audioQuality: audioQuality ?? this.audioQuality,
|
||||
downloadOnMobileData: downloadOnMobileData ?? this.downloadOnMobileData,
|
||||
showExplicitContent: showExplicitContent ?? this.showExplicitContent,
|
||||
crossfadeEnabled: crossfadeEnabled ?? this.crossfadeEnabled,
|
||||
crossfadeDuration: crossfadeDuration ?? this.crossfadeDuration,
|
||||
gaplessPlayback: gaplessPlayback ?? this.gaplessPlayback,
|
||||
normalizeVolume: normalizeVolume ?? this.normalizeVolume,
|
||||
cacheSize: cacheSize ?? this.cacheSize,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings notifier
|
||||
class SettingsNotifier extends StateNotifier<SettingsState> {
|
||||
SettingsNotifier(this._authApiService) : super(const SettingsState()) {
|
||||
_loadSettingsFromPrefs();
|
||||
}
|
||||
|
||||
final AuthApiService _authApiService;
|
||||
SharedPreferences? _prefs;
|
||||
|
||||
// Keys for shared preferences
|
||||
static const String _audioQualityKey = 'audio_quality';
|
||||
static const String _downloadOnMobileDataKey = 'download_on_mobile_data';
|
||||
static const String _showExplicitContentKey = 'show_explicit_content';
|
||||
static const String _crossfadeEnabledKey = 'crossfade_enabled';
|
||||
static const String _crossfadeDurationKey = 'crossfade_duration';
|
||||
static const String _gaplessPlaybackKey = 'gapless_playback';
|
||||
static const String _normalizeVolumeKey = 'normalize_volume';
|
||||
|
||||
/// Initialize shared preferences and load settings
|
||||
Future<void> _loadSettingsFromPrefs() async {
|
||||
_prefs = await SharedPreferences.getInstance();
|
||||
|
||||
final audioQualityIndex = _prefs?.getInt(_audioQualityKey) ?? 2;
|
||||
final downloadOnMobileData = _prefs?.getBool(_downloadOnMobileDataKey) ?? false;
|
||||
final showExplicitContent = _prefs?.getBool(_showExplicitContentKey) ?? true;
|
||||
final crossfadeEnabled = _prefs?.getBool(_crossfadeEnabledKey) ?? false;
|
||||
final crossfadeDuration = _prefs?.getDouble(_crossfadeDurationKey) ?? 5.0;
|
||||
final gaplessPlayback = _prefs?.getBool(_gaplessPlaybackKey) ?? true;
|
||||
final normalizeVolume = _prefs?.getBool(_normalizeVolumeKey) ?? false;
|
||||
|
||||
state = state.copyWith(
|
||||
audioQuality: AudioQuality.values[audioQualityIndex],
|
||||
downloadOnMobileData: downloadOnMobileData,
|
||||
showExplicitContent: showExplicitContent,
|
||||
crossfadeEnabled: crossfadeEnabled,
|
||||
crossfadeDuration: crossfadeDuration,
|
||||
gaplessPlayback: gaplessPlayback,
|
||||
normalizeVolume: normalizeVolume,
|
||||
);
|
||||
|
||||
await _calculateCacheSize();
|
||||
}
|
||||
|
||||
/// Load user profile from API
|
||||
Future<void> loadSettings() async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
final user = await _authApiService.getCurrentUser();
|
||||
state = state.copyWith(user: user, isLoading: false);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
error: e.toString(),
|
||||
isLoading: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Update user profile
|
||||
Future<void> updateProfile({
|
||||
String? displayName,
|
||||
String? avatarUrl,
|
||||
}) async {
|
||||
state = state.copyWith(isLoading: true, error: null);
|
||||
|
||||
try {
|
||||
final updatedUser = await _authApiService.updateProfile(
|
||||
displayName: displayName,
|
||||
avatarUrl: avatarUrl,
|
||||
);
|
||||
|
||||
state = state.copyWith(
|
||||
user: updatedUser,
|
||||
isLoading: false,
|
||||
);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
error: e.toString(),
|
||||
isLoading: false,
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Set audio quality
|
||||
Future<void> setAudioQuality(AudioQuality quality) async {
|
||||
await _prefs?.setInt(_audioQualityKey, quality.index);
|
||||
state = state.copyWith(audioQuality: quality);
|
||||
}
|
||||
|
||||
/// Toggle download on mobile data
|
||||
Future<void> toggleDownloadOnMobileData(bool value) async {
|
||||
await _prefs?.setBool(_downloadOnMobileDataKey, value);
|
||||
state = state.copyWith(downloadOnMobileData: value);
|
||||
}
|
||||
|
||||
/// Toggle explicit content
|
||||
Future<void> toggleShowExplicitContent(bool value) async {
|
||||
await _prefs?.setBool(_showExplicitContentKey, value);
|
||||
state = state.copyWith(showExplicitContent: value);
|
||||
}
|
||||
|
||||
/// Toggle crossfade
|
||||
Future<void> toggleCrossfade(bool value) async {
|
||||
await _prefs?.setBool(_crossfadeEnabledKey, value);
|
||||
state = state.copyWith(crossfadeEnabled: value);
|
||||
}
|
||||
|
||||
/// Set crossfade duration
|
||||
Future<void> setCrossfadeDuration(double duration) async {
|
||||
await _prefs?.setDouble(_crossfadeDurationKey, duration);
|
||||
state = state.copyWith(crossfadeDuration: duration);
|
||||
}
|
||||
|
||||
/// Toggle gapless playback
|
||||
Future<void> toggleGaplessPlayback(bool value) async {
|
||||
await _prefs?.setBool(_gaplessPlaybackKey, value);
|
||||
state = state.copyWith(gaplessPlayback: value);
|
||||
}
|
||||
|
||||
/// Toggle normalize volume
|
||||
Future<void> toggleNormalizeVolume(bool value) async {
|
||||
await _prefs?.setBool(_normalizeVolumeKey, value);
|
||||
state = state.copyWith(normalizeVolume: value);
|
||||
}
|
||||
|
||||
/// Calculate cache size
|
||||
Future<void> _calculateCacheSize() async {
|
||||
try {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final appDocDir = await getApplicationDocumentsDirectory();
|
||||
|
||||
final tempSize = _getFolderSize(tempDir);
|
||||
final docSize = _getFolderSize(appDocDir);
|
||||
|
||||
final totalSize = tempSize + docSize;
|
||||
final cacheSizeStr = _formatBytes(totalSize);
|
||||
|
||||
state = state.copyWith(cacheSize: cacheSizeStr);
|
||||
} catch (e) {
|
||||
state = state.copyWith(cacheSize: 'Unknown');
|
||||
}
|
||||
}
|
||||
|
||||
/// Get folder size in bytes
|
||||
int _getFolderSize(Directory dir) {
|
||||
int size = 0;
|
||||
try {
|
||||
if (dir.existsSync()) {
|
||||
dir
|
||||
.listSync(recursive: true, followLinks: false)
|
||||
.forEach((FileSystemEntity entity) {
|
||||
if (entity is File) {
|
||||
size += entity.lengthSync();
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
/// Format bytes to human readable string
|
||||
String _formatBytes(int bytes) {
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||
}
|
||||
|
||||
/// Clear cache
|
||||
Future<void> clearCache() async {
|
||||
state = state.copyWith(isLoading: true);
|
||||
|
||||
try {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
if (await tempDir.exists()) {
|
||||
await _deleteFolderContents(tempDir);
|
||||
}
|
||||
|
||||
await _calculateCacheSize();
|
||||
state = state.copyWith(isLoading: false);
|
||||
} catch (e) {
|
||||
state = state.copyWith(
|
||||
error: e.toString(),
|
||||
isLoading: false,
|
||||
);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete folder contents
|
||||
Future<void> _deleteFolderContents(Directory dir) async {
|
||||
try {
|
||||
if (await dir.exists()) {
|
||||
await for (final entity in dir.list()) {
|
||||
if (entity is File) {
|
||||
await entity.delete();
|
||||
} else if (entity is Directory) {
|
||||
await entity.delete(recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings provider
|
||||
final settingsProvider = StateNotifierProvider<SettingsNotifier, SettingsState>(
|
||||
(ref) {
|
||||
final authApiService = ref.watch(authApiServiceProvider);
|
||||
return SettingsNotifier(authApiService);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,230 @@
|
||||
/// Album Track Tile - Track item for album details
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../domain/entities/track.dart';
|
||||
import '../../../../core/theme/colors.dart';
|
||||
import '../../providers/music_provider.dart';
|
||||
import '../common/cached_network_image_with_fallback.dart';
|
||||
|
||||
class AlbumTrackTile extends ConsumerWidget {
|
||||
final Track track;
|
||||
final int index;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onMenuTap;
|
||||
|
||||
const AlbumTrackTile({
|
||||
required this.track,
|
||||
required this.index,
|
||||
this.onTap,
|
||||
this.onMenuTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final playerState = ref.watch(playerProvider);
|
||||
final isCurrentlyPlaying =
|
||||
playerState.currentTrack?.id == track.id && playerState.isPlaying;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface.withOpacity(isCurrentlyPlaying ? 0.8 : 0.4),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isCurrentlyPlaying
|
||||
? AppColors.cyan.withOpacity(0.5)
|
||||
: AppColors.cyan.withOpacity(0.1),
|
||||
width: isCurrentlyPlaying ? 2 : 1,
|
||||
),
|
||||
boxShadow: isCurrentlyPlaying ? AppColors.cyanGlow : null,
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Track number or playing indicator
|
||||
_buildTrackIndicator(isCurrentlyPlaying),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Track info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
track.title,
|
||||
style: TextStyle(
|
||||
color: isCurrentlyPlaying
|
||||
? AppColors.cyan
|
||||
: AppColors.onBackground,
|
||||
fontWeight:
|
||||
isCurrentlyPlaying ? FontWeight.w700 : FontWeight.w600,
|
||||
fontSize: 15,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (track.artist != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
track.artist!.name,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Duration
|
||||
Text(
|
||||
track.formattedDuration,
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Menu button
|
||||
_buildMenuButton(ref),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrackIndicator(bool isPlaying) {
|
||||
return SizedBox(
|
||||
width: 24,
|
||||
child: isPlaying
|
||||
? _buildPlayingIndicator()
|
||||
: Text(
|
||||
'${index + 1}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlayingIndicator() {
|
||||
return SizedBox(
|
||||
width: 24,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
_buildBar(0.6),
|
||||
const SizedBox(height: 2),
|
||||
_buildBar(1.0),
|
||||
const SizedBox(height: 2),
|
||||
_buildBar(0.4),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBar(double height) {
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
width: 3,
|
||||
height: 8 * height,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cyan,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMenuButton(WidgetRef ref) {
|
||||
return PopupMenuButton<String>(
|
||||
icon: const Icon(
|
||||
Icons.more_vert,
|
||||
color: AppColors.muted,
|
||||
size: 20,
|
||||
),
|
||||
color: AppColors.surface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(
|
||||
color: AppColors.cyan.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
onSelected: (choice) {
|
||||
switch (choice) {
|
||||
case 'queue':
|
||||
ref.read(playerProvider.notifier).addToQueue(track);
|
||||
ScaffoldMessenger.of(ref.context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('${track.title} added to queue'),
|
||||
duration: const Duration(seconds: 2),
|
||||
backgroundColor: AppColors.surface,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
break;
|
||||
case 'playlist':
|
||||
// TODO: Implement add to playlist
|
||||
ScaffoldMessenger.of(ref.context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Add to playlist coming soon'),
|
||||
duration: const Duration(seconds: 2),
|
||||
backgroundColor: AppColors.surface,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem<String>(
|
||||
value: 'queue',
|
||||
child: Row(
|
||||
children: const [
|
||||
Icon(Icons.playlist_add, color: AppColors.cyan, size: 20),
|
||||
SizedBox(width: 12),
|
||||
Text(
|
||||
'Add to queue',
|
||||
style: TextStyle(color: AppColors.onBackground),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
value: 'playlist',
|
||||
child: Row(
|
||||
children: const [
|
||||
Icon(Icons.playlist_play, color: AppColors.violet, size: 20),
|
||||
SizedBox(width: 12),
|
||||
Text(
|
||||
'Add to playlist',
|
||||
style: TextStyle(color: AppColors.onBackground),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/// Album Widgets Export
|
||||
library;
|
||||
|
||||
export 'album_track_tile.dart';
|
||||
@@ -0,0 +1,108 @@
|
||||
/// Artist Album Card - Album item for artist details
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../domain/entities/album.dart';
|
||||
import '../../../../core/theme/colors.dart';
|
||||
import '../common/cached_network_image_with_fallback.dart';
|
||||
|
||||
class ArtistAlbumCard extends StatelessWidget {
|
||||
final Album album;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const ArtistAlbumCard({
|
||||
required this.album,
|
||||
this.onTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 160,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.violet.withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Album art
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(12),
|
||||
),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.accentGradient,
|
||||
),
|
||||
child: CachedNetworkImageWithFallback(
|
||||
imageUrl: album.imageUrl,
|
||||
fallbackIcon: Icons.album,
|
||||
progressColor: AppColors.violet,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Album info
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
album.title,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onBackground,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_buildAlbumSubtitle(),
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _buildAlbumSubtitle() {
|
||||
final parts = <String>[];
|
||||
|
||||
if (album.releaseDate != null) {
|
||||
final year = album.releaseDate!.year;
|
||||
parts.add(year.toString());
|
||||
}
|
||||
|
||||
if (album.totalTracks > 0) {
|
||||
parts.add('${album.totalTracks} songs');
|
||||
}
|
||||
|
||||
return parts.join(' • ');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
/// Artist Track Tile - Track item for artist details
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../../domain/entities/track.dart';
|
||||
import '../../../../core/theme/colors.dart';
|
||||
import '../../providers/music_provider.dart';
|
||||
import '../common/cached_network_image_with_fallback.dart';
|
||||
|
||||
class ArtistTrackTile extends ConsumerWidget {
|
||||
final Track track;
|
||||
final int index;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const ArtistTrackTile({
|
||||
required this.track,
|
||||
required this.index,
|
||||
this.onTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final playerState = ref.watch(playerProvider);
|
||||
final isCurrentlyPlaying =
|
||||
playerState.currentTrack?.id == track.id && playerState.isPlaying;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface.withOpacity(isCurrentlyPlaying ? 0.8 : 0.4),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isCurrentlyPlaying
|
||||
? AppColors.cyan.withOpacity(0.5)
|
||||
: AppColors.cyan.withOpacity(0.1),
|
||||
width: isCurrentlyPlaying ? 2 : 1,
|
||||
),
|
||||
boxShadow: isCurrentlyPlaying ? AppColors.cyanGlow : null,
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Track number or playing indicator
|
||||
_buildTrackIndicator(isCurrentlyPlaying),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Album art
|
||||
_buildAlbumArt(),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Track info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
track.title,
|
||||
style: TextStyle(
|
||||
color: isCurrentlyPlaying
|
||||
? AppColors.cyan
|
||||
: AppColors.onBackground,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 15,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (track.album != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
track.album!.title,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Play count
|
||||
if (track.playCount != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: _buildPlayCount(),
|
||||
),
|
||||
|
||||
// Duration
|
||||
Text(
|
||||
track.formattedDuration,
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Add to queue button
|
||||
_buildAddToQueueButton(ref),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrackIndicator(bool isPlaying) {
|
||||
return SizedBox(
|
||||
width: 24,
|
||||
child: isPlaying
|
||||
? _buildPlayingIndicator()
|
||||
: Text(
|
||||
'${index + 1}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlayingIndicator() {
|
||||
return SizedBox(
|
||||
width: 24,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
_buildBar(0.6),
|
||||
const SizedBox(height: 2),
|
||||
_buildBar(1.0),
|
||||
const SizedBox(height: 2),
|
||||
_buildBar(0.4),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBar(double height) {
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
width: 3,
|
||||
height: 8 * height,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cyan,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlbumArt() {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: CachedNetworkImageWithFallback(
|
||||
imageUrl: track.imageUrl,
|
||||
fallbackIcon: Icons.album,
|
||||
progressColor: AppColors.cyan,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlayCount() {
|
||||
final playCount = track.playCount!;
|
||||
String countText;
|
||||
|
||||
if (playCount >= 1000000) {
|
||||
countText = '${(playCount / 1000000).toStringAsFixed(1)}M';
|
||||
} else if (playCount >= 1000) {
|
||||
countText = '${(playCount / 1000).toStringAsFixed(1)}K';
|
||||
} else {
|
||||
countText = playCount.toString();
|
||||
}
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.play_arrow,
|
||||
size: 16,
|
||||
color: AppColors.muted,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
countText,
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAddToQueueButton(WidgetRef ref) {
|
||||
return IconButton(
|
||||
icon: const Icon(
|
||||
Icons.playlist_add,
|
||||
color: AppColors.muted,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () {
|
||||
ref.read(playerProvider.notifier).addToQueue(track);
|
||||
ScaffoldMessenger.of(ref.context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('${track.title} added to queue'),
|
||||
duration: const Duration(seconds: 2),
|
||||
backgroundColor: AppColors.surface,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
},
|
||||
tooltip: 'Add to queue',
|
||||
splashRadius: 20,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/// Artist Widgets - Export all artist-related widgets
|
||||
library;
|
||||
|
||||
export 'artist_track_tile.dart';
|
||||
export 'artist_album_card.dart';
|
||||
@@ -0,0 +1,62 @@
|
||||
/// Cached network image with themed fallback
|
||||
library;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/colors.dart';
|
||||
|
||||
class CachedNetworkImageWithFallback extends StatelessWidget {
|
||||
final String? imageUrl;
|
||||
final IconData fallbackIcon;
|
||||
final Color? progressColor;
|
||||
final BoxFit? fit;
|
||||
|
||||
const CachedNetworkImageWithFallback({
|
||||
required this.imageUrl,
|
||||
required this.fallbackIcon,
|
||||
this.progressColor,
|
||||
this.fit,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return imageUrl != null && imageUrl!.isNotEmpty
|
||||
? CachedNetworkImage(
|
||||
imageUrl: imageUrl!,
|
||||
fit: fit ?? BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
color: AppColors.surfaceVariant,
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
progressColor ?? AppColors.cyan,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => Container(
|
||||
color: AppColors.surfaceVariant,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
fallbackIcon,
|
||||
color: AppColors.onBackground.withOpacity(0.8),
|
||||
size: 40,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
color: AppColors.surfaceVariant,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
fallbackIcon,
|
||||
color: AppColors.onBackground.withOpacity(0.8),
|
||||
size: 40,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/theme/colors.dart';
|
||||
import '../../providers/music_provider.dart';
|
||||
import '../../pages/player/queue_view_page.dart';
|
||||
|
||||
/// Mini Player Widget
|
||||
class MiniPlayer extends ConsumerWidget {
|
||||
final bool compact;
|
||||
|
||||
const MiniPlayer({
|
||||
super.key,
|
||||
this.compact = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final playerState = ref.watch(playerProvider);
|
||||
final currentTrack = playerState.currentTrack;
|
||||
final isPlaying = playerState.isPlaying;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
// TODO: Open fullscreen player
|
||||
},
|
||||
child: Container(
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border(
|
||||
top: 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: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
// Album art
|
||||
_buildAlbumArt(currentTrack),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Track info
|
||||
Expanded(
|
||||
child: _buildTrackInfo(currentTrack, playerState),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Controls
|
||||
if (!compact)
|
||||
_buildControls(ref, isPlaying)
|
||||
else
|
||||
_buildCompactControls(ref, isPlaying),
|
||||
|
||||
// Queue button
|
||||
if (!compact) _buildQueueButton(context, ref),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlbumArt(dynamic currentTrack) {
|
||||
return Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.accentGradient,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
boxShadow: AppColors.violetGlow,
|
||||
),
|
||||
child: currentTrack?.imageUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Image.network(
|
||||
currentTrack!.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Icon(
|
||||
Icons.music_note,
|
||||
color: AppColors.onBackground,
|
||||
size: 24,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.music_note,
|
||||
color: AppColors.onBackground,
|
||||
size: 24,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrackInfo(dynamic currentTrack, PlayerState playerState) {
|
||||
if (currentTrack == null) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: const [
|
||||
Text(
|
||||
'No track playing',
|
||||
style: TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
SizedBox(height: 2),
|
||||
Text(
|
||||
'Tap to select music',
|
||||
style: TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
if (playerState.isPlaying)
|
||||
Container(
|
||||
width: 4,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(right: 6),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.vert,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
currentTrack.title,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
currentTrack.artist?.name ?? 'Unknown Artist',
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildControls(WidgetRef ref, bool isPlaying) {
|
||||
return Row(
|
||||
children: [
|
||||
_ControlButton(
|
||||
icon: Icons.skip_previous,
|
||||
onTap: () {
|
||||
ref.read(playerProvider.notifier).previous();
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ControlButton(
|
||||
icon: isPlaying ? Icons.pause : Icons.play_arrow,
|
||||
isPrimary: true,
|
||||
onTap: () {
|
||||
if (isPlaying) {
|
||||
ref.read(playerProvider.notifier).pause();
|
||||
} else {
|
||||
ref.read(playerProvider.notifier).play();
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_ControlButton(
|
||||
icon: Icons.skip_next,
|
||||
onTap: () {
|
||||
ref.read(playerProvider.notifier).next();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompactControls(WidgetRef ref, bool isPlaying) {
|
||||
return _ControlButton(
|
||||
icon: isPlaying ? Icons.pause : Icons.play_arrow,
|
||||
isPrimary: true,
|
||||
size: 40,
|
||||
onTap: () {
|
||||
if (isPlaying) {
|
||||
ref.read(playerProvider.notifier).pause();
|
||||
} else {
|
||||
ref.read(playerProvider.notifier).play();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQueueButton(BuildContext context, WidgetRef ref) {
|
||||
final queueData = ref.watch(queueProvider);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: () => _openQueueView(context),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: queueData.hasNextTracks
|
||||
? AppColors.violet.withOpacity(0.5)
|
||||
: Colors.transparent,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.queue_music,
|
||||
color: AppColors.onSurface,
|
||||
size: 20,
|
||||
),
|
||||
if (queueData.hasNextTracks)
|
||||
Positioned(
|
||||
top: 6,
|
||||
right: 6,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.violet,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
queueData.queueCount > 9
|
||||
? '9+'
|
||||
: '${queueData.queueCount}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.primary,
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _openQueueView(BuildContext context) {
|
||||
Navigator.of(context).push(
|
||||
PageRouteBuilder(
|
||||
pageBuilder: (context, animation, secondaryAnimation) {
|
||||
return const QueueViewPage();
|
||||
},
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
const begin = Offset(0.0, 1.0);
|
||||
const end = Offset.zero;
|
||||
const curve = Curves.easeInOut;
|
||||
|
||||
var tween = Tween(begin: begin, end: end).chain(
|
||||
CurveTween(curve: curve),
|
||||
);
|
||||
|
||||
return SlideTransition(
|
||||
position: animation.drive(tween),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Control Button
|
||||
class _ControlButton extends StatefulWidget {
|
||||
final IconData icon;
|
||||
final bool isPrimary;
|
||||
final double? size;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ControlButton({
|
||||
required this.icon,
|
||||
this.isPrimary = false,
|
||||
this.size,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_ControlButton> createState() => _ControlButtonState();
|
||||
}
|
||||
|
||||
class _ControlButtonState extends State<_ControlButton>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _scaleAnimation;
|
||||
bool _isPressed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
vsync: this,
|
||||
);
|
||||
_scaleAnimation = Tween<double>(begin: 1.0, end: 0.95).animate(
|
||||
CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOut,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleTapDown(TapDownDetails details) {
|
||||
setState(() => _isPressed = true);
|
||||
_animationController.forward();
|
||||
}
|
||||
|
||||
void _handleTapUp(TapUpDetails details) {
|
||||
setState(() => _isPressed = false);
|
||||
_animationController.reverse();
|
||||
}
|
||||
|
||||
void _handleTapCancel() {
|
||||
setState(() => _isPressed = false);
|
||||
_animationController.reverse();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = widget.size ?? (widget.isPrimary ? 50 : 40);
|
||||
|
||||
return GestureDetector(
|
||||
onTapDown: _handleTapDown,
|
||||
onTapUp: _handleTapUp,
|
||||
onTapCancel: _handleTapCancel,
|
||||
onTap: widget.onTap,
|
||||
child: ScaleTransition(
|
||||
scale: _scaleAnimation,
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.isPrimary
|
||||
? AppColors.cyan
|
||||
: AppColors.surfaceVariant,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: widget.isPrimary ? AppColors.cyanGlow : null,
|
||||
),
|
||||
child: Icon(
|
||||
widget.icon,
|
||||
color: widget.isPrimary ? AppColors.primary : AppColors.onSurface,
|
||||
size: size * 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/theme/colors.dart';
|
||||
import '../../providers/navigation_provider.dart';
|
||||
import '../common/mini_player.dart';
|
||||
|
||||
/// Desktop Navigation Sidebar
|
||||
class DesktopSidebar extends ConsumerWidget {
|
||||
final double width;
|
||||
|
||||
const DesktopSidebar({
|
||||
super.key,
|
||||
required this.width,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final currentPage = ref.watch(currentPageProvider);
|
||||
final navigationNotifier = ref.read(navigationProvider.notifier);
|
||||
|
||||
return Container(
|
||||
width: width,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border(
|
||||
right: BorderSide(
|
||||
color: AppColors.cyan.withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Logo
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'Spotify Le 2',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
foreground: AppColors.primaryGradient,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(height: 1, color: AppColors.surfaceVariant),
|
||||
|
||||
// Navigation items
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
children: [
|
||||
..._navItems.map(
|
||||
(item) => _NavItemTile(
|
||||
icon: item.icon,
|
||||
label: item.label,
|
||||
isSelected: currentPage == item.page,
|
||||
onTap: () => navigationNotifier.navigateTo(item.page),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Mini player in sidebar
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: MiniPlayer(compact: true),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NavItem {
|
||||
final String page;
|
||||
final String label;
|
||||
final IconData icon;
|
||||
|
||||
const _NavItem({
|
||||
required this.page,
|
||||
required this.label,
|
||||
required this.icon,
|
||||
});
|
||||
}
|
||||
|
||||
final List<_NavItem> _navItems = const [
|
||||
_NavItem(page: 'home', label: 'Home', icon: Icons.home_outlined),
|
||||
_NavItem(page: 'search', label: 'Search', icon: Icons.search_outlined),
|
||||
_NavItem(page: 'library', label: 'Library', icon: Icons.library_music_outlined),
|
||||
_NavItem(page: 'settings', label: 'Settings', icon: Icons.settings_outlined),
|
||||
];
|
||||
|
||||
/// Navigation Item Tile
|
||||
class _NavItemTile extends StatefulWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _NavItemTile({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_NavItemTile> createState() => _NavItemTileState();
|
||||
}
|
||||
|
||||
class _NavItemTileState extends State<_NavItemTile>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _scaleAnimation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
vsync: this,
|
||||
);
|
||||
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.02).animate(
|
||||
CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOut,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MouseRegion(
|
||||
onEnter: (_) => _animationController.forward(),
|
||||
onExit: (_) => _animationController.reverse(),
|
||||
child: ScaleTransition(
|
||||
scale: _scaleAnimation,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.isSelected
|
||||
? AppColors.cyan.withOpacity(0.1)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: widget.isSelected
|
||||
? AppColors.cyan.withOpacity(0.3)
|
||||
: Colors.transparent,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
widget.icon,
|
||||
color: widget.isSelected ? AppColors.cyan : AppColors.onSurface,
|
||||
),
|
||||
title: Text(
|
||||
widget.label,
|
||||
style: TextStyle(
|
||||
color: widget.isSelected
|
||||
? AppColors.cyan
|
||||
: AppColors.onSurface,
|
||||
fontWeight:
|
||||
widget.isSelected ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
onTap: widget.onTap,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/colors.dart';
|
||||
|
||||
/// Desktop Top Bar
|
||||
class DesktopTopBar extends StatelessWidget {
|
||||
const DesktopTopBar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 60,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: AppColors.cyan.withOpacity(0.1),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Search bar
|
||||
Expanded(
|
||||
child: _SearchBar(),
|
||||
),
|
||||
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// User profile
|
||||
// TODO: Implement user profile menu
|
||||
const _UserAvatar(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Search Bar
|
||||
class _SearchBar extends StatefulWidget {
|
||||
@override
|
||||
State<_SearchBar> createState() => _SearchBarState();
|
||||
}
|
||||
|
||||
class _SearchBarState extends State<_SearchBar> {
|
||||
final _focusNode = FocusNode();
|
||||
bool _isFocused = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focusNode.addListener(() {
|
||||
setState(() {
|
||||
_isFocused = _focusNode.hasFocus;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: _isFocused ? AppColors.cyan : AppColors.cyan.withOpacity(0.2),
|
||||
width: _isFocused ? 2 : 1,
|
||||
),
|
||||
boxShadow: _isFocused
|
||||
? [
|
||||
BoxShadow(
|
||||
color: AppColors.cyan.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
spreadRadius: 0,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: TextField(
|
||||
focusNode: _focusNode,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontSize: 14,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search tracks, artists, albums...',
|
||||
hintStyle: TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 14,
|
||||
),
|
||||
prefixIcon: const Icon(
|
||||
Icons.search,
|
||||
color: AppColors.cyan,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// User Avatar
|
||||
class _UserAvatar extends StatelessWidget {
|
||||
const _UserAvatar();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: AppColors.cyanGlow,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.person,
|
||||
color: AppColors.onBackground,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/theme/colors.dart';
|
||||
import '../../../../domain/entities/playlist.dart';
|
||||
import '../common/cached_network_image_with_fallback.dart';
|
||||
|
||||
/// Playlist tile widget for library
|
||||
class PlaylistTile extends StatelessWidget {
|
||||
final Playlist playlist;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onDelete;
|
||||
final bool canDelete;
|
||||
|
||||
const PlaylistTile({
|
||||
required this.playlist,
|
||||
this.onTap,
|
||||
this.onDelete,
|
||||
this.canDelete = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.cyan.withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Playlist cover
|
||||
ClipRRect(
|
||||
borderRadius: const BorderRadius.horizontal(
|
||||
left: Radius.circular(12),
|
||||
),
|
||||
child: SizedBox(
|
||||
width: 80,
|
||||
height: 80,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.primaryGradient,
|
||||
),
|
||||
child: CachedNetworkImageWithFallback(
|
||||
imageUrl: playlist.imageUrl,
|
||||
fallbackIcon: Icons.playlist_play,
|
||||
progressColor: AppColors.cyan,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Playlist info
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
playlist.name,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${playlist.trackCount} songs • ${playlist.formattedDuration}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
if (playlist.description != null &&
|
||||
playlist.description!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
playlist.description!,
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Delete button (if owned)
|
||||
if (canDelete && onDelete != null)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline, color: AppColors.muted),
|
||||
onPressed: onDelete,
|
||||
tooltip: 'Delete playlist',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../core/theme/colors.dart';
|
||||
import '../../../../domain/entities/track.dart';
|
||||
|
||||
/// Queue Track Tile Widget
|
||||
///
|
||||
/// Displays a track in the queue with:
|
||||
/// - Track info (art, title, artist, duration)
|
||||
/// - Remove button
|
||||
/// - Drag handle
|
||||
/// - Visual indication for currently playing track
|
||||
class QueueTrackTile extends StatelessWidget {
|
||||
final Track track;
|
||||
final bool isPlaying;
|
||||
final int index;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onRemove;
|
||||
final bool isDragging;
|
||||
|
||||
const QueueTrackTile({
|
||||
super.key,
|
||||
required this.track,
|
||||
this.isPlaying = false,
|
||||
required this.index,
|
||||
this.onTap,
|
||||
this.onRemove,
|
||||
this.isDragging = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isPlaying
|
||||
? AppColors.cyan.withOpacity(0.1)
|
||||
: AppColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isPlaying
|
||||
? AppColors.cyan.withOpacity(0.3)
|
||||
: AppColors.surfaceVariant,
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: isPlaying
|
||||
? [
|
||||
BoxShadow(
|
||||
color: AppColors.cyan.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
// Drag handle
|
||||
_buildDragHandle(),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Track number or playing indicator
|
||||
_buildTrackIndicator(),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Album art
|
||||
_buildAlbumArt(),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Track info
|
||||
Expanded(
|
||||
child: _buildTrackInfo(),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Duration
|
||||
_buildDuration(),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Remove button
|
||||
if (onRemove != null) _buildRemoveButton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDragHandle() {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.grab,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Icon(
|
||||
Icons.drag_handle,
|
||||
color: AppColors.muted,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrackIndicator() {
|
||||
if (isPlaying) {
|
||||
return SizedBox(
|
||||
width: 20,
|
||||
child: _PlayingAnimation(),
|
||||
);
|
||||
}
|
||||
|
||||
return SizedBox(
|
||||
width: 20,
|
||||
child: Text(
|
||||
'${index + 1}',
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlbumArt() {
|
||||
return Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.accentGradient,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: isPlaying ? AppColors.violetGlow : null,
|
||||
),
|
||||
child: track.imageUrl != null
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.network(
|
||||
track.imageUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Icon(
|
||||
Icons.music_note,
|
||||
color: AppColors.onBackground,
|
||||
size: 24,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.music_note,
|
||||
color: AppColors.onBackground,
|
||||
size: 24,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrackInfo() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
track.title,
|
||||
style: TextStyle(
|
||||
color: isPlaying ? AppColors.cyan : AppColors.onSurface,
|
||||
fontSize: 14,
|
||||
fontWeight: isPlaying ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
track.artist?.name ?? 'Unknown Artist',
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDuration() {
|
||||
return Text(
|
||||
track.formattedDuration,
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRemoveButton() {
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: onRemove,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.rouge.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.close,
|
||||
color: AppColors.rouge,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Playing Animation Widget
|
||||
class _PlayingAnimation extends StatefulWidget {
|
||||
@override
|
||||
State<_PlayingAnimation> createState() => _PlayingAnimationState();
|
||||
}
|
||||
|
||||
class _PlayingAnimationState extends State<_PlayingAnimation>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 800),
|
||||
vsync: this,
|
||||
)..repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(3, (index) {
|
||||
final delay = index * 0.2;
|
||||
final animation = _controller
|
||||
.drive(CurveTween(curve: Curves.easeInOut))
|
||||
.drive(Tween<double>(begin: 0.3, end: 1.0));
|
||||
|
||||
return Transform.scale(
|
||||
scale: (animation.value - delay + 1) % 1 * 0.7 + 0.3,
|
||||
child: Container(
|
||||
width: 3,
|
||||
height: 12,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cyan,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
/// Playlist Track Tile - Displays a track in a playlist with drag-to-reorder support
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../domain/entities/track.dart';
|
||||
import '../../../../core/theme/colors.dart';
|
||||
import '../common/cached_network_image_with_fallback.dart';
|
||||
|
||||
/// Playlist track tile
|
||||
class PlaylistTrackTile extends StatelessWidget {
|
||||
final Track track;
|
||||
final int position;
|
||||
final bool isOwner;
|
||||
final bool isDragging;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onRemove;
|
||||
final VoidCallback? onAddToQueue;
|
||||
final VoidCallback? onDragStart;
|
||||
|
||||
const PlaylistTrackTile({
|
||||
required this.track,
|
||||
required this.position,
|
||||
this.isOwner = false,
|
||||
this.isDragging = false,
|
||||
this.onTap,
|
||||
this.onRemove,
|
||||
this.onAddToQueue,
|
||||
this.onDragStart,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isDragging
|
||||
? AppColors.surfaceElevated.withOpacity(0.5)
|
||||
: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isDragging
|
||||
? AppColors.cyan.withOpacity(0.5)
|
||||
: Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
// Drag handle (for owners)
|
||||
if (isOwner)
|
||||
_buildDragHandle()
|
||||
else
|
||||
_buildPositionNumber(),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Album art
|
||||
_buildAlbumArt(),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Track info
|
||||
Expanded(
|
||||
child: _buildTrackInfo(),
|
||||
),
|
||||
|
||||
// Duration
|
||||
_buildDuration(),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Menu button
|
||||
_buildMenuButton(context),
|
||||
|
||||
// Remove button (for owners)
|
||||
if (isOwner) ...[
|
||||
const SizedBox(width: 8),
|
||||
_buildRemoveButton(),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDragHandle() {
|
||||
return GestureDetector(
|
||||
onPanStart: (_) => onDragStart?.call(),
|
||||
child: Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
alignment: Alignment.center,
|
||||
child: Icon(
|
||||
Icons.drag_handle,
|
||||
color: AppColors.onSurfaceVariant,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPositionNumber() {
|
||||
return SizedBox(
|
||||
width: 24,
|
||||
child: Text(
|
||||
'${position + 1}',
|
||||
style: TextStyle(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAlbumArt() {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: CachedNetworkImageWithFallback(
|
||||
imageUrl: track.imageUrl,
|
||||
fallbackIcon: Icons.music_note,
|
||||
progressColor: AppColors.cyan,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrackInfo() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
track.title,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onBackground,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
track.artist?.name ?? 'Unknown Artist',
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDuration() {
|
||||
return Text(
|
||||
track.formattedDuration,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMenuButton(BuildContext context) {
|
||||
return PopupMenuButton<String>(
|
||||
icon: Icon(
|
||||
Icons.more_vert,
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
color: AppColors.surfaceElevated,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
side: BorderSide(
|
||||
color: AppColors.cyan.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
onSelected: (value) {
|
||||
switch (value) {
|
||||
case 'queue':
|
||||
onAddToQueue?.call();
|
||||
break;
|
||||
case 'remove':
|
||||
onRemove?.call();
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: 'queue',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.queue_music, color: AppColors.cyan, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'Add to queue',
|
||||
style: TextStyle(color: AppColors.onBackground),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isOwner)
|
||||
PopupMenuItem(
|
||||
value: 'remove',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.remove_circle_outline, color: AppColors.rose, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'Remove from playlist',
|
||||
style: TextStyle(color: AppColors.onBackground),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRemoveButton() {
|
||||
return IconButton(
|
||||
icon: Icon(
|
||||
Icons.remove_circle_outline,
|
||||
color: AppColors.rose,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: onRemove,
|
||||
tooltip: 'Remove from playlist',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(
|
||||
minWidth: 32,
|
||||
minHeight: 32,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reorderable playlist track tile
|
||||
class ReorderablePlaylistTrackTile extends StatelessWidget {
|
||||
final Track track;
|
||||
final int position;
|
||||
final bool isOwner;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onRemove;
|
||||
final VoidCallback? onAddToQueue;
|
||||
|
||||
const ReorderablePlaylistTrackTile({
|
||||
required this.track,
|
||||
required this.position,
|
||||
this.isOwner = false,
|
||||
this.onTap,
|
||||
this.onRemove,
|
||||
this.onAddToQueue,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ReorderableDragStartListener(
|
||||
key: ValueKey(track.id),
|
||||
index: position,
|
||||
enabled: isOwner,
|
||||
child: PlaylistTrackTile(
|
||||
track: track,
|
||||
position: position,
|
||||
isOwner: isOwner,
|
||||
onTap: onTap,
|
||||
onRemove: onRemove,
|
||||
onAddToQueue: onAddToQueue,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../domain/entities/album.dart';
|
||||
import '../common/cached_network_image_with_fallback.dart';
|
||||
|
||||
/// Search result card for an album
|
||||
class SearchAlbumCard extends StatelessWidget {
|
||||
final Album album;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const SearchAlbumCard({
|
||||
required this.album,
|
||||
this.onTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.rose.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Album cover or placeholder
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(12),
|
||||
),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.fullGradient,
|
||||
),
|
||||
child: CachedNetworkImageWithFallback(
|
||||
imageUrl: album.imageUrl,
|
||||
fallbackIcon: Icons.album,
|
||||
progressColor: AppColors.rose,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Album info
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../domain/entities/artist.dart';
|
||||
import '../common/cached_network_image_with_fallback.dart';
|
||||
|
||||
/// Search result card for an artist
|
||||
class SearchArtistCard extends StatelessWidget {
|
||||
final Artist artist;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const SearchArtistCard({
|
||||
required this.artist,
|
||||
this.onTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.violet.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Artist image or placeholder
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(12),
|
||||
),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.accentGradient,
|
||||
),
|
||||
child: CachedNetworkImageWithFallback(
|
||||
imageUrl: artist.imageUrl,
|
||||
fallbackIcon: Icons.person,
|
||||
progressColor: AppColors.violet,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Artist name
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../../domain/entities/track.dart';
|
||||
import '../../../../core/theme/colors.dart';
|
||||
import '../common/cached_network_image_with_fallback.dart';
|
||||
|
||||
/// Search result card for a track
|
||||
class SearchTrackCard extends StatelessWidget {
|
||||
final Track track;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const SearchTrackCard({
|
||||
required this.track,
|
||||
this.onTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.cyan.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// Thumbnail or icon
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: CachedNetworkImageWithFallback(
|
||||
imageUrl: track.imageUrl,
|
||||
fallbackIcon: Icons.music_note,
|
||||
progressColor: AppColors.cyan,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Track info
|
||||
Text(
|
||||
track.title,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onBackground,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
track.artist?.name ?? 'Unknown Artist',
|
||||
style: const TextStyle(
|
||||
color: AppColors.onBackground,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/// Search Widgets Export
|
||||
library;
|
||||
|
||||
export 'search_track_card.dart';
|
||||
export 'search_artist_card.dart';
|
||||
export 'search_album_card.dart';
|
||||
@@ -0,0 +1,247 @@
|
||||
/// Audio Quality Selector Widget
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/theme/colors.dart';
|
||||
import '../../../core/theme/text_styles.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
|
||||
/// Audio quality selector widget
|
||||
class AudioQualitySelector extends ConsumerWidget {
|
||||
const AudioQualitySelector({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settingsState = ref.watch(settingsProvider);
|
||||
final currentQuality = settingsState.audioQuality;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppColors.cyan.withOpacity(0.15),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cyan.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.high_quality_outlined,
|
||||
color: AppColors.cyan,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Audio Quality',
|
||||
style: AppTextStyles.body.copyWith(
|
||||
color: AppColors.onBackground,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Higher quality uses more data',
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: AppColors.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, color: AppColors.surfaceVariant),
|
||||
// Audio quality options
|
||||
_buildQualityOption(
|
||||
context,
|
||||
ref,
|
||||
AudioQuality.low,
|
||||
'Low',
|
||||
'96 kbps',
|
||||
'Best for data saving',
|
||||
currentQuality,
|
||||
),
|
||||
_buildQualityOption(
|
||||
context,
|
||||
ref,
|
||||
AudioQuality.medium,
|
||||
'Medium',
|
||||
'160 kbps',
|
||||
'Good balance',
|
||||
currentQuality,
|
||||
),
|
||||
_buildQualityOption(
|
||||
context,
|
||||
ref,
|
||||
AudioQuality.high,
|
||||
'High',
|
||||
'320 kbps',
|
||||
'Best quality',
|
||||
currentQuality,
|
||||
),
|
||||
_buildQualityOption(
|
||||
context,
|
||||
ref,
|
||||
AudioQuality.lossless,
|
||||
'Lossless',
|
||||
'FLAC',
|
||||
'Requires Premium',
|
||||
currentQuality,
|
||||
requiresPremium: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQualityOption(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
AudioQuality quality,
|
||||
String title,
|
||||
String bitrate,
|
||||
String description,
|
||||
AudioQuality currentQuality, {
|
||||
bool requiresPremium = false,
|
||||
}) {
|
||||
final isSelected = currentQuality == quality;
|
||||
final settingsState = ref.watch(settingsProvider);
|
||||
final isPremium = settingsState.user?.isPremium ?? false;
|
||||
final isLocked = requiresPremium && !isPremium;
|
||||
|
||||
return InkWell(
|
||||
onTap: isLocked
|
||||
? null
|
||||
: () => ref.read(settingsProvider.notifier).setAudioQuality(quality),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? AppColors.cyan.withOpacity(0.1)
|
||||
: Colors.transparent,
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: isSelected ? AppColors.cyan : Colors.transparent,
|
||||
width: 3,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: AppTextStyles.body.copyWith(
|
||||
color: isLocked
|
||||
? AppColors.muted
|
||||
: AppColors.onBackground,
|
||||
fontWeight:
|
||||
isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (requiresPremium) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.violet.withOpacity(0.2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'PRO',
|
||||
style: AppTextStyles.caption.copyWith(
|
||||
color: AppColors.violet,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceVariant,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
bitrate,
|
||||
style: AppTextStyles.caption.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
description,
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: AppColors.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isLocked) ...[
|
||||
Icon(
|
||||
Icons.lock_outline,
|
||||
color: AppColors.muted,
|
||||
size: 20,
|
||||
),
|
||||
] else if (isSelected) ...[
|
||||
Icon(
|
||||
Icons.check_circle,
|
||||
color: AppColors.cyan,
|
||||
size: 24,
|
||||
),
|
||||
] else ...[
|
||||
Icon(
|
||||
Icons.radio_button_unchecked,
|
||||
color: AppColors.muted,
|
||||
size: 24,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
/// Cache Management Tile Widget
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/theme/colors.dart';
|
||||
import '../../../core/theme/text_styles.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
|
||||
/// Cache management tile widget
|
||||
class CacheManagementTile extends ConsumerWidget {
|
||||
const CacheManagementTile({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settingsState = ref.watch(settingsProvider);
|
||||
final cacheSize = settingsState.cacheSize;
|
||||
final isLoading = settingsState.isLoading;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppColors.cyan.withOpacity(0.15),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cyan.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.storage_outlined,
|
||||
color: AppColors.cyan,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Storage',
|
||||
style: AppTextStyles.body.copyWith(
|
||||
color: AppColors.onBackground,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'Cache and offline data',
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: AppColors.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Cache size display
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceVariant.withOpacity(0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Cache Size',
|
||||
style: AppTextStyles.body.copyWith(
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
cacheSize,
|
||||
style: AppTextStyles.h3.copyWith(
|
||||
color: AppColors.cyan,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Icon(
|
||||
Icons.folder_outlined,
|
||||
color: AppColors.muted,
|
||||
size: 32,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// Clear cache button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () => _showClearCacheDialog(context, ref),
|
||||
icon: isLoading
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
AppColors.cyan,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.delete_outline, size: 18),
|
||||
label: Text(
|
||||
isLoading ? 'Clearing...' : 'Clear Cache',
|
||||
style: AppTextStyles.button,
|
||||
),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
side: BorderSide(
|
||||
color: AppColors.rose.withOpacity(0.5),
|
||||
width: 1.5,
|
||||
),
|
||||
foregroundColor: AppColors.rose,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showClearCacheDialog(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.warning_outlined,
|
||||
color: AppColors.rose,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'Clear Cache',
|
||||
style: AppTextStyles.h3.copyWith(
|
||||
color: AppColors.onBackground,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
content: Text(
|
||||
'This will delete all cached data. You may need to re-download content for offline use.\n\nContinue?',
|
||||
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);
|
||||
try {
|
||||
await ref.read(settingsProvider.notifier).clearCache();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Cache cleared successfully',
|
||||
style: AppTextStyles.body.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
backgroundColor: AppColors.vert,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Failed to clear cache: ${e.toString()}',
|
||||
style: AppTextStyles.body.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
backgroundColor: AppColors.error,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.rose,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
child: Text(
|
||||
'Clear',
|
||||
style: AppTextStyles.button,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
/// Edit Profile Dialog Widget
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import '../../../core/theme/colors.dart';
|
||||
import '../../../core/theme/text_styles.dart';
|
||||
import '../../../domain/entities/user.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
|
||||
/// Edit profile dialog
|
||||
class EditProfileDialog extends ConsumerStatefulWidget {
|
||||
const EditProfileDialog({
|
||||
super.key,
|
||||
required this.user,
|
||||
});
|
||||
|
||||
final User user;
|
||||
|
||||
@override
|
||||
ConsumerState<EditProfileDialog> createState() => _EditProfileDialogState();
|
||||
}
|
||||
|
||||
class _EditProfileDialogState extends ConsumerState<EditProfileDialog> {
|
||||
late final TextEditingController _displayNameController;
|
||||
final ImagePicker _imagePicker = ImagePicker();
|
||||
String? _avatarUrl;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_displayNameController = TextEditingController(
|
||||
text: widget.user.displayName ?? widget.user.username,
|
||||
);
|
||||
_avatarUrl = widget.user.avatarUrl;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_displayNameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickImage() async {
|
||||
try {
|
||||
final XFile? image = await _imagePicker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
maxWidth: 512,
|
||||
maxHeight: 512,
|
||||
imageQuality: 85,
|
||||
);
|
||||
|
||||
if (image != null && mounted) {
|
||||
// For now, just show the selected image
|
||||
// In production, you would upload this to your server
|
||||
setState(() {
|
||||
_avatarUrl = image.path;
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Image selected. Note: Avatar upload requires server implementation.',
|
||||
style: AppTextStyles.body.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
backgroundColor: AppColors.info,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 3),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Failed to pick image: ${e.toString()}',
|
||||
style: AppTextStyles.body.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
backgroundColor: AppColors.error,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveProfile() async {
|
||||
final displayName = _displayNameController.text.trim();
|
||||
|
||||
if (displayName.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Display name cannot be empty',
|
||||
style: AppTextStyles.body.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
backgroundColor: AppColors.error,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.pop(context);
|
||||
|
||||
try {
|
||||
await ref.read(settingsProvider.notifier).updateProfile(
|
||||
displayName: displayName,
|
||||
avatarUrl: _avatarUrl,
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Profile updated successfully',
|
||||
style: AppTextStyles.body.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
backgroundColor: AppColors.vert,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Failed to update profile: ${e.toString()}',
|
||||
style: AppTextStyles.body.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
backgroundColor: AppColors.error,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
backgroundColor: AppColors.surface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
side: BorderSide(
|
||||
color: AppColors.cyan.withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Edit Profile',
|
||||
style: AppTextStyles.h3.copyWith(
|
||||
color: AppColors.onBackground,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: const Icon(Icons.close),
|
||||
color: AppColors.muted,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// Avatar
|
||||
Center(
|
||||
child: GestureDetector(
|
||||
onTap: _pickImage,
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: AppColors.primaryGradient,
|
||||
boxShadow: AppColors.cyanGlow,
|
||||
),
|
||||
child: ClipOval(
|
||||
child: _avatarUrl != null
|
||||
// Check if it's a network URL or local file path
|
||||
? (_avatarUrl!.startsWith('http')
|
||||
? Image.network(
|
||||
_avatarUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder:
|
||||
(context, error, stackTrace) {
|
||||
return _buildDefaultAvatar();
|
||||
},
|
||||
)
|
||||
: Image.file(
|
||||
// Use File for local path
|
||||
// ignore: unnecessary_null_comparison
|
||||
_avatarUrl != null
|
||||
? _avatarUrl as Object
|
||||
: Object(),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder:
|
||||
(context, error, stackTrace) {
|
||||
return _buildDefaultAvatar();
|
||||
},
|
||||
))
|
||||
: _buildDefaultAvatar(),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cyan,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: AppColors.surface,
|
||||
width: 3,
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.camera_alt_outlined,
|
||||
color: AppColors.primary,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: Text(
|
||||
'Tap to change photo',
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: AppColors.cyan,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// Display name field
|
||||
Text(
|
||||
'Display Name',
|
||||
style: AppTextStyles.label.copyWith(
|
||||
color: AppColors.onSurface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextField(
|
||||
controller: _displayNameController,
|
||||
style: AppTextStyles.body.copyWith(
|
||||
color: AppColors.onBackground,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Enter display name',
|
||||
hintStyle: AppTextStyles.body.copyWith(
|
||||
color: AppColors.muted,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: AppColors.surfaceVariant.withOpacity(0.5),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: AppColors.cyan.withOpacity(0.2),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: AppColors.cyan.withOpacity(0.2),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(
|
||||
color: AppColors.cyan,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// Buttons
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
side: BorderSide(
|
||||
color: AppColors.muted.withOpacity(0.5),
|
||||
width: 1.5,
|
||||
),
|
||||
foregroundColor: AppColors.muted,
|
||||
),
|
||||
child: Text(
|
||||
'Cancel',
|
||||
style: AppTextStyles.button,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: _saveProfile,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
backgroundColor: AppColors.cyan,
|
||||
foregroundColor: AppColors.primary,
|
||||
),
|
||||
child: Text(
|
||||
'Save',
|
||||
style: AppTextStyles.button,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDefaultAvatar() {
|
||||
final firstLetter = (widget.user.displayName ?? widget.user.username)
|
||||
.substring(0, 1)
|
||||
.toUpperCase();
|
||||
|
||||
return Container(
|
||||
color: AppColors.surfaceVariant,
|
||||
child: Center(
|
||||
child: Text(
|
||||
firstLetter,
|
||||
style: AppTextStyles.h2.copyWith(
|
||||
color: AppColors.cyan,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/// Profile Section Widget
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/theme/colors.dart';
|
||||
import '../../../core/theme/text_styles.dart';
|
||||
import '../../../domain/entities/user.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
import 'edit_profile_dialog.dart';
|
||||
|
||||
/// Profile section widget
|
||||
class ProfileSection extends ConsumerWidget {
|
||||
const ProfileSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final settingsState = ref.watch(settingsProvider);
|
||||
final user = settingsState.user;
|
||||
|
||||
if (user == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
AppColors.surface,
|
||||
AppColors.surfaceVariant,
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: AppColors.cyan.withOpacity(0.2),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.cyan.withOpacity(0.1),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
children: [
|
||||
// Avatar and name
|
||||
Row(
|
||||
children: [
|
||||
// Avatar
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: AppColors.primaryGradient,
|
||||
boxShadow: AppColors.cyanGlow,
|
||||
),
|
||||
child: ClipOval(
|
||||
child: user.avatarUrl != null
|
||||
? Image.network(
|
||||
user.avatarUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return _buildDefaultAvatar(user);
|
||||
},
|
||||
)
|
||||
: _buildDefaultAvatar(user),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
// Name and email
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
user.displayName ?? user.username,
|
||||
style: AppTextStyles.h3.copyWith(
|
||||
color: AppColors.onBackground,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (user.isPremium) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.accentGradient,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.violet.withOpacity(0.3),
|
||||
blurRadius: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
'PREMIUM',
|
||||
style: AppTextStyles.caption.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
user.email,
|
||||
style: AppTextStyles.body.copyWith(
|
||||
color: AppColors.muted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'@${user.username}',
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// Edit Profile Button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _showEditProfileDialog(context, ref, user),
|
||||
icon: const Icon(Icons.edit_outlined, size: 18),
|
||||
label: const Text('Edit Profile'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
backgroundColor: AppColors.cyan.withOpacity(0.15),
|
||||
foregroundColor: AppColors.cyan,
|
||||
elevation: 0,
|
||||
side: BorderSide(
|
||||
color: AppColors.cyan.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDefaultAvatar(User user) {
|
||||
return Container(
|
||||
color: AppColors.surfaceVariant,
|
||||
child: Center(
|
||||
child: Text(
|
||||
(user.displayName ?? user.username)
|
||||
.substring(0, 1)
|
||||
.toUpperCase(),
|
||||
style: AppTextStyles.h2.copyWith(
|
||||
color: AppColors.cyan,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showEditProfileDialog(BuildContext context, WidgetRef ref, User user) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => EditProfileDialog(user: user),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
/// Settings Tile - Reusable settings item widget
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/colors.dart';
|
||||
import '../../../core/theme/text_styles.dart';
|
||||
|
||||
/// Reusable settings tile widget
|
||||
class SettingsTile extends StatelessWidget {
|
||||
const SettingsTile({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.leading,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
this.isEnabled = true,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final Widget? leading;
|
||||
final Widget? trailing;
|
||||
final VoidCallback? onTap;
|
||||
final bool isEnabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Opacity(
|
||||
opacity: isEnabled ? 1.0 : 0.5,
|
||||
child: InkWell(
|
||||
onTap: isEnabled ? onTap : null,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (leading != null) ...[
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cyan.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: IconTheme(
|
||||
data: IconThemeData(
|
||||
color: AppColors.cyan,
|
||||
size: 20,
|
||||
),
|
||||
child: leading!,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: AppTextStyles.body.copyWith(
|
||||
color: AppColors.onBackground,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subtitle!,
|
||||
style: AppTextStyles.bodySmall.copyWith(
|
||||
color: AppColors.muted,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) trailing!,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings tile with toggle switch
|
||||
class SettingsToggleTile extends StatelessWidget {
|
||||
const SettingsToggleTile({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.leading,
|
||||
required this.value,
|
||||
required this.onChanged,
|
||||
this.isEnabled = true,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final Widget? leading;
|
||||
final bool value;
|
||||
final ValueChanged<bool>? onChanged;
|
||||
final bool isEnabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SettingsTile(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
leading: leading,
|
||||
isEnabled: isEnabled,
|
||||
trailing: Switch(
|
||||
value: value,
|
||||
onChanged: isEnabled ? onChanged : null,
|
||||
activeColor: AppColors.cyan,
|
||||
activeTrackColor: AppColors.cyan.withOpacity(0.3),
|
||||
inactiveTrackColor: AppColors.surfaceVariant,
|
||||
inactiveThumbColor: AppColors.muted,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings section header
|
||||
class SettingsSectionHeader extends StatelessWidget {
|
||||
const SettingsSectionHeader({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.padding = const EdgeInsets.fromLTRB(16, 24, 16, 8),
|
||||
});
|
||||
|
||||
final String title;
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: padding,
|
||||
child: Text(
|
||||
title.toUpperCase(),
|
||||
style: AppTextStyles.label.copyWith(
|
||||
color: AppColors.cyan,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings card container
|
||||
class SettingsCard extends StatelessWidget {
|
||||
const SettingsCard({
|
||||
super.key,
|
||||
required this.children,
|
||||
this.padding = const EdgeInsets.all(8),
|
||||
});
|
||||
|
||||
final List<Widget> children;
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppColors.cyan.withOpacity(0.15),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.cyan.withOpacity(0.05),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: padding,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: children,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/// Settings Widgets Export
|
||||
library;
|
||||
|
||||
export 'profile_section.dart';
|
||||
export 'audio_quality_selector.dart';
|
||||
export 'cache_management_tile.dart';
|
||||
export 'settings_tile.dart';
|
||||
export 'edit_profile_dialog.dart';
|
||||
Reference in New Issue
Block a user