Initial commit: AudiOhm - Alternative Spotify avec streaming YouTube

Backend:
- FastAPI avec PostgreSQL et Redis
- Authentification JWT complète
- API REST pour musique, playlists, recherche
- Streaming audio via yt-dlp
- SQLAlchemy 2.0 async

Frontend:
- Flutter avec thème néon cyberpunk
- State management Riverpod
- Layout adaptatif desktop/mobile
- Lecteur audio avec mini-player

Infrastructure:
- Docker Compose (PostgreSQL + Redis)
- Scripts d'installation automatisés
- Scripts de build pour exécutables

Fichiers ajoutés:
- BUILD_CLIENT_*.bat/sh: Scripts de compilation
- BUILD_CLIENT_README.md: Documentation compilation
- CHECK_FLUTTER.sh: Vérificateur d'environnement
- requirements.txt mis à jour pour Python 3.13
- Modèles SQLAlchemy corrigés (metadata -> extra_metadata)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
root
2026-01-18 20:08:36 +00:00
commit a89c7894cf
132 changed files with 23178 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# VSCode related
.vscode/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
**/doc/api/
**/ios/Flutter/.last_build_id
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
/build/
# Symbolication related
app.*.symbols
# Obfuscation related
app.*.map.json
# Android Studio will place build artifacts here
/android/app/debug
/android/app/profile
/android/app/release
# Coverage
coverage/
# Generated files
*.freezed.dart
*.g.dart
*.mocks.dart
+211
View File
@@ -0,0 +1,211 @@
# Artist Details Page - Implementation Guide
## Overview
Complete Artist Details Page for Spotify Le 2 with neon cyberpunk theme, featuring adaptive layouts for mobile and desktop.
## Files Created
### 1. Provider
- **`frontend/lib/presentation/providers/artist_provider.dart`**
- `ArtistState` - Contains artist data, tracks, albums, and loading states
- `ArtistNotifier` - Manages artist data fetching and state
- `artistProvider` - Riverpod StateNotifierProvider
- `artistDataProvider` - Family provider for specific artist IDs
### 2. API Endpoints Added
- **`frontend/lib/core/constants/api_constants.dart`**
- Added `/music/artists` and `/music/albums` endpoints
- **`frontend/lib/infrastructure/datasources/remote/music_api_service.dart`**
- `getArtist(String artistId)` - Fetch artist details
- `getArtistTopTracks(String artistId)` - Fetch artist's top tracks
- `getArtistAlbums(String artistId)` - Fetch artist's albums
- `getAlbum(String albumId)` - Fetch album details
- `getAlbumTracks(String albumId)` - Fetch album tracks
### 3. Widgets
- **`frontend/lib/presentation/widgets/artist/artist_track_tile.dart`**
- Displays track with number, title, duration, and play count
- Animated playing indicator for currently playing track
- Add to queue button
- Neon glow effect for active track
- **`frontend/lib/presentation/widgets/artist/artist_album_card.dart`**
- Displays album art, title, release year, and track count
- Gradient border with violet accent
- Horizontal scrollable layout
### 4. Pages
- **`frontend/lib/presentation/pages/artist/artist_details_page.dart`**
- Adaptive entry point (mobile/desktop)
- **`frontend/lib/presentation/pages/artist/artist_mobile_page.dart`**
- Vertical scrolling layout
- Hero image with gradient overlay
- Play All button
- Popular tracks section
- Horizontal scrolling albums
- Related tracks section
- **`frontend/lib/presentation/pages/artist/artist_desktop_page.dart`**
- Two-column layout
- Larger hero image (220x220)
- Play All button
- Popular tracks in left column
- Albums and related tracks in right column
## Features
### Visual Design
- **Neon Cyberpunk Theme**: Cyan, violet, and rose accent colors
- **Hero Header**: Large artist image with gradient overlay
- **Glow Effects**: Neon glow on active/hovered elements
- **Smooth Animations**: Playing indicator, hover states
- **Genre Tags**: Styled badges for artist genres
### Functionality
- **Load All Data**: Fetches artist, tracks, albums, and recommendations in parallel
- **Play All**: Plays all tracks from the artist
- **Play Track**: Plays specific track and sets queue
- **Add to Queue**: Add individual tracks to queue
- **Error Handling**: Loading states, error messages, retry functionality
- **Responsive Layout**: Adapts between mobile (<800px) and desktop (>=800px)
### Performance
- **Parallel Loading**: All artist data fetched simultaneously
- **State Management**: Efficient Riverpod state handling
- **Lazy Loading**: Related tracks loaded on demand
- **Cached Images**: Uses cached_network_image for album art
## Usage Example
### Navigation
```dart
// Navigate to artist details
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => ArtistDetailsPage(
artistId: 'artist-id-here',
),
),
);
```
### Provider Access
```dart
// Watch artist state
final artistState = ref.watch(artistProvider);
// Access artist data
final artist = artistState.artist;
final topTracks = artistState.topTracks;
final albums = artistState.albums;
// Load artist data
ref.read(artistProvider.notifier).loadAllArtistData('artist-id');
// Load specific data
ref.read(artistProvider.notifier).loadArtist('artist-id');
ref.read(artistProvider.notifier).loadTopTracks('artist-id');
ref.read(artistProvider.notifier).loadAlbums('artist-id');
```
### Integration with Search
Update `search_desktop_page.dart` or `search_mobile_page.dart`:
```dart
void _showArtistDetails(Artist artist) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => ArtistDetailsPage(
artistId: artist.id,
),
),
);
}
```
## API Requirements
The backend API should support these endpoints:
```
GET /api/v1/music/artists/{id}
Response: {
"id": "string",
"name": "string",
"image_url": "string?",
"bio": "string?",
"genres": ["string"],
"popularity": int,
...
}
GET /api/v1/music/artists/{id}/tracks?limit=10
Response: [
{
"id": "string",
"title": "string",
"duration": int?,
"image_url": "string?",
"play_count": int?,
...
}
]
GET /api/v1/music/artists/{id}/albums?limit=20
Response: [
{
"id": "string",
"title": "string",
"release_date": "string?",
"image_url": "string?",
"total_tracks": int,
...
}
]
```
## Theme Integration
The page uses these theme colors from `AppColors`:
- `AppColors.primary` - Main background (#0A0E27)
- `AppColors.surface` - Card background (#1A1F3A)
- `AppColors.cyan` - Primary accent (#00F0FF)
- `AppColors.violet` - Secondary accent (#BF00FF)
- `AppColors.rose` - Tertiary accent (#FF006E)
- `AppColors.onBackground` - Primary text (#E0E6FF)
- `AppColors.onSurface` - Secondary text (#B0B8D4)
- `AppColors.muted` - Muted text (#6A7294)
## Dependencies
Ensure these packages are in `pubspec.yaml`:
```yaml
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.3.6
cached_network_image: ^3.2.3
equatable: ^2.0.5
dio: ^5.3.2
```
## Future Enhancements
- [ ] Biography section with expandable text
- [ ] Artist followers count
- [ ] Similar artists section
- [ ] Concert/tour dates
- [ ] Social media links
- [ ] Playlists featuring this artist
- [ ] Share functionality
- [ ] Follow/unfollow artist
- [ ] Track duration sorting
- [ ] Album filtering by type (single, EP, album)
+298
View File
@@ -0,0 +1,298 @@
# Settings Page Integration Checklist
## Setup Steps
### 1. Install Dependencies
```bash
cd frontend
flutter pub get
```
Required dependencies (already added to pubspec.yaml):
-`package_info_plus: ^5.0.1`
-`image_picker: ^1.0.7`
-`shared_preferences: ^2.2.2` (already present)
-`path_provider: ^2.1.2` (already present)
### 2. Platform Configuration
#### Android (android/app/src/main/AndroidManifest.xml)
Add these permissions inside `<manifest>` tag:
```xml
<!-- For image picker -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" android:minSdkVersion="33"/>
<!-- For cache management -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
```
#### iOS (ios/Runner/Info.plist)
Add these keys:
```xml
<key>NSPhotoLibraryUsageDescription</key>
<string>We need access to your photo library to let you select a profile picture.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>We need access to save photos to your library.</string>
```
### 3. Import the Settings Page
```dart
import 'package:spotify_le_2/presentation/pages/settings/settings_page.dart';
```
### 4. Add Navigation Route
#### Option A: Direct Navigation
```dart
// In your home page, profile button, etc.
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SettingsPage(),
),
);
}
```
#### Option B: Go Router
```dart
// In router configuration
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsPage(),
),
```
#### Option C: Bottom Navigation
```dart
NavigationBar(
destinations: const [
NavigationDestination(icon: Icon(Icons.home), label: 'Home'),
NavigationDestination(icon: Icon(Icons.search), label: 'Search'),
NavigationDestination(icon: Icon(Icons.settings), label: 'Settings'),
],
onDestinationSelected: (index) {
if (index == 2) { // Settings tab
// Navigate to settings or show as current page
}
},
)
```
### 5. Provider Setup (Already Done)
The `settingsProvider` is already set up in:
`frontend/lib/presentation/providers/settings_provider.dart`
It automatically:
- Initializes SharedPreferences
- Loads settings on app start
- Watches AuthApiService for user data
- Persists all settings changes
### 6. Test the Integration
#### Manual Testing Checklist
**Profile Section:**
- [ ] Avatar displays correctly (default or from URL)
- [ ] Display name and email show
- [ ] Premium badge appears for premium users
- [ ] Edit Profile button opens dialog
- [ ] Display name can be edited
- [ ] Image picker opens (requires device/emulator)
**Audio Quality:**
- [ ] All four quality options display
- [ ] Selection works correctly
- [ ] Premium lock shows for non-premium users
- [ ] Settings persist after app restart
**Playback Settings:**
- [ ] Crossfade toggle works
- [ ] Duration slider appears when enabled
- [ ] Gapless playback toggle works
- [ ] Normalize volume toggle works
- [ ] All settings persist
**Downloads:**
- [ ] Mobile data toggle works
- [ ] Explicit content toggle works
- [ ] Settings persist after restart
**Cache Management:**
- [ ] Cache size displays (may show "0 MB" initially)
- [ ] Clear cache button works
- [ ] Confirmation dialog appears
- [ ] Success snackbar shows
- [ ] Cache size updates after clearing
**About Section:**
- [ ] App version displays correctly
- [ ] Licenses page opens
- [ ] License information loads
**Logout:**
- [ ] Logout button works
- [ ] Confirmation dialog appears
- [ ] User is logged out
- [ ] Redirected to login page
**Error Handling:**
- [ ] Network errors display
- [ ] Error messages are clear
- [ ] Error dismiss button works
- [ ] Retry possible
### 7. Optional Enhancements
#### Add Settings Icon to App Bar
```dart
AppBar(
title: Text('Home'),
actions: [
IconButton(
icon: Icon(Icons.settings),
onPressed: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SettingsPage()),
),
),
],
)
```
#### Add Settings to Drawer Menu
```dart
Drawer(
child: ListView(
children: [
DrawerHeader(...),
ListTile(
leading: Icon(Icons.settings),
title: Text('Settings'),
onTap: () {
Navigator.pop(context); // Close drawer
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SettingsPage()),
);
},
),
],
),
)
```
#### Add to User Profile Menu
```dart
PopupMenuButton<String>(
onSelected: (value) {
if (value == 'settings') {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SettingsPage()),
);
}
},
itemBuilder: (context) => [
PopupMenuItem(value: 'settings', child: Text('Settings')),
],
)
```
### 8. Verification Commands
```bash
# Check if all files exist
ls -la frontend/lib/presentation/providers/settings_provider.dart
ls -la frontend/lib/presentation/pages/settings/settings_page.dart
ls -la frontend/lib/presentation/widgets/settings/*.dart
# Verify dependencies
flutter pub deps | grep -E "(package_info_plus|image_picker)"
# Run the app
flutter run
# Build for testing
flutter build apk --debug
flutter build ios --debug
```
### 9. Common Issues & Solutions
**Issue: Image picker doesn't open**
- Solution: Add permissions to AndroidManifest.xml and Info.plist
- Real device required (doesn't work on some emulators)
**Issue: Cache size shows "Unknown"**
- Solution: Normal on first launch or if no cache exists
- Cache will accumulate as app is used
**Issue: Settings don't persist**
- Solution: Ensure SharedPreferences is initialized
- Check for storage permissions on older Android versions
**Issue: Premium features not unlocking**
- Solution: Ensure backend correctly sets `is_premium` flag
- Check user data is loaded from API
**Issue: Avatar upload doesn't work**
- Solution: Server-side upload endpoint required
- Current implementation only selects local image
- Implement multipart/form-data upload on backend
### 10. Next Steps
1. **Backend**: Implement avatar upload endpoint
2. **Testing**: Test on real devices (iOS and Android)
3. **Polish**: Add loading skeletons during initial load
4. **Analytics**: Track settings changes
5. **A/B Testing**: Test default settings values
6. **Documentation**: Add user-facing help text
7. **Localization**: Add translations for all text
## Support Files Created
-`SETTINGS_PAGE_README.md` - Complete implementation guide
-`SETTINGS_PREVIEW.md` - Visual design documentation
-`settings_page_example.dart` - Integration examples
-`INTEGRATION_CHECKLIST.md` - This file
## Quick Reference
**Files Created:**
- `frontend/lib/presentation/providers/settings_provider.dart`
- `frontend/lib/presentation/pages/settings/settings_page.dart`
- `frontend/lib/presentation/widgets/settings/settings_tile.dart`
- `frontend/lib/presentation/widgets/settings/profile_section.dart`
- `frontend/lib/presentation/widgets/settings/audio_quality_selector.dart`
- `frontend/lib/presentation/widgets/settings/cache_management_tile.dart`
- `frontend/lib/presentation/widgets/settings/edit_profile_dialog.dart`
**Import Path:**
```dart
import 'package:spotify_le_2/presentation/pages/settings/settings_page.dart';
```
**Provider Access:**
```dart
final settingsState = ref.watch(settingsProvider);
final settingsNotifier = ref.read(settingsProvider.notifier);
```
**Navigation:**
```dart
Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsPage()));
```
---
**All files created and ready for integration! 🚀**
+247
View File
@@ -0,0 +1,247 @@
# Spotify Le 2 - Frontend Flutter
Application Flutter cross-platform (Desktop + Android) avec thème néon cyberpunk.
## Stack Technique
- **Flutter** 3.2+ (Dart 3.2+)
- **Riverpod** 2.4+ - State management
- **Dio** 5.4+ - HTTP client
- **just_audio** 0.9+ - Audio playback
- **drift** 2.14+ - Local database
- **cached_network_image** 3.3+ - Image caching
## Structure du Projet
```
lib/
├── main.dart # Entry point
├── core/ # Configuration partagée
│ ├── constants/
│ │ └── api_constants.dart
│ └── theme/
│ ├── colors.dart # Palette néon cyberpunk
│ ├── text_styles.dart # Typographie
│ └── app_theme.dart # Thème Material
├── domain/ # Business logic
│ └── entities/
│ ├── user.dart
│ ├── track.dart
│ └── playlist.dart
├── infrastructure/ # External dependencies
│ └── datasources/
│ ├── local/ # Database locale
│ └── remote/ # API client
├── presentation/ # UI layer
│ ├── providers/
│ │ └── navigation_provider.dart
│ ├── adaptive/
│ │ └── adaptive_layout.dart # Desktop vs Mobile
│ ├── pages/
│ │ ├── desktop/
│ │ │ └── home_page.dart
│ │ └── mobile/
│ │ └── mobile_home_page.dart
│ └── widgets/
│ ├── common/
│ │ └── mini_player.dart
│ └── desktop/
│ ├── desktop_sidebar.dart
│ └── desktop_top_bar.dart
└── l10n/ # Internationalization
```
## Installation
### Prérequis
- Flutter 3.2+
- Dart 3.2+
- Android Studio / VS Code
- Android SDK (pour Android)
### 1. Cloner le projet
```bash
cd Spotify_le_2/frontend
```
### 2. Installer les dépendances
```bash
flutter pub get
```
### 3. Générer le code (Riverpod generators)
```bash
dart run build_runner build --delete-conflicting-outputs
```
### 4. Lancer l'app
```bash
# Desktop
flutter run -d windows
# Android
flutter run -d android
# Linux
flutter run -d linux
```
## Thème Néon Cyberpunk
### Palette de Couleurs
```dart
// Backgrounds
primary: #0A0E27 // Bleu nuit très foncé
surface: #1A1F3A // Bleu nuit
surfaceVariant: #252B4A
// Accent néon
cyan: #00F0FF // Cyan électrique
violet: #BF00FF // Violet néon
rose: #FF006E // Rose néon
vert: #39FF14 // Vert néon
```
### Utilisation du Thème
```dart
// Import
import 'package:spotify_le_2/core/theme/colors.dart';
// Utiliser les couleurs
Container(
color: AppColors.surface,
child: Text(
'Hello',
style: TextStyle(color: AppColors.cyan),
),
)
// Gradients
Container(
decoration: BoxDecoration(
gradient: AppColors.primaryGradient,
),
)
// Effets glow
Container(
decoration: BoxDecoration(
boxShadow: AppColors.cyanGlow,
),
)
```
## Layout Adaptatif
L'application utilise deux layouts distincts selon la largeur de l'écran :
**Desktop (≥ 800px) :**
- Sidebar navigation à gauche (240px)
- Top bar avec recherche
- Contenu principal scrollable
- Mini player persistant en bas
**Mobile (< 800px) :**
- Top bar
- Contenu principal
- Mini player sticky
- Bottom navigation bar (4 onglets)
## Navigation
```dart
// Naviguer vers une page
ref.read(navigationProvider.notifier).navigateTo('search');
// Watcher la page courante
final currentPage = ref.watch(currentPageProvider);
```
## Développement
### Formatter
```bash
flutter format .
```
### Linter
```bash
flutter analyze
```
### Tests
```bash
# Tous les tests
flutter test
# Tests avec coverage
flutter test --coverage
```
### Build
```bash
# Windows
flutter build windows
# Android APK
flutter build apk
# Android App Bundle
flutter build appbundle
# Linux
flutter build linux
```
## Performance
### Optimisations implémentées
1. **Streaming 60fps** - StreamSubscription pour progress bar
2. **Infinite scroll** - ListView.builder avec préchargement
3. **Image caching** - cached_network_image avec cache infini
4. **Animations optimisées** - 200ms avec easeOutCubic
### Profiling
```bash
# DevTools
flutter pub global activate devtools
flutter pub global run devtools
# Puis lancer l'app avec profiling
flutter run --profile
```
## Configuration API
Modifier `lib/core/constants/api_constants.dart` :
```dart
class ApiConstants {
static const String baseUrl = 'http://localhost:8000/api/v1';
static const String wsUrl = 'ws://localhost:8000';
static const Duration connectionTimeout = Duration(seconds: 30);
}
```
## Ressources
- [Flutter Documentation](https://flutter.dev/docs)
- [Riverpod Documentation](https://riverpod.dev)
- [Design Preview](../docs/design-preview.html) - Aperçu HTML du thème
## License
MIT
+239
View File
@@ -0,0 +1,239 @@
# Settings Page Implementation - Spotify Le 2
## Overview
Complete Settings Page implementation with neon cyberpunk theme for user profile management, audio settings, and app preferences.
## Files Created
### 1. Provider Layer
**File:** `frontend/lib/presentation/providers/settings_provider.dart`
Features:
- **SettingsState**: Manages user data, audio quality, download preferences, and cache
- **SettingsNotifier**: Handles all settings operations
- Load user profile from API
- Update profile (display name, avatar URL)
- Audio quality management (Low/Medium/High/Lossless)
- Cache calculation and clearing
- Persistent settings with SharedPreferences
- **AudioQuality Enum**: low, medium, high, lossless
### 2. Widgets Layer
#### `frontend/lib/presentation/widgets/settings/settings_tile.dart`
Reusable components:
- **SettingsTile**: Basic settings item with title, subtitle, icon, and trailing widget
- **SettingsToggleTile**: Settings item with toggle switch
- **SettingsSectionHeader**: Section title with uppercase styling
- **SettingsCard**: Container with neon glow border
#### `frontend/lib/presentation/widgets/settings/profile_section.dart`
Features:
- User avatar with gradient glow
- Display name, username, and email
- Premium badge indicator
- Edit Profile button
- Gradient background with cyberpunk styling
#### `frontend/lib/presentation/widgets/settings/audio_quality_selector.dart`
Features:
- Four audio quality options (Low/Medium/High/Lossless)
- Bitrate display (96/160/320 kbps / FLAC)
- Premium lock for Lossless quality
- Visual selection indicator
- Description for each quality level
#### `frontend/lib/presentation/widgets/settings/cache_management_tile.dart`
Features:
- Cache size calculation and display
- Format bytes (B/KB/MB/GB)
- Clear cache button with confirmation dialog
- Loading state during cache clearing
- Success/error snackbar notifications
#### `frontend/lib/presentation/widgets/settings/edit_profile_dialog.dart`
Features:
- Edit display name
- Avatar image picker
- Image preview
- Save/Cancel buttons
- Validation and error handling
- Note about server-side avatar upload
### 3. Page Layer
**File:** `frontend/lib/presentation/pages/settings/settings_page.dart`
Complete settings page with sections:
- **Profile Section**: User info with avatar
- **Audio Section**: Audio quality selector
- **Playback Section**:
- Crossfade toggle with duration slider
- Gapless playback toggle
- Normalize volume toggle
- **Downloads Section**:
- Download on mobile data toggle
- Show explicit content toggle
- **Storage Section**: Cache management
- **About Section**:
- App version (with package_info_plus)
- Licenses page
- **Logout**: Confirmation dialog
## Dependencies Added
```yaml
package_info_plus: ^5.0.1 # For app version info
image_picker: ^1.0.7 # For avatar image selection
```
## Key Features
### 1. Persistent Storage
All settings are persisted using SharedPreferences:
- Audio quality
- Download on mobile data
- Show explicit content
- Crossfade settings
- Gapless playback
- Normalize volume
### 2. Cache Management
- Automatic cache size calculation
- Temporary and documents directory scanning
- Human-readable size formatting
- Clear cache functionality
### 3. Profile Management
- Integration with AuthApiService
- Load profile from `/api/v1/auth/me`
- Update profile with PUT request
- Display name and avatar URL updates
- Error handling with snackbar notifications
### 4. Neon Cyberpunk Theme
- Cyan glow effects
- Gradient backgrounds
- Border styling with transparency
- Custom toggle switches
- Elevated and outlined button styles
- Consistent with existing app theme
### 5. Responsive Design
- CustomScrollView with SliverAppBar
- Card-based layout
- Proper spacing and padding
- Responsive width constraints
- Mobile-optimized
## API Integration
### GET /api/v1/auth/me
```dart
final user = await _authApiService.getCurrentUser();
```
### PUT /api/v1/auth/me
```dart
final updatedUser = await _authApiService.updateProfile(
displayName: displayName,
avatarUrl: avatarUrl,
);
```
## Usage Example
### Navigate to Settings Page
```dart
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SettingsPage(),
),
);
```
### Using Settings Provider
```dart
final settingsState = ref.watch(settingsProvider);
// Update audio quality
ref.read(settingsProvider.notifier).setAudioQuality(AudioQuality.high);
// Toggle setting
ref.read(settingsProvider.notifier).toggleCrossfade(true);
// Update profile
await ref.read(settingsProvider.notifier).updateProfile(
displayName: 'New Name',
);
// Clear cache
await ref.read(settingsProvider.notifier).clearCache();
```
## Theme Integration
All widgets follow the neon cyberpunk theme:
- **Primary Colors**: Cyan (#00F0FF), Violet (#BF00FF), Rose (#FF006E)
- **Backgrounds**: Dark blue tones (#0A0E27, #1A1F3A)
- **Text**: OnBackground (#E0E6FF), OnSurface (#B0B8D4)
- **Effects**: Glow shadows, gradients, borders
## Error Handling
- Network errors caught and displayed
- Snackbar notifications for user feedback
- Loading states during async operations
- Validation for profile updates
- Graceful fallbacks for missing data
## Future Enhancements
1. **Avatar Upload**: Implement server-side image upload
2. **Equalizer**: Add customizable EQ presets
3. **Language**: Add language selector
4. **Theme**: Add light/dark theme toggle
5. **Notifications**: Add notification settings
6. **Privacy**: Add privacy settings page
7. **Account**: Add account deletion
8. **Social**: Add social links management
## Testing Recommendations
1. **Profile Updates**: Test display name changes
2. **Audio Quality**: Test all quality levels
3. **Cache**: Test cache clearing on different devices
4. **Toggles**: Test all toggle switches persist
5. **Logout**: Test logout flow
6. **Network**: Test with poor network conditions
7. **Validation**: Test empty display names
8. **Image Picker**: Test on iOS and Android
## File Structure
```
frontend/lib/
├── presentation/
│ ├── providers/
│ │ └── settings_provider.dart
│ ├── pages/
│ │ └── settings/
│ │ └── settings_page.dart
│ └── widgets/
│ └── settings/
│ ├── settings_tile.dart
│ ├── profile_section.dart
│ ├── audio_quality_selector.dart
│ ├── cache_management_tile.dart
│ ├── edit_profile_dialog.dart
│ └── settings_widgets.dart
```
## Notes
- Avatar upload requires server implementation
- Image picker requires permissions in AndroidManifest.xml and Info.plist
- Cache clearing may take time on large caches
- Premium features should be validated on backend
- Audio quality changes should take effect on next track load
@@ -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';
}
+257
View File
@@ -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,
);
}
+76
View File
@@ -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,
),
];
}
+89
View File
@@ -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,
);
}
+63
View File
@@ -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];
}
+54
View File
@@ -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';
+130
View File
@@ -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];
}
+119
View File
@@ -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,
];
}
+58
View File
@@ -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);
});
+41
View File
@@ -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';
+79
View File
@@ -0,0 +1,79 @@
name: spotify_le_2
description: Alternative to Spotify with YouTube streaming
publish_to: 'none'
version: 0.1.0+1
environment:
sdk: '>=3.2.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
# State Management
flutter_riverpod: ^2.4.9
riverpod_annotation: ^2.3.3
# Networking
dio: ^5.4.0
connectivity_plus: ^5.0.2
pretty_dio_logger: ^1.3.1
# Audio
just_audio: ^0.9.36
audio_service: ^0.18.12
# Local Storage
drift: ^2.14.1
sqlite3_flutter_libs: ^0.5.18
path_provider: ^2.1.2
shared_preferences: ^2.2.2
flutter_secure_storage: ^9.0.0
hive_flutter: ^1.1.0
# UI
flutter_hooks: ^0.20.5
cached_network_image: ^3.3.1
shimmer: ^3.0.0
go_router: ^13.0.1
# Utils
permission_handler: ^11.2.0
intl: ^0.19.0
uuid: ^4.3.1
url_launcher: ^6.2.3
# Icons
cupertino_icons: ^1.0.6
font_awesome_flutter: ^10.6.0
# Additional
package_info_plus: ^5.0.1
image_picker: ^1.0.7
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.1
# Code Generation
build_runner: ^2.4.8
riverpod_generator: ^2.3.9
freezed: ^2.4.6
freezed_annotation: ^2.4.1
drift_dev: ^2.14.1
json_serializable: ^6.7.1
flutter:
uses-material-design: true
fonts:
- family: Outfit
fonts:
- asset: assets/fonts/Outfit-Regular.ttf
- asset: assets/fonts/Outfit-Medium.ttf
weight: 500
- asset: assets/fonts/Outfit-SemiBold.ttf
weight: 600
- asset: assets/fonts/Outfit-Bold.ttf
weight: 700
@@ -0,0 +1,25 @@
/// Search Page Widget Tests
library;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify_le_2/presentation/pages/search/search_page.dart';
void main() {
testWidgets('SearchPage shows desktop layout on wide screen', (tester) async {
// TODO: Implement test with MediaQuery width >= 800
});
testWidgets('SearchPage shows mobile layout on narrow screen', (tester) async {
// TODO: Implement test with MediaQuery width < 800
});
testWidgets('Search input triggers search', (tester) async {
// TODO: Test typing in search bar
});
testWidgets('Track tap plays music', (tester) async {
// TODO: Test tapping a track card
});
}
@@ -0,0 +1,28 @@
/// Search Provider Tests
library;
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify_le_2/presentation/providers/search_provider.dart';
void main() {
group('SearchNotifier', () {
// TODO: Setup mock MusicApiService
test('initial state is empty', () {
// TODO: Implement test
});
test('search updates state with results', () {
// TODO: Implement test with mocked API
});
test('search debounces input', () async {
// TODO: Test that debounce timer works correctly
});
test('clear resets state', () {
// TODO: Test clear functionality
});
});
}