From 65c9f292138b5784bd2b555b232a61a2b6300164 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Sep 2025 09:18:15 +0100 Subject: [PATCH 01/21] refactor(content_management): enhance ArchivedHeadlinesState with pending deletions - Add pendingDeletions map to track optimistic deletions - Implement lastPendingDeletionId to manage undo operations - Expand ArchivedHeadlinesStatus with initial, loading, success, and failure states - Update copyWith method to support new properties - Add extensive documentation for class and properties --- .../archived_headlines_state.dart | 54 +++++++++++++++++-- 1 file changed, 49 insertions(+), 5 deletions(-) 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 3ad27f9b..b81b6b6a 100644 --- a/lib/content_management/bloc/archived_headlines/archived_headlines_state.dart +++ b/lib/content_management/bloc/archived_headlines/archived_headlines_state.dart @@ -2,14 +2,27 @@ part of 'archived_headlines_bloc.dart'; /// Represents the status of archived content operations. enum ArchivedHeadlinesStatus { + /// The operation is in its initial state. initial, + + /// Data is currently being loaded or an operation is in progress. loading, + + /// Data has been successfully loaded or an operation completed. success, + + /// An error occurred during data loading or an operation. failure, } +/// {@template archived_headlines_state} /// The state for the archived content feature. +/// +/// Manages the list of archived headlines, pagination details, +/// and any pending deletion operations. +/// {@endtemplate} class ArchivedHeadlinesState extends Equatable { + /// {@macro archived_headlines_state} const ArchivedHeadlinesState({ this.status = ArchivedHeadlinesStatus.initial, this.headlines = const [], @@ -17,17 +30,42 @@ class ArchivedHeadlinesState extends Equatable { this.hasMore = false, this.exception, this.restoredHeadline, - this.lastDeletedHeadline, + this.pendingDeletions = const {}, + this.lastPendingDeletionId, }); + /// The current status of the archived headlines operations. final ArchivedHeadlinesStatus status; + + /// The list of archived headlines currently displayed. final List headlines; + + /// The cursor for fetching the next page of archived headlines. + /// A `null` value indicates no more pages. final String? cursor; + + /// Indicates if there are more archived headlines available to load. final bool hasMore; + + /// The exception encountered during a failed operation, if any. final HttpException? exception; + + /// The headline that was most recently restored, if any. final Headline? restoredHeadline; - final Headline? lastDeletedHeadline; + /// A map of headlines that are currently in a "pending deletion" state. + /// + /// The key is the headline ID, and the value is the Headline object. + /// These headlines have been optimistically removed from the main list + /// and are awaiting permanent deletion after an undo period. + final Map pendingDeletions; + + /// The ID of the headline that was most recently moved to pending deletion. + /// + /// Used to identify which headline's undo snackbar should be displayed. + final String? lastPendingDeletionId; + + /// Creates a copy of this [ArchivedHeadlinesState] with updated values. ArchivedHeadlinesState copyWith({ ArchivedHeadlinesStatus? status, List? headlines, @@ -35,16 +73,21 @@ class ArchivedHeadlinesState extends Equatable { bool? hasMore, HttpException? exception, Headline? restoredHeadline, - Headline? lastDeletedHeadline, + Map? pendingDeletions, + String? lastPendingDeletionId, }) { return ArchivedHeadlinesState( status: status ?? this.status, headlines: headlines ?? this.headlines, cursor: cursor ?? this.cursor, hasMore: hasMore ?? this.hasMore, + // Exception and restoredHeadline are explicitly set to null if not provided + // to ensure they are cleared after being handled. exception: exception, restoredHeadline: restoredHeadline, - lastDeletedHeadline: lastDeletedHeadline, + pendingDeletions: pendingDeletions ?? this.pendingDeletions, + lastPendingDeletionId: + lastPendingDeletionId ?? this.lastPendingDeletionId, ); } @@ -56,6 +99,7 @@ class ArchivedHeadlinesState extends Equatable { hasMore, exception, restoredHeadline, - lastDeletedHeadline, + pendingDeletions, + lastPendingDeletionId, ]; } From 744a05547c489958372675101508eb4432ce81e8 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Sep 2025 09:18:24 +0100 Subject: [PATCH 02/21] refactor(content_management): enhance UndoDeleteHeadlineRequested event with id - Add 'id' parameter to UndoDeleteHeadlineRequested event - Implement props getter for UndoDeleteHeadlineRequested event --- .../bloc/archived_headlines/archived_headlines_event.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 1d164085..f53ed1ba 100644 --- a/lib/content_management/bloc/archived_headlines/archived_headlines_event.dart +++ b/lib/content_management/bloc/archived_headlines/archived_headlines_event.dart @@ -40,7 +40,12 @@ final class DeleteHeadlineForeverRequested extends ArchivedHeadlinesEvent { /// Event to undo the deletion of a headline. final class UndoDeleteHeadlineRequested extends ArchivedHeadlinesEvent { - const UndoDeleteHeadlineRequested(); + const UndoDeleteHeadlineRequested(this.id); + + final String id; + + @override + List get props => [id]; } /// Internal event to confirm the permanent deletion of a headline after a delay. From 54016627db32a0b817e19996213cd05f6b7c3c21 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Sep 2025 09:18:44 +0100 Subject: [PATCH 03/21] refactor(content_management): improve undo functionality in archived headlines - Implement individual subscriptions for each pending deletion to allow independent undo operations. - Add optimistic UI updates for delete and restore actions. - Enhance error handling and state management for failed operations. - Introduce detailed documentation for bloc methods and functionality. - Refactor timer-based deletion to a stream-based approach for better control and testability. --- .../archived_headlines_bloc.dart | 213 +++++++++++++++--- 1 file changed, 177 insertions(+), 36 deletions(-) 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 a778bd5e..a0829fca 100644 --- a/lib/content_management/bloc/archived_headlines/archived_headlines_bloc.dart +++ b/lib/content_management/bloc/archived_headlines/archived_headlines_bloc.dart @@ -8,8 +8,15 @@ import 'package:equatable/equatable.dart'; part 'archived_headlines_event.dart'; part 'archived_headlines_state.dart'; +/// {@template archived_headlines_bloc} +/// A BLoC responsible for managing the state of archived headlines. +/// +/// It handles loading, restoring, and permanently deleting archived headlines, +/// including a temporary "undo" period for deletions. +/// {@endtemplate} class ArchivedHeadlinesBloc extends Bloc { + /// {@macro archived_headlines_bloc} ArchivedHeadlinesBloc({ required DataRepository headlinesRepository, }) : _headlinesRepository = headlinesRepository, @@ -22,14 +29,26 @@ class ArchivedHeadlinesBloc } final DataRepository _headlinesRepository; - Timer? _deleteTimer; + + /// Manages individual subscriptions for delayed permanent deletions. + /// + /// Each entry maps a headline ID to its corresponding StreamSubscription, + /// allowing for independent undo functionality. + final Map> _pendingDeletionSubscriptions = {}; @override - Future close() { - _deleteTimer?.cancel(); + Future close() async { + // Cancel all pending deletion subscriptions to prevent memory leaks + // and ensure no unexpected deletions occur after the bloc is closed. + for (final subscription in _pendingDeletionSubscriptions.values) { + await subscription.cancel(); + } return super.close(); } + /// Handles the request to load archived headlines. + /// + /// Fetches paginated archived headlines from the repository and updates the state. Future _onLoadArchivedHeadlinesRequested( LoadArchivedHeadlinesRequested event, Emitter emit, @@ -72,6 +91,11 @@ class ArchivedHeadlinesBloc } } + /// Handles the request to restore an archived headline. + /// + /// Optimistically removes the headline from the UI, updates its status to active + /// in the repository, and then updates the state. If the headline was pending + /// deletion, its pending deletion is cancelled. Future _onRestoreHeadlineRequested( RestoreHeadlineRequested event, Emitter emit, @@ -83,7 +107,20 @@ class ArchivedHeadlinesBloc final headlineToRestore = originalHeadlines[headlineIndex]; final updatedHeadlines = originalHeadlines..removeAt(headlineIndex); - emit(state.copyWith(headlines: updatedHeadlines)); + // If the headline was pending deletion, cancel its subscription and remove it + // from pendingDeletions. + final subscription = _pendingDeletionSubscriptions.remove(event.id); + if (subscription != null) { + await subscription.cancel(); + } + + emit( + state.copyWith( + headlines: updatedHeadlines, + pendingDeletions: Map.from(state.pendingDeletions) + ..remove(event.id), + ), + ); try { final restoredHeadline = await _headlinesRepository.update( @@ -92,22 +129,41 @@ class ArchivedHeadlinesBloc ); emit(state.copyWith(restoredHeadline: restoredHeadline)); } on HttpException catch (e) { - emit(state.copyWith(headlines: originalHeadlines, exception: e)); + // If the update fails, revert the change in the UI and re-add to pendingDeletions + emit( + state.copyWith( + headlines: originalHeadlines, + exception: e, + pendingDeletions: Map.from(state.pendingDeletions) + ..[event.id] = headlineToRestore, + ), + ); } catch (e) { emit( state.copyWith( headlines: originalHeadlines, exception: UnknownException('An unexpected error occurred: $e'), + pendingDeletions: Map.from(state.pendingDeletions) + ..[event.id] = headlineToRestore, ), ); } } + /// Handles the request to permanently delete a headline. + /// + /// Optimistically removes the headline from the UI and starts a 5-second timer. + /// If the timer completes without an undo, a `_ConfirmDeleteHeadlineRequested` + /// event is dispatched. Future _onDeleteHeadlineForeverRequested( DeleteHeadlineForeverRequested event, Emitter emit, ) async { - _deleteTimer?.cancel(); + // Cancel any existing subscription for this headline if it was already pending + final subscription = _pendingDeletionSubscriptions.remove(event.id); + if (subscription != null) { + await subscription.cancel(); + } final headlineIndex = state.headlines.indexWhere((h) => h.id == event.id); if (headlineIndex == -1) return; @@ -116,78 +172,163 @@ class ArchivedHeadlinesBloc final updatedHeadlines = List.from(state.headlines) ..removeAt(headlineIndex); + final updatedPendingDeletions = Map.from( + state.pendingDeletions, + )..[event.id] = headlineToDelete; + emit( state.copyWith( headlines: updatedHeadlines, - lastDeletedHeadline: headlineToDelete, + pendingDeletions: updatedPendingDeletions, + lastPendingDeletionId: event.id, ), ); - _deleteTimer = Timer( + // Start a new delayed deletion subscription. + // The `add` method returns a Future, which is intentionally not awaited here. + _pendingDeletionSubscriptions[event.id] = Future.delayed( const Duration(seconds: 5), () => add(_ConfirmDeleteHeadlineRequested(event.id)), - ); + ).asStream().listen( + (_) {}, // No-op, just to keep the stream alive + onError: (Object error) { + // Handle potential errors during the delay, though unlikely. + // Explicitly cast error to Object to satisfy addError signature. + addError(error, StackTrace.current); + }, + onDone: () { + // Clean up the subscription once it's done. + _pendingDeletionSubscriptions.remove(event.id); + }, + ); } + /// Handles the internal event to confirm the permanent deletion of a headline. + /// + /// This event is typically dispatched after the undo timer expires. Future _onConfirmDeleteHeadlineRequested( _ConfirmDeleteHeadlineRequested event, Emitter emit, ) async { + // Ensure the subscription is cancelled and removed, as the deletion is now confirmed. + final subscription = _pendingDeletionSubscriptions.remove(event.id); + if (subscription != null) { + await subscription.cancel(); + } + + final headlineToDelete = state.pendingDeletions[event.id]; + if (headlineToDelete == null) { + // Headline not found in pending deletions, might have been undone or already deleted. + return; + } + + final updatedPendingDeletions = Map.from( + state.pendingDeletions, + )..remove(event.id); + try { await _headlinesRepository.delete(id: event.id); - emit(state.copyWith(lastDeletedHeadline: null)); + emit(state.copyWith(pendingDeletions: updatedPendingDeletions)); } on HttpException catch (e) { - // If deletion fails, restore the headline to the list + // If deletion fails, restore the headline to the list and re-add to pendingDeletions + // (without restarting the timer, as the user can undo it from the UI if they are still on the page). final originalHeadlines = List.from(state.headlines) - ..add(state.lastDeletedHeadline!); + ..insert( + state.headlines.indexWhere( + (h) => h.updatedAt.isBefore( + headlineToDelete.updatedAt, + ), + ) != + -1 + ? state.headlines.indexWhere( + (h) => h.updatedAt.isBefore( + headlineToDelete.updatedAt, + ), + ) + : state.headlines.length, + headlineToDelete, + ); emit( state.copyWith( headlines: originalHeadlines, exception: e, - lastDeletedHeadline: null, + pendingDeletions: updatedPendingDeletions..[event.id] = headlineToDelete, ), ); } 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, + headlineToDelete.updatedAt, ), ) != -1 ? state.headlines.indexWhere( (h) => h.updatedAt.isBefore( - state.lastDeletedHeadline!.updatedAt, + headlineToDelete.updatedAt, ), ) : state.headlines.length, - state.lastDeletedHeadline!, + headlineToDelete, ); emit( state.copyWith( - headlines: updatedHeadlines, - lastDeletedHeadline: null, + headlines: originalHeadlines, + exception: UnknownException('An unexpected error occurred: $e'), + pendingDeletions: updatedPendingDeletions..[event.id] = headlineToDelete, ), ); } } + + /// Handles the request to undo a pending headline deletion. + /// + /// Cancels the permanent deletion for the specified headline and restores it to the UI. + void _onUndoDeleteHeadlineRequested( + UndoDeleteHeadlineRequested event, + Emitter emit, + ) { + // Cancel the specific pending deletion subscription. + final subscription = _pendingDeletionSubscriptions.remove(event.id); + if (subscription != null) { + // No await here as this is a void method and we don't want to block. + // The subscription will eventually cancel. + subscription.cancel(); + } + + final undoneHeadline = state.pendingDeletions[event.id]; + if (undoneHeadline == null) { + // Headline not found in pending deletions, might have been already confirmed or not pending. + return; + } + + final updatedHeadlines = List.from(state.headlines) + ..insert( + state.headlines.indexWhere( + (h) => h.updatedAt.isBefore( + undoneHeadline.updatedAt, + ), + ) != + -1 + ? state.headlines.indexWhere( + (h) => h.updatedAt.isBefore( + undoneHeadline.updatedAt, + ), + ) + : state.headlines.length, + undoneHeadline, + ); + + final updatedPendingDeletions = Map.from( + state.pendingDeletions, + )..remove(event.id); + + emit( + state.copyWith( + headlines: updatedHeadlines, + pendingDeletions: updatedPendingDeletions, + ), + ); + } } From 955003a3051b877dc09f1ce7df7ce5af24f64be1 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Sep 2025 09:18:59 +0100 Subject: [PATCH 04/21] refactor(content_management): update undo delete functionality - Replace lastDeletedHeadline check with pendingDeletions length comparison - Add support for undoing multiple deletions - Update SnackBar action to use the correct headline ID for undo operation --- .../view/archived_headlines_page.dart | 46 ++++++++++--------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/lib/content_management/view/archived_headlines_page.dart b/lib/content_management/view/archived_headlines_page.dart index c10e4efb..5e9dcfe1 100644 --- a/lib/content_management/view/archived_headlines_page.dart +++ b/lib/content_management/view/archived_headlines_page.dart @@ -39,7 +39,8 @@ class _ArchivedHeadlinesView extends StatelessWidget { padding: const EdgeInsets.all(AppSpacing.lg), child: BlocListener( listenWhen: (previous, current) => - previous.lastDeletedHeadline != current.lastDeletedHeadline || + previous.pendingDeletions.length != + current.pendingDeletions.length || previous.restoredHeadline != current.restoredHeadline, listener: (context, state) { if (state.restoredHeadline != null) { @@ -47,27 +48,30 @@ class _ArchivedHeadlinesView extends StatelessWidget { 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(), - ); - }, + + if (state.lastPendingDeletionId != null && + state.pendingDeletions.containsKey(state.lastPendingDeletionId)) { + final headline = state.pendingDeletions[state.lastPendingDeletionId]; + if (headline != null) { + final truncatedTitle = headline.title.truncate(30); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + l10n.headlineDeleted(truncatedTitle), + ), + action: SnackBarAction( + label: l10n.undo, + onPressed: () { + context.read().add( + UndoDeleteHeadlineRequested(headline.id), + ); + }, + ), ), - ), - ); + ); + } } }, child: BlocBuilder( From 88ae002b2ecc5ce4e62b3f6971af5e02ea9356f9 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Sep 2025 09:19:19 +0100 Subject: [PATCH 05/21] style: format --- .../archived_headlines_bloc.dart | 21 +++++++++++-------- .../view/archived_headlines_page.dart | 7 +++++-- 2 files changed, 17 insertions(+), 11 deletions(-) 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 a0829fca..73c1b04b 100644 --- a/lib/content_management/bloc/archived_headlines/archived_headlines_bloc.dart +++ b/lib/content_management/bloc/archived_headlines/archived_headlines_bloc.dart @@ -34,7 +34,8 @@ class ArchivedHeadlinesBloc /// /// Each entry maps a headline ID to its corresponding StreamSubscription, /// allowing for independent undo functionality. - final Map> _pendingDeletionSubscriptions = {}; + final Map> _pendingDeletionSubscriptions = + {}; @override Future close() async { @@ -117,8 +118,7 @@ class ArchivedHeadlinesBloc emit( state.copyWith( headlines: updatedHeadlines, - pendingDeletions: Map.from(state.pendingDeletions) - ..remove(event.id), + pendingDeletions: Map.from(state.pendingDeletions)..remove(event.id), ), ); @@ -186,10 +186,11 @@ class ArchivedHeadlinesBloc // Start a new delayed deletion subscription. // The `add` method returns a Future, which is intentionally not awaited here. - _pendingDeletionSubscriptions[event.id] = Future.delayed( - const Duration(seconds: 5), - () => add(_ConfirmDeleteHeadlineRequested(event.id)), - ).asStream().listen( + _pendingDeletionSubscriptions[event.id] = + Future.delayed( + const Duration(seconds: 5), + () => add(_ConfirmDeleteHeadlineRequested(event.id)), + ).asStream().listen( (_) {}, // No-op, just to keep the stream alive onError: (Object error) { // Handle potential errors during the delay, though unlikely. @@ -252,7 +253,8 @@ class ArchivedHeadlinesBloc state.copyWith( headlines: originalHeadlines, exception: e, - pendingDeletions: updatedPendingDeletions..[event.id] = headlineToDelete, + pendingDeletions: updatedPendingDeletions + ..[event.id] = headlineToDelete, ), ); } catch (e) { @@ -276,7 +278,8 @@ class ArchivedHeadlinesBloc state.copyWith( headlines: originalHeadlines, exception: UnknownException('An unexpected error occurred: $e'), - pendingDeletions: updatedPendingDeletions..[event.id] = headlineToDelete, + pendingDeletions: updatedPendingDeletions + ..[event.id] = headlineToDelete, ), ); } diff --git a/lib/content_management/view/archived_headlines_page.dart b/lib/content_management/view/archived_headlines_page.dart index 5e9dcfe1..41c0c984 100644 --- a/lib/content_management/view/archived_headlines_page.dart +++ b/lib/content_management/view/archived_headlines_page.dart @@ -50,8 +50,11 @@ class _ArchivedHeadlinesView extends StatelessWidget { } if (state.lastPendingDeletionId != null && - state.pendingDeletions.containsKey(state.lastPendingDeletionId)) { - final headline = state.pendingDeletions[state.lastPendingDeletionId]; + state.pendingDeletions.containsKey( + state.lastPendingDeletionId, + )) { + final headline = + state.pendingDeletions[state.lastPendingDeletionId]; if (headline != null) { final truncatedTitle = headline.title.truncate(30); ScaffoldMessenger.of(context) From bf00b0ffcf4454e1dadb104c5537a0f09cf79e35 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Sep 2025 12:11:40 +0100 Subject: [PATCH 06/21] feat(shared): add PendingDeletionsService for managing undoable deletions - Implement PendingDeletionsService abstract class and its implementation - Add DeletionStatus enum and DeletionEvent class - Service allows requesting deletions with an undo period - Emits events when deletions are confirmed or undone - Handles multiple pending deletions and ensures proper cleanup --- .../services/pending_deletions_service.dart | 159 ++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 lib/shared/services/pending_deletions_service.dart diff --git a/lib/shared/services/pending_deletions_service.dart b/lib/shared/services/pending_deletions_service.dart new file mode 100644 index 00000000..7c103cc7 --- /dev/null +++ b/lib/shared/services/pending_deletions_service.dart @@ -0,0 +1,159 @@ +import 'dart:async'; + +import 'package:data_repository/data_repository.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; +import 'package:logging/logging.dart'; // Import the logging package + +/// Represents the status of a pending deletion. +enum DeletionStatus { + /// The deletion has been confirmed and executed. + confirmed, + + /// The deletion has been successfully undone. + undone, +} + +/// {@template deletion_event} +/// An event representing a change in the status of a pending deletion. +/// +/// Contains the ID of the item and its new status. +/// {@endtemplate} +@immutable +class DeletionEvent extends Equatable { + /// {@macro deletion_event} + const DeletionEvent(this.id, this.status); + + /// The unique identifier of the item. + final String id; + + /// The new status of the deletion. + final DeletionStatus status; + + @override + List get props => [id, status]; +} + +/// {@template pending_deletions_service} +/// An abstract interface for a service that manages pending deletions. +/// +/// This service provides a mechanism to request a delayed deletion of an item, +/// allowing for an "undo" period. It is designed to be a singleton with a + +/// lifecycle tied to the application, ensuring that deletion timers persist +/// even if the user navigates away from the page where the deletion was +/// initiated. +/// {@endtemplate} +abstract class PendingDeletionsService { + /// A stream that emits [DeletionEvent]s when a deletion is confirmed or undone. + /// + /// BLoCs can listen to this stream to react to deletion status changes, + /// such as removing an item from the state permanently or re-inserting it. + Stream get deletionEvents; + + /// Requests the deletion of an item of a specific type [T]. + /// + /// - [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. + void requestDeletion({ + required T item, + required DataRepository repository, + required Duration undoDuration, + }); + + /// Cancels a pending deletion for the item with the given [id]. + void undoDeletion(String id); + + /// Disposes of the service's resources, like closing the stream controller. + void dispose(); +} + +/// {@template pending_deletions_service_impl} +/// A concrete implementation of [PendingDeletionsService]. +/// +/// This class manages a map of timers for items pending deletion. When a +/// deletion is requested, it starts a timer. If the timer completes, it calls +/// the appropriate repository to perform the deletion and emits a `confirmed` +/// event. If an undo is requested, it cancels the timer and emits an `undone` +/// event. +/// {@endtemplate} +class PendingDeletionsServiceImpl implements PendingDeletionsService { + /// {@macro pending_deletions_service_impl} + PendingDeletionsServiceImpl({Logger? logger}) + : _logger = logger ?? Logger('PendingDeletionsServiceImpl'); + + /// The logger instance for this service. + final Logger _logger; + + /// The stream controller that broadcasts [DeletionEvent]s. + final _deletionEventController = StreamController.broadcast(); + + /// A map that stores the `Timer` for each pending deletion, keyed by item ID. + final Map _pendingDeletionTimers = {}; + + @override + Stream get deletionEvents => _deletionEventController.stream; + + @override + void requestDeletion({ + required T item, + required DataRepository repository, + required Duration undoDuration, + }) { + // The item must have an 'id' property. + final id = (item as dynamic).id as String; + _logger.info('Requesting deletion for item ID: $id'); + + // If there's already a pending deletion for this item, cancel it first. + if (_pendingDeletionTimers.containsKey(id)) { + _logger.info('Cancelling existing pending deletion for ID: $id'); + _pendingDeletionTimers.remove(id)?.cancel(); + } + + // Start a new timer for the deletion. + _pendingDeletionTimers[id] = Timer(undoDuration, () async { + try { + await repository.delete(id: id); + _logger.info('Deletion confirmed for item ID: $id'); + _deletionEventController.add( + DeletionEvent(id, DeletionStatus.confirmed), + ); + } catch (error) { + _logger.severe('Error confirming deletion for item ID: $id: $error'); + _deletionEventController.addError(error); + } finally { + // Clean up the timer once the operation is complete. + _pendingDeletionTimers.remove(id); + } + }); + } + + @override + void undoDeletion(String id) { + _logger.info('Attempting to undo deletion for item ID: $id'); + // Cancel the timer and remove it from the map. + final timer = _pendingDeletionTimers.remove(id); + if (timer != null) { + timer.cancel(); + _logger.info('Deletion undone for item ID: $id'); + // Notify listeners that the deletion was undone. + _deletionEventController.add(DeletionEvent(id, DeletionStatus.undone)); + } else { + _logger.warning('No pending deletion found for ID: $id to undo.'); + } + } + + @override + void dispose() { + _logger.info( + 'Disposing PendingDeletionsService. Cancelling ${_pendingDeletionTimers.length} pending timers.', + ); + // Cancel all pending timers to prevent memory leaks. + for (final timer in _pendingDeletionTimers.values) { + timer.cancel(); + } + _pendingDeletionTimers.clear(); + _deletionEventController.close(); + } +} From 13de7872cc71939f341b29b9babbbd2196226182 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Sep 2025 12:11:53 +0100 Subject: [PATCH 07/21] refactor(content_management): clarify archived headlines state comments - Simplify and update comments for pendingDeletions and lastPendingDeletionId - Explicitly allow null for lastPendingDeletionId in copyWith method --- .../archived_headlines_state.dart | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) 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 b81b6b6a..927cf6b8 100644 --- a/lib/content_management/bloc/archived_headlines/archived_headlines_state.dart +++ b/lib/content_management/bloc/archived_headlines/archived_headlines_state.dart @@ -53,16 +53,12 @@ class ArchivedHeadlinesState extends Equatable { /// The headline that was most recently restored, if any. final Headline? restoredHeadline; - /// A map of headlines that are currently in a "pending deletion" state. - /// - /// The key is the headline ID, and the value is the Headline object. - /// These headlines have been optimistically removed from the main list - /// and are awaiting permanent deletion after an undo period. + /// A map of headlines that are currently pending permanent deletion, + /// keyed by their ID. final Map pendingDeletions; - /// The ID of the headline that was most recently moved to pending deletion. - /// - /// Used to identify which headline's undo snackbar should be displayed. + /// The ID of the headline that was most recently added to pending deletions. + /// Used to trigger the snackbar display. final String? lastPendingDeletionId; /// Creates a copy of this [ArchivedHeadlinesState] with updated values. @@ -87,7 +83,7 @@ class ArchivedHeadlinesState extends Equatable { restoredHeadline: restoredHeadline, pendingDeletions: pendingDeletions ?? this.pendingDeletions, lastPendingDeletionId: - lastPendingDeletionId ?? this.lastPendingDeletionId, + lastPendingDeletionId, // Explicitly allow null to clear ); } From 60c1743c3d05f34a32f12ff774e19c2e920b1f9b Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Sep 2025 12:12:14 +0100 Subject: [PATCH 08/21] refactor(content_management): update and add archived headlines events - Rename and update existing events in archived headlines BLoC - Add new events for clearing restored headlines and handling deletion service updates - Improve documentation and code structure for better readability and maintainability --- .../archived_headlines_event.dart | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) 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 f53ed1ba..ce7d13d1 100644 --- a/lib/content_management/bloc/archived_headlines/archived_headlines_event.dart +++ b/lib/content_management/bloc/archived_headlines/archived_headlines_event.dart @@ -28,32 +28,45 @@ final class RestoreHeadlineRequested extends ArchivedHeadlinesEvent { List get props => [id]; } -/// Event to permanently delete an archived headline. +/// Event to request permanent deletion of an archived headline. final class DeleteHeadlineForeverRequested extends ArchivedHeadlinesEvent { + /// {@macro delete_headline_forever_requested} const DeleteHeadlineForeverRequested(this.id); + /// The ID of the headline to permanently delete. final String id; @override List get props => [id]; } -/// Event to undo the deletion of a headline. +/// Event to undo a pending deletion of an archived headline. final class UndoDeleteHeadlineRequested extends ArchivedHeadlinesEvent { + /// {@macro undo_delete_headline_requested} const UndoDeleteHeadlineRequested(this.id); + /// The ID of the headline whose deletion should be undone. final String id; @override List get props => [id]; } -/// Internal event to confirm the permanent deletion of a headline after a delay. -final class _ConfirmDeleteHeadlineRequested extends ArchivedHeadlinesEvent { - const _ConfirmDeleteHeadlineRequested(this.id); +/// Event to clear the restored headline from the state. +final class ClearRestoredHeadline extends ArchivedHeadlinesEvent { + /// {@macro clear_restored_headline} + const ClearRestoredHeadline(); - final String id; + @override + List get props => []; +} + +/// Event to handle updates from the pending deletions service. +final class _DeletionServiceStatusChanged extends ArchivedHeadlinesEvent { + const _DeletionServiceStatusChanged(this.event); + + final DeletionEvent event; @override - List get props => [id]; + List get props => [event]; } From b9d2f78c212d3c56298411aae553f7ead44e72cc Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Sep 2025 12:12:30 +0100 Subject: [PATCH 09/21] refactor(archived_headlines_bloc): integrate PendingDeletionsService for improved undo functionality - Replace individual deletion subscriptions with a single service for better memory management - Optimize state updates for deletion, undo, and restore operations - Externalize deletion logic to enhance testability and separation of concerns - Add support for clearing restored headlines from state --- .../archived_headlines_bloc.dart | 269 +++++++----------- 1 file changed, 107 insertions(+), 162 deletions(-) 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 73c1b04b..767f94e1 100644 --- a/lib/content_management/bloc/archived_headlines/archived_headlines_bloc.dart +++ b/lib/content_management/bloc/archived_headlines/archived_headlines_bloc.dart @@ -4,6 +4,7 @@ import 'package:bloc/bloc.dart'; import 'package:core/core.dart'; import 'package:data_repository/data_repository.dart'; import 'package:equatable/equatable.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/services/pending_deletions_service.dart'; // Import the pending deletions service part 'archived_headlines_event.dart'; part 'archived_headlines_state.dart'; @@ -19,31 +20,41 @@ class ArchivedHeadlinesBloc /// {@macro archived_headlines_bloc} ArchivedHeadlinesBloc({ required DataRepository headlinesRepository, + required PendingDeletionsService + pendingDeletionsService, // Inject PendingDeletionsService }) : _headlinesRepository = headlinesRepository, + _pendingDeletionsService = + pendingDeletionsService, // Initialize the service super(const ArchivedHeadlinesState()) { on(_onLoadArchivedHeadlinesRequested); on(_onRestoreHeadlineRequested); + on<_DeletionServiceStatusChanged>( + _onDeletionServiceStatusChanged, + ); // Handle deletion service events + + // Listen to deletion events from the PendingDeletionsService + _deletionEventSubscription = _pendingDeletionsService.deletionEvents + .where( + (event) => event.id.startsWith('headline_'), + ) // Filter for headline deletions + .listen((event) => add(_DeletionServiceStatusChanged(event))); + on(_onDeleteHeadlineForeverRequested); on(_onUndoDeleteHeadlineRequested); - on<_ConfirmDeleteHeadlineRequested>(_onConfirmDeleteHeadlineRequested); + on(_onClearRestoredHeadline); } final DataRepository _headlinesRepository; + final PendingDeletionsService + _pendingDeletionsService; // The injected service - /// Manages individual subscriptions for delayed permanent deletions. - /// - /// Each entry maps a headline ID to its corresponding StreamSubscription, - /// allowing for independent undo functionality. - final Map> _pendingDeletionSubscriptions = - {}; + /// Subscription to deletion events from the PendingDeletionsService. + late final StreamSubscription _deletionEventSubscription; @override Future close() async { - // Cancel all pending deletion subscriptions to prevent memory leaks - // and ensure no unexpected deletions occur after the bloc is closed. - for (final subscription in _pendingDeletionSubscriptions.values) { - await subscription.cancel(); - } + // Cancel the subscription to deletion events to prevent memory leaks. + await _deletionEventSubscription.cancel(); return super.close(); } @@ -108,17 +119,16 @@ class ArchivedHeadlinesBloc final headlineToRestore = originalHeadlines[headlineIndex]; final updatedHeadlines = originalHeadlines..removeAt(headlineIndex); - // If the headline was pending deletion, cancel its subscription and remove it - // from pendingDeletions. - final subscription = _pendingDeletionSubscriptions.remove(event.id); - if (subscription != null) { - await subscription.cancel(); - } - + // Optimistically remove the headline from the UI. emit( state.copyWith( headlines: updatedHeadlines, - pendingDeletions: Map.from(state.pendingDeletions)..remove(event.id), + // Also remove from pendingDeletions if it was there, as it's being restored. + pendingDeletions: Map.from(state.pendingDeletions) + ..remove(event.id), + lastPendingDeletionId: state.lastPendingDeletionId == event.id + ? null + : state.lastPendingDeletionId, ), ); @@ -134,8 +144,9 @@ class ArchivedHeadlinesBloc state.copyWith( headlines: originalHeadlines, exception: e, - pendingDeletions: Map.from(state.pendingDeletions) + pendingDeletions: Map.from(state.pendingDeletions) ..[event.id] = headlineToRestore, + lastPendingDeletionId: state.lastPendingDeletionId, ), ); } catch (e) { @@ -143,195 +154,129 @@ class ArchivedHeadlinesBloc state.copyWith( headlines: originalHeadlines, exception: UnknownException('An unexpected error occurred: $e'), - pendingDeletions: Map.from(state.pendingDeletions) + pendingDeletions: Map.from(state.pendingDeletions) ..[event.id] = headlineToRestore, + lastPendingDeletionId: state.lastPendingDeletionId, ), ); } } - /// Handles the request to permanently delete a headline. - /// - /// Optimistically removes the headline from the UI and starts a 5-second timer. - /// If the timer completes without an undo, a `_ConfirmDeleteHeadlineRequested` - /// event is dispatched. - Future _onDeleteHeadlineForeverRequested( - DeleteHeadlineForeverRequested event, - Emitter emit, - ) async { - // Cancel any existing subscription for this headline if it was already pending - final subscription = _pendingDeletionSubscriptions.remove(event.id); - if (subscription != null) { - await subscription.cancel(); - } - - final headlineIndex = state.headlines.indexWhere((h) => h.id == event.id); - if (headlineIndex == -1) return; - - final headlineToDelete = state.headlines[headlineIndex]; - final updatedHeadlines = List.from(state.headlines) - ..removeAt(headlineIndex); - - final updatedPendingDeletions = Map.from( - state.pendingDeletions, - )..[event.id] = headlineToDelete; - - emit( - state.copyWith( - headlines: updatedHeadlines, - pendingDeletions: updatedPendingDeletions, - lastPendingDeletionId: event.id, - ), - ); - - // Start a new delayed deletion subscription. - // The `add` method returns a Future, which is intentionally not awaited here. - _pendingDeletionSubscriptions[event.id] = - Future.delayed( - const Duration(seconds: 5), - () => add(_ConfirmDeleteHeadlineRequested(event.id)), - ).asStream().listen( - (_) {}, // No-op, just to keep the stream alive - onError: (Object error) { - // Handle potential errors during the delay, though unlikely. - // Explicitly cast error to Object to satisfy addError signature. - addError(error, StackTrace.current); - }, - onDone: () { - // Clean up the subscription once it's done. - _pendingDeletionSubscriptions.remove(event.id); - }, - ); - } - - /// Handles the internal event to confirm the permanent deletion of a headline. + /// Handles deletion events from the [PendingDeletionsService]. /// - /// This event is typically dispatched after the undo timer expires. - Future _onConfirmDeleteHeadlineRequested( - _ConfirmDeleteHeadlineRequested event, + /// This method is called when an item's deletion is confirmed or undone + /// by the service. It updates the BLoC's state accordingly. + Future _onDeletionServiceStatusChanged( + _DeletionServiceStatusChanged event, Emitter emit, ) async { - // Ensure the subscription is cancelled and removed, as the deletion is now confirmed. - final subscription = _pendingDeletionSubscriptions.remove(event.id); - if (subscription != null) { - await subscription.cancel(); - } + final id = event.event.id; + final status = event.event.status; - final headlineToDelete = state.pendingDeletions[event.id]; - if (headlineToDelete == null) { - // Headline not found in pending deletions, might have been undone or already deleted. + final headlineInPending = state.pendingDeletions[id]; + if (headlineInPending == null) { + // If the headline is not in pending deletions, it might have been + // restored by other means or was never pending. return; } final updatedPendingDeletions = Map.from( state.pendingDeletions, - )..remove(event.id); + )..remove(id); - try { - await _headlinesRepository.delete(id: event.id); - emit(state.copyWith(pendingDeletions: updatedPendingDeletions)); - } on HttpException catch (e) { - // If deletion fails, restore the headline to the list and re-add to pendingDeletions - // (without restarting the timer, as the user can undo it from the UI if they are still on the page). - final originalHeadlines = List.from(state.headlines) - ..insert( - state.headlines.indexWhere( - (h) => h.updatedAt.isBefore( - headlineToDelete.updatedAt, - ), - ) != - -1 - ? state.headlines.indexWhere( - (h) => h.updatedAt.isBefore( - headlineToDelete.updatedAt, - ), - ) - : state.headlines.length, - headlineToDelete, - ); + if (status == DeletionStatus.confirmed) { + // Deletion confirmed, simply update pending deletions. emit( state.copyWith( - headlines: originalHeadlines, - exception: e, - pendingDeletions: updatedPendingDeletions - ..[event.id] = headlineToDelete, + pendingDeletions: updatedPendingDeletions, + lastPendingDeletionId: state.lastPendingDeletionId == id + ? null + : state.lastPendingDeletionId, ), ); - } catch (e) { - final originalHeadlines = List.from(state.headlines) + } else if (status == DeletionStatus.undone) { + // Deletion undone, restore the headline to the main list. + final updatedHeadlines = List.from(state.headlines) ..insert( state.headlines.indexWhere( (h) => h.updatedAt.isBefore( - headlineToDelete.updatedAt, + headlineInPending.updatedAt, ), ) != -1 ? state.headlines.indexWhere( (h) => h.updatedAt.isBefore( - headlineToDelete.updatedAt, + headlineInPending.updatedAt, ), ) : state.headlines.length, - headlineToDelete, + headlineInPending, ); emit( state.copyWith( - headlines: originalHeadlines, - exception: UnknownException('An unexpected error occurred: $e'), - pendingDeletions: updatedPendingDeletions - ..[event.id] = headlineToDelete, + headlines: updatedHeadlines, + pendingDeletions: updatedPendingDeletions, + lastPendingDeletionId: state.lastPendingDeletionId == id + ? null + : state.lastPendingDeletionId, ), ); } } - /// Handles the request to undo a pending headline deletion. + /// Handles the request to permanently delete an archived headline. /// - /// Cancels the permanent deletion for the specified headline and restores it to the UI. - void _onUndoDeleteHeadlineRequested( - UndoDeleteHeadlineRequested event, + /// This optimistically removes the headline from the UI and initiates a + /// timed deletion via the [PendingDeletionsService]. + Future _onDeleteHeadlineForeverRequested( + DeleteHeadlineForeverRequested event, Emitter emit, - ) { - // Cancel the specific pending deletion subscription. - final subscription = _pendingDeletionSubscriptions.remove(event.id); - if (subscription != null) { - // No await here as this is a void method and we don't want to block. - // The subscription will eventually cancel. - subscription.cancel(); - } - - final undoneHeadline = state.pendingDeletions[event.id]; - if (undoneHeadline == null) { - // Headline not found in pending deletions, might have been already confirmed or not pending. - return; - } + ) async { + final headlineToDelete = state.headlines.firstWhere( + (h) => h.id == event.id, + ); + // Optimistically remove the headline from the UI. final updatedHeadlines = List.from(state.headlines) - ..insert( - state.headlines.indexWhere( - (h) => h.updatedAt.isBefore( - undoneHeadline.updatedAt, - ), - ) != - -1 - ? state.headlines.indexWhere( - (h) => h.updatedAt.isBefore( - undoneHeadline.updatedAt, - ), - ) - : state.headlines.length, - undoneHeadline, - ); - - final updatedPendingDeletions = Map.from( - state.pendingDeletions, - )..remove(event.id); + ..removeWhere((h) => h.id == event.id); emit( state.copyWith( headlines: updatedHeadlines, - pendingDeletions: updatedPendingDeletions, + pendingDeletions: Map.from(state.pendingDeletions) + ..[event.id] = headlineToDelete, + lastPendingDeletionId: event.id, ), ); + + // Request deletion via the service. + _pendingDeletionsService.requestDeletion( + item: headlineToDelete, + repository: _headlinesRepository, + undoDuration: const Duration(seconds: 5), // Configurable undo duration + ); + } + + /// Handles the request to undo a pending deletion of an archived headline. + /// + /// This cancels the deletion timer in the [PendingDeletionsService]. + Future _onUndoDeleteHeadlineRequested( + UndoDeleteHeadlineRequested event, + Emitter emit, + ) async { + _pendingDeletionsService.undoDeletion(event.id); + // The _onDeletionServiceStatusChanged will handle re-adding to the list + // and updating pendingDeletions when DeletionStatus.undone is emitted. + } + + /// Handles the request to clear the restored headline from the state. + /// + /// This is typically called after the UI has processed the restored headline + /// and no longer needs it in the state. + void _onClearRestoredHeadline( + ClearRestoredHeadline event, + Emitter emit, + ) { + emit(state.copyWith(restoredHeadline: null)); } } From 9ec0bd76b75cef8ad98521cf98c558f5b553cea2 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Sep 2025 12:12:59 +0100 Subject: [PATCH 10/21] refactor(content_management): improve headline restoration and undo functionality - Add PendingDeletionsService to manage undo operations - Implement headline restoration and main list refresh - Enhance undo functionality with snackbar notification - Optimize state management and event handling --- .../view/archived_headlines_page.dart | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/content_management/view/archived_headlines_page.dart b/lib/content_management/view/archived_headlines_page.dart index 41c0c984..bdfb0140 100644 --- a/lib/content_management/view/archived_headlines_page.dart +++ b/lib/content_management/view/archived_headlines_page.dart @@ -8,6 +8,7 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/content_manageme 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:flutter_news_app_web_dashboard_full_source_code/shared/services/pending_deletions_service.dart'; // Import PendingDeletionsService import 'package:intl/intl.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -19,6 +20,8 @@ class ArchivedHeadlinesPage extends StatelessWidget { return BlocProvider( create: (context) => ArchivedHeadlinesBloc( headlinesRepository: context.read>(), + pendingDeletionsService: context + .read(), // Provide the service )..add(const LoadArchivedHeadlinesRequested(limit: kDefaultRowsPerPage)), child: const _ArchivedHeadlinesView(), ); @@ -41,14 +44,21 @@ class _ArchivedHeadlinesView extends StatelessWidget { listenWhen: (previous, current) => previous.pendingDeletions.length != current.pendingDeletions.length || - previous.restoredHeadline != current.restoredHeadline, + previous.restoredHeadline != current.restoredHeadline || + previous.lastPendingDeletionId != current.lastPendingDeletionId, listener: (context, state) { if (state.restoredHeadline != null) { + // When a headline is restored, refresh the main headlines list. context.read().add( const LoadHeadlinesRequested(limit: kDefaultRowsPerPage), ); + // Clear the restoredHeadline after it's been handled. + context.read().add( + const ClearRestoredHeadline(), + ); } + // Show snackbar for pending deletions. if (state.lastPendingDeletionId != null && state.pendingDeletions.containsKey( state.lastPendingDeletionId, @@ -67,6 +77,7 @@ class _ArchivedHeadlinesView extends StatelessWidget { action: SnackBarAction( label: l10n.undo, onPressed: () { + // Dispatch UndoDeleteHeadlineRequested to the BLoC. context.read().add( UndoDeleteHeadlineRequested(headline.id), ); From 554777598d8a2aef4406d1de920f181f755caefe Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Sep 2025 12:13:12 +0100 Subject: [PATCH 11/21] feat(shared): add pending deletions service - Implement PendingDeletionsService for managing undoable deletions - Add service to App widget and bootstrap process - Provide the service through RepositoryProvider for dependency injection --- lib/app/view/app.dart | 14 +++++++++++++- lib/bootstrap.dart | 10 ++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index a12d842b..7179fa57 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -15,6 +15,7 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/content_manageme import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/overview/bloc/overview_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/router/router.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/services/pending_deletions_service.dart'; // Import the PendingDeletionsService import 'package:flutter_news_app_web_dashboard_full_source_code/shared/shared.dart'; import 'package:go_router/go_router.dart'; import 'package:kv_storage_service/kv_storage_service.dart'; @@ -37,6 +38,8 @@ class App extends StatelessWidget { required DataRepository localAdsRepository, required KVStorageService storageService, required AppEnvironment environment, + required PendingDeletionsService + pendingDeletionsService, // Add PendingDeletionsService to constructor super.key, }) : _authenticationRepository = authenticationRepository, _headlinesRepository = headlinesRepository, @@ -50,7 +53,9 @@ class App extends StatelessWidget { _countriesRepository = countriesRepository, _languagesRepository = languagesRepository, _localAdsRepository = localAdsRepository, - _environment = environment; + _environment = environment, + _pendingDeletionsService = + pendingDeletionsService; // Initialize the service final AuthRepository _authenticationRepository; final DataRepository _headlinesRepository; @@ -67,6 +72,9 @@ class App extends StatelessWidget { final KVStorageService _kvStorageService; final AppEnvironment _environment; + /// The service for managing pending deletions with an undo period. + final PendingDeletionsService _pendingDeletionsService; + @override Widget build(BuildContext context) { return MultiRepositoryProvider( @@ -86,6 +94,10 @@ class App extends StatelessWidget { RepositoryProvider( create: (context) => const ThrottledFetchingService(), ), + RepositoryProvider.value( + value: + _pendingDeletionsService, // Provide the PendingDeletionsService + ), ], child: MultiBlocProvider( providers: [ diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 730f06c0..bfd83784 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -13,6 +13,7 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/app/app.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/app/config/config.dart' as app_config; import 'package:flutter_news_app_web_dashboard_full_source_code/bloc_observer.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/services/pending_deletions_service.dart'; import 'package:http_client/http_client.dart'; import 'package:kv_storage_shared_preferences/kv_storage_shared_preferences.dart'; import 'package:logging/logging.dart'; @@ -34,6 +35,7 @@ Future bootstrap( late final AuthClient authClient; late final AuthRepository authenticationRepository; HttpClient? httpClient; + late final PendingDeletionsService pendingDeletionsService; if (appConfig.environment == app_config.AppEnvironment.demo) { authClient = AuthInmemory(logger: Logger('AuthInmemory')); @@ -51,6 +53,9 @@ Future bootstrap( authClient: authClient, storageService: kvStorage, ); + pendingDeletionsService = PendingDeletionsServiceImpl( + logger: Logger('PendingDeletionsService'), + ); } DataClient headlinesClient; @@ -267,6 +272,10 @@ Future bootstrap( ); } + pendingDeletionsService = PendingDeletionsServiceImpl( + logger: Logger('PendingDeletionsService'), + ); + final headlinesRepository = DataRepository( dataClient: headlinesClient, ); @@ -309,5 +318,6 @@ Future bootstrap( localAdsRepository: localAdsRepository, storageService: kvStorage, environment: environment, + pendingDeletionsService: pendingDeletionsService, ); } From 9f268e9a10fd86a4009fe0095178a75bc4f30584 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Sep 2025 16:03:47 +0100 Subject: [PATCH 12/21] refactor(bloc_observer): enhance state change logging and error handling - Replace print statements with dart:developer/log for better logging practices - Implement selective logging of state 'status' property when available - Add error handling and logging for accessing status property - Truncate long state string representations to 250 characters - Suppress dynamic calls lint for the file --- lib/bloc_observer.dart | 49 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/lib/bloc_observer.dart b/lib/bloc_observer.dart index 52fca24a..c216d56e 100644 --- a/lib/bloc_observer.dart +++ b/lib/bloc_observer.dart @@ -1,3 +1,7 @@ +// ignore_for_file: avoid_dynamic_calls + +import 'dart:developer'; + import 'package:bloc/bloc.dart'; class AppBlocObserver extends BlocObserver { @@ -6,14 +10,51 @@ class AppBlocObserver extends BlocObserver { @override void onChange(BlocBase bloc, Change change) { super.onChange(bloc, change); - // log('onChange(${bloc.runtimeType}, $change)'); - print('onChange(${bloc.runtimeType}, $change)'); + final dynamic oldState = change.currentState; + final dynamic newState = change.nextState; + + // Initialize state information strings. + // By default, truncate the full string representation of the state + // to the first 250 characters to prevent excessively long logs. + var oldStateInfo = oldState.toString().substring( + 0, + oldState.toString().length > 250 ? 250 : oldState.toString().length, + ); + var newStateInfo = newState.toString().substring( + 0, + newState.toString().length > 250 ? 250 : newState.toString().length, + ); + + try { + // Attempt to access a 'status' property on the state objects. + // Many BLoC states use a 'status' property (e.g., Loading, Success, Failure) + // to represent their current lifecycle phase. If this property exists + // and is not null, prioritize logging its value for conciseness. + if (oldState.status != null) { + oldStateInfo = 'status: ${oldState.status}'; + } + if (newState.status != null) { + newStateInfo = 'status: ${newState.status}'; + } + } catch (e) { + // This catch block handles cases where: + // 1. The 'status' property does not exist on the state object (NoSuchMethodError). + // 2. Accessing 'status' throws any other runtime error. + // In such scenarios, the `oldStateInfo` and `newStateInfo` variables + // will retain their initially truncated string representations, + // providing a fallback for states without a 'status' property. + // Log the error for debugging purposes, but do not rethrow to avoid + // crashing the observer. + log('Error accessing status property for ${bloc.runtimeType}: $e'); + } + + // Log the state change, including the BLoC type and the old and new state information. + log('onChange(${bloc.runtimeType}, $oldStateInfo -> $newStateInfo)'); } @override void onError(BlocBase bloc, Object error, StackTrace stackTrace) { - // log('onError(${bloc.runtimeType}, $error, $stackTrace)'); - print('onError(${bloc.runtimeType}, $error, $stackTrace)'); + log('onError(${bloc.runtimeType}, $error, $stackTrace)'); super.onError(bloc, error, stackTrace); } } From 3aa8067af1a41ccb9bbffc74d6235bd0cbea827e Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Sep 2025 16:38:53 +0100 Subject: [PATCH 13/21] feat(pending_deletions_service): add item to DeletionEvent and improve undo functionality - Add generic type T to DeletionEvent to allow associating the item with the event - Implement undoDeletion with the ability to return the original item - Refactor PendingDeletionsServiceImpl to use a private _PendingDeletion class - Update deletionEvents stream to use dynamic type for better flexibility --- .../services/pending_deletions_service.dart | 55 +++++++++++++------ 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/lib/shared/services/pending_deletions_service.dart b/lib/shared/services/pending_deletions_service.dart index 7c103cc7..e8201bb8 100644 --- a/lib/shared/services/pending_deletions_service.dart +++ b/lib/shared/services/pending_deletions_service.dart @@ -20,9 +20,9 @@ enum DeletionStatus { /// Contains the ID of the item and its new status. /// {@endtemplate} @immutable -class DeletionEvent extends Equatable { +class DeletionEvent extends Equatable { /// {@macro deletion_event} - const DeletionEvent(this.id, this.status); + const DeletionEvent(this.id, this.status, {this.item}); /// The unique identifier of the item. final String id; @@ -30,8 +30,12 @@ class DeletionEvent extends Equatable { /// The new status of the deletion. final DeletionStatus status; + /// The item associated with the deletion event. + /// This is typically provided when a deletion is undone. + final T? item; + @override - List get props => [id, status]; + List get props => [id, status, item]; } /// {@template pending_deletions_service} @@ -49,7 +53,7 @@ abstract class PendingDeletionsService { /// /// BLoCs can listen to this stream to react to deletion status changes, /// such as removing an item from the state permanently or re-inserting it. - Stream get deletionEvents; + Stream> get deletionEvents; /// Requests the deletion of an item of a specific type [T]. /// @@ -87,13 +91,13 @@ class PendingDeletionsServiceImpl implements PendingDeletionsService { final Logger _logger; /// The stream controller that broadcasts [DeletionEvent]s. - final _deletionEventController = StreamController.broadcast(); + final _deletionEventController = StreamController>.broadcast(); /// A map that stores the `Timer` for each pending deletion, keyed by item ID. - final Map _pendingDeletionTimers = {}; + final Map> _pendingDeletionTimers = {}; @override - Stream get deletionEvents => _deletionEventController.stream; + Stream> get deletionEvents => _deletionEventController.stream; @override void requestDeletion({ @@ -108,16 +112,15 @@ class PendingDeletionsServiceImpl implements PendingDeletionsService { // If there's already a pending deletion for this item, cancel it first. if (_pendingDeletionTimers.containsKey(id)) { _logger.info('Cancelling existing pending deletion for ID: $id'); - _pendingDeletionTimers.remove(id)?.cancel(); + _pendingDeletionTimers.remove(id)?.timer.cancel(); } - // Start a new timer for the deletion. - _pendingDeletionTimers[id] = Timer(undoDuration, () async { + final timer = Timer(undoDuration, () async { try { await repository.delete(id: id); _logger.info('Deletion confirmed for item ID: $id'); _deletionEventController.add( - DeletionEvent(id, DeletionStatus.confirmed), + DeletionEvent(id, DeletionStatus.confirmed), ); } catch (error) { _logger.severe('Error confirming deletion for item ID: $id: $error'); @@ -127,18 +130,22 @@ class PendingDeletionsServiceImpl implements PendingDeletionsService { _pendingDeletionTimers.remove(id); } }); + + _pendingDeletionTimers[id] = _PendingDeletion(timer: timer, item: item); } @override void undoDeletion(String id) { _logger.info('Attempting to undo deletion for item ID: $id'); // Cancel the timer and remove it from the map. - final timer = _pendingDeletionTimers.remove(id); - if (timer != null) { - timer.cancel(); + final pendingDeletion = _pendingDeletionTimers.remove(id); + if (pendingDeletion != null) { + pendingDeletion.timer.cancel(); _logger.info('Deletion undone for item ID: $id'); - // Notify listeners that the deletion was undone. - _deletionEventController.add(DeletionEvent(id, DeletionStatus.undone)); + // Notify listeners that the deletion was undone, including the item. + _deletionEventController.add( + DeletionEvent(id, DeletionStatus.undone, item: pendingDeletion.item), + ); } else { _logger.warning('No pending deletion found for ID: $id to undo.'); } @@ -150,10 +157,22 @@ class PendingDeletionsServiceImpl implements PendingDeletionsService { 'Disposing PendingDeletionsService. Cancelling ${_pendingDeletionTimers.length} pending timers.', ); // Cancel all pending timers to prevent memory leaks. - for (final timer in _pendingDeletionTimers.values) { - timer.cancel(); + for (final pendingDeletion in _pendingDeletionTimers.values) { + pendingDeletion.timer.cancel(); } _pendingDeletionTimers.clear(); _deletionEventController.close(); } } + +/// 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}); + + final Timer timer; + final T item; + + @override + List get props => [timer, item]; +} From 0eac06a934fcdf35c38ed0825286c7039010f637 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Sep 2025 16:39:02 +0100 Subject: [PATCH 14/21] refactor(content_management): replace pendingDeletions map with snackbarHeadlineTitle - Remove pendingDeletions map from ArchivedHeadlinesState - Add snackbarHeadlineTitle to store the title of the headline for which the snackbar should be displayed - Update copyWith method to reflect changes - Update props list in ArchivedHeadlinesState to include new field --- .../archived_headlines_state.dart | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) 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 927cf6b8..8ade9747 100644 --- a/lib/content_management/bloc/archived_headlines/archived_headlines_state.dart +++ b/lib/content_management/bloc/archived_headlines/archived_headlines_state.dart @@ -30,8 +30,8 @@ class ArchivedHeadlinesState extends Equatable { this.hasMore = false, this.exception, this.restoredHeadline, - this.pendingDeletions = const {}, this.lastPendingDeletionId, + this.snackbarHeadlineTitle, }); /// The current status of the archived headlines operations. @@ -53,14 +53,15 @@ class ArchivedHeadlinesState extends Equatable { /// The headline that was most recently restored, if any. final Headline? restoredHeadline; - /// A map of headlines that are currently pending permanent deletion, - /// keyed by their ID. - final Map pendingDeletions; - /// The ID of the headline that was most recently added to pending deletions. /// Used to trigger the snackbar display. final String? lastPendingDeletionId; + /// The title of the headline for which the snackbar should be displayed. + /// This is set when a deletion is requested and cleared when the snackbar + /// is no longer needed. + final String? snackbarHeadlineTitle; + /// Creates a copy of this [ArchivedHeadlinesState] with updated values. ArchivedHeadlinesState copyWith({ ArchivedHeadlinesStatus? status, @@ -69,8 +70,8 @@ class ArchivedHeadlinesState extends Equatable { bool? hasMore, HttpException? exception, Headline? restoredHeadline, - Map? pendingDeletions, String? lastPendingDeletionId, + String? snackbarHeadlineTitle, }) { return ArchivedHeadlinesState( status: status ?? this.status, @@ -81,9 +82,9 @@ class ArchivedHeadlinesState extends Equatable { // to ensure they are cleared after being handled. exception: exception, restoredHeadline: restoredHeadline, - pendingDeletions: pendingDeletions ?? this.pendingDeletions, lastPendingDeletionId: lastPendingDeletionId, // Explicitly allow null to clear + snackbarHeadlineTitle: snackbarHeadlineTitle, ); } @@ -95,7 +96,7 @@ class ArchivedHeadlinesState extends Equatable { hasMore, exception, restoredHeadline, - pendingDeletions, lastPendingDeletionId, + snackbarHeadlineTitle, ]; } From b89bc08ac5738d9fe3f90a81a6f289237af44ffc Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Sep 2025 16:39:13 +0100 Subject: [PATCH 15/21] refactor(content_management): add type to DeletionEvent in archived headlines - Specify generic type for DeletionEvent in _DeletionServiceStatusChanged event --- .../bloc/archived_headlines/archived_headlines_event.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ce7d13d1..437d0ff9 100644 --- a/lib/content_management/bloc/archived_headlines/archived_headlines_event.dart +++ b/lib/content_management/bloc/archived_headlines/archived_headlines_event.dart @@ -65,7 +65,7 @@ final class ClearRestoredHeadline extends ArchivedHeadlinesEvent { final class _DeletionServiceStatusChanged extends ArchivedHeadlinesEvent { const _DeletionServiceStatusChanged(this.event); - final DeletionEvent event; + final DeletionEvent event; @override List get props => [event]; From d61a85d64918c5a1dda256f2cea2b5618cf865db Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Sep 2025 16:39:25 +0100 Subject: [PATCH 16/21] refactor(content_management): optimize archived headlines state management - Remove pendingDeletions map from ArchivedHeadlinesState - Simplify deletion undo logic by checking item type - Add snackbarHeadlineTitle to state for managing snackbar content - Update related emissions in bloc to reflect changes --- .../archived_headlines_bloc.dart | 80 ++++++++----------- 1 file changed, 33 insertions(+), 47 deletions(-) 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 767f94e1..fdb98527 100644 --- a/lib/content_management/bloc/archived_headlines/archived_headlines_bloc.dart +++ b/lib/content_management/bloc/archived_headlines/archived_headlines_bloc.dart @@ -49,7 +49,7 @@ class ArchivedHeadlinesBloc _pendingDeletionsService; // The injected service /// Subscription to deletion events from the PendingDeletionsService. - late final StreamSubscription _deletionEventSubscription; + late final StreamSubscription> _deletionEventSubscription; @override Future close() async { @@ -123,12 +123,10 @@ class ArchivedHeadlinesBloc emit( state.copyWith( headlines: updatedHeadlines, - // Also remove from pendingDeletions if it was there, as it's being restored. - pendingDeletions: Map.from(state.pendingDeletions) - ..remove(event.id), lastPendingDeletionId: state.lastPendingDeletionId == event.id ? null : state.lastPendingDeletionId, + snackbarHeadlineTitle: null, // Clear snackbar when restoring ), ); @@ -139,13 +137,11 @@ class ArchivedHeadlinesBloc ); emit(state.copyWith(restoredHeadline: restoredHeadline)); } on HttpException catch (e) { - // If the update fails, revert the change in the UI and re-add to pendingDeletions + // If the update fails, revert the change in the UI emit( state.copyWith( headlines: originalHeadlines, exception: e, - pendingDeletions: Map.from(state.pendingDeletions) - ..[event.id] = headlineToRestore, lastPendingDeletionId: state.lastPendingDeletionId, ), ); @@ -154,8 +150,6 @@ class ArchivedHeadlinesBloc state.copyWith( headlines: originalHeadlines, exception: UnknownException('An unexpected error occurred: $e'), - pendingDeletions: Map.from(state.pendingDeletions) - ..[event.id] = headlineToRestore, lastPendingDeletionId: state.lastPendingDeletionId, ), ); @@ -172,55 +166,48 @@ class ArchivedHeadlinesBloc ) async { final id = event.event.id; final status = event.event.status; - - final headlineInPending = state.pendingDeletions[id]; - if (headlineInPending == null) { - // If the headline is not in pending deletions, it might have been - // restored by other means or was never pending. - return; - } - - final updatedPendingDeletions = Map.from( - state.pendingDeletions, - )..remove(id); + final item = event.event.item; if (status == DeletionStatus.confirmed) { - // Deletion confirmed, simply update pending deletions. + // Deletion confirmed, no action needed in BLoC as it was optimistically removed. + // Ensure lastPendingDeletionId and snackbarHeadlineTitle are cleared if this was the one. emit( state.copyWith( - pendingDeletions: updatedPendingDeletions, lastPendingDeletionId: state.lastPendingDeletionId == id ? null : state.lastPendingDeletionId, + snackbarHeadlineTitle: null, ), ); } else if (status == DeletionStatus.undone) { // Deletion undone, restore the headline to the main list. - final updatedHeadlines = List.from(state.headlines) - ..insert( - state.headlines.indexWhere( + if (item is Headline) { + final updatedHeadlines = List.from(state.headlines) + ..insert( + state.headlines.indexWhere( + (h) => h.updatedAt.isBefore( + item.updatedAt, + ), + ) != + -1 + ? state.headlines.indexWhere( (h) => h.updatedAt.isBefore( - headlineInPending.updatedAt, + item.updatedAt, ), - ) != - -1 - ? state.headlines.indexWhere( - (h) => h.updatedAt.isBefore( - headlineInPending.updatedAt, - ), - ) - : state.headlines.length, - headlineInPending, + ) + : state.headlines.length, + item, + ); + emit( + state.copyWith( + headlines: updatedHeadlines, + lastPendingDeletionId: state.lastPendingDeletionId == id + ? null + : state.lastPendingDeletionId, + snackbarHeadlineTitle: null, + ), ); - emit( - state.copyWith( - headlines: updatedHeadlines, - pendingDeletions: updatedPendingDeletions, - lastPendingDeletionId: state.lastPendingDeletionId == id - ? null - : state.lastPendingDeletionId, - ), - ); + } } } @@ -243,9 +230,8 @@ class ArchivedHeadlinesBloc emit( state.copyWith( headlines: updatedHeadlines, - pendingDeletions: Map.from(state.pendingDeletions) - ..[event.id] = headlineToDelete, lastPendingDeletionId: event.id, + snackbarHeadlineTitle: headlineToDelete.title, // Set title for snackbar ), ); @@ -277,6 +263,6 @@ class ArchivedHeadlinesBloc ClearRestoredHeadline event, Emitter emit, ) { - emit(state.copyWith(restoredHeadline: null)); + emit(state.copyWith(restoredHeadline: null, snackbarHeadlineTitle: null)); } } From a8d2b3a30e2e5fcb2f6ac2e2ecff2420073afbb4 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Sep 2025 16:39:41 +0100 Subject: [PATCH 17/21] refactor(content_management): improve snackbar logic and pending deletions handling - Move snackbar logic out of the state and into the view - Replace direct BLoC access with PendingDeletionsService for undo action - Optimize state comparison for snackbar triggers - Simplify headline deletion snackbar display --- .../view/archived_headlines_page.dart | 50 ++++++++----------- 1 file changed, 21 insertions(+), 29 deletions(-) diff --git a/lib/content_management/view/archived_headlines_page.dart b/lib/content_management/view/archived_headlines_page.dart index bdfb0140..83f8bcbf 100644 --- a/lib/content_management/view/archived_headlines_page.dart +++ b/lib/content_management/view/archived_headlines_page.dart @@ -34,6 +34,7 @@ class _ArchivedHeadlinesView extends StatelessWidget { @override Widget build(BuildContext context) { final l10n = AppLocalizationsX(context).l10n; + final pendingDeletionsService = context.read(); return Scaffold( appBar: AppBar( title: Text(l10n.archivedHeadlines), @@ -42,10 +43,9 @@ class _ArchivedHeadlinesView extends StatelessWidget { padding: const EdgeInsets.all(AppSpacing.lg), child: BlocListener( listenWhen: (previous, current) => - previous.pendingDeletions.length != - current.pendingDeletions.length || + previous.lastPendingDeletionId != current.lastPendingDeletionId || previous.restoredHeadline != current.restoredHeadline || - previous.lastPendingDeletionId != current.lastPendingDeletionId, + previous.snackbarHeadlineTitle != current.snackbarHeadlineTitle, listener: (context, state) { if (state.restoredHeadline != null) { // When a headline is restored, refresh the main headlines list. @@ -59,33 +59,25 @@ class _ArchivedHeadlinesView extends StatelessWidget { } // Show snackbar for pending deletions. - if (state.lastPendingDeletionId != null && - state.pendingDeletions.containsKey( - state.lastPendingDeletionId, - )) { - final headline = - state.pendingDeletions[state.lastPendingDeletionId]; - if (headline != null) { - final truncatedTitle = headline.title.truncate(30); - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - content: Text( - l10n.headlineDeleted(truncatedTitle), - ), - action: SnackBarAction( - label: l10n.undo, - onPressed: () { - // Dispatch UndoDeleteHeadlineRequested to the BLoC. - context.read().add( - UndoDeleteHeadlineRequested(headline.id), - ); - }, - ), + if (state.snackbarHeadlineTitle != null) { + final headlineId = state.lastPendingDeletionId!; + final truncatedTitle = state.snackbarHeadlineTitle!.truncate(30); + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + l10n.headlineDeleted(truncatedTitle), ), - ); - } + action: SnackBarAction( + label: l10n.undo, + onPressed: () { + // Directly call undoDeletion on the service. + pendingDeletionsService.undoDeletion(headlineId); + }, + ), + ), + ); } }, child: BlocBuilder( From 165db41a5db0f0da9c376c416d3a2514b1c8fe40 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Sep 2025 17:16:25 +0100 Subject: [PATCH 18/21] fix(archived headlines ): undo UI refresh Corrected the stream subscription in ArchivedHeadlinesBloc to properly filter and process DeletionEvent for Headline items. This ensures that when a user undoes a deletion from the snackbar within the archived headlines page, the UI immediately reflects the restored item. --- .../archived_headlines_bloc.dart | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) 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 fdb98527..e414a35d 100644 --- a/lib/content_management/bloc/archived_headlines/archived_headlines_bloc.dart +++ b/lib/content_management/bloc/archived_headlines/archived_headlines_bloc.dart @@ -32,12 +32,15 @@ class ArchivedHeadlinesBloc _onDeletionServiceStatusChanged, ); // Handle deletion service events - // Listen to deletion events from the PendingDeletionsService - _deletionEventSubscription = _pendingDeletionsService.deletionEvents - .where( - (event) => event.id.startsWith('headline_'), - ) // Filter for headline deletions - .listen((event) => add(_DeletionServiceStatusChanged(event))); + // Listen to deletion events from the PendingDeletionsService. + // The filter now correctly checks the type of the item in the event. + _deletionEventSubscription = _pendingDeletionsService.deletionEvents.listen( + (event) { + if (event.item is Headline) { + add(_DeletionServiceStatusChanged(event)); + } + }, + ); on(_onDeleteHeadlineForeverRequested); on(_onUndoDeleteHeadlineRequested); From eadffe353e130f8dad3e3904bae8950d2f49aa95 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Sep 2025 17:17:49 +0100 Subject: [PATCH 19/21] style: cleanup --- lib/app/bloc/app_bloc.dart | 2 +- lib/app/view/app.dart | 11 +++----- .../view/app_configuration_page.dart | 2 +- .../advertisements_configuration_tab.dart | 8 +++--- .../widgets/ad_platform_config_form.dart | 26 +++++++------------ .../widgets/app_config_form_fields.dart | 10 +++---- .../widgets/feed_ad_settings_form.dart | 16 +++++------- .../widgets/feed_decorator_form.dart | 2 +- .../interstitial_ad_settings_form.dart | 2 +- .../widgets/user_preference_limits_form.dart | 2 +- .../archived_headlines_bloc.dart | 22 +++++++--------- .../archived_headlines_state.dart | 3 +-- .../bloc/content_management_bloc.dart | 6 ++--- .../view/archived_headlines_page.dart | 5 ++-- .../archive_local_ads_bloc.dart | 4 +-- .../bloc/local_ads_management_bloc.dart | 4 +-- .../view/archived_local_ads_page.dart | 8 +++--- .../view/local_ads_management_page.dart | 8 +++--- lib/overview/view/overview_page.dart | 2 +- lib/shared/extensions/app_user_role_l10n.dart | 3 +-- .../services/pending_deletions_service.dart | 14 +++++++--- .../widgets/searchable_selection_input.dart | 4 +-- .../bloc/searchable_selection_bloc.dart | 2 +- 23 files changed, 77 insertions(+), 89 deletions(-) diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index 019c0835..a287481b 100644 --- a/lib/app/bloc/app_bloc.dart +++ b/lib/app/bloc/app_bloc.dart @@ -135,7 +135,7 @@ class AppBloc extends Bloc { unawaited(_authenticationRepository.signOut()); emit( state.copyWith(clearUserAppSettings: true), - ); // Clear settings on logout + ); } @override diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 7179fa57..6186a35f 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -15,7 +15,7 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/content_manageme import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/overview/bloc/overview_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/router/router.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/shared/services/pending_deletions_service.dart'; // Import the PendingDeletionsService +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/services/pending_deletions_service.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/shared.dart'; import 'package:go_router/go_router.dart'; import 'package:kv_storage_service/kv_storage_service.dart'; @@ -38,8 +38,7 @@ class App extends StatelessWidget { required DataRepository localAdsRepository, required KVStorageService storageService, required AppEnvironment environment, - required PendingDeletionsService - pendingDeletionsService, // Add PendingDeletionsService to constructor + required PendingDeletionsService pendingDeletionsService, super.key, }) : _authenticationRepository = authenticationRepository, _headlinesRepository = headlinesRepository, @@ -54,8 +53,7 @@ class App extends StatelessWidget { _languagesRepository = languagesRepository, _localAdsRepository = localAdsRepository, _environment = environment, - _pendingDeletionsService = - pendingDeletionsService; // Initialize the service + _pendingDeletionsService = pendingDeletionsService; final AuthRepository _authenticationRepository; final DataRepository _headlinesRepository; @@ -95,8 +93,7 @@ class App extends StatelessWidget { create: (context) => const ThrottledFetchingService(), ), RepositoryProvider.value( - value: - _pendingDeletionsService, // Provide the PendingDeletionsService + value: _pendingDeletionsService, ), ], child: MultiBlocProvider( diff --git a/lib/app_configuration/view/app_configuration_page.dart b/lib/app_configuration/view/app_configuration_page.dart index 1b84ac19..e8144e30 100644 --- a/lib/app_configuration/view/app_configuration_page.dart +++ b/lib/app_configuration/view/app_configuration_page.dart @@ -174,7 +174,7 @@ class _AppConfigurationPageState extends State icon: Icons.settings_applications_outlined, headline: l10n.appConfigurationPageTitle, subheadline: l10n.loadAppSettingsSubheadline, - ); // Fallback + ); }, ), bottomNavigationBar: _buildBottomAppBar(context), diff --git a/lib/app_configuration/view/tabs/advertisements_configuration_tab.dart b/lib/app_configuration/view/tabs/advertisements_configuration_tab.dart index 12d494a7..a7797129 100644 --- a/lib/app_configuration/view/tabs/advertisements_configuration_tab.dart +++ b/lib/app_configuration/view/tabs/advertisements_configuration_tab.dart @@ -79,7 +79,7 @@ class _AdvertisementsConfigurationTabState } : null, initiallyExpanded: expandedIndex == tileIndex && adConfig.enabled, - enabled: adConfig.enabled, // Disable the tile itself + enabled: adConfig.enabled, children: [ AdPlatformConfigForm( remoteConfig: widget.remoteConfig, @@ -110,7 +110,7 @@ class _AdvertisementsConfigurationTabState } : null, initiallyExpanded: expandedIndex == tileIndex && adConfig.enabled, - enabled: adConfig.enabled, // Disable the tile itself + enabled: adConfig.enabled, children: [ FeedAdSettingsForm( remoteConfig: widget.remoteConfig, @@ -141,7 +141,7 @@ class _AdvertisementsConfigurationTabState } : null, initiallyExpanded: expandedIndex == tileIndex && adConfig.enabled, - enabled: adConfig.enabled, // Disable the tile itself + enabled: adConfig.enabled, children: [ ArticleAdSettingsForm( remoteConfig: widget.remoteConfig, @@ -172,7 +172,7 @@ class _AdvertisementsConfigurationTabState } : null, initiallyExpanded: expandedIndex == tileIndex && adConfig.enabled, - enabled: adConfig.enabled, // Disable the tile itself + enabled: adConfig.enabled, children: [ InterstitialAdSettingsForm( remoteConfig: widget.remoteConfig, diff --git a/lib/app_configuration/widgets/ad_platform_config_form.dart b/lib/app_configuration/widgets/ad_platform_config_form.dart index c2d65aa9..38e111ee 100644 --- a/lib/app_configuration/widgets/ad_platform_config_form.dart +++ b/lib/app_configuration/widgets/ad_platform_config_form.dart @@ -219,12 +219,11 @@ class _AdPlatformConfigFormState extends State { ExpansionTile( title: Text(l10n.primaryAdPlatformTitle), childrenPadding: const EdgeInsetsDirectional.only( - start: AppSpacing.lg, // Adjusted padding for hierarchy + start: AppSpacing.lg, top: AppSpacing.md, bottom: AppSpacing.md, ), - expandedCrossAxisAlignment: - CrossAxisAlignment.start, // Align content to start + expandedCrossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.primaryAdPlatformDescription, @@ -233,7 +232,7 @@ class _AdPlatformConfigFormState extends State { context, ).colorScheme.onSurface.withOpacity(0.7), ), - textAlign: TextAlign.start, // Ensure text aligns to start + textAlign: TextAlign.start, ), const SizedBox(height: AppSpacing.lg), Align( @@ -246,10 +245,7 @@ class _AdPlatformConfigFormState extends State { ), segments: AdPlatformType.values .where( - (type) => - type != - AdPlatformType - .demo, // Ignore demo ad platform for dashboard + (type) => type != AdPlatformType.demo, ) .map( (type) => ButtonSegment( @@ -281,12 +277,11 @@ class _AdPlatformConfigFormState extends State { ExpansionTile( title: Text(l10n.adUnitIdentifiersTitle), childrenPadding: const EdgeInsetsDirectional.only( - start: AppSpacing.lg, // Adjusted padding for hierarchy + start: AppSpacing.lg, top: AppSpacing.md, bottom: AppSpacing.md, ), - expandedCrossAxisAlignment: - CrossAxisAlignment.start, // Align content to start + expandedCrossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.adUnitIdentifiersDescription, @@ -295,7 +290,7 @@ class _AdPlatformConfigFormState extends State { context, ).colorScheme.onSurface.withOpacity(0.7), ), - textAlign: TextAlign.start, // Ensure text aligns to start + textAlign: TextAlign.start, ), const SizedBox(height: AppSpacing.lg), _buildAdUnitIdentifierFields( @@ -313,12 +308,11 @@ class _AdPlatformConfigFormState extends State { ExpansionTile( title: Text(l10n.localAdManagementTitle), childrenPadding: const EdgeInsetsDirectional.only( - start: AppSpacing.lg, // Adjusted padding for hierarchy + start: AppSpacing.lg, top: AppSpacing.md, bottom: AppSpacing.md, ), - expandedCrossAxisAlignment: - CrossAxisAlignment.start, // Align content to start + expandedCrossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.localAdManagementDescription, @@ -327,7 +321,7 @@ class _AdPlatformConfigFormState extends State { context, ).colorScheme.onSurface.withOpacity(0.7), ), - textAlign: TextAlign.start, // Ensure text aligns to start + textAlign: TextAlign.start, ), const SizedBox(height: AppSpacing.lg), Center( diff --git a/lib/app_configuration/widgets/app_config_form_fields.dart b/lib/app_configuration/widgets/app_config_form_fields.dart index c694a7dc..d6d03b39 100644 --- a/lib/app_configuration/widgets/app_config_form_fields.dart +++ b/lib/app_configuration/widgets/app_config_form_fields.dart @@ -37,8 +37,7 @@ class AppConfigIntField extends StatelessWidget { return Padding( padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, // Ensure alignment to start + crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: theme.textTheme.titleMedium), const SizedBox(height: AppSpacing.xs), @@ -47,7 +46,7 @@ class AppConfigIntField extends StatelessWidget { style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurface.withOpacity(0.7), ), - textAlign: TextAlign.start, // Ensure text aligns to start + textAlign: TextAlign.start, ), const SizedBox(height: AppSpacing.xs), TextFormField( @@ -107,8 +106,7 @@ class AppConfigTextField extends StatelessWidget { return Padding( padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm), child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, // Ensure alignment to start + crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: theme.textTheme.titleMedium), const SizedBox(height: AppSpacing.xs), @@ -117,7 +115,7 @@ class AppConfigTextField extends StatelessWidget { style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurface.withOpacity(0.7), ), - textAlign: TextAlign.start, // Ensure text aligns to start + textAlign: TextAlign.start, ), const SizedBox(height: AppSpacing.xs), TextFormField( diff --git a/lib/app_configuration/widgets/feed_ad_settings_form.dart b/lib/app_configuration/widgets/feed_ad_settings_form.dart index 364fa671..49f52b60 100644 --- a/lib/app_configuration/widgets/feed_ad_settings_form.dart +++ b/lib/app_configuration/widgets/feed_ad_settings_form.dart @@ -145,19 +145,18 @@ class _FeedAdSettingsFormState extends State ExpansionTile( title: Text(l10n.feedAdTypeSelectionTitle), childrenPadding: const EdgeInsetsDirectional.only( - start: AppSpacing.lg, // Adjusted padding for hierarchy + start: AppSpacing.lg, top: AppSpacing.md, bottom: AppSpacing.md, ), - expandedCrossAxisAlignment: - CrossAxisAlignment.start, // Align content to start + expandedCrossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.feedAdTypeSelectionDescription, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), ), - textAlign: TextAlign.start, // Ensure text aligns to start + textAlign: TextAlign.start, ), const SizedBox(height: AppSpacing.lg), Align( @@ -199,19 +198,18 @@ class _FeedAdSettingsFormState extends State ExpansionTile( title: Text(l10n.userRoleFrequencySettingsTitle), childrenPadding: const EdgeInsetsDirectional.only( - start: AppSpacing.lg, // Adjusted padding for hierarchy + start: AppSpacing.lg, top: AppSpacing.md, bottom: AppSpacing.md, ), - expandedCrossAxisAlignment: - CrossAxisAlignment.start, // Align content to start + expandedCrossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.userRoleFrequencySettingsDescription, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.onSurface.withOpacity(0.7), ), - textAlign: TextAlign.start, // Ensure text aligns to start + textAlign: TextAlign.start, ), const SizedBox(height: AppSpacing.lg), // Replaced SegmentedButton with TabBar for role selection @@ -232,7 +230,7 @@ class _FeedAdSettingsFormState extends State const SizedBox(height: AppSpacing.lg), // TabBarView to display role-specific fields SizedBox( - height: 250, // Fixed height for TabBarView within a ListView + height: 250, child: TabBarView( controller: _tabController, children: AppUserRole.values diff --git a/lib/app_configuration/widgets/feed_decorator_form.dart b/lib/app_configuration/widgets/feed_decorator_form.dart index f317515e..e02ab891 100644 --- a/lib/app_configuration/widgets/feed_decorator_form.dart +++ b/lib/app_configuration/widgets/feed_decorator_form.dart @@ -184,7 +184,7 @@ class _FeedDecoratorFormState extends State const SizedBox(height: AppSpacing.lg), // TabBarView to display role-specific fields SizedBox( - height: 250, // Fixed height for TabBarView within a ListView + height: 250, child: TabBarView( controller: _tabController, children: AppUserRole.values diff --git a/lib/app_configuration/widgets/interstitial_ad_settings_form.dart b/lib/app_configuration/widgets/interstitial_ad_settings_form.dart index 82ba5764..a4f252b7 100644 --- a/lib/app_configuration/widgets/interstitial_ad_settings_form.dart +++ b/lib/app_configuration/widgets/interstitial_ad_settings_form.dart @@ -163,7 +163,7 @@ class _InterstitialAdSettingsFormState extends State ), const SizedBox(height: AppSpacing.lg), SizedBox( - height: 250, // Fixed height for TabBarView within a ListView + height: 250, child: TabBarView( controller: _tabController, children: AppUserRole.values diff --git a/lib/app_configuration/widgets/user_preference_limits_form.dart b/lib/app_configuration/widgets/user_preference_limits_form.dart index e88e0cff..b862df7b 100644 --- a/lib/app_configuration/widgets/user_preference_limits_form.dart +++ b/lib/app_configuration/widgets/user_preference_limits_form.dart @@ -167,7 +167,7 @@ class _UserPreferenceLimitsFormState extends State const SizedBox(height: AppSpacing.lg), // TabBarView to display role-specific fields SizedBox( - height: 250, // Fixed height for TabBarView within a ListView + height: 250, child: TabBarView( controller: _tabController, children: AppUserRole.values 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 e414a35d..28b3fc73 100644 --- a/lib/content_management/bloc/archived_headlines/archived_headlines_bloc.dart +++ b/lib/content_management/bloc/archived_headlines/archived_headlines_bloc.dart @@ -4,7 +4,7 @@ import 'package:bloc/bloc.dart'; import 'package:core/core.dart'; import 'package:data_repository/data_repository.dart'; import 'package:equatable/equatable.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/shared/services/pending_deletions_service.dart'; // Import the pending deletions service +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/services/pending_deletions_service.dart'; part 'archived_headlines_event.dart'; part 'archived_headlines_state.dart'; @@ -20,17 +20,15 @@ class ArchivedHeadlinesBloc /// {@macro archived_headlines_bloc} ArchivedHeadlinesBloc({ required DataRepository headlinesRepository, - required PendingDeletionsService - pendingDeletionsService, // Inject PendingDeletionsService + required PendingDeletionsService pendingDeletionsService, }) : _headlinesRepository = headlinesRepository, - _pendingDeletionsService = - pendingDeletionsService, // Initialize the service + _pendingDeletionsService = pendingDeletionsService, super(const ArchivedHeadlinesState()) { on(_onLoadArchivedHeadlinesRequested); on(_onRestoreHeadlineRequested); on<_DeletionServiceStatusChanged>( _onDeletionServiceStatusChanged, - ); // Handle deletion service events + ); // Listen to deletion events from the PendingDeletionsService. // The filter now correctly checks the type of the item in the event. @@ -48,11 +46,11 @@ class ArchivedHeadlinesBloc } final DataRepository _headlinesRepository; - final PendingDeletionsService - _pendingDeletionsService; // The injected service + final PendingDeletionsService _pendingDeletionsService; /// Subscription to deletion events from the PendingDeletionsService. - late final StreamSubscription> _deletionEventSubscription; + late final StreamSubscription> + _deletionEventSubscription; @override Future close() async { @@ -129,7 +127,7 @@ class ArchivedHeadlinesBloc lastPendingDeletionId: state.lastPendingDeletionId == event.id ? null : state.lastPendingDeletionId, - snackbarHeadlineTitle: null, // Clear snackbar when restoring + snackbarHeadlineTitle: null, ), ); @@ -234,7 +232,7 @@ class ArchivedHeadlinesBloc state.copyWith( headlines: updatedHeadlines, lastPendingDeletionId: event.id, - snackbarHeadlineTitle: headlineToDelete.title, // Set title for snackbar + snackbarHeadlineTitle: headlineToDelete.title, ), ); @@ -242,7 +240,7 @@ class ArchivedHeadlinesBloc _pendingDeletionsService.requestDeletion( item: headlineToDelete, repository: _headlinesRepository, - undoDuration: const Duration(seconds: 5), // Configurable undo duration + undoDuration: const Duration(seconds: 5), ); } 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 8ade9747..dd8e44be 100644 --- a/lib/content_management/bloc/archived_headlines/archived_headlines_state.dart +++ b/lib/content_management/bloc/archived_headlines/archived_headlines_state.dart @@ -82,8 +82,7 @@ class ArchivedHeadlinesState extends Equatable { // to ensure they are cleared after being handled. exception: exception, restoredHeadline: restoredHeadline, - lastPendingDeletionId: - lastPendingDeletionId, // Explicitly allow null to clear + lastPendingDeletionId: lastPendingDeletionId, snackbarHeadlineTitle: snackbarHeadlineTitle, ); } diff --git a/lib/content_management/bloc/content_management_bloc.dart b/lib/content_management/bloc/content_management_bloc.dart index 26db6685..733f6db9 100644 --- a/lib/content_management/bloc/content_management_bloc.dart +++ b/lib/content_management/bloc/content_management_bloc.dart @@ -154,7 +154,7 @@ class ContentManagementBloc // Optimistically remove the headline from the list final originalHeadlines = List.from(state.headlines); final headlineIndex = originalHeadlines.indexWhere((h) => h.id == event.id); - if (headlineIndex == -1) return; // Headline not found + if (headlineIndex == -1) return; final headlineToArchive = originalHeadlines[headlineIndex]; final updatedHeadlines = originalHeadlines..removeAt(headlineIndex); @@ -244,7 +244,7 @@ class ContentManagementBloc // Optimistically remove the topic from the list final originalTopics = List.from(state.topics); final topicIndex = originalTopics.indexWhere((t) => t.id == event.id); - if (topicIndex == -1) return; // Topic not found + if (topicIndex == -1) return; final topicToArchive = originalTopics[topicIndex]; final updatedTopics = originalTopics..removeAt(topicIndex); @@ -334,7 +334,7 @@ class ContentManagementBloc // Optimistically remove the source from the list final originalSources = List.from(state.sources); final sourceIndex = originalSources.indexWhere((s) => s.id == event.id); - if (sourceIndex == -1) return; // Source not found + if (sourceIndex == -1) return; final sourceToArchive = originalSources[sourceIndex]; final updatedSources = originalSources..removeAt(sourceIndex); diff --git a/lib/content_management/view/archived_headlines_page.dart b/lib/content_management/view/archived_headlines_page.dart index 83f8bcbf..a92fc289 100644 --- a/lib/content_management/view/archived_headlines_page.dart +++ b/lib/content_management/view/archived_headlines_page.dart @@ -8,7 +8,7 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/content_manageme 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:flutter_news_app_web_dashboard_full_source_code/shared/services/pending_deletions_service.dart'; // Import PendingDeletionsService +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/services/pending_deletions_service.dart'; import 'package:intl/intl.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -20,8 +20,7 @@ class ArchivedHeadlinesPage extends StatelessWidget { return BlocProvider( create: (context) => ArchivedHeadlinesBloc( headlinesRepository: context.read>(), - pendingDeletionsService: context - .read(), // Provide the service + pendingDeletionsService: context.read(), )..add(const LoadArchivedHeadlinesRequested(limit: kDefaultRowsPerPage)), child: const _ArchivedHeadlinesView(), ); diff --git a/lib/local_ads_management/bloc/archive_local_ads/archive_local_ads_bloc.dart b/lib/local_ads_management/bloc/archive_local_ads/archive_local_ads_bloc.dart index 633ac7fd..4e1cfaea 100644 --- a/lib/local_ads_management/bloc/archive_local_ads/archive_local_ads_bloc.dart +++ b/lib/local_ads_management/bloc/archive_local_ads/archive_local_ads_bloc.dart @@ -26,7 +26,7 @@ class ArchiveLocalAdsBloc .listen((_) { add( const LoadArchivedLocalAdsRequested( - adType: AdType.native, // Default to native for refresh + adType: AdType.native, limit: kDefaultRowsPerPage, ), ); @@ -445,7 +445,7 @@ class ArchiveLocalAdsBloc ..insert( 0, restoredAd as LocalNativeAd, - ); // Insert at beginning for simplicity + ); emit( state.copyWith( nativeAds: updatedAds, diff --git a/lib/local_ads_management/bloc/local_ads_management_bloc.dart b/lib/local_ads_management/bloc/local_ads_management_bloc.dart index 519139d8..5db898af 100644 --- a/lib/local_ads_management/bloc/local_ads_management_bloc.dart +++ b/lib/local_ads_management/bloc/local_ads_management_bloc.dart @@ -28,7 +28,7 @@ class LocalAdsManagementBloc .listen((_) { add( const LoadLocalAdsRequested( - adType: AdType.native, // Default to native for refresh + adType: AdType.native, limit: kDefaultRowsPerPage, forceRefresh: true, ), @@ -518,7 +518,7 @@ class LocalAdsManagementBloc ..insert( 0, restoredAd as LocalNativeAd, - ); // Insert at beginning for simplicity + ); emit( state.copyWith( nativeAds: updatedAds, diff --git a/lib/local_ads_management/view/archived_local_ads_page.dart b/lib/local_ads_management/view/archived_local_ads_page.dart index 6e81215f..94e29789 100644 --- a/lib/local_ads_management/view/archived_local_ads_page.dart +++ b/lib/local_ads_management/view/archived_local_ads_page.dart @@ -319,23 +319,23 @@ class _ArchivedLocalAdsDataSource extends DataTableSource { status = nativeAd.status; case 'banner': final bannerAd = ad as LocalBannerAd; - title = bannerAd.imageUrl; // Use image URL as title for banners + title = bannerAd.imageUrl; updatedAt = bannerAd.updatedAt; status = bannerAd.status; case 'interstitial': final interstitialAd = ad as LocalInterstitialAd; - title = interstitialAd.imageUrl; // Use image URL as title + title = interstitialAd.imageUrl; updatedAt = interstitialAd.updatedAt; status = interstitialAd.status; case 'video': final videoAd = ad as LocalVideoAd; - title = videoAd.videoUrl; // Use video URL as title + title = videoAd.videoUrl; updatedAt = videoAd.updatedAt; status = videoAd.status; default: title = 'Unknown Ad Type'; updatedAt = DateTime.now(); - status = ContentStatus.active; // Default status + status = ContentStatus.active; } return DataRow2( diff --git a/lib/local_ads_management/view/local_ads_management_page.dart b/lib/local_ads_management/view/local_ads_management_page.dart index 424ac213..c0dcd806 100644 --- a/lib/local_ads_management/view/local_ads_management_page.dart +++ b/lib/local_ads_management/view/local_ads_management_page.dart @@ -359,23 +359,23 @@ class _LocalAdsDataSource extends DataTableSource { status = nativeAd.status; case 'banner': final bannerAd = ad as LocalBannerAd; - title = bannerAd.imageUrl; // Use image URL as title for banners + title = bannerAd.imageUrl; updatedAt = bannerAd.updatedAt; status = bannerAd.status; case 'interstitial': final interstitialAd = ad as LocalInterstitialAd; - title = interstitialAd.imageUrl; // Use image URL as title + title = interstitialAd.imageUrl; updatedAt = interstitialAd.updatedAt; status = interstitialAd.status; case 'video': final videoAd = ad as LocalVideoAd; - title = videoAd.videoUrl; // Use video URL as title + title = videoAd.videoUrl; updatedAt = videoAd.updatedAt; status = videoAd.status; default: title = 'Unknown Ad Type'; updatedAt = DateTime.now(); - status = ContentStatus.active; // Default status + status = ContentStatus.active; } return DataRow2( diff --git a/lib/overview/view/overview_page.dart b/lib/overview/view/overview_page.dart index d684fe56..562d7ae3 100644 --- a/lib/overview/view/overview_page.dart +++ b/lib/overview/view/overview_page.dart @@ -136,7 +136,7 @@ class _OverviewPageState extends State { ], ); } - return const SizedBox.shrink(); // Fallback for unexpected states + return const SizedBox.shrink(); }, ), ); diff --git a/lib/shared/extensions/app_user_role_l10n.dart b/lib/shared/extensions/app_user_role_l10n.dart index a4815e2c..23228905 100644 --- a/lib/shared/extensions/app_user_role_l10n.dart +++ b/lib/shared/extensions/app_user_role_l10n.dart @@ -13,8 +13,7 @@ extension AppUserRoleL10n on AppUserRole { case AppUserRole.guestUser: return l10n.guestUserTab; case AppUserRole.standardUser: - return l10n - .authenticatedUserTab; // Using authenticatedUserTab for standardUser + return l10n.authenticatedUserTab; case AppUserRole.premiumUser: return l10n.premiumUserTab; } diff --git a/lib/shared/services/pending_deletions_service.dart b/lib/shared/services/pending_deletions_service.dart index e8201bb8..2084811c 100644 --- a/lib/shared/services/pending_deletions_service.dart +++ b/lib/shared/services/pending_deletions_service.dart @@ -3,7 +3,7 @@ import 'dart:async'; import 'package:data_repository/data_repository.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/foundation.dart'; -import 'package:logging/logging.dart'; // Import the logging package +import 'package:logging/logging.dart'; /// Represents the status of a pending deletion. enum DeletionStatus { @@ -91,13 +91,15 @@ class PendingDeletionsServiceImpl implements PendingDeletionsService { final Logger _logger; /// The stream controller that broadcasts [DeletionEvent]s. - final _deletionEventController = StreamController>.broadcast(); + final _deletionEventController = + StreamController>.broadcast(); /// A map that stores the `Timer` for each pending deletion, keyed by item ID. final Map> _pendingDeletionTimers = {}; @override - Stream> get deletionEvents => _deletionEventController.stream; + Stream> get deletionEvents => + _deletionEventController.stream; @override void requestDeletion({ @@ -144,7 +146,11 @@ class PendingDeletionsServiceImpl implements PendingDeletionsService { _logger.info('Deletion undone for item ID: $id'); // Notify listeners that the deletion was undone, including the item. _deletionEventController.add( - DeletionEvent(id, DeletionStatus.undone, item: pendingDeletion.item), + DeletionEvent( + id, + DeletionStatus.undone, + item: pendingDeletion.item, + ), ); } else { _logger.warning('No pending deletion found for ID: $id to undo.'); diff --git a/lib/shared/widgets/searchable_selection_input.dart b/lib/shared/widgets/searchable_selection_input.dart index 92cbb35f..f60018d2 100644 --- a/lib/shared/widgets/searchable_selection_input.dart +++ b/lib/shared/widgets/searchable_selection_input.dart @@ -172,8 +172,8 @@ class _SearchableSelectionInputState icon: const Icon(Icons.clear), tooltip: l10n.clearSelection, onPressed: () { - widget.onChanged(null); // Clear the selected item - _textController.clear(); // Clear the text field + widget.onChanged(null); + _textController.clear(); }, ) : null, diff --git a/lib/shared/widgets/selection_page/bloc/searchable_selection_bloc.dart b/lib/shared/widgets/selection_page/bloc/searchable_selection_bloc.dart index e90c9d75..d45e30c6 100644 --- a/lib/shared/widgets/selection_page/bloc/searchable_selection_bloc.dart +++ b/lib/shared/widgets/selection_page/bloc/searchable_selection_bloc.dart @@ -154,7 +154,7 @@ class SearchableSelectionBloc if (!state.hasMore || state.status == SearchableSelectionStatus.loading || _arguments.staticItems != null) { - return; // No more items, already loading, or static list + return; } emit(state.copyWith(status: SearchableSelectionStatus.loading)); From 2e4a0dc6ac0247a734903ac8aa1da9757ac50591 Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Sep 2025 17:41:16 +0100 Subject: [PATCH 20/21] refactor: remove pending deletions service initialization - Remove unnecessary initialization of PendingDeletionsServiceImpl - This change simplifies the bootstrap process and reduces unnecessary code --- lib/bootstrap.dart | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index bfd83784..cefb30ab 100644 --- a/lib/bootstrap.dart +++ b/lib/bootstrap.dart @@ -53,9 +53,6 @@ Future bootstrap( authClient: authClient, storageService: kvStorage, ); - pendingDeletionsService = PendingDeletionsServiceImpl( - logger: Logger('PendingDeletionsService'), - ); } DataClient headlinesClient; From f46afd5e303db9812ed9241bd11ca8a4280057ae Mon Sep 17 00:00:00 2001 From: fulleni Date: Sun, 21 Sep 2025 17:42:50 +0100 Subject: [PATCH 21/21] refactor(content_management): improve headline restoration logic - Simplify index calculation for restored headlines - Enhance readability and performance of insertion logic --- .../archived_headlines_bloc.dart | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) 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 28b3fc73..aa4e9106 100644 --- a/lib/content_management/bloc/archived_headlines/archived_headlines_bloc.dart +++ b/lib/content_management/bloc/archived_headlines/archived_headlines_bloc.dart @@ -183,20 +183,12 @@ class ArchivedHeadlinesBloc } else if (status == DeletionStatus.undone) { // Deletion undone, restore the headline to the main list. if (item is Headline) { + final insertionIndex = state.headlines.indexWhere( + (h) => h.updatedAt.isBefore(item.updatedAt), + ); final updatedHeadlines = List.from(state.headlines) ..insert( - state.headlines.indexWhere( - (h) => h.updatedAt.isBefore( - item.updatedAt, - ), - ) != - -1 - ? state.headlines.indexWhere( - (h) => h.updatedAt.isBefore( - item.updatedAt, - ), - ) - : state.headlines.length, + insertionIndex != -1 ? insertionIndex : state.headlines.length, item, ); emit(