feat: Modernisation UI/UX et configuration Flutter multi-plateforme
Phase 1 - Corrections Critiques: - Fixed memory leaks dans music_provider.dart (stream subscriptions) - Fixed race conditions dans search_provider.dart (stale results) - Fixed token refresh errors dans api_service.dart - Improved error handling avec messages utilisateur - Changed API URL to HTTPS by default Phase 2 - Améliorations UX Desktop: - Ajouté cursor pointers sur tous les éléments cliquables - Implémenté hover states avec effets néon glow (200ms transitions) - Créé skeleton loading states avec shimmer animation - Ajouté widgets: ClickableWrapper, ErrorDisplay, SkeletonLoading - Enhanced visual feedback pour desktop users Phase 3 - Configuration Flutter: - Configuré Android (Gradle 8.1.0, Kotlin 1.9.0, minSdk 21, targetSdk 34) - Créé launcher icons cyberpunk néon (5 densités) - Configuré Windows desktop (structure complète) - Activé Linux desktop support - Ajouté package équatable pour entités de domaine - Corrigé imports (colors.dart, auth_provider.dart) - Fixed Dio API compatibility (RequestOptions) Documentation: - STYLE_GUIDE.md: Guide complet (100+ pages) - DESIGN_IMPLEMENTATION_GUIDE.md: Implémentation Flutter - BUILD_STATUS.md: Status builds + troubleshooting - QUICKSTART_BUILDS.md: Guide rapide - BUILD_INDEX.md: Index documentation - PHASE_1_CORRECTIONS.md: Corrections Phase 1 - PHASE_2_UX_IMPROVEMENTS.md: Améliorations Phase 2 - PR_REVIEW_SUMMARY.md: Revue code complète - CODE_ANALYSIS_AND_PRIORITIES.md: Analyse code Scripts & Builds: - BUILD_ALL.sh: Script automatisé builds multi-plateforme - builds/: Structure avec README par plateforme - design-system/: Système de design complet Backend: - Ajouté streaming HTTP Range pour audio progressif - Enhanced YouTube service avec métadonnées complètes - Improved error handling et validation Generated with [Claude Code](https://claude.com/claude-code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
@@ -1,13 +1,18 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/colors.dart';
|
||||
import '../../widgets/common/skeleton_loading.dart';
|
||||
|
||||
/// Mobile Home Page
|
||||
/// Mobile Home Page with loading states
|
||||
class MobileHomePage extends StatelessWidget {
|
||||
const MobileHomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// TODO: Integrate with actual data provider
|
||||
// For now, showing skeleton loading as example
|
||||
final isLoading = false; // Change to true to see skeleton
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
// Header
|
||||
@@ -39,63 +44,68 @@ class MobileHomePage extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// Content sections
|
||||
// Content sections or skeleton
|
||||
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();
|
||||
},
|
||||
),
|
||||
child: isLoading
|
||||
? const PageSkeleton(
|
||||
showHero: false,
|
||||
sectionCount: 3,
|
||||
)
|
||||
: 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),
|
||||
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();
|
||||
},
|
||||
// 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();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
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();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/// Music Provider - Player state management
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
|
||||
@@ -56,44 +59,65 @@ class PlayerState {
|
||||
class PlayerNotifier extends StateNotifier<PlayerState> {
|
||||
PlayerNotifier() : super(const PlayerState()) {
|
||||
_player = AudioPlayer();
|
||||
_subscriptions = [];
|
||||
_init();
|
||||
}
|
||||
|
||||
late final AudioPlayer _player;
|
||||
final List<StreamSubscription> _subscriptions = [];
|
||||
|
||||
void _init() {
|
||||
_player.positionStream.listen((position) {
|
||||
// Subscribe to position stream and store subscription
|
||||
_subscriptions.add(_player.positionStream.listen((position) {
|
||||
state = state.copyWith(position: position);
|
||||
});
|
||||
}));
|
||||
|
||||
_player.durationStream.listen((duration) {
|
||||
// Subscribe to duration stream and store subscription
|
||||
_subscriptions.add(_player.durationStream.listen((duration) {
|
||||
state = state.copyWith(duration: duration ?? Duration.zero);
|
||||
});
|
||||
}));
|
||||
|
||||
_player.playerStateStream.listen((playerState) {
|
||||
// Subscribe to player state stream and store subscription
|
||||
_subscriptions.add(_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);
|
||||
state = state.copyWith(isLoading: true, errorMessage: null);
|
||||
|
||||
try {
|
||||
// Get stream URL from API
|
||||
final streamUrl = track.audioUrl ?? '';
|
||||
// Validate audio URL exists
|
||||
final streamUrl = track.audioUrl;
|
||||
|
||||
if (streamUrl == null || streamUrl.isEmpty) {
|
||||
throw Exception('No audio URL available for track: ${track.title}');
|
||||
}
|
||||
|
||||
await _player.setUrl(streamUrl);
|
||||
|
||||
if (state.queue.isEmpty) {
|
||||
state = state.copyWith(queue: [track], currentIndex: 0);
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
// Clear error and loading state on success
|
||||
state = state.copyWith(isLoading: false, errorMessage: null);
|
||||
} on PlayerException catch (e) {
|
||||
// Specific audio player errors
|
||||
debugPrint('Player error loading track: ${e.message}');
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: e.toString(),
|
||||
errorMessage: 'Unable to play this track. Please try another.',
|
||||
);
|
||||
} catch (e) {
|
||||
// Network or other errors
|
||||
debugPrint('Error loading track: $e');
|
||||
state = state.copyWith(
|
||||
isLoading: false,
|
||||
errorMessage: 'An error occurred while loading the track.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -110,6 +134,15 @@ class PlayerNotifier extends StateNotifier<PlayerState> {
|
||||
state = state.copyWith(isPlaying: false);
|
||||
}
|
||||
|
||||
/// Convenience method to toggle play/pause
|
||||
Future<void> togglePlay() async {
|
||||
if (state.isPlaying) {
|
||||
await pause();
|
||||
} else {
|
||||
await play();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> seek(Duration position) async {
|
||||
await _player.seek(position);
|
||||
}
|
||||
@@ -153,6 +186,10 @@ class PlayerNotifier extends StateNotifier<PlayerState> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// Cancel all stream subscriptions to prevent memory leaks
|
||||
for (final subscription in _subscriptions) {
|
||||
subscription.cancel();
|
||||
}
|
||||
_player.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../infrastructure/datasources/remote/music_api_service.dart';
|
||||
@@ -71,6 +72,9 @@ class SearchNotifier extends StateNotifier<SearchState> {
|
||||
}
|
||||
|
||||
Future<void> _performSearch(String query) async {
|
||||
// Store the original query to check for race conditions
|
||||
final originalQuery = query;
|
||||
|
||||
try {
|
||||
final results = await _musicApiService.search(
|
||||
query,
|
||||
@@ -78,29 +82,40 @@ class SearchNotifier extends StateNotifier<SearchState> {
|
||||
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() ??
|
||||
[],
|
||||
);
|
||||
// CRITICAL: Only update state if this is still the current search query
|
||||
// This prevents race conditions where old search results overwrite newer ones
|
||||
if (state.query == originalQuery) {
|
||||
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() ??
|
||||
[],
|
||||
);
|
||||
} else {
|
||||
// This search result is stale, ignore it
|
||||
debugPrint('Ignoring stale search results for "$originalQuery" (current: "${state.query}")');
|
||||
}
|
||||
} catch (e) {
|
||||
state = SearchState(
|
||||
query: query,
|
||||
error: e.toString(),
|
||||
);
|
||||
// Only update error state if this is still the current query
|
||||
if (state.query == originalQuery) {
|
||||
debugPrint('Search failed for "$originalQuery": $e');
|
||||
state = SearchState(
|
||||
query: query,
|
||||
error: e.toString(),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
// Keep isSearching false if this was the latest search
|
||||
if (state.query == query) {
|
||||
// Only clear loading state if this is still the current query
|
||||
if (state.query == originalQuery) {
|
||||
state = state.copyWith(isSearching: false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ library;
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/colors.dart';
|
||||
import '../../../core/theme/colors.dart';
|
||||
|
||||
class CachedNetworkImageWithFallback extends StatelessWidget {
|
||||
final String? imageUrl;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Wrapper widget that adds click cursor to clickable elements
|
||||
/// Usage: ClickableWrapper(child: YourClickableWidget())
|
||||
class ClickableWrapper extends StatelessWidget {
|
||||
final Widget child;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onDoubleTap;
|
||||
final VoidCallback? onLongPress;
|
||||
final bool isClickable;
|
||||
|
||||
const ClickableWrapper({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.onTap,
|
||||
this.onDoubleTap,
|
||||
this.onLongPress,
|
||||
this.isClickable = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!isClickable) {
|
||||
return child;
|
||||
}
|
||||
|
||||
return MouseRegion(
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
onDoubleTap: onDoubleTap,
|
||||
onLongPress: onLongPress,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension method to wrap any widget with click cursor
|
||||
extension ClickableWrapperExtension on Widget {
|
||||
Widget withClickCursor({
|
||||
VoidCallback? onTap,
|
||||
VoidCallback? onDoubleTap,
|
||||
VoidCallback? onLongPress,
|
||||
}) {
|
||||
return ClickableWrapper(
|
||||
onTap: onTap,
|
||||
onDoubleTap: onDoubleTap,
|
||||
onLongPress: onLongPress,
|
||||
child: this,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../core/theme/colors.dart';
|
||||
|
||||
/// Widget to display error messages in a user-friendly way
|
||||
class ErrorDisplay extends StatelessWidget {
|
||||
final String? errorMessage;
|
||||
final VoidCallback? onRetry;
|
||||
final Widget? child;
|
||||
|
||||
const ErrorDisplay({
|
||||
super.key,
|
||||
required this.errorMessage,
|
||||
this.onRetry,
|
||||
this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (errorMessage == null) {
|
||||
return child ?? const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
AppColors.error.withOpacity(0.1),
|
||||
AppColors.error.withOpacity(0.05),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.error.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
color: AppColors.error,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
errorMessage!,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (onRetry != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: onRetry,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.error,
|
||||
foregroundColor: AppColors.textInverted,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'Retry',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Small inline error message for compact spaces
|
||||
class InlineError extends StatelessWidget {
|
||||
final String message;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
const InlineError({
|
||||
super.key,
|
||||
required this.message,
|
||||
this.onRetry,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
color: AppColors.error,
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
color: AppColors.error,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (onRetry != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: onRetry,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: AppColors.error.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'Retry',
|
||||
style: TextStyle(
|
||||
color: AppColors.error,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Snackbar helper to show error messages
|
||||
class ErrorSnackbar {
|
||||
static void show(
|
||||
BuildContext context,
|
||||
String message, {
|
||||
VoidCallback? action,
|
||||
String? actionLabel,
|
||||
Duration duration = const Duration(seconds: 4),
|
||||
}) {
|
||||
final snackBar = SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Colors.white),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(message)),
|
||||
],
|
||||
),
|
||||
backgroundColor: AppColors.error,
|
||||
duration: duration,
|
||||
action: action != null && actionLabel != null
|
||||
? SnackBarAction(
|
||||
label: actionLabel,
|
||||
textColor: Colors.white,
|
||||
onPressed: action,
|
||||
)
|
||||
: null,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
margin: const EdgeInsets.all(16),
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(snackBar);
|
||||
}
|
||||
|
||||
static void showInfo(
|
||||
BuildContext context,
|
||||
String message, {
|
||||
Duration duration = const Duration(seconds: 3),
|
||||
}) {
|
||||
final snackBar = SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, color: Colors.white),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(message)),
|
||||
],
|
||||
),
|
||||
backgroundColor: AppColors.cyan,
|
||||
duration: duration,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
margin: const EdgeInsets.all(16),
|
||||
);
|
||||
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(snackBar);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/theme/colors.dart';
|
||||
import '../../providers/music_provider.dart';
|
||||
import '../../pages/player/queue_view_page.dart';
|
||||
import 'error_display.dart';
|
||||
|
||||
/// Mini Player Widget
|
||||
class MiniPlayer extends ConsumerWidget {
|
||||
@@ -19,56 +20,87 @@ class MiniPlayer extends ConsumerWidget {
|
||||
final playerState = ref.watch(playerProvider);
|
||||
final currentTrack = playerState.currentTrack;
|
||||
final isPlaying = playerState.isPlaying;
|
||||
final errorMessage = playerState.errorMessage;
|
||||
|
||||
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),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Error display (shown above mini player)
|
||||
if (errorMessage != null)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error.withOpacity(0.1),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: AppColors.error.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: InlineError(
|
||||
message: errorMessage,
|
||||
onRetry: () {
|
||||
// Retry loading the current track
|
||||
if (currentTrack != null) {
|
||||
ref.read(playerProvider.notifier).loadTrack(currentTrack);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
// Mini player
|
||||
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),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Controls
|
||||
if (!compact)
|
||||
_buildControls(ref, isPlaying)
|
||||
else
|
||||
_buildCompactControls(ref, isPlaying),
|
||||
// Track info
|
||||
Expanded(
|
||||
child: _buildTrackInfo(currentTrack, playerState),
|
||||
),
|
||||
|
||||
// Queue button
|
||||
if (!compact) _buildQueueButton(context, ref),
|
||||
],
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Controls
|
||||
if (!compact)
|
||||
_buildControls(ref, isPlaying)
|
||||
else
|
||||
_buildCompactControls(ref, isPlaying),
|
||||
|
||||
// Queue button
|
||||
if (!compact) _buildQueueButton(context, ref),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shimmer/shimmer.dart';
|
||||
|
||||
import '../../../core/theme/colors.dart';
|
||||
|
||||
/// Skeleton loading card for albums/playlists/tracks
|
||||
class ContentCardSkeleton extends StatelessWidget {
|
||||
final double? width;
|
||||
final double? height;
|
||||
|
||||
const ContentCardSkeleton({
|
||||
super.key,
|
||||
this.width,
|
||||
this.height,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Shimmer.fromColors(
|
||||
baseColor: AppColors.surfaceVariant,
|
||||
highlightColor: AppColors.surfaceElevated,
|
||||
child: Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Image placeholder
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Title placeholder
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// Subtitle placeholder
|
||||
Container(
|
||||
width: 100,
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Skeleton for list items (e.g., track list items)
|
||||
class ListItemSkeleton extends StatelessWidget {
|
||||
final bool showLeading;
|
||||
final bool showTrailing;
|
||||
|
||||
const ListItemSkeleton({
|
||||
super.key,
|
||||
this.showLeading = true,
|
||||
this.showTrailing = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Shimmer.fromColors(
|
||||
baseColor: AppColors.surfaceVariant,
|
||||
highlightColor: AppColors.surfaceElevated,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Leading icon/image
|
||||
if (showLeading) ...[
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
// Title and subtitle
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 14,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
width: 150,
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Trailing icon
|
||||
if (showTrailing) ...[
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Skeleton for search results grid
|
||||
class SearchGridSkeleton extends StatelessWidget {
|
||||
final int itemCount;
|
||||
|
||||
const SearchGridSkeleton({
|
||||
super.key,
|
||||
this.itemCount = 6,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 1.2,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) => const ContentCardSkeleton(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Skeleton for horizontal scrolling lists
|
||||
class HorizontalListSkeleton extends StatelessWidget {
|
||||
final int itemCount;
|
||||
final double itemHeight;
|
||||
final double itemWidth;
|
||||
|
||||
const HorizontalListSkeleton({
|
||||
super.key,
|
||||
this.itemCount = 6,
|
||||
this.itemHeight = 160,
|
||||
this.itemWidth = 120,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: itemHeight,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: itemCount,
|
||||
separatorBuilder: (context, index) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, index) => ContentCardSkeleton(
|
||||
width: itemWidth,
|
||||
height: itemHeight,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Full page skeleton with multiple sections
|
||||
class PageSkeleton extends StatelessWidget {
|
||||
final bool showHero;
|
||||
final int sectionCount;
|
||||
|
||||
const PageSkeleton({
|
||||
super.key,
|
||||
this.showHero = true,
|
||||
this.sectionCount = 3,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Shimmer.fromColors(
|
||||
baseColor: AppColors.surfaceVariant,
|
||||
highlightColor: AppColors.surfaceElevated,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Hero section
|
||||
if (showHero) ...[
|
||||
Container(
|
||||
height: 180,
|
||||
margin: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
],
|
||||
// Sections
|
||||
...List.generate(
|
||||
sectionCount,
|
||||
(index) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Section title
|
||||
Container(
|
||||
width: 150,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// Horizontal list
|
||||
SizedBox(
|
||||
height: 160,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: 6,
|
||||
separatorBuilder: (context, index) =>
|
||||
const SizedBox(width: 12),
|
||||
itemBuilder: (context, index) => Container(
|
||||
width: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Circular loading indicator with theme colors
|
||||
class ThemedCircularProgress extends StatelessWidget {
|
||||
final double? size;
|
||||
final double strokeWidth;
|
||||
|
||||
const ThemedCircularProgress({
|
||||
super.key,
|
||||
this.size,
|
||||
this.strokeWidth = 3.0,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: strokeWidth,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(AppColors.cyan),
|
||||
backgroundColor: AppColors.surfaceVariant,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../../../domain/entities/album.dart';
|
||||
import '../../../../core/theme/colors.dart';
|
||||
import '../common/cached_network_image_with_fallback.dart';
|
||||
|
||||
/// Search result card for an album
|
||||
class SearchAlbumCard extends StatelessWidget {
|
||||
/// Search result card for an album with hover state and click cursor
|
||||
class SearchAlbumCard extends StatefulWidget {
|
||||
final Album album;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@@ -14,70 +16,95 @@ class SearchAlbumCard extends StatelessWidget {
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SearchAlbumCard> createState() => _SearchAlbumCardState();
|
||||
}
|
||||
|
||||
class _SearchAlbumCardState extends State<SearchAlbumCard> {
|
||||
bool _isHovered = false;
|
||||
|
||||
@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),
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovered = true),
|
||||
onExit: (_) => setState(() => _isHovered = false),
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: _isHovered
|
||||
? AppColors.rose
|
||||
: AppColors.rose.withOpacity(0.3),
|
||||
width: _isHovered ? 2 : 1,
|
||||
),
|
||||
boxShadow: _isHovered
|
||||
? [
|
||||
BoxShadow(
|
||||
color: AppColors.rose.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
),
|
||||
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: Column(
|
||||
children: [
|
||||
// Album cover or placeholder
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(12),
|
||||
),
|
||||
child: CachedNetworkImageWithFallback(
|
||||
imageUrl: album.imageUrl,
|
||||
fallbackIcon: Icons.album,
|
||||
progressColor: AppColors.rose,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.fullGradient,
|
||||
),
|
||||
child: CachedNetworkImageWithFallback(
|
||||
imageUrl: widget.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)
|
||||
// Album info
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
album.artist!.name,
|
||||
widget.album.title,
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 12,
|
||||
color: AppColors.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
if (widget.album.artist != null)
|
||||
Text(
|
||||
widget.album.artist!.name,
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontSize: 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../../../domain/entities/artist.dart';
|
||||
import '../../../../core/theme/colors.dart';
|
||||
import '../common/cached_network_image_with_fallback.dart';
|
||||
|
||||
/// Search result card for an artist
|
||||
class SearchArtistCard extends StatelessWidget {
|
||||
/// Search result card for an artist with hover state and click cursor
|
||||
class SearchArtistCard extends StatefulWidget {
|
||||
final Artist artist;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@@ -14,55 +16,80 @@ class SearchArtistCard extends StatelessWidget {
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SearchArtistCard> createState() => _SearchArtistCardState();
|
||||
}
|
||||
|
||||
class _SearchArtistCardState extends State<SearchArtistCard> {
|
||||
bool _isHovered = false;
|
||||
|
||||
@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),
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovered = true),
|
||||
onExit: (_) => setState(() => _isHovered = false),
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: _isHovered
|
||||
? AppColors.violet
|
||||
: AppColors.violet.withOpacity(0.3),
|
||||
width: _isHovered ? 2 : 1,
|
||||
),
|
||||
boxShadow: _isHovered
|
||||
? [
|
||||
BoxShadow(
|
||||
color: AppColors.violet.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
),
|
||||
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: Column(
|
||||
children: [
|
||||
// Artist image or placeholder
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(12),
|
||||
),
|
||||
child: CachedNetworkImageWithFallback(
|
||||
imageUrl: artist.imageUrl,
|
||||
fallbackIcon: Icons.person,
|
||||
progressColor: AppColors.violet,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.accentGradient,
|
||||
),
|
||||
child: CachedNetworkImageWithFallback(
|
||||
imageUrl: widget.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,
|
||||
// Artist name
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(
|
||||
widget.artist.name,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onSurface,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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 {
|
||||
/// Search result card for a track with hover state and click cursor
|
||||
class SearchTrackCard extends StatefulWidget {
|
||||
final Track track;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@@ -15,62 +16,87 @@ class SearchTrackCard extends StatelessWidget {
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SearchTrackCard> createState() => _SearchTrackCardState();
|
||||
}
|
||||
|
||||
class _SearchTrackCardState extends State<SearchTrackCard> {
|
||||
bool _isHovered = false;
|
||||
|
||||
@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),
|
||||
return MouseRegion(
|
||||
onEnter: (_) => setState(() => _isHovered = true),
|
||||
onExit: (_) => setState(() => _isHovered = false),
|
||||
cursor: SystemMouseCursors.click,
|
||||
child: GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: _isHovered
|
||||
? AppColors.cyan
|
||||
: AppColors.cyan.withOpacity(0.3),
|
||||
width: _isHovered ? 2 : 1,
|
||||
),
|
||||
boxShadow: _isHovered
|
||||
? [
|
||||
BoxShadow(
|
||||
color: AppColors.cyan.withOpacity(0.3),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
),
|
||||
),
|
||||
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,
|
||||
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: widget.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,
|
||||
const SizedBox(height: 12),
|
||||
// Track info
|
||||
Text(
|
||||
widget.track.title,
|
||||
style: const TextStyle(
|
||||
color: AppColors.onBackground,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
track.artist?.name ?? 'Unknown Artist',
|
||||
style: const TextStyle(
|
||||
color: AppColors.onBackground,
|
||||
fontSize: 14,
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
widget.track.artist?.name ?? 'Unknown Artist',
|
||||
style: const TextStyle(
|
||||
color: AppColors.onBackground,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user