diff --git a/lib/content_management/bloc/content_management_bloc.dart b/lib/content_management/bloc/content_management_bloc.dart index a312c5c4..fffe2933 100644 --- a/lib/content_management/bloc/content_management_bloc.dart +++ b/lib/content_management/bloc/content_management_bloc.dart @@ -7,7 +7,6 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/headlines_filter/headlines_filter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/sources_filter/sources_filter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/topics_filter/topics_filter_bloc.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/models/breaking_news_filter_status.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/constants/app_constants.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/services/pending_deletions_service.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -154,15 +153,9 @@ class ContentManagementBloc filter['eventCountry.id'] = {r'$in': state.selectedCountryIds}; } - // Handle the breaking news filter based on the enum status. - switch (state.isBreaking) { - case BreakingNewsFilterStatus.breakingOnly: - filter['isBreaking'] = true; - case BreakingNewsFilterStatus.nonBreakingOnly: - filter['isBreaking'] = false; - case BreakingNewsFilterStatus.all: - // For 'all', we don't add the 'isBreaking' key to the filter. - break; + // If the breaking news filter is active, add it to the query. + if (state.isBreaking) { + filter['isBreaking'] = true; } return filter; @@ -394,7 +387,7 @@ class ContentManagementBloc state.copyWith( headlines: updatedHeadlines, lastPendingDeletionId: event.id, - snackbarMessage: 'Headline "${headlineToDelete.title}" deleted.', + itemPendingDeletion: headlineToDelete, ), ); @@ -402,6 +395,7 @@ class ContentManagementBloc item: headlineToDelete, repository: _headlinesRepository, undoDuration: AppConstants.kSnackbarDuration, + // messageBuilder is omitted, UI will build the message ); } @@ -585,7 +579,7 @@ class ContentManagementBloc state.copyWith( topics: updatedTopics, lastPendingDeletionId: event.id, - snackbarMessage: 'Topic "${topicToDelete.name}" deleted.', + itemPendingDeletion: topicToDelete, ), ); @@ -593,6 +587,7 @@ class ContentManagementBloc item: topicToDelete, repository: _topicsRepository, undoDuration: AppConstants.kSnackbarDuration, + // messageBuilder is omitted, UI will build the message ); } @@ -776,7 +771,7 @@ class ContentManagementBloc state.copyWith( sources: updatedSources, lastPendingDeletionId: event.id, - snackbarMessage: 'Source "${sourceToDelete.name}" deleted.', + itemPendingDeletion: sourceToDelete, ), ); @@ -784,6 +779,7 @@ class ContentManagementBloc item: sourceToDelete, repository: _sourcesRepository, undoDuration: AppConstants.kSnackbarDuration, + // messageBuilder is omitted, UI will build the message ); } @@ -804,13 +800,20 @@ class ContentManagementBloc Emitter emit, ) async { switch (event.event.status) { + case DeletionStatus.requested: + // This case is now handled by the optimistic UI update in the + // specific delete handlers (e.g., _onDeleteHeadlineForeverRequested). + // The itemPendingDeletion is set there, which the UI uses to build + // the snackbar message. + break; case DeletionStatus.confirmed: // If deletion is confirmed, clear pending status. // The item was already optimistically removed from the list. emit( state.copyWith( - lastPendingDeletionId: null, - snackbarMessage: null, + lastPendingDeletionId: null, // Clear the pending ID + // Clear the item so the snackbar doesn't reappear on rebuilds + itemPendingDeletion: null, ), ); case DeletionStatus.undone: @@ -823,8 +826,8 @@ class ContentManagementBloc emit( state.copyWith( headlines: updatedHeadlines, - lastPendingDeletionId: null, - snackbarMessage: null, + lastPendingDeletionId: null, // Clear the pending ID + itemPendingDeletion: null, ), ); } else if (item is Topic) { @@ -834,8 +837,8 @@ class ContentManagementBloc emit( state.copyWith( topics: updatedTopics, - lastPendingDeletionId: null, - snackbarMessage: null, + lastPendingDeletionId: null, // Clear the pending ID + itemPendingDeletion: null, ), ); } else if (item is Source) { @@ -845,8 +848,8 @@ class ContentManagementBloc emit( state.copyWith( sources: updatedSources, - lastPendingDeletionId: null, - snackbarMessage: null, + lastPendingDeletionId: null, // Clear the pending ID + itemPendingDeletion: null, ), ); } diff --git a/lib/content_management/bloc/content_management_state.dart b/lib/content_management/bloc/content_management_state.dart index c7c6009b..11f04f6b 100644 --- a/lib/content_management/bloc/content_management_state.dart +++ b/lib/content_management/bloc/content_management_state.dart @@ -36,7 +36,7 @@ class ContentManagementState extends Equatable { this.sourcesHasMore = false, this.exception, this.lastPendingDeletionId, - this.snackbarMessage, + this.itemPendingDeletion, }); /// The currently active tab in the content management section. @@ -85,9 +85,9 @@ class ContentManagementState extends Equatable { /// Used to trigger the snackbar display. final String? lastPendingDeletionId; - /// The message to display in the snackbar for pending deletions or other - /// transient messages. - final String? snackbarMessage; + /// The item that was just requested for deletion, used by the UI to show + /// a confirmation snackbar. + final FeedItem? itemPendingDeletion; /// Creates a copy of this [ContentManagementState] with updated values. ContentManagementState copyWith({ @@ -106,7 +106,7 @@ class ContentManagementState extends Equatable { bool? sourcesHasMore, HttpException? exception, String? lastPendingDeletionId, - String? snackbarMessage, + FeedItem? itemPendingDeletion, }) { return ContentManagementState( activeTab: activeTab ?? this.activeTab, @@ -124,7 +124,7 @@ class ContentManagementState extends Equatable { sourcesHasMore: sourcesHasMore ?? this.sourcesHasMore, exception: exception, lastPendingDeletionId: lastPendingDeletionId, - snackbarMessage: snackbarMessage, + itemPendingDeletion: itemPendingDeletion, ); } @@ -145,6 +145,6 @@ class ContentManagementState extends Equatable { sourcesHasMore, exception, lastPendingDeletionId, - snackbarMessage, + itemPendingDeletion, ]; } diff --git a/lib/content_management/bloc/headlines_filter/headlines_filter_bloc.dart b/lib/content_management/bloc/headlines_filter/headlines_filter_bloc.dart index 46e70ffe..fbd19b02 100644 --- a/lib/content_management/bloc/headlines_filter/headlines_filter_bloc.dart +++ b/lib/content_management/bloc/headlines_filter/headlines_filter_bloc.dart @@ -1,7 +1,6 @@ import 'package:bloc/bloc.dart'; import 'package:core/core.dart'; import 'package:equatable/equatable.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/models/breaking_news_filter_status.dart'; part 'headlines_filter_event.dart'; part 'headlines_filter_state.dart'; @@ -79,8 +78,7 @@ class HeadlinesFilterBloc /// Handles changes to the breaking news filter. /// - /// This updates the `isBreaking` status for the filter using the - /// [BreakingNewsFilterStatus] enum. + /// This updates the `isBreaking` status for the filter. void _onHeadlinesBreakingNewsFilterChanged( HeadlinesBreakingNewsFilterChanged event, Emitter emit, @@ -139,9 +137,8 @@ class HeadlinesFilterBloc if (state.selectedCountryIds.isNotEmpty) { filter['eventCountry.id'] = {r'$in': state.selectedCountryIds}; } - if (state.isBreaking != BreakingNewsFilterStatus.all) { - filter['isBreaking'] = - state.isBreaking == BreakingNewsFilterStatus.breakingOnly; + if (state.isBreaking) { + filter['isBreaking'] = true; } return filter; diff --git a/lib/content_management/bloc/headlines_filter/headlines_filter_event.dart b/lib/content_management/bloc/headlines_filter/headlines_filter_event.dart index abd400b1..9bf28d1e 100644 --- a/lib/content_management/bloc/headlines_filter/headlines_filter_event.dart +++ b/lib/content_management/bloc/headlines_filter/headlines_filter_event.dart @@ -62,10 +62,10 @@ final class HeadlinesCountryFilterChanged extends HeadlinesFilterEvent { final class HeadlinesBreakingNewsFilterChanged extends HeadlinesFilterEvent { const HeadlinesBreakingNewsFilterChanged(this.isBreaking); - final BreakingNewsFilterStatus isBreaking; + final bool isBreaking; @override - List get props => [isBreaking]; + List get props => [isBreaking]; } /// Event to request applying all current filters. @@ -84,7 +84,7 @@ final class HeadlinesFilterApplied extends HeadlinesFilterEvent { final List selectedSourceIds; final List selectedTopicIds; final List selectedCountryIds; - final BreakingNewsFilterStatus isBreaking; + final bool isBreaking; @override List get props => [ diff --git a/lib/content_management/bloc/headlines_filter/headlines_filter_state.dart b/lib/content_management/bloc/headlines_filter/headlines_filter_state.dart index dcbb9e5e..9247da78 100644 --- a/lib/content_management/bloc/headlines_filter/headlines_filter_state.dart +++ b/lib/content_management/bloc/headlines_filter/headlines_filter_state.dart @@ -15,7 +15,7 @@ class HeadlinesFilterState extends Equatable { this.selectedSourceIds = const [], this.selectedTopicIds = const [], this.selectedCountryIds = const [], - this.isBreaking = BreakingNewsFilterStatus.all, + this.isBreaking = false, }); /// The current text in the search query field. @@ -33,9 +33,8 @@ class HeadlinesFilterState extends Equatable { /// The list of country IDs to be included in the filter. final List selectedCountryIds; - /// The breaking news status to filter by. - /// `null` = all, `true` = breaking only, `false` = non-breaking only. - final BreakingNewsFilterStatus isBreaking; + /// A flag to filter for breaking news only. + final bool isBreaking; /// Creates a copy of this state with the given fields replaced with the /// new values. @@ -45,7 +44,7 @@ class HeadlinesFilterState extends Equatable { List? selectedSourceIds, List? selectedTopicIds, List? selectedCountryIds, - BreakingNewsFilterStatus? isBreaking, + bool? isBreaking, }) { return HeadlinesFilterState( searchQuery: searchQuery ?? this.searchQuery, diff --git a/lib/content_management/view/content_management_page.dart b/lib/content_management/view/content_management_page.dart index 7f5c7ed2..60f5c572 100644 --- a/lib/content_management/view/content_management_page.dart +++ b/lib/content_management/view/content_management_page.dart @@ -135,14 +135,27 @@ class _ContentManagementPageState extends State ), BlocListener( listenWhen: (previous, current) => - previous.snackbarMessage != current.snackbarMessage && - current.snackbarMessage != null, + previous.itemPendingDeletion != current.itemPendingDeletion && + current.itemPendingDeletion != null, listener: (context, state) { + final item = state.itemPendingDeletion!; + String itemType; + String itemName; + if (item is Headline) { + itemType = l10n.headline; + itemName = item.title; + } else if (item is Topic) { + itemType = l10n.topic; + itemName = item.name; + } else { + itemType = l10n.source; + itemName = (item as Source).name; + } ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( SnackBar( - content: Text(state.snackbarMessage!), + content: Text(l10n.itemDeletedSnackbar(itemType, itemName)), action: SnackBarAction( label: l10n.undo, onPressed: () { diff --git a/lib/content_management/view/headlines_page.dart b/lib/content_management/view/headlines_page.dart index f4ba6b18..52d4d550 100644 --- a/lib/content_management/view/headlines_page.dart +++ b/lib/content_management/view/headlines_page.dart @@ -221,32 +221,30 @@ class _HeadlinesDataSource extends DataTableSource { }, cells: [ DataCell( - Stack( - alignment: AlignmentDirectional.centerStart, - children: [ - // Add padding to the text to make space for the icon only when - // the headline is breaking news. This ensures all titles align - // vertically regardless of the icon's presence. - Padding( - padding: EdgeInsetsDirectional.only( - start: headline.isBreaking - ? AppSpacing.xl + AppSpacing.xs - : 0, - ), - child: Text( - headline.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - // Conditionally display the icon at the start of the cell. - if (headline.isBreaking) - Icon( - Icons.flash_on, - size: 18, - color: Theme.of(context).colorScheme.primary, - ), - ], + RichText( + maxLines: 2, + overflow: TextOverflow.ellipsis, + text: TextSpan( + style: Theme.of(context).textTheme.bodyMedium, + children: [ + TextSpan(text: headline.title), + if (headline.isBreaking) + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Padding( + padding: const EdgeInsets.only(left: AppSpacing.xs), + child: Tooltip( + message: l10n.breakingNewsHint, + child: Icon( + Icons.flash_on, + size: 14, + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + ), + ], + ), ), ), if (!isMobile) // Conditionally show Source Name diff --git a/lib/content_management/widgets/content_action_buttons.dart b/lib/content_management/widgets/content_action_buttons.dart index e72e2742..0b7bd98d 100644 --- a/lib/content_management/widgets/content_action_buttons.dart +++ b/lib/content_management/widgets/content_action_buttons.dart @@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/content_management_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/router/routes.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/confirmation_dialog.dart'; import 'package:go_router/go_router.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -161,64 +162,7 @@ class ContentActionButtons extends StatelessWidget { icon: const Icon(Icons.more_vert), tooltip: l10n.moreActions, onSelected: (value) { - switch (value) { - case 'publish': - if (item is Headline) { - context.read().add( - PublishHeadlineRequested(itemId), - ); - } else if (item is Topic) { - context.read().add( - PublishTopicRequested(itemId), - ); - } else if (item is Source) { - context.read().add( - PublishSourceRequested(itemId), - ); - } - case 'archive': - if (item is Headline) { - context.read().add( - ArchiveHeadlineRequested(itemId), - ); - } else if (item is Topic) { - context.read().add( - ArchiveTopicRequested(itemId), - ); - } else if (item is Source) { - context.read().add( - ArchiveSourceRequested(itemId), - ); - } - case 'restore': - if (item is Headline) { - context.read().add( - RestoreHeadlineRequested(itemId), - ); - } else if (item is Topic) { - context.read().add( - RestoreTopicRequested(itemId), - ); - } else if (item is Source) { - context.read().add( - RestoreSourceRequested(itemId), - ); - } - case 'delete': - if (item is Headline) { - context.read().add( - DeleteHeadlineForeverRequested(itemId), - ); - } else if (item is Topic) { - context.read().add( - DeleteTopicForeverRequested(itemId), - ); - } else if (item is Source) { - context.read().add( - DeleteSourceForeverRequested(itemId), - ); - } - } + _handleAction(context, value, itemId, l10n); }, itemBuilder: (BuildContext context) => overflowMenuItems, ), @@ -230,4 +174,123 @@ class ContentActionButtons extends StatelessWidget { children: visibleActions, ); } + + void _showConfirmationDialog({ + required BuildContext context, + required String title, + required String content, + required String confirmText, + required VoidCallback onConfirm, + }) { + showDialog( + context: context, + builder: (BuildContext dialogContext) { + return ConfirmationDialog( + title: title, + content: content, + confirmText: confirmText, + onConfirm: onConfirm, + ); + }, + ); + } + + void _handleAction( + BuildContext context, + String action, + String itemId, + AppLocalizations l10n, + ) { + String itemType; + if (item is Headline) { + itemType = l10n.headline.toLowerCase(); + } else if (item is Topic) { + itemType = l10n.topic.toLowerCase(); + } else { + itemType = l10n.source.toLowerCase(); + } + + switch (action) { + case 'publish': + _showConfirmationDialog( + context: context, + title: l10n.publishItemTitle(itemType), + content: l10n.publishItemContent(itemType), + confirmText: l10n.publish, + onConfirm: () { + if (item is Headline) { + context.read().add( + PublishHeadlineRequested(itemId), + ); + } else if (item is Topic) { + context.read().add( + PublishTopicRequested(itemId), + ); + } else if (item is Source) { + context.read().add( + PublishSourceRequested(itemId), + ); + } + }, + ); + case 'archive': + _showConfirmationDialog( + context: context, + title: l10n.archiveItemTitle(itemType), + content: l10n.archiveItemContent(itemType), + confirmText: l10n.archive, + onConfirm: () { + if (item is Headline) { + context.read().add( + ArchiveHeadlineRequested(itemId), + ); + } else if (item is Topic) { + context.read().add( + ArchiveTopicRequested(itemId), + ); + } else if (item is Source) { + context.read().add( + ArchiveSourceRequested(itemId), + ); + } + }, + ); + case 'restore': + _showConfirmationDialog( + context: context, + title: l10n.restoreItemTitle(itemType), + content: l10n.restoreItemContent(itemType), + confirmText: l10n.restore, + onConfirm: () { + if (item is Headline) { + context.read().add( + RestoreHeadlineRequested(itemId), + ); + } else if (item is Topic) { + context.read().add( + RestoreTopicRequested(itemId), + ); + } else if (item is Source) { + context.read().add( + RestoreSourceRequested(itemId), + ); + } + }, + ); + case 'delete': + _showConfirmationDialog( + context: context, + title: l10n.deleteItemTitle(itemType), + content: l10n.deleteItemContent(itemType), + confirmText: l10n.deleteForever, + onConfirm: () { + if (item is Headline) { + context.read().add( + DeleteHeadlineForeverRequested(itemId), + ); + } + }, + ); + } + } } diff --git a/lib/content_management/widgets/filter_dialog/bloc/filter_dialog_bloc.dart b/lib/content_management/widgets/filter_dialog/bloc/filter_dialog_bloc.dart index 036f6ab7..2ba7ebcd 100644 --- a/lib/content_management/widgets/filter_dialog/bloc/filter_dialog_bloc.dart +++ b/lib/content_management/widgets/filter_dialog/bloc/filter_dialog_bloc.dart @@ -9,7 +9,6 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/content_manageme import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/headlines_filter/headlines_filter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/sources_filter/sources_filter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/topics_filter/topics_filter_bloc.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/models/breaking_news_filter_status.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/widgets/filter_dialog/filter_dialog.dart' show FilterDialog; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/constants/constants.dart'; @@ -263,6 +262,8 @@ class FilterDialogBloc extends Bloc { FilterDialogReset event, Emitter emit, ) { - emit(FilterDialogState(activeTab: state.activeTab)); + emit( + FilterDialogState(activeTab: state.activeTab).copyWith(isBreaking: false), + ); } } diff --git a/lib/content_management/widgets/filter_dialog/bloc/filter_dialog_event.dart b/lib/content_management/widgets/filter_dialog/bloc/filter_dialog_event.dart index eac566e5..4c1cd6b9 100644 --- a/lib/content_management/widgets/filter_dialog/bloc/filter_dialog_event.dart +++ b/lib/content_management/widgets/filter_dialog/bloc/filter_dialog_event.dart @@ -90,7 +90,7 @@ final class FilterDialogHeadlinesCountryIdsChanged extends FilterDialogEvent { final class FilterDialogBreakingNewsChanged extends FilterDialogEvent { const FilterDialogBreakingNewsChanged(this.isBreaking); - final BreakingNewsFilterStatus isBreaking; + final bool isBreaking; @override List get props => [isBreaking]; diff --git a/lib/content_management/widgets/filter_dialog/bloc/filter_dialog_state.dart b/lib/content_management/widgets/filter_dialog/bloc/filter_dialog_state.dart index 3373615e..376b7184 100644 --- a/lib/content_management/widgets/filter_dialog/bloc/filter_dialog_state.dart +++ b/lib/content_management/widgets/filter_dialog/bloc/filter_dialog_state.dart @@ -30,7 +30,7 @@ final class FilterDialogState extends Equatable { this.selectedSourceIds = const [], this.selectedTopicIds = const [], this.selectedCountryIds = const [], - this.isBreaking = BreakingNewsFilterStatus.all, + this.isBreaking = false, this.selectedSourceTypes = const [], this.selectedLanguageCodes = const [], this.selectedHeadquartersCountryIds = const [], @@ -67,9 +67,8 @@ final class FilterDialogState extends Equatable { /// The list of country IDs to be included in the filter for headlines. final List selectedCountryIds; - /// The breaking news status to filter by for headlines. - /// `null` = all, `true` = breaking only, `false` = non-breaking only. - final BreakingNewsFilterStatus isBreaking; + /// A flag to filter for breaking news only for headlines. + final bool isBreaking; /// The list of source types to be included in the filter for sources. final List selectedSourceTypes; @@ -103,7 +102,7 @@ final class FilterDialogState extends Equatable { List? selectedSourceIds, List? selectedTopicIds, List? selectedCountryIds, - BreakingNewsFilterStatus? isBreaking, + bool? isBreaking, List? selectedSourceTypes, List? selectedLanguageCodes, List? selectedHeadquartersCountryIds, diff --git a/lib/content_management/widgets/filter_dialog/filter_dialog.dart b/lib/content_management/widgets/filter_dialog/filter_dialog.dart index d340802b..c4f5aa27 100644 --- a/lib/content_management/widgets/filter_dialog/filter_dialog.dart +++ b/lib/content_management/widgets/filter_dialog/filter_dialog.dart @@ -6,7 +6,6 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/content_manageme import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/headlines_filter/headlines_filter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/sources_filter/sources_filter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/topics_filter/topics_filter_bloc.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/models/breaking_news_filter_status.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/widgets/filter_dialog/bloc/filter_dialog_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; @@ -135,7 +134,7 @@ class _FilterDialogState extends State { selectedSourceIds: [], selectedTopicIds: [], selectedCountryIds: [], - isBreaking: BreakingNewsFilterStatus.all, + isBreaking: false, selectedSourceTypes: [], selectedLanguageCodes: [], selectedHeadquartersCountryIds: [], @@ -255,39 +254,23 @@ class _FilterDialogState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SizedBox(height: AppSpacing.lg), - Text( - l10n.breakingNewsFilterTitle, - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: AppSpacing.sm), - Wrap( - spacing: AppSpacing.sm, - children: [ - ...BreakingNewsFilterStatus.values.map((status) { - return ChoiceChip( - label: Text(_getBreakingNewsStatusL10n(status, l10n)), - selected: filterDialogState.isBreaking == status, - onSelected: (isSelected) { - if (isSelected) { - context.read().add( - FilterDialogBreakingNewsChanged(status), - ); - } - }, - selectedColor: Theme.of( - context, - ).colorScheme.primaryContainer, - labelStyle: TextStyle( - color: filterDialogState.isBreaking == status - ? Theme.of(context).colorScheme.onPrimaryContainer - : Theme.of(context).colorScheme.onSurface, - ), - ); - }), - ], + const Divider(height: AppSpacing.lg * 2), + SwitchListTile( + title: Text(l10n.breakingNewsFilterBreakingOnly), + subtitle: Text(l10n.breakingNewsFilterDescription), + value: filterDialogState.isBreaking, + onChanged: (value) { + context.read().add( + FilterDialogBreakingNewsChanged(value), + ); + }, + secondary: Icon( + Icons.flash_on, + color: Theme.of(context).colorScheme.primary, + ), + contentPadding: EdgeInsets.zero, ), - const SizedBox(height: AppSpacing.lg), + const Divider(height: AppSpacing.lg * 2), SearchableSelectionInput( label: l10n.sources, hintText: l10n.selectSources, @@ -530,21 +513,6 @@ class _FilterDialogState extends State { } } - /// Returns the localized string for a given [BreakingNewsFilterStatus]. - String _getBreakingNewsStatusL10n( - BreakingNewsFilterStatus status, - AppLocalizations l10n, - ) { - switch (status) { - case BreakingNewsFilterStatus.all: - return l10n.breakingNewsFilterAll; - case BreakingNewsFilterStatus.breakingOnly: - return l10n.breakingNewsFilterBreakingOnly; - case BreakingNewsFilterStatus.nonBreakingOnly: - return l10n.breakingNewsFilterNonBreakingOnly; - } - } - /// Dispatches the filter applied event to the appropriate BLoC. void _dispatchFilterApplied(FilterDialogState filterDialogState) { switch (widget.activeTab) { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 427cd04c..bee5a5ce 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -236,18 +236,36 @@ abstract class AppLocalizations { /// **'Headlines'** String get headlines; + /// Label for the a singular headline + /// + /// In en, this message translates to: + /// **'Headline'** + String get headline; + /// Label for the topics subpage /// /// In en, this message translates to: /// **'Topics'** String get topics; + /// Label for the a singular topic + /// + /// In en, this message translates to: + /// **'Topic'** + String get topic; + /// Label for the sources subpage /// /// In en, this message translates to: /// **'Sources'** String get sources; + /// Label for the a singular source + /// + /// In en, this message translates to: + /// **'Source'** + String get source; + /// Label for the app configuration navigation item /// /// In en, this message translates to: @@ -4039,6 +4057,72 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Publisher'** String get publisherUserTooltip; + + /// Tooltip text for the breaking news icon on a headline + /// + /// In en, this message translates to: + /// **'This is a breaking news headline'** + String get breakingNewsHint; + + /// Subtitle for the breaking news filter switch + /// + /// In en, this message translates to: + /// **'Show only breaking news headlines'** + String get breakingNewsFilterDescription; + + /// Confirmation dialog title for publishing an item. + /// + /// In en, this message translates to: + /// **'Publish {itemType}?'** + String publishItemTitle(String itemType); + + /// Confirmation dialog content for publishing an item. + /// + /// In en, this message translates to: + /// **'Are you sure you want to publish this {itemType}? It will become publicly visible.'** + String publishItemContent(String itemType); + + /// Confirmation dialog title for archiving an item. + /// + /// In en, this message translates to: + /// **'Archive {itemType}?'** + String archiveItemTitle(String itemType); + + /// Confirmation dialog content for archiving an item. + /// + /// In en, this message translates to: + /// **'Are you sure you want to archive this {itemType}? It will be hidden from public view.'** + String archiveItemContent(String itemType); + + /// Confirmation dialog title for restoring an item. + /// + /// In en, this message translates to: + /// **'Restore {itemType}?'** + String restoreItemTitle(String itemType); + + /// Confirmation dialog content for restoring an item. + /// + /// In en, this message translates to: + /// **'Are you sure you want to restore this {itemType}? It will become active and publicly visible again.'** + String restoreItemContent(String itemType); + + /// Confirmation dialog title for deleting an item. + /// + /// In en, this message translates to: + /// **'Delete {itemType}?'** + String deleteItemTitle(String itemType); + + /// Confirmation dialog content for deleting an item. + /// + /// In en, this message translates to: + /// **'Are you sure you want to delete this {itemType}? '** + String deleteItemContent(String itemType); + + /// Snackbar message shown after an item is deleted, with an undo option. + /// + /// In en, this message translates to: + /// **'{itemType} \"{itemName}\" deleted.'** + String itemDeletedSnackbar(String itemType, String itemName); } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index a5514382..38c0f965 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -89,12 +89,21 @@ class AppLocalizationsAr extends AppLocalizations { @override String get headlines => 'العناوين الرئيسية'; + @override + String get headline => 'العنوان الرئيسي'; + @override String get topics => 'المواضيع'; + @override + String get topic => 'الموضوع'; + @override String get sources => 'المصادر'; + @override + String get source => 'المصدر'; + @override String get appConfiguration => 'إعدادات التطبيق'; @@ -2167,4 +2176,56 @@ class AppLocalizationsAr extends AppLocalizations { @override String get publisherUserTooltip => 'ناشر'; + + @override + String get breakingNewsHint => 'هذا عنوان خبر عاجل'; + + @override + String get breakingNewsFilterDescription => + 'إظهار عناوين الأخبار العاجلة فقط'; + + @override + String publishItemTitle(String itemType) { + return 'نشر $itemType؟'; + } + + @override + String publishItemContent(String itemType) { + return 'هل أنت متأكد أنك تريد نشر هذا الـ $itemType؟ سيصبح مرئيًا للعامة.'; + } + + @override + String archiveItemTitle(String itemType) { + return 'أرشفة $itemType؟'; + } + + @override + String archiveItemContent(String itemType) { + return 'هل أنت متأكد أنك تريد أرشفة هذا الـ $itemType؟ سيتم إخفاؤه عن العرض العام.'; + } + + @override + String restoreItemTitle(String itemType) { + return 'استعادة $itemType؟'; + } + + @override + String restoreItemContent(String itemType) { + return 'هل أنت متأكد أنك تريد استعادة هذا الـ $itemType؟ سيصبح نشطًا ومرئيًا للعامة مرة أخرى.'; + } + + @override + String deleteItemTitle(String itemType) { + return 'حذف $itemType؟'; + } + + @override + String deleteItemContent(String itemType) { + return 'هل أنت متأكد أنك تريد حذف هذا الـ $itemType؟ يمكن التراجع عن هذا الإجراء لفترة قصيرة.'; + } + + @override + String itemDeletedSnackbar(String itemType, String itemName) { + return 'تم حذف $itemType \"$itemName\".'; + } } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index ab2c3add..69ab05ff 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -88,12 +88,21 @@ class AppLocalizationsEn extends AppLocalizations { @override String get headlines => 'Headlines'; + @override + String get headline => 'Headline'; + @override String get topics => 'Topics'; + @override + String get topic => 'Topic'; + @override String get sources => 'Sources'; + @override + String get source => 'Source'; + @override String get appConfiguration => 'App Configuration'; @@ -2173,4 +2182,56 @@ class AppLocalizationsEn extends AppLocalizations { @override String get publisherUserTooltip => 'Publisher'; + + @override + String get breakingNewsHint => 'This is a breaking news headline'; + + @override + String get breakingNewsFilterDescription => + 'Show only breaking news headlines'; + + @override + String publishItemTitle(String itemType) { + return 'Publish $itemType?'; + } + + @override + String publishItemContent(String itemType) { + return 'Are you sure you want to publish this $itemType? It will become publicly visible.'; + } + + @override + String archiveItemTitle(String itemType) { + return 'Archive $itemType?'; + } + + @override + String archiveItemContent(String itemType) { + return 'Are you sure you want to archive this $itemType? It will be hidden from public view.'; + } + + @override + String restoreItemTitle(String itemType) { + return 'Restore $itemType?'; + } + + @override + String restoreItemContent(String itemType) { + return 'Are you sure you want to restore this $itemType? It will become active and publicly visible again.'; + } + + @override + String deleteItemTitle(String itemType) { + return 'Delete $itemType?'; + } + + @override + String deleteItemContent(String itemType) { + return 'Are you sure you want to delete this $itemType? '; + } + + @override + String itemDeletedSnackbar(String itemType, String itemName) { + return '$itemType \"$itemName\" deleted.'; + } } diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index cd58a70c..96a8e14e 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -107,14 +107,26 @@ "@headlines": { "description": "تسمية الصفحة الفرعية للعناوين الرئيسية" }, + "headline": "العنوان الرئيسي", + "@headline": { + "description": "تسمية العنوان الرئيسي" + }, "topics": "المواضيع", "@topics": { "description": "تسمية الصفحة الفرعية للمواضيع" }, + "topic": "الموضوع", + "@topic": { + "description": "تسمية الموضوع" + }, "sources": "المصادر", "@sources": { "description": "تسمية الصفحة الفرعية للمصادر" }, + "source": "المصدر", + "@source": { + "description": "تسمية المصدر" + }, "appConfiguration": "إعدادات التطبيق", "@appConfiguration": { "description": "تسمية عنصر التنقل لإعدادات التطبيق" @@ -2736,5 +2748,107 @@ "publisherUserTooltip": "ناشر", "@publisherUserTooltip": { "description": "تلميح للأيقونة التي تشير إلى مستخدم ناشر." + }, + "breakingNewsHint": "هذا عنوان خبر عاجل", + "@breakingNewsHint": { + "description": "Tooltip text for the breaking news icon on a headline" + }, + "breakingNewsFilterDescription": "إظهار عناوين الأخبار العاجلة فقط", + "@breakingNewsFilterDescription": { + "description": "Subtitle for the breaking news filter switch" + }, + "publishItemTitle": "نشر {itemType}؟", + "@publishItemTitle": { + "description": "Confirmation dialog title for publishing an item.", + "placeholders": { + "itemType": { + "type": "String", + "example": "Headline" + } + } + }, + "publishItemContent": "هل أنت متأكد أنك تريد نشر هذا الـ {itemType}؟ سيصبح مرئيًا للعامة.", + "@publishItemContent": { + "description": "Confirmation dialog content for publishing an item.", + "placeholders": { + "itemType": { + "type": "String", + "example": "headline" + } + } + }, + "archiveItemTitle": "أرشفة {itemType}؟", + "@archiveItemTitle": { + "description": "Confirmation dialog title for archiving an item.", + "placeholders": { + "itemType": { + "type": "String", + "example": "Headline" + } + } + }, + "archiveItemContent": "هل أنت متأكد أنك تريد أرشفة هذا الـ {itemType}؟ سيتم إخفاؤه عن العرض العام.", + "@archiveItemContent": { + "description": "Confirmation dialog content for archiving an item.", + "placeholders": { + "itemType": { + "type": "String", + "example": "headline" + } + } + }, + "restoreItemTitle": "استعادة {itemType}؟", + "@restoreItemTitle": { + "description": "Confirmation dialog title for restoring an item.", + "placeholders": { + "itemType": { + "type": "String", + "example": "Headline" + } + } + }, + "restoreItemContent": "هل أنت متأكد أنك تريد استعادة هذا الـ {itemType}؟ سيصبح نشطًا ومرئيًا للعامة مرة أخرى.", + "@restoreItemContent": { + "description": "Confirmation dialog content for restoring an item.", + "placeholders": { + "itemType": { + "type": "String", + "example": "headline" + } + } + }, + "deleteItemTitle": "حذف {itemType}؟", + "@deleteItemTitle": { + "description": "Confirmation dialog title for deleting an item.", + "placeholders": { + "itemType": { + "type": "String", + "example": "Headline" + } + } + }, + "deleteItemContent": "هل أنت متأكد أنك تريد حذف هذا الـ {itemType}؟ يمكن التراجع عن هذا الإجراء لفترة قصيرة.", + "@deleteItemContent": { + "description": "Confirmation dialog content for deleting an item.", + "placeholders": { + "itemType": { + "type": "String", + "example": "headline" + } + } + }, + "itemDeletedSnackbar": "تم حذف {itemType} \"{itemName}\".", + "@itemDeletedSnackbar": { + "description": "Snackbar message shown after an item is deleted, with an undo option.", + "placeholders": { + "itemType": { + "type": "String", + "example": "Headline" + }, + "itemName": { + "type": "String", + "example": "Breaking News Story" + } + } } } \ No newline at end of file diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 028171e5..8cb9fbee 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -107,14 +107,26 @@ "@headlines": { "description": "Label for the headlines subpage" }, + "headline": "Headline", + "@headline": { + "description": "Label for the a singular headline" + }, "topics": "Topics", "@topics": { "description": "Label for the topics subpage" }, + "topic": "Topic", + "@topic": { + "description": "Label for the a singular topic" + }, "sources": "Sources", "@sources": { "description": "Label for the sources subpage" }, + "source": "Source", + "@source": { + "description": "Label for the a singular source" + }, "appConfiguration": "App Configuration", "@appConfiguration": { "description": "Label for the app configuration navigation item" @@ -2732,5 +2744,107 @@ "publisherUserTooltip": "Publisher", "@publisherUserTooltip": { "description": "Tooltip for the icon indicating a publisher user." + }, + "breakingNewsHint": "This is a breaking news headline", + "@breakingNewsHint": { + "description": "Tooltip text for the breaking news icon on a headline" + }, + "breakingNewsFilterDescription": "Show only breaking news headlines", + "@breakingNewsFilterDescription": { + "description": "Subtitle for the breaking news filter switch" + }, + "publishItemTitle": "Publish {itemType}?", + "@publishItemTitle": { + "description": "Confirmation dialog title for publishing an item.", + "placeholders": { + "itemType": { + "type": "String", + "example": "Headline" + } + } + }, + "publishItemContent": "Are you sure you want to publish this {itemType}? It will become publicly visible.", + "@publishItemContent": { + "description": "Confirmation dialog content for publishing an item.", + "placeholders": { + "itemType": { + "type": "String", + "example": "headline" + } + } + }, + "archiveItemTitle": "Archive {itemType}?", + "@archiveItemTitle": { + "description": "Confirmation dialog title for archiving an item.", + "placeholders": { + "itemType": { + "type": "String", + "example": "Headline" + } + } + }, + "archiveItemContent": "Are you sure you want to archive this {itemType}? It will be hidden from public view.", + "@archiveItemContent": { + "description": "Confirmation dialog content for archiving an item.", + "placeholders": { + "itemType": { + "type": "String", + "example": "headline" + } + } + }, + "restoreItemTitle": "Restore {itemType}?", + "@restoreItemTitle": { + "description": "Confirmation dialog title for restoring an item.", + "placeholders": { + "itemType": { + "type": "String", + "example": "Headline" + } + } + }, + "restoreItemContent": "Are you sure you want to restore this {itemType}? It will become active and publicly visible again.", + "@restoreItemContent": { + "description": "Confirmation dialog content for restoring an item.", + "placeholders": { + "itemType": { + "type": "String", + "example": "headline" + } + } + }, + "deleteItemTitle": "Delete {itemType}?", + "@deleteItemTitle": { + "description": "Confirmation dialog title for deleting an item.", + "placeholders": { + "itemType": { + "type": "String", + "example": "Headline" + } + } + }, + "deleteItemContent": "Are you sure you want to delete this {itemType}? ", + "@deleteItemContent": { + "description": "Confirmation dialog content for deleting an item.", + "placeholders": { + "itemType": { + "type": "String", + "example": "headline" + } + } + }, + "itemDeletedSnackbar": "{itemType} \"{itemName}\" deleted.", + "@itemDeletedSnackbar": { + "description": "Snackbar message shown after an item is deleted, with an undo option.", + "placeholders": { + "itemType": { + "type": "String", + "example": "Headline" + }, + "itemName": { + "type": "String", + "example": "Breaking News Story" + } + } } } \ No newline at end of file diff --git a/lib/shared/services/pending_deletions_service.dart b/lib/shared/services/pending_deletions_service.dart index 2084811c..2428a018 100644 --- a/lib/shared/services/pending_deletions_service.dart +++ b/lib/shared/services/pending_deletions_service.dart @@ -7,6 +7,9 @@ import 'package:logging/logging.dart'; /// Represents the status of a pending deletion. enum DeletionStatus { + /// The deletion has been requested and is pending confirmation. + requested, + /// The deletion has been confirmed and executed. confirmed, @@ -22,7 +25,12 @@ enum DeletionStatus { @immutable class DeletionEvent extends Equatable { /// {@macro deletion_event} - const DeletionEvent(this.id, this.status, {this.item}); + const DeletionEvent( + this.id, + this.status, { + this.item, + this.message, + }); /// The unique identifier of the item. final String id; @@ -34,8 +42,11 @@ class DeletionEvent extends Equatable { /// This is typically provided when a deletion is undone. final T? item; + /// An optional message associated with the event, e.g., for snackbars. + final String? message; + @override - List get props => [id, status, item]; + List get props => [id, status, item, message]; } /// {@template pending_deletions_service} @@ -60,10 +71,12 @@ abstract class PendingDeletionsService { /// - [item]: The item to be deleted. Must have an `id` property. /// - [repository]: The `DataRepository` responsible for deleting the item. /// - [undoDuration]: The duration to wait before confirming the deletion. + /// - [messageBuilder]: An optional function to build a localized message for the UI. void requestDeletion({ required T item, required DataRepository repository, required Duration undoDuration, + String Function()? messageBuilder, }); /// Cancels a pending deletion for the item with the given [id]. @@ -106,6 +119,7 @@ class PendingDeletionsServiceImpl implements PendingDeletionsService { required T item, required DataRepository repository, required Duration undoDuration, + String Function()? messageBuilder, }) { // The item must have an 'id' property. final id = (item as dynamic).id as String; @@ -133,7 +147,21 @@ class PendingDeletionsServiceImpl implements PendingDeletionsService { } }); - _pendingDeletionTimers[id] = _PendingDeletion(timer: timer, item: item); + final message = messageBuilder?.call(); + _pendingDeletionTimers[id] = _PendingDeletion( + timer: timer, + item: item, + message: message, + ); + + // Immediately notify listeners that a deletion has been requested. + _deletionEventController.add( + DeletionEvent( + id, + DeletionStatus.requested, + message: message, + ), + ); } @override @@ -174,11 +202,16 @@ class PendingDeletionsServiceImpl implements PendingDeletionsService { /// A private class to hold the timer and the item for a pending deletion. @immutable class _PendingDeletion extends Equatable { - const _PendingDeletion({required this.timer, required this.item}); + const _PendingDeletion({ + required this.timer, + required this.item, + this.message, + }); final Timer timer; final T item; + final String? message; @override - List get props => [timer, item]; + List get props => [timer, item, message]; }