diff --git a/lib/content_management/bloc/archived_headlines/archived_headlines_bloc.dart b/lib/content_management/bloc/archived_headlines/archived_headlines_bloc.dart index ac4d22f..ad47402 100644 --- a/lib/content_management/bloc/archived_headlines/archived_headlines_bloc.dart +++ b/lib/content_management/bloc/archived_headlines/archived_headlines_bloc.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:bloc/bloc.dart'; import 'package:core/core.dart'; import 'package:data_repository/data_repository.dart'; @@ -15,9 +17,18 @@ class ArchivedHeadlinesBloc on(_onLoadArchivedHeadlinesRequested); on(_onRestoreHeadlineRequested); on(_onDeleteHeadlineForeverRequested); + on(_onUndoDeleteHeadlineRequested); + on<_ConfirmDeleteHeadlineRequested>(_onConfirmDeleteHeadlineRequested); } final DataRepository _headlinesRepository; + Timer? _deleteTimer; + + @override + Future close() { + _deleteTimer?.cancel(); + return super.close(); + } Future _onLoadArchivedHeadlinesRequested( LoadArchivedHeadlinesRequested event, @@ -96,22 +107,83 @@ class ArchivedHeadlinesBloc DeleteHeadlineForeverRequested event, Emitter emit, ) async { - final originalHeadlines = List.from(state.headlines); - final headlineIndex = originalHeadlines.indexWhere((h) => h.id == event.id); + _deleteTimer?.cancel(); + + final headlineIndex = state.headlines.indexWhere((h) => h.id == event.id); if (headlineIndex == -1) return; - final updatedHeadlines = originalHeadlines..removeAt(headlineIndex); - emit(state.copyWith(headlines: updatedHeadlines)); + final headlineToDelete = state.headlines[headlineIndex]; + final updatedHeadlines = List.from(state.headlines) + ..removeAt(headlineIndex); + + emit( + state.copyWith( + headlines: updatedHeadlines, + lastDeletedHeadline: headlineToDelete, + ), + ); + + _deleteTimer = Timer( + const Duration(seconds: 5), + () => add(_ConfirmDeleteHeadlineRequested(event.id)), + ); + } + Future _onConfirmDeleteHeadlineRequested( + _ConfirmDeleteHeadlineRequested event, + Emitter emit, + ) async { try { await _headlinesRepository.delete(id: event.id); + emit(state.copyWith(lastDeletedHeadline: null)); } on HttpException catch (e) { - emit(state.copyWith(headlines: originalHeadlines, exception: e)); + // If deletion fails, restore the headline to the list + final originalHeadlines = List.from(state.headlines) + ..add(state.lastDeletedHeadline!); + emit( + state.copyWith( + headlines: originalHeadlines, + exception: e, + lastDeletedHeadline: null, + ), + ); } catch (e) { + final originalHeadlines = List.from(state.headlines) + ..add(state.lastDeletedHeadline!); emit( state.copyWith( headlines: originalHeadlines, exception: UnknownException('An unexpected error occurred: $e'), + lastDeletedHeadline: null, + ), + ); + } + } + + void _onUndoDeleteHeadlineRequested( + UndoDeleteHeadlineRequested event, + Emitter emit, + ) { + _deleteTimer?.cancel(); + if (state.lastDeletedHeadline != null) { + final updatedHeadlines = List.from(state.headlines) + ..insert( + state.headlines.indexWhere( + (h) => + h.updatedAt.isBefore(state.lastDeletedHeadline!.updatedAt), + ) != + -1 + ? state.headlines.indexWhere( + (h) => + h.updatedAt.isBefore(state.lastDeletedHeadline!.updatedAt), + ) + : state.headlines.length, + state.lastDeletedHeadline!, + ); + emit( + state.copyWith( + headlines: updatedHeadlines, + lastDeletedHeadline: null, ), ); } diff --git a/lib/content_management/bloc/archived_headlines/archived_headlines_event.dart b/lib/content_management/bloc/archived_headlines/archived_headlines_event.dart index e191954..1d16408 100644 --- a/lib/content_management/bloc/archived_headlines/archived_headlines_event.dart +++ b/lib/content_management/bloc/archived_headlines/archived_headlines_event.dart @@ -37,3 +37,18 @@ final class DeleteHeadlineForeverRequested extends ArchivedHeadlinesEvent { @override List get props => [id]; } + +/// Event to undo the deletion of a headline. +final class UndoDeleteHeadlineRequested extends ArchivedHeadlinesEvent { + const UndoDeleteHeadlineRequested(); +} + +/// Internal event to confirm the permanent deletion of a headline after a delay. +final class _ConfirmDeleteHeadlineRequested extends ArchivedHeadlinesEvent { + const _ConfirmDeleteHeadlineRequested(this.id); + + final String id; + + @override + List get props => [id]; +} diff --git a/lib/content_management/bloc/archived_headlines/archived_headlines_state.dart b/lib/content_management/bloc/archived_headlines/archived_headlines_state.dart index 57fcec1..b91404a 100644 --- a/lib/content_management/bloc/archived_headlines/archived_headlines_state.dart +++ b/lib/content_management/bloc/archived_headlines/archived_headlines_state.dart @@ -17,6 +17,7 @@ class ArchivedHeadlinesState extends Equatable { this.hasMore = false, this.exception, this.restoredHeadline, + this.lastDeletedHeadline, }); final ArchivedHeadlinesStatus status; @@ -25,6 +26,7 @@ class ArchivedHeadlinesState extends Equatable { final bool hasMore; final HttpException? exception; final Headline? restoredHeadline; + final Headline? lastDeletedHeadline; ArchivedHeadlinesState copyWith({ ArchivedHeadlinesStatus? status, @@ -33,14 +35,16 @@ class ArchivedHeadlinesState extends Equatable { bool? hasMore, HttpException? exception, Headline? restoredHeadline, + Headline? lastDeletedHeadline, }) { return ArchivedHeadlinesState( status: status ?? this.status, headlines: headlines ?? this.headlines, cursor: cursor ?? this.cursor, hasMore: hasMore ?? this.hasMore, - exception: exception ?? this.exception, + exception: exception, restoredHeadline: restoredHeadline, + lastDeletedHeadline: lastDeletedHeadline, ); } @@ -52,5 +56,6 @@ class ArchivedHeadlinesState extends Equatable { hasMore, exception, restoredHeadline, + lastDeletedHeadline, ]; } diff --git a/lib/content_management/view/archived_headlines_page.dart b/lib/content_management/view/archived_headlines_page.dart index 0898c4d..3ce33db 100644 --- a/lib/content_management/view/archived_headlines_page.dart +++ b/lib/content_management/view/archived_headlines_page.dart @@ -7,6 +7,7 @@ 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/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/l10n/l10n.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/extensions/extensions.dart'; import 'package:intl/intl.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -38,15 +39,35 @@ class _ArchivedHeadlinesView extends StatelessWidget { padding: const EdgeInsets.all(AppSpacing.lg), child: BlocListener( listenWhen: (previous, current) => - previous.status != current.status || + previous.lastDeletedHeadline != current.lastDeletedHeadline || previous.restoredHeadline != current.restoredHeadline, listener: (context, state) { - if (state.status == ArchivedHeadlinesStatus.success && - state.restoredHeadline != null) { + if (state.restoredHeadline != null) { context.read().add( const LoadHeadlinesRequested(limit: kDefaultRowsPerPage), ); } + if (state.lastDeletedHeadline != null) { + final truncatedTitle = + state.lastDeletedHeadline!.title.truncate(30); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + l10n.headlineDeleted(truncatedTitle), + ), + action: SnackBarAction( + label: l10n.undo, + onPressed: () { + context + .read() + .add(const UndoDeleteHeadlineRequested()); + }, + ), + ), + ); + } }, child: BlocBuilder( builder: (context, state) { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 56a9275..82af566 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1753,6 +1753,18 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Archive'** String get archive; + + /// Snackbar message when a headline is deleted + /// + /// In en, this message translates to: + /// **'Deleted \'\'{title}\'\'.'** + String headlineDeleted(String title); + + /// No description provided for @undo. + /// + /// In en, this message translates to: + /// **'Undo'** + String get undo; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index 1583d68..ba60da2 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -922,4 +922,12 @@ class AppLocalizationsAr extends AppLocalizations { @override String get archive => 'أرشفة'; + + @override + String headlineDeleted(String title) { + return 'تم حذف \'\'$title\'\'.'; + } + + @override + String get undo => 'تراجع'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 77a372a..2779c0a 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -921,4 +921,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get archive => 'Archive'; + + @override + String headlineDeleted(String title) { + return 'Deleted \'\'$title\'\'.'; + } + + @override + String get undo => 'Undo'; } diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 71d801b..b68b35c 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -1148,6 +1148,17 @@ }, "archive": "أرشفة", "@archive": { - "description": "تلميح لزر الأرشفة" - } + "description": "تلميح لأرشفة زر" + }, + "headlineDeleted": "تم حذف ''{title}''.", + "@headlineDeleted": { + "description": "رسالة Snackbar عند حذف عنوان", + "placeholders": { + "title": { + "type": "String", + "example": "عاجل" + } + } + }, + "undo": "تراجع" } diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 2c1a93f..1120c81 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1080,8 +1080,7 @@ "feedActionTypeEnableNotifications": "Enable Notifications", "@feedActionTypeEnableNotifications": { "description": "Feed action type for enabling notifications" - } - , + }, "countryPickerSearchLabel": "Search", "@countryPickerSearchLabel": { "description": "Label for the search input in the country picker" @@ -1149,5 +1148,16 @@ "archive": "Archive", "@archive": { "description": "Tooltip for the archive button" - } + }, + "headlineDeleted": "Deleted ''{title}''.", + "@headlineDeleted": { + "description": "Snackbar message when a headline is deleted", + "placeholders": { + "title": { + "type": "String", + "example": "Breaking News" + } + } + }, + "undo": "Undo" } diff --git a/lib/shared/extensions/extensions.dart b/lib/shared/extensions/extensions.dart index ad4d418..32086ea 100644 --- a/lib/shared/extensions/extensions.dart +++ b/lib/shared/extensions/extensions.dart @@ -1 +1,3 @@ export 'content_status_l10n.dart'; +export 'source_type_l10n.dart'; +export 'string_truncate.dart'; diff --git a/lib/shared/extensions/string_truncate.dart b/lib/shared/extensions/string_truncate.dart new file mode 100644 index 0000000..99d227d --- /dev/null +++ b/lib/shared/extensions/string_truncate.dart @@ -0,0 +1,8 @@ +extension StringTruncate on String { + String truncate(int maxLength) { + if (length <= maxLength) { + return this; + } + return '${substring(0, maxLength)}...'; + } +}