diff --git a/lib/headline-details/bloc/similar_headlines_bloc.dart b/lib/headline-details/bloc/similar_headlines_bloc.dart new file mode 100644 index 00000000..de9eaa2e --- /dev/null +++ b/lib/headline-details/bloc/similar_headlines_bloc.dart @@ -0,0 +1,64 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:ht_data_repository/ht_data_repository.dart'; +import 'package:ht_shared/ht_shared.dart' show Headline, HtHttpException; + +part 'similar_headlines_event.dart'; +part 'similar_headlines_state.dart'; + +class SimilarHeadlinesBloc + extends Bloc { + SimilarHeadlinesBloc({ + required HtDataRepository headlinesRepository, + }) : _headlinesRepository = headlinesRepository, + super(SimilarHeadlinesInitial()) { + on(_onFetchSimilarHeadlines); + } + + final HtDataRepository _headlinesRepository; + static const int _similarHeadlinesLimit = 5; + + Future _onFetchSimilarHeadlines( + FetchSimilarHeadlines event, + Emitter emit, + ) async { + emit(SimilarHeadlinesLoading()); + try { + final currentHeadline = event.currentHeadline; + if (currentHeadline.category == null || + currentHeadline.category!.id.isEmpty) { + emit(SimilarHeadlinesEmpty()); + return; + } + + final queryParams = { + 'categories': currentHeadline.category!.id, + }; + + final response = await _headlinesRepository.readAllByQuery( + queryParams, + limit: _similarHeadlinesLimit + 1, // Fetch one extra to check if current is there + ); + + // Filter out the current headline from the results + final similarHeadlines = response.items + .where((headline) => headline.id != currentHeadline.id) + .toList(); + + // Take only the required limit after filtering + final finalSimilarHeadlines = similarHeadlines.take(_similarHeadlinesLimit).toList(); + + if (finalSimilarHeadlines.isEmpty) { + emit(SimilarHeadlinesEmpty()); + } else { + emit(SimilarHeadlinesLoaded(similarHeadlines: finalSimilarHeadlines)); + } + } on HtHttpException catch (e) { + emit(SimilarHeadlinesError(message: e.message)); + } catch (e) { + emit(SimilarHeadlinesError(message: 'An unexpected error occurred: $e')); + } + } +} diff --git a/lib/headline-details/bloc/similar_headlines_event.dart b/lib/headline-details/bloc/similar_headlines_event.dart new file mode 100644 index 00000000..6d72f577 --- /dev/null +++ b/lib/headline-details/bloc/similar_headlines_event.dart @@ -0,0 +1,17 @@ +part of 'similar_headlines_bloc.dart'; + +abstract class SimilarHeadlinesEvent extends Equatable { + const SimilarHeadlinesEvent(); + + @override + List get props => []; +} + +class FetchSimilarHeadlines extends SimilarHeadlinesEvent { + const FetchSimilarHeadlines({required this.currentHeadline}); + + final Headline currentHeadline; + + @override + List get props => [currentHeadline]; +} diff --git a/lib/headline-details/bloc/similar_headlines_state.dart b/lib/headline-details/bloc/similar_headlines_state.dart new file mode 100644 index 00000000..4a14a65d --- /dev/null +++ b/lib/headline-details/bloc/similar_headlines_state.dart @@ -0,0 +1,32 @@ +part of 'similar_headlines_bloc.dart'; + +abstract class SimilarHeadlinesState extends Equatable { + const SimilarHeadlinesState(); + + @override + List get props => []; +} + +class SimilarHeadlinesInitial extends SimilarHeadlinesState {} + +class SimilarHeadlinesLoading extends SimilarHeadlinesState {} + +class SimilarHeadlinesLoaded extends SimilarHeadlinesState { + const SimilarHeadlinesLoaded({required this.similarHeadlines}); + + final List similarHeadlines; + + @override + List get props => [similarHeadlines]; +} + +class SimilarHeadlinesEmpty extends SimilarHeadlinesState {} + +class SimilarHeadlinesError extends SimilarHeadlinesState { + const SimilarHeadlinesError({required this.message}); + + final String message; + + @override + List get props => [message]; +} diff --git a/lib/headline-details/view/headline_details_page.dart b/lib/headline-details/view/headline_details_page.dart index 34ea4030..82705f24 100644 --- a/lib/headline-details/view/headline_details_page.dart +++ b/lib/headline-details/view/headline_details_page.dart @@ -6,7 +6,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:ht_main/account/bloc/account_bloc.dart'; // Import AccountBloc import 'package:ht_main/headline-details/bloc/headline_details_bloc.dart'; // Import BLoC +import 'package:ht_main/headline-details/bloc/similar_headlines_bloc.dart'; // Import SimilarHeadlinesBloc +import 'package:ht_main/headlines-feed/widgets/headline_item_widget.dart'; // Import HeadlineItemWidget import 'package:ht_main/l10n/l10n.dart'; +import 'package:ht_main/router/routes.dart'; // Import Routes import 'package:ht_main/shared/shared.dart'; import 'package:ht_shared/ht_shared.dart' show Headline; // Import Headline model @@ -365,6 +368,17 @@ class _HeadlineDetailsPageState extends State { padding: EdgeInsets.only(bottom: AppSpacing.paddingLarge), sliver: SliverToBoxAdapter(child: SizedBox.shrink()), ), + // --- Similar Headlines Section --- + SliverToBoxAdapter( + child: Padding( + padding: horizontalPadding.copyWith(top: AppSpacing.xl), + child: Text( + l10n.similarHeadlinesSectionTitle, // Add this l10n key + style: textTheme.titleLarge, + ), + ), + ), + _buildSimilarHeadlinesSection(context), ], ); } @@ -469,4 +483,65 @@ class _HeadlineDetailsPageState extends State { return chips; } + + Widget _buildSimilarHeadlinesSection(BuildContext context) { + final l10n = context.l10n; + return BlocBuilder( + builder: (context, state) { + return switch (state) { + SimilarHeadlinesInitial() || + SimilarHeadlinesLoading() => + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(AppSpacing.lg), + child: Center(child: CircularProgressIndicator()), + ), + ), + final SimilarHeadlinesError errorState => SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Text( + errorState.message, + textAlign: TextAlign.center, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + ), + SimilarHeadlinesEmpty() => SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Text( + l10n.similarHeadlinesEmpty, // Add this l10n key + textAlign: TextAlign.center, + ), + ), + ), + final SimilarHeadlinesLoaded loadedState => SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final similarHeadline = loadedState.similarHeadlines[index]; + // Use a more compact item or reuse HeadlineItemWidget + // For now, reusing HeadlineItemWidget for simplicity + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.paddingMedium, + vertical: AppSpacing.sm, + ), + // Navigate to a new HeadlineDetailsPage instance + // Ensure the targetRouteName is appropriate or handle navigation differently + child: HeadlineItemWidget( + headline: similarHeadline, + targetRouteName: Routes.articleDetailsName, + ), + ); + }, + childCount: loadedState.similarHeadlines.length, + ), + ), + // Add a default case to satisfy exhaustiveness for the switch statement + _ => const SliverToBoxAdapter(child: SizedBox.shrink()), + }; + }, + ); + } } diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 2db82743..8aef218b 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -787,5 +787,13 @@ "sharingUnavailableSnackbar": "المشاركة غير متاحة على هذا الجهاز أو المنصة.", "@sharingUnavailableSnackbar": { "description": "Snackbar message shown when sharing is unavailable" + }, + "similarHeadlinesSectionTitle": "قد يعجبك ايضا", + "@similarHeadlinesSectionTitle": { + "description": "Title for the similar headlines section on the details page" + }, + "similarHeadlinesEmpty": "لم يتم العثور على عناوين مشابهة.", + "@similarHeadlinesEmpty": { + "description": "Message shown when no similar headlines are found" } } diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index faa9749b..4ba481fb 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -787,5 +787,13 @@ "sharingUnavailableSnackbar": "Sharing is not available on this device or platform.", "@sharingUnavailableSnackbar": { "description": "Snackbar message shown when sharing is unavailable" + }, + "similarHeadlinesSectionTitle": "You Might Also Like", + "@similarHeadlinesSectionTitle": { + "description": "Title for the similar headlines section on the details page" + }, + "similarHeadlinesEmpty": "No similar headlines found.", + "@similarHeadlinesEmpty": { + "description": "Message shown when no similar headlines are found" } } diff --git a/lib/router/router.dart b/lib/router/router.dart index 4c7a01cd..2a2bf7fb 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -20,6 +20,7 @@ import 'package:ht_main/authentication/view/authentication_page.dart'; import 'package:ht_main/authentication/view/email_code_verification_page.dart'; import 'package:ht_main/authentication/view/request_code_page.dart'; import 'package:ht_main/headline-details/bloc/headline_details_bloc.dart'; // Re-added +import 'package:ht_main/headline-details/bloc/similar_headlines_bloc.dart'; // Import SimilarHeadlinesBloc import 'package:ht_main/headline-details/view/headline_details_page.dart'; import 'package:ht_main/headlines-feed/bloc/categories_filter_bloc.dart'; // Import new BLoC // import 'package:ht_main/headlines-feed/bloc/countries_filter_bloc.dart'; // Import new BLoC - REMOVED @@ -344,11 +345,21 @@ GoRouter createRouter({ final headlineFromExtra = state.extra as Headline?; final headlineIdFromPath = state.pathParameters['id']; - return BlocProvider( - create: (context) => HeadlineDetailsBloc( - headlinesRepository: - context.read>(), - ), + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => HeadlineDetailsBloc( + headlinesRepository: + context.read>(), + ), + ), + BlocProvider( + create: (context) => SimilarHeadlinesBloc( + headlinesRepository: + context.read>(), + ), + ), + ], child: HeadlineDetailsPage( initialHeadline: headlineFromExtra, // Ensure headlineId is non-null if initialHeadline is null @@ -471,11 +482,21 @@ GoRouter createRouter({ builder: (context, state) { final headlineFromExtra = state.extra as Headline?; final headlineIdFromPath = state.pathParameters['id']; - return BlocProvider( - create: (context) => HeadlineDetailsBloc( - headlinesRepository: - context.read>(), - ), + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => HeadlineDetailsBloc( + headlinesRepository: + context.read>(), + ), + ), + BlocProvider( + create: (context) => SimilarHeadlinesBloc( + headlinesRepository: + context.read>(), + ), + ), + ], child: HeadlineDetailsPage( initialHeadline: headlineFromExtra, headlineId: headlineFromExtra?.id ?? headlineIdFromPath, @@ -625,11 +646,21 @@ GoRouter createRouter({ builder: (context, state) { final headlineFromExtra = state.extra as Headline?; final headlineIdFromPath = state.pathParameters['id']; - return BlocProvider( - create: (context) => HeadlineDetailsBloc( - headlinesRepository: - context.read>(), - ), + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => HeadlineDetailsBloc( + headlinesRepository: + context.read>(), + ), + ), + BlocProvider( + create: (context) => SimilarHeadlinesBloc( + headlinesRepository: + context.read>(), + ), + ), + ], child: HeadlineDetailsPage( initialHeadline: headlineFromExtra, headlineId: headlineFromExtra?.id ?? headlineIdFromPath,