diff --git a/lib/headline-details/bloc/headline_details_bloc.dart b/lib/headline-details/bloc/headline_details_bloc.dart index b242375c..636c9901 100644 --- a/lib/headline-details/bloc/headline_details_bloc.dart +++ b/lib/headline-details/bloc/headline_details_bloc.dart @@ -1,12 +1,10 @@ -import 'dart:async'; // Ensure async is imported +import 'dart:async'; import 'package:bloc/bloc.dart'; -import 'package:ht_data_repository/ht_data_repository.dart'; // Generic Data Repository +import 'package:equatable/equatable.dart'; +import 'package:ht_data_repository/ht_data_repository.dart'; import 'package:ht_shared/ht_shared.dart' - show - Headline, - HtHttpException, - NotFoundException; // Shared models and standardized exceptions + show Headline, HtHttpException, NotFoundException; part 'headline_details_event.dart'; part 'headline_details_state.dart'; @@ -14,15 +12,16 @@ part 'headline_details_state.dart'; class HeadlineDetailsBloc extends Bloc { HeadlineDetailsBloc({required HtDataRepository headlinesRepository}) - : _headlinesRepository = headlinesRepository, - super(HeadlineDetailsInitial()) { - on(_onHeadlineDetailsRequested); + : _headlinesRepository = headlinesRepository, + super(HeadlineDetailsInitial()) { + on(_onFetchHeadlineById); + on(_onHeadlineProvided); } final HtDataRepository _headlinesRepository; - Future _onHeadlineDetailsRequested( - HeadlineDetailsRequested event, + Future _onFetchHeadlineById( + FetchHeadlineById event, Emitter emit, ) async { emit(HeadlineDetailsLoading()); @@ -37,4 +36,11 @@ class HeadlineDetailsBloc emit(HeadlineDetailsFailure(message: 'An unexpected error occurred: $e')); } } + + void _onHeadlineProvided( + HeadlineProvided event, + Emitter emit, + ) { + emit(HeadlineDetailsLoaded(headline: event.headline)); + } } diff --git a/lib/headline-details/bloc/headline_details_event.dart b/lib/headline-details/bloc/headline_details_event.dart index 6fb7a804..7128bca4 100644 --- a/lib/headline-details/bloc/headline_details_event.dart +++ b/lib/headline-details/bloc/headline_details_event.dart @@ -1,9 +1,24 @@ part of 'headline_details_bloc.dart'; -abstract class HeadlineDetailsEvent {} +abstract class HeadlineDetailsEvent extends Equatable { + const HeadlineDetailsEvent(); -class HeadlineDetailsRequested extends HeadlineDetailsEvent { - HeadlineDetailsRequested({required this.headlineId}); + @override + List get props => []; +} +class FetchHeadlineById extends HeadlineDetailsEvent { + const FetchHeadlineById(this.headlineId); final String headlineId; + + @override + List get props => [headlineId]; +} + +class HeadlineProvided extends HeadlineDetailsEvent { + const HeadlineProvided(this.headline); + final Headline headline; + + @override + List get props => [headline]; } diff --git a/lib/headline-details/bloc/headline_details_state.dart b/lib/headline-details/bloc/headline_details_state.dart index be5c1943..df3fa3e2 100644 --- a/lib/headline-details/bloc/headline_details_state.dart +++ b/lib/headline-details/bloc/headline_details_state.dart @@ -1,19 +1,30 @@ part of 'headline_details_bloc.dart'; -abstract class HeadlineDetailsState {} +abstract class HeadlineDetailsState extends Equatable { + const HeadlineDetailsState(); + + @override + List get props => []; +} class HeadlineDetailsInitial extends HeadlineDetailsState {} class HeadlineDetailsLoading extends HeadlineDetailsState {} class HeadlineDetailsLoaded extends HeadlineDetailsState { - HeadlineDetailsLoaded({required this.headline}); + const HeadlineDetailsLoaded({required this.headline}); final Headline headline; + + @override + List get props => [headline]; } class HeadlineDetailsFailure extends HeadlineDetailsState { - HeadlineDetailsFailure({required this.message}); + const HeadlineDetailsFailure({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 257120aa..34ea4030 100644 --- a/lib/headline-details/view/headline_details_page.dart +++ b/lib/headline-details/view/headline_details_page.dart @@ -1,41 +1,63 @@ // // ignore_for_file: avoid_redundant_argument_values +import 'package:flutter/foundation.dart' show kIsWeb; // Import kIsWeb 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 'package:ht_main/headline-details/bloc/headline_details_bloc.dart'; // Import BLoC import 'package:ht_main/l10n/l10n.dart'; import 'package:ht_main/shared/shared.dart'; import 'package:ht_shared/ht_shared.dart' show Headline; // Import Headline model import 'package:intl/intl.dart'; +import 'package:share_plus/share_plus.dart'; // Import share_plus import 'package:url_launcher/url_launcher_string.dart'; -class HeadlineDetailsPage extends StatelessWidget { - const HeadlineDetailsPage({required this.headlineId, super.key}); +class HeadlineDetailsPage extends StatefulWidget { + const HeadlineDetailsPage({ + super.key, + this.headlineId, + this.initialHeadline, + }) : assert(headlineId != null || initialHeadline != null); - final String headlineId; + final String? headlineId; + final Headline? initialHeadline; + + @override + State createState() => _HeadlineDetailsPageState(); +} + +class _HeadlineDetailsPageState extends State { + @override + void initState() { + super.initState(); + if (widget.initialHeadline != null) { + context + .read() + .add(HeadlineProvided(widget.initialHeadline!)); + } else if (widget.headlineId != null) { + context + .read() + .add(FetchHeadlineById(widget.headlineId!)); + } + } @override Widget build(BuildContext context) { final l10n = context.l10n; - // Keep a reference to headlineDetailsState to use in BlocListener - final headlineDetailsState = context.watch().state; return SafeArea( child: Scaffold( - // Body contains the BlocBuilder which returns either state widgets - // or the scroll view body: BlocListener( listenWhen: (previous, current) { - // Listen if status is failure or if the saved status of *this* headline changed - if (current.status == AccountStatus.failure && - previous.status != AccountStatus.failure) { - return true; - } - if (headlineDetailsState is HeadlineDetailsLoaded) { - final currentHeadlineId = headlineDetailsState.headline.id; + final detailsState = context.read().state; + if (detailsState is HeadlineDetailsLoaded) { + if (current.status == AccountStatus.failure && + previous.status != AccountStatus.failure) { + return true; + } + final currentHeadlineId = detailsState.headline.id; final wasPreviouslySaved = previous.preferences?.savedHeadlines.any( (h) => h.id == currentHeadlineId, @@ -46,8 +68,6 @@ class HeadlineDetailsPage extends StatelessWidget { (h) => h.id == currentHeadlineId, ) ?? false; - // Listen if the specific headline's saved status changed OR - // if a general success occurred (e.g. after an optimistic update that might not change the list length but confirms persistence) return (wasPreviouslySaved != isCurrentlySaved) || (current.status == AccountStatus.success && previous.status != AccountStatus.success); @@ -55,11 +75,11 @@ class HeadlineDetailsPage extends StatelessWidget { return false; }, listener: (context, accountState) { - if (headlineDetailsState is HeadlineDetailsLoaded) { - final currentHeadline = headlineDetailsState.headline; + final detailsState = context.read().state; + if (detailsState is HeadlineDetailsLoaded) { final nowIsSaved = accountState.preferences?.savedHeadlines.any( - (h) => h.id == currentHeadline.id, + (h) => h.id == detailsState.headline.id, ) ?? false; @@ -72,12 +92,11 @@ class HeadlineDetailsPage extends StatelessWidget { content: Text( accountState.errorMessage ?? l10n.headlineSaveErrorSnackbar, - ), // Use specific or generic error + ), backgroundColor: Theme.of(context).colorScheme.error, ), ); } else { - // Only show success snackbar if the state isn't failure ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( @@ -94,36 +113,32 @@ class HeadlineDetailsPage extends StatelessWidget { } }, child: BlocBuilder( - // No need to re-watch headlineDetailsState here, already have it. - // builder: (context, state) // state here is headlineDetailsState - builder: (context, headlineDetailsBuilderState) { - // Handle Loading/Initial/Failure states outside the scroll view - // for better user experience. - // Use headlineDetailsBuilderState for the switch - return switch (headlineDetailsBuilderState) { - HeadlineDetailsInitial _ => InitialStateWidget( - icon: Icons.article, - headline: l10n.headlineDetailsInitialHeadline, - subheadline: l10n.headlineDetailsInitialSubheadline, - ), - HeadlineDetailsLoading _ => LoadingStateWidget( - icon: Icons.downloading, - headline: l10n.headlineDetailsLoadingHeadline, - subheadline: l10n.headlineDetailsLoadingSubheadline, - ), - final HeadlineDetailsFailure state => FailureStateWidget( - message: state.message, - onRetry: () { - context.read().add( - HeadlineDetailsRequested(headlineId: headlineId), - ); - }, - ), - final HeadlineDetailsLoaded state => _buildLoadedContent( - context, - state.headline, - ), - _ => const SizedBox.shrink(), // Should not happen in practice + builder: (context, state) { + return switch (state) { + HeadlineDetailsInitial() || + HeadlineDetailsLoading() => + LoadingStateWidget( + icon: Icons.downloading, + headline: l10n.headlineDetailsLoadingHeadline, + subheadline: l10n.headlineDetailsLoadingSubheadline, + ), + final HeadlineDetailsFailure failureState => FailureStateWidget( + message: failureState.message, + onRetry: () { + if (widget.headlineId != null) { + context + .read() + .add(FetchHeadlineById(widget.headlineId!)); + } + // If only initialHeadline was provided and it failed to load + // (which shouldn't happen with HeadlineProvided), + // there's no ID to refetch. Consider a different UI. + }, + ), + final HeadlineDetailsLoaded loadedState => + _buildLoadedContent(context, loadedState.headline), + // Add a default case to satisfy exhaustiveness + _ => const Center(child: Text('Unknown state')), }; }, ), @@ -166,10 +181,69 @@ class HeadlineDetailsPage extends StatelessWidget { }, ); - final shareButton = IconButton( - icon: const Icon(Icons.share), - onPressed: () { - // TODO(fulleni): Implement share functionality + // Use a Builder to get the correct context for sharePositionOrigin + final Widget shareButtonWidget = Builder( + builder: (BuildContext buttonContext) { + return IconButton( + icon: const Icon(Icons.share), + tooltip: l10n.shareActionTooltip, + onPressed: () async { + final box = buttonContext.findRenderObject() as RenderBox?; + Rect? sharePositionOrigin; + if (box != null) { + sharePositionOrigin = box.localToGlobal(Offset.zero) & box.size; + } + + String shareText = headline.title; + if (headline.url != null && headline.url!.isNotEmpty) { + shareText += '\n\n${headline.url}'; + } + + ShareParams params; + if (kIsWeb && headline.url != null && headline.url!.isNotEmpty) { + // For web, prioritize sharing the URL directly as a URI. + // The 'title' in ShareParams might be used by some platforms or if + // the plugin's web handling evolves to use it with navigator.share's title field. + params = ShareParams( + uri: Uri.parse(headline.url!), + title: headline.title, // Title hint for the shared content + sharePositionOrigin: sharePositionOrigin, + ); + } else if (headline.url != null && headline.url!.isNotEmpty) { + // For native platforms with a URL, combine title and URL in text. + // Subject can be used by email clients. + params = ShareParams( + text: '${headline.title}\n\n${headline.url!}', + subject: headline.title, + sharePositionOrigin: sharePositionOrigin, + ); + } else { + // No URL, share only the title as text (works for all platforms). + params = ShareParams( + text: headline.title, + subject: headline.title, // Subject for email clients + sharePositionOrigin: sharePositionOrigin, + ); + } + + final shareResult = await SharePlus.instance.share(params); + + // Optional: Handle ShareResult for user feedback + if (buttonContext.mounted) { // Check if context is still valid + if (shareResult.status == ShareResultStatus.unavailable) { + ScaffoldMessenger.of(buttonContext).showSnackBar( + SnackBar( + content: Text( + l10n.sharingUnavailableSnackbar, // Add this l10n key + ), + ), + ); + } + // You can add more feedback for success/dismissed if desired + // e.g., print('Share result: ${shareResult.status}, raw: ${shareResult.raw}'); + } + }, + ); }, ); @@ -181,7 +255,7 @@ class HeadlineDetailsPage extends StatelessWidget { icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.of(context).pop(), ), - actions: [bookmarkButton, shareButton], + actions: [bookmarkButton, shareButtonWidget], // Use the new widget // Pinned=false, floating=true, snap=true is common for news apps pinned: false, floating: true, // Trailing comma diff --git a/lib/headlines-feed/widgets/headline_item_widget.dart b/lib/headlines-feed/widgets/headline_item_widget.dart index 68c053be..045d0816 100644 --- a/lib/headlines-feed/widgets/headline_item_widget.dart +++ b/lib/headlines-feed/widgets/headline_item_widget.dart @@ -50,6 +50,7 @@ class HeadlineItemWidget extends StatelessWidget { context.goNamed( targetRouteName, // Use the new parameter here pathParameters: {'id': headline.id}, + extra: headline, // Pass the full headline object ); }, child: Padding( diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index c19ee923..2db82743 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -779,5 +779,13 @@ "headlineSaveErrorSnackbar": "تعذر تحديث حالة الحفظ. يرجى المحاولة مرة أخرى.", "@headlineSaveErrorSnackbar": { "description": "Snackbar message shown when saving/unsaving a headline fails" + }, + "shareActionTooltip": "مشاركة العنوان", + "@shareActionTooltip": { + "description": "Tooltip for the share button on the headline details page" + }, + "sharingUnavailableSnackbar": "المشاركة غير متاحة على هذا الجهاز أو المنصة.", + "@sharingUnavailableSnackbar": { + "description": "Snackbar message shown when sharing is unavailable" } } diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 7c6edf82..faa9749b 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -779,5 +779,13 @@ "headlineSaveErrorSnackbar": "Could not update saved status. Please try again.", "@headlineSaveErrorSnackbar": { "description": "Snackbar message shown when saving/unsaving a headline fails" + }, + "shareActionTooltip": "Share headline", + "@shareActionTooltip": { + "description": "Tooltip for the share button on the headline details page" + }, + "sharingUnavailableSnackbar": "Sharing is not available on this device or platform.", + "@sharingUnavailableSnackbar": { + "description": "Snackbar message shown when sharing is unavailable" } } diff --git a/lib/router/router.dart b/lib/router/router.dart index 724e3c44..4c7a01cd 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -19,7 +19,7 @@ import 'package:ht_main/authentication/bloc/authentication_bloc.dart'; 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'; +import 'package:ht_main/headline-details/bloc/headline_details_bloc.dart'; // Re-added 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 @@ -341,14 +341,19 @@ GoRouter createRouter({ path: 'article/:id', // Relative path name: Routes.articleDetailsName, builder: (context, state) { - final id = state.pathParameters['id']!; + final headlineFromExtra = state.extra as Headline?; + final headlineIdFromPath = state.pathParameters['id']; + return BlocProvider( - create: - (context) => HeadlineDetailsBloc( - headlinesRepository: - context.read>(), - )..add(HeadlineDetailsRequested(headlineId: id)), - child: HeadlineDetailsPage(headlineId: id), + create: (context) => HeadlineDetailsBloc( + headlinesRepository: + context.read>(), + ), + child: HeadlineDetailsPage( + initialHeadline: headlineFromExtra, + // Ensure headlineId is non-null if initialHeadline is null + headlineId: headlineFromExtra?.id ?? headlineIdFromPath, + ), ); }, ), @@ -464,14 +469,17 @@ GoRouter createRouter({ path: 'article/:id', // Relative path name: Routes.searchArticleDetailsName, // New route name builder: (context, state) { - final id = state.pathParameters['id']!; + final headlineFromExtra = state.extra as Headline?; + final headlineIdFromPath = state.pathParameters['id']; return BlocProvider( - create: - (context) => HeadlineDetailsBloc( - headlinesRepository: - context.read>(), - )..add(HeadlineDetailsRequested(headlineId: id)), - child: HeadlineDetailsPage(headlineId: id), + create: (context) => HeadlineDetailsBloc( + headlinesRepository: + context.read>(), + ), + child: HeadlineDetailsPage( + initialHeadline: headlineFromExtra, + headlineId: headlineFromExtra?.id ?? headlineIdFromPath, + ), ); }, ), @@ -615,17 +623,17 @@ GoRouter createRouter({ path: Routes.accountArticleDetails, // 'article/:id' name: Routes.accountArticleDetailsName, builder: (context, state) { - final id = state.pathParameters['id']!; + final headlineFromExtra = state.extra as Headline?; + final headlineIdFromPath = state.pathParameters['id']; return BlocProvider( - create: - (context) => HeadlineDetailsBloc( - headlinesRepository: - context - .read>(), - )..add( - HeadlineDetailsRequested(headlineId: id), - ), - child: HeadlineDetailsPage(headlineId: id), + create: (context) => HeadlineDetailsBloc( + headlinesRepository: + context.read>(), + ), + child: HeadlineDetailsPage( + initialHeadline: headlineFromExtra, + headlineId: headlineFromExtra?.id ?? headlineIdFromPath, + ), ); }, ), diff --git a/pubspec.lock b/pubspec.lock index 6c1a588e..0d77c075 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -137,6 +137,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.14.0" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670" + url: "https://pub.dev" + source: hosted + version: "0.3.4+2" crypto: dependency: transitive description: @@ -687,6 +695,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: b2961506569e28948d75ec346c28775bb111986bb69dc6a20754a457e3d97fa0 + url: "https://pub.dev" + source: hosted + version: "11.0.0" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "1032d392bc5d2095a77447a805aa3f804d2ae6a4d5eef5e6ebb3bd94c1bc19ef" + url: "https://pub.dev" + source: hosted + version: "6.0.0" shared_preferences: dependency: transitive description: @@ -1028,6 +1052,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + win32: + dependency: transitive + description: + name: win32 + sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba" + url: "https://pub.dev" + source: hosted + version: "5.13.0" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index cdf1fd32..ca2638d5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -52,6 +52,7 @@ dependencies: url: https://github.com/headlines-toolkit/ht-shared.git intl: ^0.19.0 meta: ^1.16.0 + share_plus: ^11.0.0 stream_transform: ^2.1.1 url_launcher: ^6.3.1