diff --git a/lib/core/data/models/track_dto.dart b/lib/core/data/models/track_dto.dart index 9a65d758..c01a7ac3 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? ?? '', @@ -80,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?, ); } @@ -121,6 +132,10 @@ 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/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/core/routing/app_router.dart b/lib/core/routing/app_router.dart index ee020a57..7081ba1c 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'; @@ -601,6 +602,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/feed/presentation/pages/home_screen.dart b/lib/features/feed/presentation/pages/home_screen.dart index ccbcdf5d..c3014c8d 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), ), + ), + ], + ), ), ); } diff --git a/lib/features/library/domain/entities/library_entities.dart b/lib/features/library/domain/entities/library_entities.dart index 677a3cdc..26f87b82 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,48 @@ 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..2fd608ba --- /dev/null +++ b/lib/features/library/presentation/pages/downloads_page.dart @@ -0,0 +1,481 @@ +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..70297000 --- /dev/null +++ b/lib/features/library/presentation/providers/downloads_provider.dart @@ -0,0 +1,183 @@ +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/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/data/datasources/track_remote_data_source.dart b/lib/features/track/data/datasources/track_remote_data_source.dart index edd93859..5fb1e7d3 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,12 +129,18 @@ 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) { final artworkFile = File(artworkPath); @@ -140,7 +149,12 @@ class TrackRemoteDataSourceImpl implements TrackRemoteDataSource { } } - // 2. Update other metadata if any remains + // 2. Update visibility if provided + if (isPublic != null) { + await updateTrackVisibility(trackId, isPublic); + } + + // 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,14 @@ 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/presentation/widgets/track_card.dart b/lib/features/track/presentation/widgets/track_card.dart index d9dca66b..7cb939bf 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,33 +136,35 @@ 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'), - 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), + 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), + ), ), - ), ], ), ), @@ -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..8af52ff4 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,87 @@ 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..74795db8 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,14 @@ 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,22 +120,31 @@ 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) => - Container( - width: 110, - height: 110, - color: AppTheme.perfectGrey, - child: const Icon( - Icons.music_note, - color: Colors.white, - size: 40, - ), - ), + 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, + ), + ), ) : Image.asset( track.artworkUrl, 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..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,9 @@ class UploadTrackRemoteDataSource { String? caption, required List tags, required bool isPublic, + bool isHidden = false, + String? geoRestrictionType, + List? geoRegions, void Function(double progress)? onProgress, }) async { try { @@ -81,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, @@ -92,6 +96,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'; @@ -104,9 +112,16 @@ 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 — append [] so backend parses single items as arrays + if (geoRegions != null) { + for (final region in geoRegions) { + formData.fields.add(MapEntry('geo_regions[]', region)); + } } debugPrint('=== SENDING TO BACKEND ==='); 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..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,9 @@ class UploadTrackRepositoryImpl implements UploadTrackRepository { caption: draft.caption, tags: draft.tags, isPublic: draft.isPublic, + isHidden: draft.isHidden, + 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..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,9 @@ 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; final double uploadProgress; @@ -59,6 +62,9 @@ class TrackDraft { this.caption, this.tags = const [], this.isPublic = true, + this.isHidden = false, + this.geoRestrictionType = 'worldwide', + this.geoRegions = const [], this.status = UploadStatus.draft, this.uploadProgress = 0.0, this.audioStatus = UploadStatus.uploading, // ← starts uploading @@ -80,6 +86,9 @@ class TrackDraft { String? caption, List? tags, bool? isPublic, + bool? isHidden, + String? geoRestrictionType, + List? geoRegions, UploadStatus? status, double? uploadProgress, UploadStatus? audioStatus, @@ -105,6 +114,9 @@ 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, 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..8b57d759 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,12 +175,31 @@ 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)); 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( @@ -301,6 +323,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 02fe3ac5..6578ceb1 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, @@ -473,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), @@ -623,24 +660,345 @@ 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 +1289,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) @@ -1072,8 +1393,15 @@ class _TagsPickerField 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( @@ -1082,14 +1410,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';