Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions lib/core/data/models/track_dto.dart
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import '../../domain/entities/track.dart';

class TrackDto {
static Track fromJson(Map<String, dynamic> json) {
static Track fromJson(Map<dynamic, dynamic> json) {
// Ensure we have a Map<String, dynamic> for internal logic
final Map<String, dynamic> normalizedJson = Map<String, dynamic>.from(json);

// Some responses wrap data in a 'data' field
final Map<String, dynamic> data = json.containsKey('data')
? json['data'] as Map<String, dynamic>
: json;
final Map<String, dynamic> data = normalizedJson.containsKey('data')
? Map<String, dynamic>.from(normalizedJson['data'] as Map)
: normalizedJson;

return Track(
id: data['id'] as String? ?? '',
Expand Down Expand Up @@ -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<dynamic>?)
?.map((e) => e.toString())
.toList() ??
const [],
status: data['status'] as String?,
);
}
Expand Down Expand Up @@ -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,
};
}
Expand Down
16 changes: 16 additions & 0 deletions lib/core/domain/entities/track.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ class Track {

final bool isTrending;
final bool isFeatured;
final bool isPublic;
final bool isHidden;
final String? geoRestrictionType;
final List<String> geoRegions;
final DateTime createdAt;
final DateTime? updatedAt;
final String? status;
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -105,6 +113,10 @@ class Track {
bool? explicitContent,
bool? isTrending,
bool? isFeatured,
bool? isPublic,
bool? isHidden,
String? geoRestrictionType,
List<String>? geoRegions,
DateTime? createdAt,
DateTime? updatedAt,
String? status,
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions lib/core/error/error_handler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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() ??
Expand Down
5 changes: 5 additions & 0 deletions lib/core/routing/app_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -601,6 +602,10 @@ final routerProvider = Provider<GoRouter>((ref) {
path: 'insights',
builder: (context, state) => const InsightsPage(),
),
GoRoute(
path: 'downloads',
builder: (context, state) => const DownloadsPage(),
),
],
),
],
Expand Down
234 changes: 165 additions & 69 deletions lib/features/feed/presentation/pages/home_screen.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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'),
Expand All @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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),
),
),
],
),
),
);
}
Expand Down
Loading
Loading