From 7c7b3cedb7257252b55f125cb6ffb00d5da2104f Mon Sep 17 00:00:00 2001 From: biso Date: Sat, 2 May 2026 01:26:02 +0300 Subject: [PATCH 1/6] Feat: implemented offline playing --- lib/core/data/models/track_dto.dart | 11 +- lib/core/routing/app_router.dart | 5 + .../domain/entities/library_entities.dart | 39 ++ .../presentation/pages/downloads_page.dart | 478 ++++++++++++++++++ .../presentation/pages/library_screen.dart | 14 +- .../providers/downloads_provider.dart | 168 ++++++ .../player/domain/entities/queue_state.dart | 1 + .../presentation/pages/full_player_page.dart | 51 +- .../widgets/player_action_bar.dart | 205 ++++---- .../providers/premium_provider.dart | 6 +- .../datasources/profile_mock_datasource.dart | 2 + .../presentation/widgets/track_card.dart | 28 +- .../widgets/track_details_section.dart | 76 +++ .../widgets/track_info_header.dart | 23 +- 14 files changed, 975 insertions(+), 132 deletions(-) create mode 100644 lib/features/library/presentation/pages/downloads_page.dart create mode 100644 lib/features/library/presentation/providers/downloads_provider.dart diff --git a/lib/core/data/models/track_dto.dart b/lib/core/data/models/track_dto.dart index 9a65d758..14b2c04d 100644 --- a/lib/core/data/models/track_dto.dart +++ b/lib/core/data/models/track_dto.dart @@ -1,11 +1,14 @@ import '../../domain/entities/track.dart'; class TrackDto { - static Track fromJson(Map json) { + static Track fromJson(Map json) { + // Ensure we have a Map for internal logic + final Map normalizedJson = Map.from(json); + // Some responses wrap data in a 'data' field - final Map data = json.containsKey('data') - ? json['data'] as Map - : json; + final Map data = normalizedJson.containsKey('data') + ? Map.from(normalizedJson['data'] as Map) + : normalizedJson; return Track( id: data['id'] as String? ?? '', diff --git a/lib/core/routing/app_router.dart b/lib/core/routing/app_router.dart index 31fb30ea..91076df5 100644 --- a/lib/core/routing/app_router.dart +++ b/lib/core/routing/app_router.dart @@ -88,6 +88,7 @@ import '../../features/library/presentation/pages/albums_page.dart'; import '../../features/library/presentation/pages/history_page.dart'; import '../../features/library/presentation/pages/insights_page.dart'; import '../../features/library/presentation/pages/likes_page.dart'; +import '../../features/library/presentation/pages/downloads_page.dart'; // Search imports import '../../features/search/presentation/pages/search_screen.dart'; @@ -603,6 +604,10 @@ final routerProvider = Provider((ref) { path: 'insights', builder: (context, state) => const InsightsPage(), ), + GoRoute( + path: 'downloads', + builder: (context, state) => const DownloadsPage(), + ), ], ), ], diff --git a/lib/features/library/domain/entities/library_entities.dart b/lib/features/library/domain/entities/library_entities.dart index 677a3cdc..2555418c 100644 --- a/lib/features/library/domain/entities/library_entities.dart +++ b/lib/features/library/domain/entities/library_entities.dart @@ -1,5 +1,6 @@ import 'package:equatable/equatable.dart'; import '../../../../core/domain/entities/track.dart'; +import '../../../../core/data/models/track_dto.dart'; // coverage:ignore-file /// Domain entities used by Library pages, use cases, and repositories. @@ -92,6 +93,44 @@ class UploadedTrack extends Equatable { List get props => [id, title, status, isPublic]; } +// ───────────────────────────────────────────── +// DownloadedTrack — used in Downloads page +// ───────────────────────────────────────────── +class DownloadedTrack extends Equatable { + final Track track; + final String localPath; + final DateTime downloadedAt; + + const DownloadedTrack({ + required this.track, + required this.localPath, + required this.downloadedAt, + }); + + String get id => track.id; + String get title => track.title; + String? get artworkUrl => track.coverImage; + + @override + List get props => [id, localPath, downloadedAt]; + + Map toJson() { + return { + 'track': TrackDto.toJson(track), + 'local_path': localPath, + 'downloaded_at': downloadedAt.toIso8601String(), + }; + } + + factory DownloadedTrack.fromJson(Map json) { + return DownloadedTrack( + track: TrackDto.fromJson(Map.from(json['track'] as Map)), + localPath: json['local_path'] as String? ?? '', + downloadedAt: DateTime.tryParse(json['downloaded_at'] as String? ?? '') ?? DateTime.now(), + ); + } +} + // ───────────────────────────────────────────── // LikedTrack — used in Your Likes page // ───────────────────────────────────────────── diff --git a/lib/features/library/presentation/pages/downloads_page.dart b/lib/features/library/presentation/pages/downloads_page.dart new file mode 100644 index 00000000..6f687599 --- /dev/null +++ b/lib/features/library/presentation/pages/downloads_page.dart @@ -0,0 +1,478 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../../core/theme/app_theme.dart'; +import '../providers/downloads_provider.dart'; +import '../../../track/presentation/widgets/track_card.dart'; +import '../../../../core/domain/entities/track.dart'; +import '../../../player/presentation/providers/queue_provider.dart'; +import '../../../player/domain/entities/queue_state.dart'; + +/// Your Downloads page matching SoundCloud's layout. +/// +/// Layout sections (top → bottom): +/// 1. [AppBar] — back arrow, search bar, filter icon, cast icon. +/// 2. Header area — "Downloads" title, shuffle + play FAB row. +/// 3. [ListView] of [TrackCard] tiles, or an empty state when +/// [DownloadsState.tracks] is empty. +class DownloadsPage extends ConsumerStatefulWidget { + const DownloadsPage({super.key}); + + @override + ConsumerState createState() => _DownloadsPageState(); +} + +class _DownloadsPageState extends ConsumerState { + final _scrollController = ScrollController(); + final _searchController = TextEditingController(); + String _query = ''; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + } + + void _onScroll() { + if (!_scrollController.hasClients) return; + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 200) { + ref.read(downloadsProvider.notifier).load(); + } + } + + @override + void dispose() { + _scrollController.dispose(); + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(downloadsProvider); + + return Scaffold( + backgroundColor: AppTheme.background, + appBar: AppBar( + backgroundColor: AppTheme.background, + elevation: 0, + leading: IconButton( + key: const Key('downloads_back_button'), + icon: const Icon(Icons.arrow_back, color: AppTheme.appBarItems), + onPressed: () => context.pop(), + ), + title: _SearchBar( + key: const Key('downloads_search_bar'), + controller: _searchController, + onChanged: (v) => setState(() => _query = v), + ), + actions: [ + IconButton( + key: const Key('downloads_filter_button'), + icon: const Icon(Icons.tune, color: AppTheme.appBarItems), + onPressed: () {}, + ), + IconButton( + key: const Key('downloads_cast_button'), + icon: const Icon(Icons.cast, color: AppTheme.appBarItems), + onPressed: () {}, + ), + ], + ), + body: RefreshIndicator( + color: AppTheme.primaryBrand, + onRefresh: () => ref.read(downloadsProvider.notifier).load(), + child: state.isLoading && state.tracks.isEmpty + ? const Center( + child: CircularProgressIndicator(color: AppTheme.primaryBrand), + ) + : state.error != null && state.tracks.isEmpty + ? _ErrorState( + error: state.error!, + onRetry: () => ref.read(downloadsProvider.notifier).load(), + ) + : _buildScrollView(context, state), + ), + ); + } + + Widget _buildScrollView(BuildContext context, DownloadsState state) { + final filtered = _query.isEmpty + ? state.tracks + : state.tracks + .where( + (t) => t.title.toLowerCase().contains(_query.toLowerCase()), + ) + .toList(); + + final filteredTracks = filtered.map((e) => e.track).toList(); + + void playAt(int index) { + if (filteredTracks.isEmpty) return; + ref + .read(queueStateProvider.notifier) + .playQueue( + tracks: filteredTracks, + initialIndex: index, + context: const QueueContext(type: QueueSource.downloads), + ); + } + + return CustomScrollView( + key: const Key('downloads_scroll_view'), + controller: _scrollController, + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: _DownloadsHeader( + onShuffle: () { + if (filteredTracks.isNotEmpty) { + final shuffled = List.from(filteredTracks)..shuffle(); + ref + .read(queueStateProvider.notifier) + .playQueue( + tracks: shuffled, + initialIndex: 0, + context: const QueueContext(type: QueueSource.downloads), + ); + } + }, + onPlay: () => playAt(0), + ), + ), + + // Active Downloads Section + if (state.downloadingProgress.isNotEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Active Downloads', + style: AppTheme.titleMedium.copyWith(color: AppTheme.primaryBrand), + ), + const SizedBox(height: 8), + ...state.downloadingProgress.entries.map((entry) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + const Icon(Icons.downloading, color: AppTheme.textSecondary, size: 20), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Track ID: ${entry.key}', // Ideally we'd have the track title here + style: AppTheme.bodyMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + LinearProgressIndicator( + value: entry.value, + backgroundColor: AppTheme.surface, + color: AppTheme.primaryBrand, + borderRadius: BorderRadius.circular(2), + ), + ], + ), + ), + const SizedBox(width: 12), + Text( + '${(entry.value * 100).toInt()}%', + style: AppTheme.labelSmall, + ), + ], + ), + ); + }), + const Divider(color: AppTheme.surface), + ], + ), + ), + ), + + if (state.tracks.isEmpty && state.downloadingProgress.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: const _EmptyDownloads(), + ) + else ...[ + SliverPadding( + padding: const EdgeInsets.only(bottom: 120), + sliver: SliverList.builder( + itemCount: filtered.length, + itemBuilder: (context, index) { + return TrackCard( + key: Key('downloads_item_${filteredTracks[index].id}_$index'), + track: filteredTracks[index], + onTap: () => playAt(index), + showOptions: false, + showStats: false, + ); + }, + ), + ), + ], + const SliverToBoxAdapter(child: SizedBox(height: 30)), + ], + ); + } +} + +class _SearchBar extends StatelessWidget { + const _SearchBar({ + super.key, + required this.controller, + required this.onChanged, + }); + + final TextEditingController controller; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return Container( + height: 38, + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(19), + ), + child: TextField( + controller: controller, + onChanged: onChanged, + style: AppTheme.bodyMedium.copyWith(color: AppTheme.textPrimary), + decoration: InputDecoration( + hintText: 'Search in downloads', + hintStyle: AppTheme.bodyMedium, + prefixIcon: const Icon( + Icons.search, + color: AppTheme.textSecondary, + size: 18, + ), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(vertical: 10), + isDense: true, + ), + ), + ); + } +} + +class _DownloadsHeader extends StatelessWidget { + const _DownloadsHeader({ + required this.onShuffle, + required this.onPlay, + }); + + final VoidCallback onShuffle; + final VoidCallback onPlay; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Text( + 'Downloads', + style: AppTheme.headlineLarge.copyWith(height: 1), + ), + ), + _CircleIconButton( + key: const Key('downloads_shuffle_button'), + icon: Icons.shuffle, + onTap: onShuffle, + ), + const SizedBox(width: 12), + GestureDetector( + key: const Key('downloads_play_button'), + onTap: onPlay, + child: Container( + width: 48, + height: 48, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.play_arrow_rounded, + color: Colors.black, + size: 28, + ), + ), + ), + ], + ), + const SizedBox(height: 14), + _StatChip( + key: const Key('downloads_count_chip'), + icon: Icons.download_done_rounded, + iconColor: AppTheme.primaryBrand, + label: 'Offline access enabled', + ), + const SizedBox(height: 8), + ], + ), + ); + } +} + +class _CircleIconButton extends StatelessWidget { + const _CircleIconButton({super.key, required this.icon, required this.onTap}); + + final IconData icon; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: AppTheme.textSecondary.withValues(alpha: 0.4), + ), + ), + child: Icon(icon, color: AppTheme.appBarItems, size: 20), + ), + ); + } +} + +class _StatChip extends StatelessWidget { + const _StatChip({ + super.key, + required this.icon, + required this.iconColor, + required this.label, + }); + + final IconData icon; + final Color iconColor; + final String label; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: iconColor, size: 16), + const SizedBox(width: 6), + Text( + label, + style: AppTheme.labelSmall.copyWith(color: AppTheme.textPrimary), + ), + ], + ), + ); + } +} + +class _ErrorState extends StatelessWidget { + const _ErrorState({required this.error, required this.onRetry}); + + final String error; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 40), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error_outline, color: Colors.red, size: 64), + const SizedBox(height: 24), + Text( + 'Failed to load downloads', + style: AppTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + error, + style: AppTheme.bodyMedium.copyWith(color: AppTheme.textSecondary), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: onRetry, + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryBrand, + shape: const StadiumBorder(), + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12), + ), + child: const Text('Retry'), + ), + ], + ), + ); + } +} + +class _EmptyDownloads extends StatelessWidget { + const _EmptyDownloads(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 40), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: double.infinity, + height: 160, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF004D40), Color(0xFF00251A)], + ), + ), + child: const Icon( + Icons.download_for_offline_outlined, + color: Colors.white54, + size: 64, + ), + ), + const SizedBox(height: 24), + Text( + 'No downloads yet', + key: const Key('downloads_empty_headline_text'), + style: AppTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Tracks you download for offline listening will show up here', + key: const Key('downloads_empty_body_text'), + style: AppTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} diff --git a/lib/features/library/presentation/pages/library_screen.dart b/lib/features/library/presentation/pages/library_screen.dart index 964d849a..0bf10718 100644 --- a/lib/features/library/presentation/pages/library_screen.dart +++ b/lib/features/library/presentation/pages/library_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:rythmify/core/presentation/widgets/cast_media_sheet.dart'; import '../../../../core/theme/app_theme.dart'; import '../../../authentication/presentation/providers/auth_provider.dart'; @@ -143,6 +144,12 @@ class _LibraryScreenState extends ConsumerState { key: const Key('library_uploads_item'), onTap: () => context.push('/library/uploads'), ), + _menuItem( + context, + label: 'Downloads', + key: const Key('library_downloads_item'), + onTap: () => context.push('/library/downloads'), + ), const SizedBox(height: 8), @@ -399,12 +406,13 @@ class _RecentlyPlayedItem extends ConsumerWidget { ClipRRect( borderRadius: BorderRadius.circular(6), child: entry.artworkUrl != null - ? Image.network( - entry.artworkUrl!, + ? CachedNetworkImage( + imageUrl: entry.artworkUrl!, width: 75, height: 75, fit: BoxFit.cover, - errorBuilder: (c, u, e) => _placeholder(), + placeholder: (c, u) => _placeholder(), + errorWidget: (c, u, e) => _placeholder(), ) : _placeholder(), ), diff --git a/lib/features/library/presentation/providers/downloads_provider.dart b/lib/features/library/presentation/providers/downloads_provider.dart new file mode 100644 index 00000000..15518a96 --- /dev/null +++ b/lib/features/library/presentation/providers/downloads_provider.dart @@ -0,0 +1,168 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import '../../../../core/domain/entities/track.dart'; +import '../../domain/entities/library_entities.dart'; +import '../../../track/presentation/providers/track_dependency_providers.dart'; + +class DownloadsState extends Equatable { + final List tracks; + final Map downloadingProgress; // trackId -> 0.0 to 1.0 + final Map downloadErrors; // trackId -> error message + final bool isLoading; + final String? error; + + const DownloadsState({ + this.tracks = const [], + this.downloadingProgress = const {}, + this.downloadErrors = const {}, + this.isLoading = false, + this.error, + }); + + bool isDownloaded(String trackId) => tracks.any((t) => t.id == trackId); + double? getProgress(String trackId) => downloadingProgress[trackId]; + String? getDownloadError(String trackId) => downloadErrors[trackId]; + + DownloadsState copyWith({ + List? tracks, + Map? downloadingProgress, + Map? downloadErrors, + bool? isLoading, + String? error, + }) => DownloadsState( + tracks: tracks ?? this.tracks, + downloadingProgress: downloadingProgress ?? this.downloadingProgress, + downloadErrors: downloadErrors ?? this.downloadErrors, + isLoading: isLoading ?? this.isLoading, + error: error ?? this.error, + ); + + @override + List get props => [tracks, downloadingProgress, downloadErrors, isLoading, error]; +} + +class DownloadsNotifier extends Notifier { + static const _boxName = 'downloads_box'; + static const _key = 'downloaded_tracks'; + + @override + DownloadsState build() { + // Initial load triggered via build + Future.microtask(() => _init()); + return const DownloadsState(); + } + + Future _init() async { + state = state.copyWith(isLoading: true); + try { + final box = await Hive.openBox(_boxName); + final rawData = box.get(_key) as List?; + + if (rawData != null) { + final tracks = rawData + .map((e) => DownloadedTrack.fromJson(Map.from(e as Map))) + .toList(); + state = state.copyWith(tracks: tracks, isLoading: false); + } else { + state = state.copyWith(isLoading: false); + } + } catch (e) { + state = state.copyWith(isLoading: false, error: e.toString()); + } + } + + Future _saveToHive() async { + try { + final box = await Hive.openBox(_boxName); + final rawData = state.tracks.map((t) => t.toJson()).toList(); + await box.put(_key, rawData); + } catch (_) { + // Log error in production + } + } + + Future load() async { + await _init(); + } + + Future downloadTrack(Track track) async { + if (state.isDownloaded(track.id)) return; + if (state.downloadingProgress.containsKey(track.id)) return; + + // Start download + final progressMap = Map.from(state.downloadingProgress); + progressMap[track.id] = 0.0; + + final errorMap = Map.from(state.downloadErrors); + errorMap.remove(track.id); + + state = state.copyWith( + downloadingProgress: progressMap, + downloadErrors: errorMap, + ); + + try { + // 1. Fetch Waveform Data if missing + List? waveform = track.waveformData; + if (waveform == null || waveform.isEmpty) { + try { + waveform = await ref.read(getWaveformUseCaseProvider).call(track.id); + } catch (_) {} + } + + // Simulate progress + for (int i = 1; i <= 10; i++) { + await Future.delayed(const Duration(milliseconds: 300)); + + final currentProgressMap = Map.from(state.downloadingProgress); + currentProgressMap[track.id] = i / 10.0; + state = state.copyWith(downloadingProgress: currentProgressMap); + } + + // Complete download + final completedTrack = DownloadedTrack( + track: track.copyWith( + waveformData: waveform, + likeCount: 0, + commentCount: 0, + isLiked: false, + ), + localPath: '/mock/path/${track.id}.mp3', + downloadedAt: DateTime.now(), + ); + + final finalProgressMap = Map.from(state.downloadingProgress); + finalProgressMap.remove(track.id); + + state = state.copyWith( + tracks: [completedTrack, ...state.tracks], + downloadingProgress: finalProgressMap, + ); + + await _saveToHive(); + } catch (e) { + final finalProgressMap = Map.from(state.downloadingProgress); + finalProgressMap.remove(track.id); + + final finalErrorMap = Map.from(state.downloadErrors); + finalErrorMap[track.id] = e.toString(); + + state = state.copyWith( + downloadingProgress: finalProgressMap, + downloadErrors: finalErrorMap, + ); + } + } + + Future removeTrack(String trackId) async { + state = state.copyWith( + tracks: state.tracks.where((t) => t.id != trackId).toList(), + ); + await _saveToHive(); + } +} + +final downloadsProvider = NotifierProvider( + () => DownloadsNotifier(), +); diff --git a/lib/features/player/domain/entities/queue_state.dart b/lib/features/player/domain/entities/queue_state.dart index 04943fbd..48565979 100644 --- a/lib/features/player/domain/entities/queue_state.dart +++ b/lib/features/player/domain/entities/queue_state.dart @@ -14,6 +14,7 @@ enum QueueSource { listeningHistory, reposts, userTracks, + downloads, search, unknown, } diff --git a/lib/features/player/presentation/pages/full_player_page.dart b/lib/features/player/presentation/pages/full_player_page.dart index 4393ef7f..aa267e7e 100644 --- a/lib/features/player/presentation/pages/full_player_page.dart +++ b/lib/features/player/presentation/pages/full_player_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../providers/player_provider.dart'; import '../providers/queue_provider.dart'; import '../../domain/entities/queue_item.dart'; +import '../../domain/entities/queue_state.dart'; import '../widgets/scrolling_artwork_background.dart'; import '../widgets/playback_overlay_controls.dart'; import '../widgets/track_info_box.dart'; @@ -150,6 +151,9 @@ class _PlayerTrackPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final track = item.track; + final queueContext = ref.watch(queueStateProvider.select((s) => s.context)); + final isDownloadedQueue = queueContext?.type == QueueSource.downloads; + return GestureDetector( key: Key('player_track_page_gesture_detector_${track.id}'), onTap: () { @@ -179,19 +183,21 @@ class _PlayerTrackPage extends ConsumerWidget { icon: Icons.keyboard_arrow_down, onPressed: onCollapse ?? () => Navigator.pop(context), ), - const SizedBox(height: 16), - FollowButton( - targetUserId: track.userId, - builder: (context, isFollowing, isInFlight, toggle) { - return _CircularActionButton( - key: Key('player_follow_button_${track.userId}'), - icon: isFollowing - ? Icons.person_add_alt_1 - : Icons.person_add_alt, - onPressed: toggle, - ); - }, - ), + if (!isDownloadedQueue) ...[ + const SizedBox(height: 16), + FollowButton( + targetUserId: track.userId, + builder: (context, isFollowing, isInFlight, toggle) { + return _CircularActionButton( + key: Key('player_follow_button_${track.userId}'), + icon: isFollowing + ? Icons.person_add_alt_1 + : Icons.person_add_alt, + onPressed: toggle, + ); + }, + ), + ], ], ), ), @@ -204,18 +210,22 @@ class _PlayerTrackPage extends ConsumerWidget { children: [ // Only the current track has an active/interactive waveform if (isCurrent) - const TrackWaveformVisualizer() + AbsorbPointer( + absorbing: isDownloadedQueue, + child: const TrackWaveformVisualizer(), + ) else const _StaticWaveformPlaceholder(), const SizedBox(height: 5), - if (isCurrent) const FloatingCommentBar(), + if (isCurrent && !isDownloadedQueue) const FloatingCommentBar(), const SizedBox(height: 40), if (isCurrent) PlayerActionBar( key: Key('player_action_bar_${track.id}'), trackId: track.id, onCollapse: onCollapse, + isInteractive: !isDownloadedQueue, ), ], ), @@ -225,10 +235,13 @@ class _PlayerTrackPage extends ConsumerWidget { Positioned( top: 60, left: 16, - child: TrackInfoBox( - key: Key('player_track_info_box_${track.id}'), - trackInfo: track, - onNavigateBehindTrack: onNavigateBehindTrack, + child: IgnorePointer( + ignoring: isDownloadedQueue, + child: TrackInfoBox( + key: Key('player_track_info_box_${track.id}'), + trackInfo: track, + onNavigateBehindTrack: onNavigateBehindTrack, + ), ), ), ], diff --git a/lib/features/player/presentation/widgets/player_action_bar.dart b/lib/features/player/presentation/widgets/player_action_bar.dart index 5e5e78c2..91d0d9d8 100644 --- a/lib/features/player/presentation/widgets/player_action_bar.dart +++ b/lib/features/player/presentation/widgets/player_action_bar.dart @@ -22,7 +22,15 @@ class PlayerActionBar extends ConsumerWidget { /// Callback to collapse the player. final VoidCallback? onCollapse; - const PlayerActionBar({super.key, required this.trackId, this.onCollapse}); + /// Whether the actions are interactive (disabled for downloaded tracks). + final bool isInteractive; + + const PlayerActionBar({ + super.key, + required this.trackId, + this.onCollapse, + this.isInteractive = true, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -44,17 +52,17 @@ class PlayerActionBar extends ConsumerWidget { // ------ 1. Like Action ------ InkWell( key: Key('player_action_bar_like_inkwell_$trackId'), - onTap: () { - if (track != null) { - ref - .read(trackInteractionProvider) - .handleToggleLike( - track.id, - track.isLiked, - currentTrack: track, - ); - } - }, + onTap: isInteractive + ? () { + if (track != null) { + ref.read(trackInteractionProvider).handleToggleLike( + track.id, + track.isLiked, + currentTrack: track, + ); + } + } + : null, borderRadius: BorderRadius.circular(8), highlightColor: Colors.white.withValues(alpha: 0.1), splashColor: Colors.white.withValues(alpha: 0.2), @@ -72,39 +80,44 @@ class PlayerActionBar extends ConsumerWidget { : Icons.favorite_border, color: (track?.isLiked ?? false) ? AppTheme.primaryBrand - : Colors.white, + : (isInteractive ? Colors.white : Colors.grey), ), const SizedBox(width: 6), - trackAsync.when( - data: (_) => Text( - Formatters.formatCount(track?.likeCount ?? 0), - key: const Key('player_action_bar_like_count_text'), - style: AppTheme.bodyNormal.copyWith( - color: (track?.isLiked ?? false) - ? AppTheme.primaryBrand - : Colors.white, - ), - ), - loading: () => const SizedBox( - width: 10, - height: 10, - child: CircularProgressIndicator( - key: Key( - 'player_action_bar_like_count_loading_indicator', + !isInteractive + ? const Text( + '0', + style: TextStyle(color: Colors.grey), + ) + : trackAsync.when( + data: (_) => Text( + Formatters.formatCount(track?.likeCount ?? 0), + key: const Key('player_action_bar_like_count_text'), + style: AppTheme.bodyNormal.copyWith( + color: (track?.isLiked ?? false) + ? AppTheme.primaryBrand + : (isInteractive ? Colors.white : Colors.grey), + ), + ), + loading: () => const SizedBox( + width: 10, + height: 10, + child: CircularProgressIndicator( + key: Key( + 'player_action_bar_like_count_loading_indicator', + ), + strokeWidth: 2, + color: Colors.white, + ), + ), + error: (a, b) => const Text( + '0', + key: Key('player_action_bar_like_count_error_text'), + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), ), - strokeWidth: 2, - color: Colors.white, - ), - ), - error: (a, b) => const Text( - '0', - key: Key('player_action_bar_like_count_error_text'), - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, ), - ), - ), ], ), ), @@ -113,16 +126,18 @@ class PlayerActionBar extends ConsumerWidget { // ------ 2. Comment Action ------ InkWell( key: Key('player_action_bar_comment_inkwell_$trackId'), - onTap: () { - trackAsync.whenData((track) { - onCollapse?.call(); - context.pushNamed( - 'comments', - pathParameters: {'trackId': track.id}, - extra: track, - ); - }); - }, + onTap: isInteractive + ? () { + trackAsync.whenData((track) { + onCollapse?.call(); + context.pushNamed( + 'comments', + pathParameters: {'trackId': track.id}, + extra: track, + ); + }); + } + : null, borderRadius: BorderRadius.circular(8), highlightColor: Colors.white.withValues(alpha: 0.1), splashColor: Colors.white.withValues(alpha: 0.2), @@ -133,16 +148,18 @@ class PlayerActionBar extends ConsumerWidget { ), // This changes the inkwell height and width child: Row( children: [ - const Icon( - key: Key('player_action_bar_comment_icon'), + Icon( + key: const Key('player_action_bar_comment_icon'), Icons.chat_outlined, - color: Colors.white, + color: isInteractive ? Colors.white : Colors.grey, ), const SizedBox(width: 6), Text( - Formatters.formatCount(track?.commentCount ?? 0), + !isInteractive ? '0' : Formatters.formatCount(track?.commentCount ?? 0), key: const Key('player_action_bar_comment_count_text'), - style: AppTheme.bodyNormal, + style: AppTheme.bodyNormal.copyWith( + color: isInteractive ? Colors.white : Colors.grey, + ), ), ], ), @@ -152,22 +169,24 @@ class PlayerActionBar extends ConsumerWidget { // ------ 3. Share Action ------ InkWell( key: Key('player_action_bar_share_inkwell_$trackId'), - onTap: () { - trackAsync.whenData((track) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - useRootNavigator: true, - backgroundColor: Colors.transparent, - builder: (context) => TrackOptionsModal( - track: track, - mode: TrackModalMode - .share, // Tell it to render the share view! - onCollapse: onCollapse, - ), - ); - }); - }, + onTap: isInteractive + ? () { + trackAsync.whenData((track) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useRootNavigator: true, + backgroundColor: Colors.transparent, + builder: (context) => TrackOptionsModal( + track: track, + mode: TrackModalMode + .share, // Tell it to render the share view! + onCollapse: onCollapse, + ), + ); + }); + } + : null, borderRadius: BorderRadius.circular(8), highlightColor: Colors.white.withValues(alpha: 0.1), splashColor: Colors.white.withValues(alpha: 0.2), @@ -179,7 +198,7 @@ class PlayerActionBar extends ConsumerWidget { child: Icon( key: Key('player_action_bar_share_icon'), Icons.share_outlined, - color: Colors.white, + color: isInteractive ? Colors.white : Colors.grey, ), ), ), @@ -187,9 +206,11 @@ class PlayerActionBar extends ConsumerWidget { // ------ 4. Playlist Action ------ InkWell( key: Key('player_action_bar_playlist_inkwell_$trackId'), - onTap: () { - context.push('/queue'); - }, + onTap: isInteractive + ? () { + context.push('/queue'); + } + : null, borderRadius: BorderRadius.circular(8), highlightColor: Colors.white.withValues(alpha: 0.1), splashColor: Colors.white.withValues(alpha: 0.2), @@ -202,7 +223,7 @@ class PlayerActionBar extends ConsumerWidget { key: Key('player_action_bar_playlist_icon'), Icons.queue_music, size: 25, - color: Colors.white, + color: isInteractive ? Colors.white : Colors.grey, ), ), ), @@ -210,18 +231,22 @@ class PlayerActionBar extends ConsumerWidget { // ------ 5. More Action ------ InkWell( key: Key('player_action_bar_more_inkwell_$trackId'), - onTap: () { - trackAsync.whenData((track) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - useRootNavigator: true, - builder: (context) => - TrackOptionsModal(track: track, onCollapse: onCollapse), - ); - }); - }, + onTap: isInteractive + ? () { + trackAsync.whenData((track) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + useRootNavigator: true, + builder: (context) => TrackOptionsModal( + track: track, + onCollapse: onCollapse, + ), + ); + }); + } + : null, borderRadius: BorderRadius.circular(8), highlightColor: Colors.white.withValues(alpha: 0.1), splashColor: Colors.white.withValues(alpha: 0.2), @@ -233,7 +258,7 @@ class PlayerActionBar extends ConsumerWidget { child: Icon( key: Key('player_action_bar_more_icon'), Icons.more_vert, - color: Colors.white, + color: isInteractive ? Colors.white : Colors.grey, ), ), ), diff --git a/lib/features/premium/presentation/providers/premium_provider.dart b/lib/features/premium/presentation/providers/premium_provider.dart index 59ac4687..e3be9ed6 100644 --- a/lib/features/premium/presentation/providers/premium_provider.dart +++ b/lib/features/premium/presentation/providers/premium_provider.dart @@ -299,13 +299,13 @@ final isPremiumProvider = Provider((ref) { /// Granular gate providers — use these instead of rolling your own checks. final canUploadTracksProvider = Provider((ref) { - return ref.watch(premiumProvider).canUploadMoreTracks; + return ref.watch(isPremiumProvider); }); final canCreatePlaylistsProvider = Provider((ref) { - return ref.watch(premiumProvider).canCreateMorePlaylists; + return ref.watch(isPremiumProvider); }); final canDownloadProvider = Provider((ref) { - return ref.watch(premiumProvider).canDownload; + return ref.watch(isPremiumProvider); }); diff --git a/lib/features/profile/data/datasources/profile_mock_datasource.dart b/lib/features/profile/data/datasources/profile_mock_datasource.dart index 70bb7172..345b2a76 100644 --- a/lib/features/profile/data/datasources/profile_mock_datasource.dart +++ b/lib/features/profile/data/datasources/profile_mock_datasource.dart @@ -24,6 +24,7 @@ class ProfileMockDatasource implements ProfileRemoteDatasource { 'following_count': 380, 'tracks_count': 14, 'is_following': false, + 'is_user_premium': true, }, { 'id': 'user-002', @@ -38,6 +39,7 @@ class ProfileMockDatasource implements ProfileRemoteDatasource { 'following_count': 88, 'tracks_count': 23, 'is_following': true, + 'is_user_premium': true, }, { 'id': 'user-003', diff --git a/lib/features/track/presentation/widgets/track_card.dart b/lib/features/track/presentation/widgets/track_card.dart index d9dca66b..23efed0d 100644 --- a/lib/features/track/presentation/widgets/track_card.dart +++ b/lib/features/track/presentation/widgets/track_card.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import '../../../../core/domain/entities/track.dart'; import '../../../../core/theme/app_theme.dart'; import '../../../player/presentation/providers/player_provider.dart'; @@ -29,6 +30,12 @@ class TrackCard extends ConsumerWidget { /// Whether to observe player state and show dynamic playback status. final bool observePlayerState; + /// Whether to show the more options (3 dots) icon. + final bool showOptions; + + /// Whether to show the bottom stats row (play count, duration, etc.). + final bool showStats; + /// Creates a [TrackCard]. const TrackCard({ super.key, @@ -36,6 +43,8 @@ class TrackCard extends ConsumerWidget { this.onTap, this.queueContext, this.observePlayerState = true, + this.showOptions = true, + this.showStats = true, }); // --- Formatting Helpers --- @@ -127,16 +136,18 @@ class TrackCard extends ConsumerWidget { const SizedBox(height: 2), // 3. Bottom Status Row (Dynamic) - isThisTrackLoaded - ? _buildPlayingState(isPlaying) - : _buildStatsState(track), + if (showStats) + isThisTrackLoaded + ? _buildPlayingState(isPlaying) + : _buildStatsState(track), ], ), ), // 4. Trailing More Icon - InkWell( - key: Key('track_card_${track.id}_more_inkwell'), + if (showOptions) + InkWell( + key: Key('track_card_${track.id}_more_inkwell'), onTap: () { showModalBottomSheet( context: context, @@ -210,12 +221,13 @@ class TrackCard extends ConsumerWidget { Widget _buildArtwork(String url) { if (url.startsWith('http')) { - return Image.network( - url, + return CachedNetworkImage( + imageUrl: url, width: 65, height: 65, fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => _buildPlaceholder(), + placeholder: (context, url) => _buildPlaceholder(), + errorWidget: (context, url, error) => _buildPlaceholder(), ); } else { return Image.asset( diff --git a/lib/features/track/presentation/widgets/track_details_section.dart b/lib/features/track/presentation/widgets/track_details_section.dart index 71247539..2e24f83e 100644 --- a/lib/features/track/presentation/widgets/track_details_section.dart +++ b/lib/features/track/presentation/widgets/track_details_section.dart @@ -5,6 +5,8 @@ import '../../../../core/theme/app_theme.dart'; import '../../../../core/presentation/widgets/custom_bottom_sheet.dart'; import '../../../../core/domain/entities/track.dart'; import '../../../../core/presentation/widgets/follow_button.dart'; +import '../../../premium/presentation/providers/premium_provider.dart'; +import '../../../library/presentation/providers/downloads_provider.dart'; /// A horizontal bar containing interactive engagement metrics and playback controls. /// @@ -60,6 +62,80 @@ class TrackDetailsSection extends ConsumerWidget { ), const SizedBox(height: 24), ], + // Download Button for Premium Users + Consumer( + builder: (context, ref, child) { + final canDownload = ref.watch(canDownloadProvider); + if (!canDownload) return const SizedBox.shrink(); + + final downloadsState = ref.watch(downloadsProvider); + final isDownloaded = downloadsState.isDownloaded(track.id); + final progress = downloadsState.getProgress(track.id); + final error = downloadsState.getDownloadError(track.id); + + String label = 'Download'; + IconData icon = Icons.download_rounded; + VoidCallback? onPressed = () => ref.read(downloadsProvider.notifier).downloadTrack(track); + + if (isDownloaded) { + label = 'Downloaded'; + icon = Icons.download_done_rounded; + onPressed = null; // Already downloaded + } else if (progress != null) { + label = 'Downloading ${(progress * 100).toInt()}%'; + icon = Icons.sync_rounded; + onPressed = null; // Already in progress + } else if (error != null) { + label = 'Retry Download'; + icon = Icons.error_outline_rounded; + // onPressed remains the same to allow retry + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + OutlinedButton.icon( + key: const Key('behind_the_track_download_button'), + onPressed: onPressed, + icon: progress != null + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + value: progress, + strokeWidth: 2, + color: AppTheme.textPrimary, + ), + ) + : Icon(icon, size: 20), + label: Text(label), + style: OutlinedButton.styleFrom( + foregroundColor: isDownloaded ? AppTheme.primaryBrand : AppTheme.textPrimary, + side: BorderSide(color: isDownloaded ? AppTheme.primaryBrand : AppTheme.textSecondary), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + ), + ), + if (error != null) ...[ + const SizedBox(height: 4), + Text( + 'Download failed: $error', + style: const TextStyle(color: Colors.red, fontSize: 12), + ), + ], + const SizedBox(height: 16), + ], + ), + ); + }, + ), if (track.tags.isNotEmpty) SizedBox( height: 32, diff --git a/lib/features/track/presentation/widgets/track_info_header.dart b/lib/features/track/presentation/widgets/track_info_header.dart index 8bc6a9d7..2eb63374 100644 --- a/lib/features/track/presentation/widgets/track_info_header.dart +++ b/lib/features/track/presentation/widgets/track_info_header.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import '../../../../core/theme/app_theme.dart'; import '../../../../core/utils/formatters.dart'; @@ -52,9 +53,11 @@ class TrackInfoHeader extends StatelessWidget { child: ClipRRect( borderRadius: BorderRadius.circular(16), child: track.artworkUrl.toString().startsWith('http') - ? Image.network( - track.artworkUrl, + ? CachedNetworkImage( + imageUrl: track.artworkUrl, fit: BoxFit.cover, + placeholder: (c, u) => const Center(child: CircularProgressIndicator()), + errorWidget: (c, u, e) => const Icon(Icons.error), ) : Image.asset( track.artworkUrl, @@ -114,12 +117,22 @@ class TrackInfoHeader extends StatelessWidget { child: ClipRRect( borderRadius: BorderRadius.circular(8), child: track.artworkUrl.toString().startsWith('http') - ? Image.network( - track.artworkUrl, + ? CachedNetworkImage( + imageUrl: track.artworkUrl, width: 110, height: 110, fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => + placeholder: (context, url) => Container( + width: 110, + height: 110, + color: AppTheme.perfectGrey, + child: const Icon( + Icons.music_note, + color: Colors.white, + size: 40, + ), + ), + errorWidget: (context, url, error) => Container( width: 110, height: 110, From 10393320706ff98a60b70b62dd991c52561fbec9 Mon Sep 17 00:00:00 2001 From: biso Date: Sat, 2 May 2026 02:07:41 +0300 Subject: [PATCH 2/6] Fix: fixed the downloads --- .../presentation/pages/full_player_page.dart | 53 ++--- .../widgets/player_action_bar.dart | 207 ++++++++---------- 2 files changed, 111 insertions(+), 149 deletions(-) diff --git a/lib/features/player/presentation/pages/full_player_page.dart b/lib/features/player/presentation/pages/full_player_page.dart index aa267e7e..62640563 100644 --- a/lib/features/player/presentation/pages/full_player_page.dart +++ b/lib/features/player/presentation/pages/full_player_page.dart @@ -3,7 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../providers/player_provider.dart'; import '../providers/queue_provider.dart'; import '../../domain/entities/queue_item.dart'; -import '../../domain/entities/queue_state.dart'; import '../widgets/scrolling_artwork_background.dart'; import '../widgets/playback_overlay_controls.dart'; import '../widgets/track_info_box.dart'; @@ -151,9 +150,6 @@ class _PlayerTrackPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final track = item.track; - final queueContext = ref.watch(queueStateProvider.select((s) => s.context)); - final isDownloadedQueue = queueContext?.type == QueueSource.downloads; - return GestureDetector( key: Key('player_track_page_gesture_detector_${track.id}'), onTap: () { @@ -183,21 +179,19 @@ class _PlayerTrackPage extends ConsumerWidget { icon: Icons.keyboard_arrow_down, onPressed: onCollapse ?? () => Navigator.pop(context), ), - if (!isDownloadedQueue) ...[ - const SizedBox(height: 16), - FollowButton( - targetUserId: track.userId, - builder: (context, isFollowing, isInFlight, toggle) { - return _CircularActionButton( - key: Key('player_follow_button_${track.userId}'), - icon: isFollowing - ? Icons.person_add_alt_1 - : Icons.person_add_alt, - onPressed: toggle, - ); - }, - ), - ], + const SizedBox(height: 16), + FollowButton( + targetUserId: track.userId, + builder: (context, isFollowing, isInFlight, toggle) { + return _CircularActionButton( + key: Key('player_follow_button_${track.userId}'), + icon: isFollowing + ? Icons.person_add_alt_1 + : Icons.person_add_alt, + onPressed: toggle, + ); + }, + ), ], ), ), @@ -210,22 +204,18 @@ class _PlayerTrackPage extends ConsumerWidget { children: [ // Only the current track has an active/interactive waveform if (isCurrent) - AbsorbPointer( - absorbing: isDownloadedQueue, - child: const TrackWaveformVisualizer(), - ) + const TrackWaveformVisualizer() else const _StaticWaveformPlaceholder(), const SizedBox(height: 5), - if (isCurrent && !isDownloadedQueue) const FloatingCommentBar(), + if (isCurrent) const FloatingCommentBar(), const SizedBox(height: 40), if (isCurrent) PlayerActionBar( key: Key('player_action_bar_${track.id}'), trackId: track.id, onCollapse: onCollapse, - isInteractive: !isDownloadedQueue, ), ], ), @@ -235,13 +225,10 @@ class _PlayerTrackPage extends ConsumerWidget { Positioned( top: 60, left: 16, - child: IgnorePointer( - ignoring: isDownloadedQueue, - child: TrackInfoBox( - key: Key('player_track_info_box_${track.id}'), - trackInfo: track, - onNavigateBehindTrack: onNavigateBehindTrack, - ), + child: TrackInfoBox( + key: Key('player_track_info_box_${track.id}'), + trackInfo: track, + onNavigateBehindTrack: onNavigateBehindTrack, ), ), ], @@ -295,4 +282,4 @@ class _StaticWaveformPlaceholder extends StatelessWidget { ), ); } -} +} \ No newline at end of file diff --git a/lib/features/player/presentation/widgets/player_action_bar.dart b/lib/features/player/presentation/widgets/player_action_bar.dart index 91d0d9d8..57ddb0f4 100644 --- a/lib/features/player/presentation/widgets/player_action_bar.dart +++ b/lib/features/player/presentation/widgets/player_action_bar.dart @@ -22,15 +22,7 @@ class PlayerActionBar extends ConsumerWidget { /// Callback to collapse the player. final VoidCallback? onCollapse; - /// Whether the actions are interactive (disabled for downloaded tracks). - final bool isInteractive; - - const PlayerActionBar({ - super.key, - required this.trackId, - this.onCollapse, - this.isInteractive = true, - }); + const PlayerActionBar({super.key, required this.trackId, this.onCollapse}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -52,17 +44,17 @@ class PlayerActionBar extends ConsumerWidget { // ------ 1. Like Action ------ InkWell( key: Key('player_action_bar_like_inkwell_$trackId'), - onTap: isInteractive - ? () { - if (track != null) { - ref.read(trackInteractionProvider).handleToggleLike( - track.id, - track.isLiked, - currentTrack: track, - ); - } - } - : null, + onTap: () { + if (track != null) { + ref + .read(trackInteractionProvider) + .handleToggleLike( + track.id, + track.isLiked, + currentTrack: track, + ); + } + }, borderRadius: BorderRadius.circular(8), highlightColor: Colors.white.withValues(alpha: 0.1), splashColor: Colors.white.withValues(alpha: 0.2), @@ -80,44 +72,39 @@ class PlayerActionBar extends ConsumerWidget { : Icons.favorite_border, color: (track?.isLiked ?? false) ? AppTheme.primaryBrand - : (isInteractive ? Colors.white : Colors.grey), + : Colors.white, ), const SizedBox(width: 6), - !isInteractive - ? const Text( - '0', - style: TextStyle(color: Colors.grey), - ) - : trackAsync.when( - data: (_) => Text( - Formatters.formatCount(track?.likeCount ?? 0), - key: const Key('player_action_bar_like_count_text'), - style: AppTheme.bodyNormal.copyWith( - color: (track?.isLiked ?? false) - ? AppTheme.primaryBrand - : (isInteractive ? Colors.white : Colors.grey), - ), - ), - loading: () => const SizedBox( - width: 10, - height: 10, - child: CircularProgressIndicator( - key: Key( - 'player_action_bar_like_count_loading_indicator', - ), - strokeWidth: 2, - color: Colors.white, - ), - ), - error: (a, b) => const Text( - '0', - key: Key('player_action_bar_like_count_error_text'), - style: TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - ), + trackAsync.when( + data: (_) => Text( + Formatters.formatCount(track?.likeCount ?? 0), + key: const Key('player_action_bar_like_count_text'), + style: AppTheme.bodyNormal.copyWith( + color: (track?.isLiked ?? false) + ? AppTheme.primaryBrand + : Colors.white, + ), + ), + loading: () => const SizedBox( + width: 10, + height: 10, + child: CircularProgressIndicator( + key: Key( + 'player_action_bar_like_count_loading_indicator', ), + strokeWidth: 2, + color: Colors.white, + ), + ), + error: (a, b) => const Text( + '0', + key: Key('player_action_bar_like_count_error_text'), + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, ), + ), + ), ], ), ), @@ -126,18 +113,16 @@ class PlayerActionBar extends ConsumerWidget { // ------ 2. Comment Action ------ InkWell( key: Key('player_action_bar_comment_inkwell_$trackId'), - onTap: isInteractive - ? () { - trackAsync.whenData((track) { - onCollapse?.call(); - context.pushNamed( - 'comments', - pathParameters: {'trackId': track.id}, - extra: track, - ); - }); - } - : null, + onTap: () { + trackAsync.whenData((track) { + onCollapse?.call(); + context.pushNamed( + 'comments', + pathParameters: {'trackId': track.id}, + extra: track, + ); + }); + }, borderRadius: BorderRadius.circular(8), highlightColor: Colors.white.withValues(alpha: 0.1), splashColor: Colors.white.withValues(alpha: 0.2), @@ -148,18 +133,16 @@ class PlayerActionBar extends ConsumerWidget { ), // This changes the inkwell height and width child: Row( children: [ - Icon( - key: const Key('player_action_bar_comment_icon'), + const Icon( + key: Key('player_action_bar_comment_icon'), Icons.chat_outlined, - color: isInteractive ? Colors.white : Colors.grey, + color: Colors.white, ), const SizedBox(width: 6), Text( - !isInteractive ? '0' : Formatters.formatCount(track?.commentCount ?? 0), + Formatters.formatCount(track?.commentCount ?? 0), key: const Key('player_action_bar_comment_count_text'), - style: AppTheme.bodyNormal.copyWith( - color: isInteractive ? Colors.white : Colors.grey, - ), + style: AppTheme.bodyNormal, ), ], ), @@ -169,24 +152,22 @@ class PlayerActionBar extends ConsumerWidget { // ------ 3. Share Action ------ InkWell( key: Key('player_action_bar_share_inkwell_$trackId'), - onTap: isInteractive - ? () { - trackAsync.whenData((track) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - useRootNavigator: true, - backgroundColor: Colors.transparent, - builder: (context) => TrackOptionsModal( - track: track, - mode: TrackModalMode - .share, // Tell it to render the share view! - onCollapse: onCollapse, - ), - ); - }); - } - : null, + onTap: () { + trackAsync.whenData((track) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useRootNavigator: true, + backgroundColor: Colors.transparent, + builder: (context) => TrackOptionsModal( + track: track, + mode: TrackModalMode + .share, // Tell it to render the share view! + onCollapse: onCollapse, + ), + ); + }); + }, borderRadius: BorderRadius.circular(8), highlightColor: Colors.white.withValues(alpha: 0.1), splashColor: Colors.white.withValues(alpha: 0.2), @@ -198,7 +179,7 @@ class PlayerActionBar extends ConsumerWidget { child: Icon( key: Key('player_action_bar_share_icon'), Icons.share_outlined, - color: isInteractive ? Colors.white : Colors.grey, + color: Colors.white, ), ), ), @@ -206,11 +187,9 @@ class PlayerActionBar extends ConsumerWidget { // ------ 4. Playlist Action ------ InkWell( key: Key('player_action_bar_playlist_inkwell_$trackId'), - onTap: isInteractive - ? () { - context.push('/queue'); - } - : null, + onTap: () { + context.push('/queue'); + }, borderRadius: BorderRadius.circular(8), highlightColor: Colors.white.withValues(alpha: 0.1), splashColor: Colors.white.withValues(alpha: 0.2), @@ -223,7 +202,7 @@ class PlayerActionBar extends ConsumerWidget { key: Key('player_action_bar_playlist_icon'), Icons.queue_music, size: 25, - color: isInteractive ? Colors.white : Colors.grey, + color: Colors.white, ), ), ), @@ -231,22 +210,18 @@ class PlayerActionBar extends ConsumerWidget { // ------ 5. More Action ------ InkWell( key: Key('player_action_bar_more_inkwell_$trackId'), - onTap: isInteractive - ? () { - trackAsync.whenData((track) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - useRootNavigator: true, - builder: (context) => TrackOptionsModal( - track: track, - onCollapse: onCollapse, - ), - ); - }); - } - : null, + onTap: () { + trackAsync.whenData((track) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + useRootNavigator: true, + builder: (context) => + TrackOptionsModal(track: track, onCollapse: onCollapse), + ); + }); + }, borderRadius: BorderRadius.circular(8), highlightColor: Colors.white.withValues(alpha: 0.1), splashColor: Colors.white.withValues(alpha: 0.2), @@ -258,7 +233,7 @@ class PlayerActionBar extends ConsumerWidget { child: Icon( key: Key('player_action_bar_more_icon'), Icons.more_vert, - color: isInteractive ? Colors.white : Colors.grey, + color: Colors.white, ), ), ), @@ -267,4 +242,4 @@ class PlayerActionBar extends ConsumerWidget { ), ); } -} +} \ No newline at end of file From e94b0debd37ac989c960042d30251efb97a893da Mon Sep 17 00:00:00 2001 From: biso Date: Sat, 2 May 2026 02:35:48 +0300 Subject: [PATCH 3/6] Feat: added a placeholder for home page if there is no internet --- lib/core/error/error_handler.dart | 6 +- .../feed/presentation/pages/home_screen.dart | 234 ++++++++++++------ 2 files changed, 169 insertions(+), 71 deletions(-) diff --git a/lib/core/error/error_handler.dart b/lib/core/error/error_handler.dart index 82e14c95..9a8d193c 100644 --- a/lib/core/error/error_handler.dart +++ b/lib/core/error/error_handler.dart @@ -36,8 +36,10 @@ class ErrorHandler { } if (error.type == DioExceptionType.connectionTimeout || - error.type == DioExceptionType.receiveTimeout) { - return 'Connection timed out. Please check your internet.'; + error.type == DioExceptionType.sendTimeout || + error.type == DioExceptionType.receiveTimeout || + error.type == DioExceptionType.connectionError) { + return 'Connection error. Please check your internet.'; } return serverMessage?.toString() ?? diff --git a/lib/features/feed/presentation/pages/home_screen.dart b/lib/features/feed/presentation/pages/home_screen.dart index ccbcdf5d..298704a5 100644 --- a/lib/features/feed/presentation/pages/home_screen.dart +++ b/lib/features/feed/presentation/pages/home_screen.dart @@ -1,4 +1,6 @@ import 'dart:convert'; +import 'dart:io'; +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:go_router/go_router.dart'; @@ -22,6 +24,7 @@ import '../providers/home_providers.dart'; import '../widgets/home_likes_card.dart'; import '../widgets/home_top_track_card.dart'; import '../widgets/shimmers/home_shimmer_screen.dart'; +import '../../../../core/error/error_handler.dart'; // ── Added for artist name fix ── import 'package:rythmify/features/authentication/presentation/providers/auth_provider.dart'; @@ -106,7 +109,59 @@ class HomeScreen extends ConsumerWidget { (screenWidth - horizontalPadding - crossAxisSpacing) / crossAxisCount; final dynamicAspectRatio = itemWidth / 59.6; - final isLoading = asyncHome.isLoading && asyncHome.value == null; + final homeContent = ListView( + key: const Key('home_scroll_view'), + padding: const EdgeInsets.only(bottom: 150), + physics: const AlwaysScrollableScrollPhysics(), + children: [ + const LikesBannerWidget(), + + if (latestTracks.isNotEmpty) + Padding( + padding: const EdgeInsets.only(bottom: 24.0), + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 16), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisSpacing: 8, + crossAxisSpacing: crossAxisSpacing, + childAspectRatio: dynamicAspectRatio, + ), + itemCount: latestTracks.length, + itemBuilder: (context, index) => HomeTopTrackCard( + track: latestTracks[index], + allTracks: latestTracks, + index: index, + ), + ), + ), + + const SizedBox(height: 4), + + /// Displays trending tracks grouped by genre. + TrendingByGenre(), + + /// Displays personalized "Hot For You" recommendations. + HotForYouSection(), + + const SizedBox(height: 40), + + /// Displays mixed playlist recommendations. + MixedPlaylistsSection(), + + const SizedBox(height: 30), + + /// Displays discovery-based station suggestions. + DiscoverWithStationsSection(), + + //const SizedBox(height: 10), + + /// Displays additional personalized music suggestions. + MoreOfWhatYouLikeSection(), + ], + ); return Scaffold( key: const Key('home_scaffold'), @@ -123,21 +178,6 @@ class HomeScreen extends ConsumerWidget { icon: const Icon(Icons.arrow_circle_up), /// Handles audio file selection and prepares it for upload. - /// - /// Workflow: - /// 1. Opens file picker restricted to audio files - /// 2. Extracts selected file path and metadata - /// 3. Determines audio duration using local player - /// 4. Initializes upload draft via Riverpod provider - /// 5. Navigates to upload screen if successful - /// - /// Parameters: - /// - Uses [context] for navigation and UI feedback - /// - Uses [ref] to update upload state in presentation layer - /// - /// Output: - /// - Initializes upload state in provider - /// - Navigates to '/upload-track' route on success onPressed: () async { final result = await FilePicker.platform.pickFiles( type: FileType.audio, @@ -201,9 +241,6 @@ class HomeScreen extends ConsumerWidget { ), /// Main scrollable feed containing multiple music discovery sections. - /// - /// Each section represents a modular UI component responsible for - /// displaying a specific type of recommendation or content grouping. body: RefreshIndicator( color: AppTheme.primaryBrand, onRefresh: () async { @@ -217,61 +254,120 @@ class HomeScreen extends ConsumerWidget { ref.read(historyProvider.notifier).load(refresh: true), ]); }, - child: isLoading - ? const HomeShimmerScreen() - : ListView( - key: const Key('home_scroll_view'), - padding: const EdgeInsets.only(bottom: 150), - physics: const AlwaysScrollableScrollPhysics(), - children: [ - const LikesBannerWidget(), - - if (latestTracks.isNotEmpty) - Padding( - padding: const EdgeInsets.only(bottom: 24.0), - child: GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - padding: const EdgeInsets.symmetric(horizontal: 16), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: crossAxisCount, - mainAxisSpacing: 8, - crossAxisSpacing: crossAxisSpacing, - childAspectRatio: dynamicAspectRatio, - ), - itemCount: latestTracks.length, - itemBuilder: (context, index) => HomeTopTrackCard( - track: latestTracks[index], - allTracks: latestTracks, - index: index, - ), - ), + child: asyncHome.when( + data: (_) => homeContent, + loading: () => const HomeShimmerScreen(), + error: (error, stack) { + // If we have cached/previous data, continue showing it even on error. + if (asyncHome.hasValue) return homeContent; + + // If initial load fails due to connectivity, show the specific placeholder. + if (_isConnectivityError(error)) { + return const _NoInternetPlaceholder(); + } + + // Fallback for other types of errors. + return Center( + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + size: 48, + color: AppTheme.textSecondary, ), + const SizedBox(height: 16), + Text( + ErrorHandler.getFriendlyMessage(error), + textAlign: TextAlign.center, + style: AppTheme.bodyMedium, + ), + ], + ), + ), + ); + }, + ), + ), + ); + } +} - const SizedBox(height: 4), - - /// Displays trending tracks grouped by genre. - TrendingByGenre(), - - /// Displays personalized "Hot For You" recommendations. - HotForYouSection(), - - const SizedBox(height: 40), - - /// Displays mixed playlist recommendations. - MixedPlaylistsSection(), - - const SizedBox(height: 30), - - /// Displays discovery-based station suggestions. - DiscoverWithStationsSection(), +/// Helper to detect if a [DioException] is related to connectivity. +bool _isConnectivityError(dynamic error) { + if (error is DioException) { + return error.type == DioExceptionType.connectionTimeout || + error.type == DioExceptionType.sendTimeout || + error.type == DioExceptionType.receiveTimeout || + error.type == DioExceptionType.connectionError || + error.error is SocketException; + } + return false; +} - //const SizedBox(height: 10), +/// A placeholder displayed when the home screen fails to load due to no internet. +/// +/// It features a "wifi off" icon, a descriptive message, and a button that +/// navigates the user to the Downloads page in the Library tab. +class _NoInternetPlaceholder extends StatelessWidget { + const _NoInternetPlaceholder(); - /// Displays additional personalized music suggestions. - MoreOfWhatYouLikeSection(), - ], + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: Container( + // Centers the content while allowing enough height for RefreshIndicator to work. + height: MediaQuery.of(context).size.height - 200, + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.wifi_off_rounded, + size: 80, + color: AppTheme.textSecondary, + ), + const SizedBox(height: 24), + Text( + 'No internet connection', + style: AppTheme.titleLarge.copyWith( + fontWeight: FontWeight.bold, + color: AppTheme.textPrimary, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Text( + 'Please check your connection or listen to your downloaded music.', + style: AppTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + ElevatedButton( + onPressed: () => context.go('/library/downloads'), + style: ElevatedButton.styleFrom( + backgroundColor: AppTheme.primaryBrand, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(30), + ), + elevation: 0, + ), + child: const Text( + 'Go to Downloads', + style: TextStyle(fontWeight: FontWeight.bold), ), + ), + ], + ), ), ); } From 77c26abc6b2ee98c6d7c7103152f97a564de53a1 Mon Sep 17 00:00:00 2001 From: biso Date: Sat, 2 May 2026 03:30:33 +0300 Subject: [PATCH 4/6] Fix: fixed all the upload track --- .../upload_track_remote_datasource.dart | 13 + .../upload_track_repository_impl.dart | 2 + .../domain/entities/track_draft.dart | 8 + .../providers/upload_track_provider.dart | 17 + .../screens/upload_track_screen.dart | 478 ++++++++++++++---- 5 files changed, 430 insertions(+), 88 deletions(-) diff --git a/lib/features/track_upload/data/datasources/upload_track_remote_datasource.dart b/lib/features/track_upload/data/datasources/upload_track_remote_datasource.dart index 09a70a2a..f4a6f91a 100644 --- a/lib/features/track_upload/data/datasources/upload_track_remote_datasource.dart +++ b/lib/features/track_upload/data/datasources/upload_track_remote_datasource.dart @@ -70,6 +70,8 @@ class UploadTrackRemoteDataSource { String? caption, required List tags, required bool isPublic, + String? geoRestrictionType, + List? geoRegions, void Function(double progress)? onProgress, }) async { try { @@ -92,6 +94,10 @@ class UploadTrackRemoteDataSource { fields['description'] = description; } + if (geoRestrictionType != null) { + fields['geo_restriction_type'] = geoRestrictionType; + } + // Cover image — spec: 'cover_image' if (artworkFile != null) { final artMime = lookupMimeType(artworkFile.path) ?? 'image/jpeg'; @@ -109,6 +115,13 @@ class UploadTrackRemoteDataSource { formData.fields.add(MapEntry('tags', tag)); } + // Geo regions sent as repeated fields + if (geoRegions != null) { + for (final region in geoRegions) { + formData.fields.add(MapEntry('geo_regions', region)); + } + } + debugPrint('=== SENDING TO BACKEND ==='); debugPrint( 'Fields: ${formData.fields.map((e) => '${e.key}=${e.value}').toList()}', diff --git a/lib/features/track_upload/data/repositories/upload_track_repository_impl.dart b/lib/features/track_upload/data/repositories/upload_track_repository_impl.dart index 8322849e..15b83661 100644 --- a/lib/features/track_upload/data/repositories/upload_track_repository_impl.dart +++ b/lib/features/track_upload/data/repositories/upload_track_repository_impl.dart @@ -52,6 +52,8 @@ class UploadTrackRepositoryImpl implements UploadTrackRepository { caption: draft.caption, tags: draft.tags, isPublic: draft.isPublic, + geoRestrictionType: draft.geoRestrictionType, + geoRegions: draft.geoRegions, onProgress: onProgress, ); return Right(response.id); diff --git a/lib/features/track_upload/domain/entities/track_draft.dart b/lib/features/track_upload/domain/entities/track_draft.dart index ab843281..52d3f9fa 100644 --- a/lib/features/track_upload/domain/entities/track_draft.dart +++ b/lib/features/track_upload/domain/entities/track_draft.dart @@ -27,6 +27,8 @@ class TrackDraft { // Has defaults final List tags; // multiple tag selections final bool isPublic; + final String geoRestrictionType; + final List geoRegions; // list of country codes final UploadStatus status; final double uploadProgress; @@ -59,6 +61,8 @@ class TrackDraft { this.caption, this.tags = const [], this.isPublic = true, + this.geoRestrictionType = 'worldwide', + this.geoRegions = const [], this.status = UploadStatus.draft, this.uploadProgress = 0.0, this.audioStatus = UploadStatus.uploading, // ← starts uploading @@ -80,6 +84,8 @@ class TrackDraft { String? caption, List? tags, bool? isPublic, + String? geoRestrictionType, + List? geoRegions, UploadStatus? status, double? uploadProgress, UploadStatus? audioStatus, @@ -105,6 +111,8 @@ class TrackDraft { caption: clearCaption ? null : caption ?? this.caption, tags: tags ?? this.tags, isPublic: isPublic ?? this.isPublic, + geoRestrictionType: geoRestrictionType ?? this.geoRestrictionType, + geoRegions: geoRegions ?? this.geoRegions, status: status ?? this.status, uploadProgress: uploadProgress ?? this.uploadProgress, audioStatus: audioStatus ?? this.audioStatus, diff --git a/lib/features/track_upload/presentation/providers/upload_track_provider.dart b/lib/features/track_upload/presentation/providers/upload_track_provider.dart index 587df6f1..e0e1320e 100644 --- a/lib/features/track_upload/presentation/providers/upload_track_provider.dart +++ b/lib/features/track_upload/presentation/providers/upload_track_provider.dart @@ -178,6 +178,23 @@ class UploadFormNotifier extends Notifier { void removeArtwork() => _updateDraft(state.draft!.copyWith(clearArtwork: true)); + void setGeoRestrictionType(String type) => + _updateDraft(state.draft!.copyWith(geoRestrictionType: type)); + + void toggleGeoRegion(String region) { + if (state.draft == null) return; + final current = List.from(state.draft!.geoRegions); + if (current.contains(region)) { + current.remove(region); + } else { + current.add(region); + } + _updateDraft(state.draft!.copyWith(geoRegions: current)); + } + + void clearGeoRegions() => + _updateDraft(state.draft!.copyWith(geoRegions: [])); + void setUploadProgress(double progress) { if (state.draft == null) return; _updateDraft( diff --git a/lib/features/track_upload/presentation/screens/upload_track_screen.dart b/lib/features/track_upload/presentation/screens/upload_track_screen.dart index 02fe3ac5..9e584288 100644 --- a/lib/features/track_upload/presentation/screens/upload_track_screen.dart +++ b/lib/features/track_upload/presentation/screens/upload_track_screen.dart @@ -19,6 +19,35 @@ class UploadTrackScreen extends ConsumerStatefulWidget { final track_entity.Track? track; const UploadTrackScreen({super.key, this.track}); + static const Map _countries = { + 'Egypt': 'EG', + 'Palestine': 'PS', + 'Saudi Arabia': 'SA', + 'United Arab Emirates': 'AE', + 'Jordan': 'JO', + 'Lebanon': 'LB', + 'Syria': 'SY', + 'Iraq': 'IQ', + 'Kuwait': 'KW', + 'Qatar': 'QA', + 'Bahrain': 'BH', + 'Oman': 'OM', + 'Yemen': 'YE', + 'Libya': 'LY', + 'Tunisia': 'TN', + 'Algeria': 'DZ', + 'Morocco': 'MA', + 'Sudan': 'SD', + 'United States': 'US', + 'United Kingdom': 'GB', + 'Germany': 'DE', + 'France': 'FR', + 'Japan': 'JP', + 'Brazil': 'BR', + 'Canada': 'CA', + 'Australia': 'AU', + }; + @override ConsumerState createState() => _UploadTrackScreenState(); } @@ -231,6 +260,7 @@ class _UploadTrackScreenState extends ConsumerState content: Text('Track uploaded successfully!'), ), ); + context.pop(); } }, onError: (error) { @@ -272,6 +302,7 @@ class _TrackInfoTab extends ConsumerStatefulWidget { class _TrackInfoTabState extends ConsumerState<_TrackInfoTab> { late TextEditingController _titleController; + late TextEditingController _tagController; late TextEditingController _collaboratorController; late TextEditingController _descriptionController; final ImagePicker _picker = ImagePicker(); @@ -281,6 +312,7 @@ class _TrackInfoTabState extends ConsumerState<_TrackInfoTab> { super.initState(); final draft = ref.read(uploadFormProvider).draft; _titleController = TextEditingController(text: draft?.title ?? ''); + _tagController = TextEditingController(); _collaboratorController = TextEditingController(); _descriptionController = TextEditingController( text: draft?.description ?? '', @@ -290,6 +322,7 @@ class _TrackInfoTabState extends ConsumerState<_TrackInfoTab> { @override void dispose() { _titleController.dispose(); + _tagController.dispose(); _collaboratorController.dispose(); _descriptionController.dispose(); super.dispose(); @@ -444,11 +477,11 @@ class _TrackInfoTabState extends ConsumerState<_TrackInfoTab> { ), const SizedBox(height: 24), - // ── Tags — picker from backend list ─────────────────── + // ── Tags — free text input ─────────────────────────── const _FieldLabel(label: 'Tags'), const SizedBox(height: 8), - _TagsPickerField( - availableTags: state.availableTags, + _TagsInput( + controller: _tagController, selectedTags: draft?.tags ?? [], onAdd: notifier.addTag, onRemove: notifier.removeTag, @@ -623,24 +656,330 @@ class _TrackInfoTabState extends ConsumerState<_TrackInfoTab> { // ADVANCED TAB // ══════════════════════════════════════════════════════════════════════════════ -class _AdvancedTab extends StatelessWidget { +class _AdvancedTab extends ConsumerWidget { const _AdvancedTab(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(uploadFormProvider); + final draft = state.draft; + final notifier = ref.read(uploadFormProvider.notifier); + + if (draft == null) { + return const Center(child: CircularProgressIndicator()); + } + + return ListView( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), + children: [ + const _FieldLabel(label: 'Geo-Restrictions'), + const SizedBox(height: 16), + _GeoRestrictionSelector( + currentType: draft.geoRestrictionType, + onChanged: (type) { + notifier.setGeoRestrictionType(type); + if (type == 'worldwide') { + notifier.clearGeoRegions(); + } + }, + ), + if (draft.geoRestrictionType != 'worldwide') ...[ + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _FieldLabel( + label: draft.geoRestrictionType == 'exclusive_regions' + ? 'Allowed Countries' + : 'Blocked Countries', + ), + TextButton( + onPressed: () => _showCountryPicker(context, ref), + child: const Text( + 'Add Countries', + style: TextStyle(color: Colors.white, fontSize: 13), + ), + ), + ], + ), + const SizedBox(height: 8), + if (draft.geoRegions.isEmpty) + const Text( + 'No countries selected yet.', + style: TextStyle(color: Colors.grey, fontSize: 14), + ) + else + Wrap( + spacing: 8, + runSpacing: 8, + children: draft.geoRegions.map((code) { + final name = UploadTrackScreen._countries.entries + .firstWhere((e) => e.value == code, + orElse: () => MapEntry(code, code)) + .key; + return Chip( + label: Text( + name, + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + backgroundColor: Colors.white12, + deleteIcon: const Icon(Icons.close, size: 14, color: Colors.white54), + onDeleted: () => notifier.toggleGeoRegion(code), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + side: const BorderSide(color: Colors.white24), + ), + ); + }).toList(), + ), + ], + const SizedBox(height: 40), + const Text( + 'Advanced features like scheduled releases and custom licenses are coming soon.', + style: TextStyle(color: Colors.grey, fontSize: 12), + textAlign: TextAlign.center, + ), + ], + ); + } + + void _showCountryPicker(BuildContext context, WidgetRef ref) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: const Color(0xFF1A1A1A), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (_) => const _CountryPickerModal(), + ); + } +} + +class _GeoRestrictionSelector extends StatelessWidget { + final String currentType; + final ValueChanged onChanged; + + const _GeoRestrictionSelector({ + required this.currentType, + required this.onChanged, + }); + @override Widget build(BuildContext context) { - return const Center( + return Column( + children: [ + _RestrictionOption( + title: 'Worldwide', + subtitle: 'Available everywhere', + isSelected: currentType == 'worldwide', + onTap: () => onChanged('worldwide'), + ), + const SizedBox(height: 16), + _RestrictionOption( + title: 'Allow Selected', + subtitle: 'Only available in specific countries', + isSelected: currentType == 'exclusive_regions', + onTap: () => onChanged('exclusive_regions'), + ), + const SizedBox(height: 16), + _RestrictionOption( + title: 'Block Selected', + subtitle: 'Available everywhere except specific countries', + isSelected: currentType == 'blocked_regions', + onTap: () => onChanged('blocked_regions'), + ), + ], + ); + } +} + +class _RestrictionOption extends StatelessWidget { + final String title; + final String subtitle; + final bool isSelected; + final VoidCallback onTap; + + const _RestrictionOption({ + required this.title, + required this.subtitle, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isSelected ? Colors.white.withValues(alpha: 0.05) : Colors.transparent, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected ? Colors.white : Colors.white12, + width: 1.5, + ), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + color: isSelected ? Colors.white : Colors.grey, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: const TextStyle(color: Colors.grey, fontSize: 12), + ), + ], + ), + ), + if (isSelected) + const Icon(Icons.check_circle_rounded, color: Colors.white, size: 20), + ], + ), + ), + ); + } +} + +class _CountryPickerModal extends ConsumerStatefulWidget { + const _CountryPickerModal(); + + @override + ConsumerState<_CountryPickerModal> createState() => _CountryPickerModalState(); +} + +class _CountryPickerModalState extends ConsumerState<_CountryPickerModal> { + String _searchQuery = ''; + late TextEditingController _searchController; + + @override + void initState() { + super.initState(); + _searchController = TextEditingController(); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(uploadFormProvider); + final draft = state.draft!; + final notifier = ref.read(uploadFormProvider.notifier); + + final filteredCountries = UploadTrackScreen._countries.entries.where((e) { + return e.key.toLowerCase().contains(_searchQuery.toLowerCase()); + }).toList(); + + return Container( + height: MediaQuery.of(context).size.height * 0.8, + padding: const EdgeInsets.symmetric(horizontal: 20), child: Column( - mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.tune_rounded, color: Colors.grey, size: 48), - SizedBox(height: 16), - Text( - 'Advanced settings', - style: TextStyle(color: Colors.white, fontSize: 16), + const SizedBox(height: 12), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.white24, + borderRadius: BorderRadius.circular(2), + ), ), - SizedBox(height: 8), - Text( - 'Coming soon', - style: TextStyle(color: Colors.grey, fontSize: 14), + const SizedBox(height: 20), + const Text( + 'Select Countries', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + TextField( + controller: _searchController, + onChanged: (v) => setState(() => _searchQuery = v), + style: const TextStyle(color: Colors.white), + decoration: InputDecoration( + hintText: 'Search countries...', + hintStyle: const TextStyle(color: Colors.grey), + prefixIcon: const Icon(Icons.search, color: Colors.grey), + filled: true, + fillColor: Colors.white10, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + ), + const SizedBox(height: 16), + Expanded( + child: ListView.builder( + itemCount: filteredCountries.length, + itemBuilder: (context, i) { + final entry = filteredCountries[i]; + final isSelected = draft.geoRegions.contains(entry.value); + + return ListTile( + contentPadding: EdgeInsets.zero, + title: Text( + entry.key, + style: TextStyle( + color: isSelected ? Colors.white : Colors.grey, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + ), + ), + trailing: Checkbox( + value: isSelected, + onChanged: (v) => notifier.toggleGeoRegion(entry.value), + activeColor: Colors.white, + checkColor: Colors.black, + side: const BorderSide(color: Colors.white38, width: 2), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + ), + ), + onTap: () => notifier.toggleGeoRegion(entry.value), + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(30), + ), + ), + onPressed: () => Navigator.pop(context), + child: const Text( + 'Done', + style: TextStyle( + color: Colors.black, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ), + ), ), ], ), @@ -931,97 +1270,60 @@ class _GenrePicker extends StatelessWidget { } } -// ── Tags picker (from backend list — replaces free-text input) ──────────────── +// ── Tags input (free text) ────────────────────────────────────────────────── -class _TagsPickerField extends StatelessWidget { - final List availableTags; +class _TagsInput extends StatelessWidget { + final TextEditingController controller; final List selectedTags; final ValueChanged onAdd; final ValueChanged onRemove; - const _TagsPickerField({ - required this.availableTags, + const _TagsInput({ + required this.controller, required this.selectedTags, required this.onAdd, required this.onRemove, }); - void _showPicker(BuildContext context) { - showModalBottomSheet( - context: context, - backgroundColor: const Color(0xFF1A1A1A), - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(16)), - ), - builder: (_) => StatefulBuilder( - builder: (context, setModalState) => ListView.builder( - itemCount: availableTags.length, - itemBuilder: (_, i) { - final tag = availableTags[i]; - final isSelected = selectedTags.contains(tag); - return ListTile( - title: Text( - tag, - style: TextStyle( - color: isSelected ? Colors.white : Colors.grey, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, - ), - ), - trailing: isSelected - ? const Icon(Icons.check_rounded, color: Colors.white) - : null, - onTap: () { - if (isSelected) { - onRemove(tag); - } else if (selectedTags.length < 10) { - onAdd(tag); - } - setModalState(() {}); // refresh checkmarks inside modal - }, - ); - }, - ), - ), - ); - } - @override Widget build(BuildContext context) { + final atLimit = selectedTags.length >= 10; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - GestureDetector( - onTap: availableTags.isEmpty ? null : () => _showPicker(context), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: const BoxDecoration( - border: Border(bottom: BorderSide(color: Colors.white24)), + TextField( + controller: controller, + enabled: !atLimit, + style: const TextStyle(color: Colors.white, fontSize: 14), + cursorColor: Colors.white, + decoration: InputDecoration( + hintText: atLimit + ? 'Maximum 10 tags reached' + : 'Add tags to describe track for reachability', + hintStyle: const TextStyle(color: Colors.grey, fontSize: 14), + filled: true, + fillColor: AppTheme.background, + contentPadding: const EdgeInsets.symmetric( + horizontal: 0, + vertical: 10, ), - child: Row( - children: [ - Expanded( - child: Text( - availableTags.isEmpty - ? 'Loading tags...' - : selectedTags.isEmpty - ? 'Add tags to describe your track' - : '${selectedTags.length} tag(s) selected', - style: TextStyle( - color: selectedTags.isNotEmpty - ? Colors.white - : Colors.grey, - fontSize: 14, - ), - ), - ), - const Icon( - Icons.unfold_more_rounded, - color: Colors.grey, - size: 20, - ), - ], + enabledBorder: const UnderlineInputBorder( + borderSide: BorderSide(color: Colors.white24), + ), + focusedBorder: const UnderlineInputBorder( + borderSide: BorderSide(color: Colors.white, width: 1.5), + ), + suffixIcon: const Icon( + Icons.chevron_right_rounded, + color: Colors.grey, ), ), + onSubmitted: (value) { + final tag = value.trim().toLowerCase(); + if (tag.isEmpty || atLimit) return; + onAdd(tag); + controller.clear(); + }, ), const SizedBox(height: 10), if (selectedTags.isNotEmpty) From d2734ce89a8c774085a93dc56ea14f1e8b8afbb3 Mon Sep 17 00:00:00 2001 From: biso Date: Sat, 2 May 2026 04:09:34 +0300 Subject: [PATCH 5/6] Feat: implemented the geo restirction for tracks --- lib/core/data/models/track_dto.dart | 14 +++++++++++++ lib/core/domain/entities/track.dart | 16 ++++++++++++++ .../datasources/track_remote_data_source.dart | 21 ++++++++++++++++++- .../upload_track_remote_datasource.dart | 10 +++++---- .../upload_track_repository_impl.dart | 1 + .../domain/entities/track_draft.dart | 4 ++++ .../providers/upload_track_provider.dart | 12 ++++++++++- .../screens/upload_track_screen.dart | 21 ++++++++++++++----- .../data/models/conversation_model_test.dart | 2 +- .../block_unblock_notifier_test.dart | 2 +- .../providers/send_message_notifier_test.dart | 1 - 11 files changed, 90 insertions(+), 14 deletions(-) diff --git a/lib/core/data/models/track_dto.dart b/lib/core/data/models/track_dto.dart index 14b2c04d..6f7c210b 100644 --- a/lib/core/data/models/track_dto.dart +++ b/lib/core/data/models/track_dto.dart @@ -83,6 +83,14 @@ class TrackDto { explicitContent: data['explicit_content'] as bool? ?? false, isTrending: data['is_trending'] as bool? ?? false, isFeatured: data['is_featured'] as bool? ?? false, + isPublic: data['is_public'] as bool? ?? true, + isHidden: data['is_hidden'] as bool? ?? false, + geoRestrictionType: data['geo_restriction_type'] as String?, + geoRegions: + (data['geo_regions'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + const [], status: data['status'] as String?, ); } @@ -124,7 +132,13 @@ class TrackDto { 'explicit_content': track.explicitContent, 'is_trending': track.isTrending, 'is_featured': track.isFeatured, + 'is_public': track.isPublic, + 'is_hidden': track.isHidden, + 'geo_restriction_type': track.geoRestrictionType, + 'geo_regions': track.geoRegions, 'status': track.status, }; } } + + diff --git a/lib/core/domain/entities/track.dart b/lib/core/domain/entities/track.dart index f0300730..806c4324 100644 --- a/lib/core/domain/entities/track.dart +++ b/lib/core/domain/entities/track.dart @@ -33,6 +33,10 @@ class Track { final bool isTrending; final bool isFeatured; + final bool isPublic; + final bool isHidden; + final String? geoRestrictionType; + final List geoRegions; final DateTime createdAt; final DateTime? updatedAt; final String? status; @@ -68,6 +72,10 @@ class Track { this.explicitContent = false, this.isTrending = false, this.isFeatured = false, + this.isPublic = true, + this.isHidden = false, + this.geoRestrictionType, + this.geoRegions = const [], this.updatedAt, this.status, }); @@ -105,6 +113,10 @@ class Track { bool? explicitContent, bool? isTrending, bool? isFeatured, + bool? isPublic, + bool? isHidden, + String? geoRestrictionType, + List? geoRegions, DateTime? createdAt, DateTime? updatedAt, String? status, @@ -139,6 +151,10 @@ class Track { explicitContent: explicitContent ?? this.explicitContent, isTrending: isTrending ?? this.isTrending, isFeatured: isFeatured ?? this.isFeatured, + isPublic: isPublic ?? this.isPublic, + isHidden: isHidden ?? this.isHidden, + geoRestrictionType: geoRestrictionType ?? this.geoRestrictionType, + geoRegions: geoRegions ?? this.geoRegions, createdAt: createdAt ?? this.createdAt, updatedAt: updatedAt ?? this.updatedAt, status: status ?? this.status, diff --git a/lib/features/track/data/datasources/track_remote_data_source.dart b/lib/features/track/data/datasources/track_remote_data_source.dart index edd93859..6bffc4fb 100644 --- a/lib/features/track/data/datasources/track_remote_data_source.dart +++ b/lib/features/track/data/datasources/track_remote_data_source.dart @@ -42,6 +42,9 @@ abstract class TrackRemoteDataSource { /// Updates the metadata for the provided track. Future updateTrack(String trackId, Map data); + /// Updates the visibility of the provided track. + Future updateTrackVisibility(String trackId, bool isPublic); + /// Updates the cover image for the provided track. Future updateTrackCover(String trackId, File imageFile); @@ -126,11 +129,17 @@ class TrackRemoteDataSourceImpl implements TrackRemoteDataSource { Future updateTrack(String trackId, Map data) async { final Map metadata = Map.from(data); String? artworkPath; + bool? isPublic; // Extract artwork path if present if (metadata.containsKey('cover_image_path')) { artworkPath = metadata.remove('cover_image_path') as String?; } + + // Extract is_public if present for separate API call + if (metadata.containsKey('is_public')) { + isPublic = metadata.remove('is_public') as bool?; + } // 1. Update artwork if a new local path is provided if (artworkPath != null) { @@ -139,8 +148,13 @@ class TrackRemoteDataSourceImpl implements TrackRemoteDataSource { await updateTrackCover(trackId, artworkFile); } } + + // 2. Update visibility if provided + if (isPublic != null) { + await updateTrackVisibility(trackId, isPublic); + } - // 2. Update other metadata if any remains + // 3. Update other metadata if any remains // We filter out null values to avoid overwriting existing data with nulls metadata.removeWhere((key, value) => value == null); @@ -149,6 +163,11 @@ class TrackRemoteDataSourceImpl implements TrackRemoteDataSource { } } + @override + Future updateTrackVisibility(String trackId, bool isPublic) async { + await client.dio.patch('/tracks/$trackId/visibility', data: {'is_public': isPublic}); + } + @override Future updateTrackCover(String trackId, File imageFile) async { final artMime = lookupMimeType(imageFile.path) ?? 'image/jpeg'; diff --git a/lib/features/track_upload/data/datasources/upload_track_remote_datasource.dart b/lib/features/track_upload/data/datasources/upload_track_remote_datasource.dart index f4a6f91a..28c04dc8 100644 --- a/lib/features/track_upload/data/datasources/upload_track_remote_datasource.dart +++ b/lib/features/track_upload/data/datasources/upload_track_remote_datasource.dart @@ -70,6 +70,7 @@ class UploadTrackRemoteDataSource { String? caption, required List tags, required bool isPublic, + bool isHidden = false, String? geoRestrictionType, List? geoRegions, void Function(double progress)? onProgress, @@ -83,6 +84,7 @@ class UploadTrackRemoteDataSource { 'artists': artist, // spec: 'artists' 'genre': genre, 'is_public': isPublic, // Pass as boolean + 'is_hidden': isHidden, 'audio_file': await MultipartFile.fromFile( // spec: 'audio_file' audioFile.path, @@ -110,15 +112,15 @@ class UploadTrackRemoteDataSource { final formData = FormData.fromMap(fields); - // Tags sent as repeated fields — spec: tags is array + // Tags sent as repeated fields — append [] so backend parses single items as arrays for (final tag in tags) { - formData.fields.add(MapEntry('tags', tag)); + formData.fields.add(MapEntry('tags[]', tag)); } - // Geo regions sent as repeated fields + // Geo regions sent as repeated fields — append [] so backend parses single items as arrays if (geoRegions != null) { for (final region in geoRegions) { - formData.fields.add(MapEntry('geo_regions', region)); + formData.fields.add(MapEntry('geo_regions[]', region)); } } diff --git a/lib/features/track_upload/data/repositories/upload_track_repository_impl.dart b/lib/features/track_upload/data/repositories/upload_track_repository_impl.dart index 15b83661..241b76e1 100644 --- a/lib/features/track_upload/data/repositories/upload_track_repository_impl.dart +++ b/lib/features/track_upload/data/repositories/upload_track_repository_impl.dart @@ -52,6 +52,7 @@ class UploadTrackRepositoryImpl implements UploadTrackRepository { caption: draft.caption, tags: draft.tags, isPublic: draft.isPublic, + isHidden: draft.isHidden, geoRestrictionType: draft.geoRestrictionType, geoRegions: draft.geoRegions, onProgress: onProgress, diff --git a/lib/features/track_upload/domain/entities/track_draft.dart b/lib/features/track_upload/domain/entities/track_draft.dart index 52d3f9fa..8d3e6052 100644 --- a/lib/features/track_upload/domain/entities/track_draft.dart +++ b/lib/features/track_upload/domain/entities/track_draft.dart @@ -27,6 +27,7 @@ class TrackDraft { // Has defaults final List tags; // multiple tag selections final bool isPublic; + final bool isHidden; final String geoRestrictionType; final List geoRegions; // list of country codes final UploadStatus status; @@ -61,6 +62,7 @@ class TrackDraft { this.caption, this.tags = const [], this.isPublic = true, + this.isHidden = false, this.geoRestrictionType = 'worldwide', this.geoRegions = const [], this.status = UploadStatus.draft, @@ -84,6 +86,7 @@ class TrackDraft { String? caption, List? tags, bool? isPublic, + bool? isHidden, String? geoRestrictionType, List? geoRegions, UploadStatus? status, @@ -111,6 +114,7 @@ class TrackDraft { caption: clearCaption ? null : caption ?? this.caption, tags: tags ?? this.tags, isPublic: isPublic ?? this.isPublic, + isHidden: isHidden ?? this.isHidden, geoRestrictionType: geoRestrictionType ?? this.geoRestrictionType, geoRegions: geoRegions ?? this.geoRegions, status: status ?? this.status, diff --git a/lib/features/track_upload/presentation/providers/upload_track_provider.dart b/lib/features/track_upload/presentation/providers/upload_track_provider.dart index e0e1320e..3684f2ff 100644 --- a/lib/features/track_upload/presentation/providers/upload_track_provider.dart +++ b/lib/features/track_upload/presentation/providers/upload_track_provider.dart @@ -92,7 +92,10 @@ class UploadFormNotifier extends Notifier { genre: track.genre, tags: track.tags, description: track.description, - isPublic: true, // Default to public for now + isPublic: track.isPublic, + isHidden: track.isHidden, + geoRestrictionType: track.geoRestrictionType ?? 'worldwide', + geoRegions: track.geoRegions, status: UploadStatus.draft, audioStatus: UploadStatus.success, // Audio already on server ), @@ -172,6 +175,9 @@ class UploadFormNotifier extends Notifier { void setIsPublic(bool value) => _updateDraft(state.draft!.copyWith(isPublic: value)); + void setIsHidden(bool value) => + _updateDraft(state.draft!.copyWith(isHidden: value)); + void setArtwork(String localPath) => _updateDraft(state.draft!.copyWith(localArtworkPath: localPath)); @@ -318,6 +324,10 @@ class UploadFormNotifier extends Notifier { "tags": draft.tags, "artists": draft.artist, "cover_image_path": draft.localArtworkPath, + "is_public": draft.isPublic, + "is_hidden": draft.isHidden, + "geo_restriction_type": draft.geoRestrictionType, + "geo_regions": draft.geoRegions, }; try { diff --git a/lib/features/track_upload/presentation/screens/upload_track_screen.dart b/lib/features/track_upload/presentation/screens/upload_track_screen.dart index 9e584288..13712545 100644 --- a/lib/features/track_upload/presentation/screens/upload_track_screen.dart +++ b/lib/features/track_upload/presentation/screens/upload_track_screen.dart @@ -506,7 +506,11 @@ class _TrackInfoTabState extends ConsumerState<_TrackInfoTab> { const SizedBox(height: 16), _PrivacySelector( isPublic: draft?.isPublic ?? true, - onChanged: notifier.setIsPublic, + isHidden: draft?.isHidden ?? false, + onChanged: (public, hidden) { + notifier.setIsPublic(public); + notifier.setIsHidden(hidden); + }, ), const SizedBox(height: 24), @@ -1374,8 +1378,15 @@ class _TagsInput extends StatelessWidget { class _PrivacySelector extends StatelessWidget { final bool isPublic; - final ValueChanged onChanged; - const _PrivacySelector({required this.isPublic, required this.onChanged}); + final bool isHidden; + final void Function(bool isPublic, bool isHidden) onChanged; + + const _PrivacySelector({ + required this.isPublic, + required this.isHidden, + required this.onChanged, + }); + @override Widget build(BuildContext context) { return Column( @@ -1384,14 +1395,14 @@ class _PrivacySelector extends StatelessWidget { title: 'Public', subtitle: 'Anyone can find this', isSelected: isPublic, - onTap: () => onChanged(true), + onTap: () => onChanged(true, false), ), const SizedBox(height: 16), _PrivacyOption( title: 'Unlisted (Private)', subtitle: 'Anyone with private link can access', isSelected: !isPublic, - onTap: () => onChanged(false), + onTap: () => onChanged(false, false), ), ], ); diff --git a/test/features/messaging/data/models/conversation_model_test.dart b/test/features/messaging/data/models/conversation_model_test.dart index 640a7c08..1abfd70f 100644 --- a/test/features/messaging/data/models/conversation_model_test.dart +++ b/test/features/messaging/data/models/conversation_model_test.dart @@ -14,7 +14,7 @@ Map _buildJson({ return { 'id': id, 'participant': participant ?? {'id': 'user-2', 'display_name': 'Alice'}, - if (lastMessage != null) 'last_message': lastMessage, + 'last_message': ?lastMessage, 'unread_count': unreadCount, }; } diff --git a/test/features/messaging/presentation/providers/block_unblock_notifier_test.dart b/test/features/messaging/presentation/providers/block_unblock_notifier_test.dart index 06d4f1b0..c68ee708 100644 --- a/test/features/messaging/presentation/providers/block_unblock_notifier_test.dart +++ b/test/features/messaging/presentation/providers/block_unblock_notifier_test.dart @@ -197,7 +197,7 @@ void main() { }), ); - expect(await result, true); + expect(result, true); }); }); } diff --git a/test/features/messaging/presentation/providers/send_message_notifier_test.dart b/test/features/messaging/presentation/providers/send_message_notifier_test.dart index f71c8b7a..ce642a68 100644 --- a/test/features/messaging/presentation/providers/send_message_notifier_test.dart +++ b/test/features/messaging/presentation/providers/send_message_notifier_test.dart @@ -1,5 +1,4 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_riverpod/legacy.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:rythmify/features/messaging/data/datasources/data_sources_sockets.dart'; From 203ae12f8be7b2b7ea34abfc886e195480276ed0 Mon Sep 17 00:00:00 2001 From: biso Date: Sat, 2 May 2026 04:10:43 +0300 Subject: [PATCH 6/6] Final commit for today ;(( --- lib/core/data/models/track_dto.dart | 4 +- .../feed/presentation/pages/home_screen.dart | 2 +- .../domain/entities/library_entities.dart | 8 +++- .../presentation/pages/downloads_page.dart | 25 +++++----- .../providers/downloads_provider.dart | 35 ++++++++++---- .../presentation/pages/full_player_page.dart | 2 +- .../widgets/player_action_bar.dart | 2 +- .../datasources/track_remote_data_source.dart | 9 ++-- .../presentation/widgets/track_card.dart | 32 ++++++------- .../widgets/track_details_section.dart | 35 ++++++++------ .../widgets/track_info_header.dart | 46 ++++++++++--------- .../providers/upload_track_provider.dart | 3 +- .../screens/upload_track_screen.dart | 29 +++++++++--- 13 files changed, 139 insertions(+), 93 deletions(-) diff --git a/lib/core/data/models/track_dto.dart b/lib/core/data/models/track_dto.dart index 6f7c210b..c01a7ac3 100644 --- a/lib/core/data/models/track_dto.dart +++ b/lib/core/data/models/track_dto.dart @@ -4,7 +4,7 @@ class TrackDto { static Track fromJson(Map json) { // Ensure we have a Map for internal logic final Map normalizedJson = Map.from(json); - + // Some responses wrap data in a 'data' field final Map data = normalizedJson.containsKey('data') ? Map.from(normalizedJson['data'] as Map) @@ -140,5 +140,3 @@ class TrackDto { }; } } - - diff --git a/lib/features/feed/presentation/pages/home_screen.dart b/lib/features/feed/presentation/pages/home_screen.dart index 298704a5..c3014c8d 100644 --- a/lib/features/feed/presentation/pages/home_screen.dart +++ b/lib/features/feed/presentation/pages/home_screen.dart @@ -341,7 +341,7 @@ class _NoInternetPlaceholder extends StatelessWidget { textAlign: TextAlign.center, ), const SizedBox(height: 12), - Text( + Text( 'Please check your connection or listen to your downloaded music.', style: AppTheme.bodyMedium, textAlign: TextAlign.center, diff --git a/lib/features/library/domain/entities/library_entities.dart b/lib/features/library/domain/entities/library_entities.dart index 2555418c..26f87b82 100644 --- a/lib/features/library/domain/entities/library_entities.dart +++ b/lib/features/library/domain/entities/library_entities.dart @@ -124,9 +124,13 @@ class DownloadedTrack extends Equatable { factory DownloadedTrack.fromJson(Map json) { return DownloadedTrack( - track: TrackDto.fromJson(Map.from(json['track'] as Map)), + track: TrackDto.fromJson( + Map.from(json['track'] as Map), + ), localPath: json['local_path'] as String? ?? '', - downloadedAt: DateTime.tryParse(json['downloaded_at'] as String? ?? '') ?? DateTime.now(), + downloadedAt: + DateTime.tryParse(json['downloaded_at'] as String? ?? '') ?? + DateTime.now(), ); } } diff --git a/lib/features/library/presentation/pages/downloads_page.dart b/lib/features/library/presentation/pages/downloads_page.dart index 6f687599..2fd608ba 100644 --- a/lib/features/library/presentation/pages/downloads_page.dart +++ b/lib/features/library/presentation/pages/downloads_page.dart @@ -88,11 +88,11 @@ class _DownloadsPageState extends ConsumerState { child: CircularProgressIndicator(color: AppTheme.primaryBrand), ) : state.error != null && state.tracks.isEmpty - ? _ErrorState( - error: state.error!, - onRetry: () => ref.read(downloadsProvider.notifier).load(), - ) - : _buildScrollView(context, state), + ? _ErrorState( + error: state.error!, + onRetry: () => ref.read(downloadsProvider.notifier).load(), + ) + : _buildScrollView(context, state), ), ); } @@ -152,7 +152,9 @@ class _DownloadsPageState extends ConsumerState { children: [ Text( 'Active Downloads', - style: AppTheme.titleMedium.copyWith(color: AppTheme.primaryBrand), + style: AppTheme.titleMedium.copyWith( + color: AppTheme.primaryBrand, + ), ), const SizedBox(height: 8), ...state.downloadingProgress.entries.map((entry) { @@ -160,7 +162,11 @@ class _DownloadsPageState extends ConsumerState { padding: const EdgeInsets.only(bottom: 12), child: Row( children: [ - const Icon(Icons.downloading, color: AppTheme.textSecondary, size: 20), + const Icon( + Icons.downloading, + color: AppTheme.textSecondary, + size: 20, + ), const SizedBox(width: 12), Expanded( child: Column( @@ -265,10 +271,7 @@ class _SearchBar extends StatelessWidget { } class _DownloadsHeader extends StatelessWidget { - const _DownloadsHeader({ - required this.onShuffle, - required this.onPlay, - }); + const _DownloadsHeader({required this.onShuffle, required this.onPlay}); final VoidCallback onShuffle; final VoidCallback onPlay; diff --git a/lib/features/library/presentation/providers/downloads_provider.dart b/lib/features/library/presentation/providers/downloads_provider.dart index 15518a96..70297000 100644 --- a/lib/features/library/presentation/providers/downloads_provider.dart +++ b/lib/features/library/presentation/providers/downloads_provider.dart @@ -39,7 +39,13 @@ class DownloadsState extends Equatable { ); @override - List get props => [tracks, downloadingProgress, downloadErrors, isLoading, error]; + List get props => [ + tracks, + downloadingProgress, + downloadErrors, + isLoading, + error, + ]; } class DownloadsNotifier extends Notifier { @@ -58,10 +64,13 @@ class DownloadsNotifier extends Notifier { try { final box = await Hive.openBox(_boxName); final rawData = box.get(_key) as List?; - + if (rawData != null) { final tracks = rawData - .map((e) => DownloadedTrack.fromJson(Map.from(e as Map))) + .map( + (e) => + DownloadedTrack.fromJson(Map.from(e as Map)), + ) .toList(); state = state.copyWith(tracks: tracks, isLoading: false); } else { @@ -93,7 +102,7 @@ class DownloadsNotifier extends Notifier { // Start download final progressMap = Map.from(state.downloadingProgress); progressMap[track.id] = 0.0; - + final errorMap = Map.from(state.downloadErrors); errorMap.remove(track.id); @@ -114,8 +123,10 @@ class DownloadsNotifier extends Notifier { // Simulate progress for (int i = 1; i <= 10; i++) { await Future.delayed(const Duration(milliseconds: 300)); - - final currentProgressMap = Map.from(state.downloadingProgress); + + final currentProgressMap = Map.from( + state.downloadingProgress, + ); currentProgressMap[track.id] = i / 10.0; state = state.copyWith(downloadingProgress: currentProgressMap); } @@ -132,19 +143,23 @@ class DownloadsNotifier extends Notifier { downloadedAt: DateTime.now(), ); - final finalProgressMap = Map.from(state.downloadingProgress); + final finalProgressMap = Map.from( + state.downloadingProgress, + ); finalProgressMap.remove(track.id); state = state.copyWith( tracks: [completedTrack, ...state.tracks], downloadingProgress: finalProgressMap, ); - + await _saveToHive(); } catch (e) { - final finalProgressMap = Map.from(state.downloadingProgress); + final finalProgressMap = Map.from( + state.downloadingProgress, + ); finalProgressMap.remove(track.id); - + final finalErrorMap = Map.from(state.downloadErrors); finalErrorMap[track.id] = e.toString(); diff --git a/lib/features/player/presentation/pages/full_player_page.dart b/lib/features/player/presentation/pages/full_player_page.dart index 62640563..4393ef7f 100644 --- a/lib/features/player/presentation/pages/full_player_page.dart +++ b/lib/features/player/presentation/pages/full_player_page.dart @@ -282,4 +282,4 @@ class _StaticWaveformPlaceholder extends StatelessWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/features/player/presentation/widgets/player_action_bar.dart b/lib/features/player/presentation/widgets/player_action_bar.dart index 57ddb0f4..5e5e78c2 100644 --- a/lib/features/player/presentation/widgets/player_action_bar.dart +++ b/lib/features/player/presentation/widgets/player_action_bar.dart @@ -242,4 +242,4 @@ class PlayerActionBar extends ConsumerWidget { ), ); } -} \ No newline at end of file +} diff --git a/lib/features/track/data/datasources/track_remote_data_source.dart b/lib/features/track/data/datasources/track_remote_data_source.dart index 6bffc4fb..5fb1e7d3 100644 --- a/lib/features/track/data/datasources/track_remote_data_source.dart +++ b/lib/features/track/data/datasources/track_remote_data_source.dart @@ -135,7 +135,7 @@ class TrackRemoteDataSourceImpl implements TrackRemoteDataSource { if (metadata.containsKey('cover_image_path')) { artworkPath = metadata.remove('cover_image_path') as String?; } - + // Extract is_public if present for separate API call if (metadata.containsKey('is_public')) { isPublic = metadata.remove('is_public') as bool?; @@ -148,7 +148,7 @@ class TrackRemoteDataSourceImpl implements TrackRemoteDataSource { await updateTrackCover(trackId, artworkFile); } } - + // 2. Update visibility if provided if (isPublic != null) { await updateTrackVisibility(trackId, isPublic); @@ -165,7 +165,10 @@ class TrackRemoteDataSourceImpl implements TrackRemoteDataSource { @override Future updateTrackVisibility(String trackId, bool isPublic) async { - await client.dio.patch('/tracks/$trackId/visibility', data: {'is_public': isPublic}); + await client.dio.patch( + '/tracks/$trackId/visibility', + data: {'is_public': isPublic}, + ); } @override diff --git a/lib/features/track/presentation/widgets/track_card.dart b/lib/features/track/presentation/widgets/track_card.dart index 23efed0d..7cb939bf 100644 --- a/lib/features/track/presentation/widgets/track_card.dart +++ b/lib/features/track/presentation/widgets/track_card.dart @@ -148,23 +148,23 @@ class TrackCard extends ConsumerWidget { if (showOptions) InkWell( key: Key('track_card_${track.id}_more_inkwell'), - onTap: () { - showModalBottomSheet( - context: context, - isScrollControlled: true, - useRootNavigator: true, - backgroundColor: Colors.transparent, - builder: (context) => TrackOptionsModal(track: track), - ); - }, - borderRadius: BorderRadius.circular(8), - highlightColor: Colors.white.withValues(alpha: 0.1), - splashColor: Colors.white.withValues(alpha: 0.2), - child: const Padding( - padding: EdgeInsets.symmetric(horizontal: 8, vertical: 8), - child: Icon(Icons.more_vert, color: Colors.grey), + onTap: () { + showModalBottomSheet( + context: context, + isScrollControlled: true, + useRootNavigator: true, + backgroundColor: Colors.transparent, + builder: (context) => TrackOptionsModal(track: track), + ); + }, + borderRadius: BorderRadius.circular(8), + highlightColor: Colors.white.withValues(alpha: 0.1), + splashColor: Colors.white.withValues(alpha: 0.2), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 8), + child: Icon(Icons.more_vert, color: Colors.grey), + ), ), - ), ], ), ), diff --git a/lib/features/track/presentation/widgets/track_details_section.dart b/lib/features/track/presentation/widgets/track_details_section.dart index 2e24f83e..8af52ff4 100644 --- a/lib/features/track/presentation/widgets/track_details_section.dart +++ b/lib/features/track/presentation/widgets/track_details_section.dart @@ -75,7 +75,8 @@ class TrackDetailsSection extends ConsumerWidget { String label = 'Download'; IconData icon = Icons.download_rounded; - VoidCallback? onPressed = () => ref.read(downloadsProvider.notifier).downloadTrack(track); + VoidCallback? onPressed = () => + ref.read(downloadsProvider.notifier).downloadTrack(track); if (isDownloaded) { label = 'Downloaded'; @@ -99,21 +100,27 @@ class TrackDetailsSection extends ConsumerWidget { OutlinedButton.icon( key: const Key('behind_the_track_download_button'), onPressed: onPressed, - icon: progress != null - ? SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - value: progress, - strokeWidth: 2, - color: AppTheme.textPrimary, - ), - ) - : Icon(icon, size: 20), + icon: progress != null + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + value: progress, + strokeWidth: 2, + color: AppTheme.textPrimary, + ), + ) + : Icon(icon, size: 20), label: Text(label), style: OutlinedButton.styleFrom( - foregroundColor: isDownloaded ? AppTheme.primaryBrand : AppTheme.textPrimary, - side: BorderSide(color: isDownloaded ? AppTheme.primaryBrand : AppTheme.textSecondary), + foregroundColor: isDownloaded + ? AppTheme.primaryBrand + : AppTheme.textPrimary, + side: BorderSide( + color: isDownloaded + ? AppTheme.primaryBrand + : AppTheme.textSecondary, + ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20), ), diff --git a/lib/features/track/presentation/widgets/track_info_header.dart b/lib/features/track/presentation/widgets/track_info_header.dart index 2eb63374..74795db8 100644 --- a/lib/features/track/presentation/widgets/track_info_header.dart +++ b/lib/features/track/presentation/widgets/track_info_header.dart @@ -56,8 +56,11 @@ class TrackInfoHeader extends StatelessWidget { ? CachedNetworkImage( imageUrl: track.artworkUrl, fit: BoxFit.cover, - placeholder: (c, u) => const Center(child: CircularProgressIndicator()), - errorWidget: (c, u, e) => const Icon(Icons.error), + placeholder: (c, u) => const Center( + child: CircularProgressIndicator(), + ), + errorWidget: (c, u, e) => + const Icon(Icons.error), ) : Image.asset( track.artworkUrl, @@ -123,26 +126,25 @@ class TrackInfoHeader extends StatelessWidget { height: 110, fit: BoxFit.cover, placeholder: (context, url) => Container( - width: 110, - height: 110, - color: AppTheme.perfectGrey, - child: const Icon( - Icons.music_note, - color: Colors.white, - size: 40, - ), - ), - errorWidget: (context, url, error) => - Container( - width: 110, - height: 110, - color: AppTheme.perfectGrey, - child: const Icon( - Icons.music_note, - color: Colors.white, - size: 40, - ), - ), + width: 110, + height: 110, + color: AppTheme.perfectGrey, + child: const Icon( + Icons.music_note, + color: Colors.white, + size: 40, + ), + ), + errorWidget: (context, url, error) => Container( + width: 110, + height: 110, + color: AppTheme.perfectGrey, + child: const Icon( + Icons.music_note, + color: Colors.white, + size: 40, + ), + ), ) : Image.asset( track.artworkUrl, diff --git a/lib/features/track_upload/presentation/providers/upload_track_provider.dart b/lib/features/track_upload/presentation/providers/upload_track_provider.dart index 3684f2ff..8b57d759 100644 --- a/lib/features/track_upload/presentation/providers/upload_track_provider.dart +++ b/lib/features/track_upload/presentation/providers/upload_track_provider.dart @@ -198,8 +198,7 @@ class UploadFormNotifier extends Notifier { _updateDraft(state.draft!.copyWith(geoRegions: current)); } - void clearGeoRegions() => - _updateDraft(state.draft!.copyWith(geoRegions: [])); + void clearGeoRegions() => _updateDraft(state.draft!.copyWith(geoRegions: [])); void setUploadProgress(double progress) { if (state.draft == null) return; diff --git a/lib/features/track_upload/presentation/screens/upload_track_screen.dart b/lib/features/track_upload/presentation/screens/upload_track_screen.dart index 13712545..6578ceb1 100644 --- a/lib/features/track_upload/presentation/screens/upload_track_screen.dart +++ b/lib/features/track_upload/presentation/screens/upload_track_screen.dart @@ -718,8 +718,10 @@ class _AdvancedTab extends ConsumerWidget { runSpacing: 8, children: draft.geoRegions.map((code) { final name = UploadTrackScreen._countries.entries - .firstWhere((e) => e.value == code, - orElse: () => MapEntry(code, code)) + .firstWhere( + (e) => e.value == code, + orElse: () => MapEntry(code, code), + ) .key; return Chip( label: Text( @@ -727,7 +729,11 @@ class _AdvancedTab extends ConsumerWidget { style: const TextStyle(color: Colors.white, fontSize: 12), ), backgroundColor: Colors.white12, - deleteIcon: const Icon(Icons.close, size: 14, color: Colors.white54), + deleteIcon: const Icon( + Icons.close, + size: 14, + color: Colors.white54, + ), onDeleted: () => notifier.toggleGeoRegion(code), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(20), @@ -818,7 +824,9 @@ class _RestrictionOption extends StatelessWidget { child: Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: isSelected ? Colors.white.withValues(alpha: 0.05) : Colors.transparent, + color: isSelected + ? Colors.white.withValues(alpha: 0.05) + : Colors.transparent, borderRadius: BorderRadius.circular(12), border: Border.all( color: isSelected ? Colors.white : Colors.white12, @@ -848,7 +856,11 @@ class _RestrictionOption extends StatelessWidget { ), ), if (isSelected) - const Icon(Icons.check_circle_rounded, color: Colors.white, size: 20), + const Icon( + Icons.check_circle_rounded, + color: Colors.white, + size: 20, + ), ], ), ), @@ -860,7 +872,8 @@ class _CountryPickerModal extends ConsumerStatefulWidget { const _CountryPickerModal(); @override - ConsumerState<_CountryPickerModal> createState() => _CountryPickerModalState(); + ConsumerState<_CountryPickerModal> createState() => + _CountryPickerModalState(); } class _CountryPickerModalState extends ConsumerState<_CountryPickerModal> { @@ -943,7 +956,9 @@ class _CountryPickerModalState extends ConsumerState<_CountryPickerModal> { entry.key, style: TextStyle( color: isSelected ? Colors.white : Colors.grey, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.normal, ), ), trailing: Checkbox(