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 a12d842b..6186a35f 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 '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,7 @@ class App extends StatelessWidget { required DataRepository localAdsRepository, required KVStorageService storageService, required AppEnvironment environment, + required PendingDeletionsService pendingDeletionsService, super.key, }) : _authenticationRepository = authenticationRepository, _headlinesRepository = headlinesRepository, @@ -50,7 +52,8 @@ class App extends StatelessWidget { _countriesRepository = countriesRepository, _languagesRepository = languagesRepository, _localAdsRepository = localAdsRepository, - _environment = environment; + _environment = environment, + _pendingDeletionsService = pendingDeletionsService; final AuthRepository _authenticationRepository; final DataRepository _headlinesRepository; @@ -67,6 +70,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 +92,9 @@ class App extends StatelessWidget { RepositoryProvider( create: (context) => const ThrottledFetchingService(), ), + RepositoryProvider.value( + value: _pendingDeletionsService, + ), ], child: MultiBlocProvider( providers: [ 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/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); } } diff --git a/lib/bootstrap.dart b/lib/bootstrap.dart index 730f06c0..cefb30ab 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')); @@ -267,6 +269,10 @@ Future bootstrap( ); } + pendingDeletionsService = PendingDeletionsServiceImpl( + logger: Logger('PendingDeletionsService'), + ); + final headlinesRepository = DataRepository( dataClient: headlinesClient, ); @@ -309,5 +315,6 @@ Future bootstrap( localAdsRepository: localAdsRepository, storageService: kvStorage, environment: environment, + pendingDeletionsService: pendingDeletionsService, ); } 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..aa4e9106 100644 --- a/lib/content_management/bloc/archived_headlines/archived_headlines_bloc.dart +++ b/lib/content_management/bloc/archived_headlines/archived_headlines_bloc.dart @@ -4,32 +4,64 @@ 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'; 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, + required PendingDeletionsService pendingDeletionsService, }) : _headlinesRepository = headlinesRepository, + _pendingDeletionsService = pendingDeletionsService, super(const ArchivedHeadlinesState()) { on(_onLoadArchivedHeadlinesRequested); on(_onRestoreHeadlineRequested); + on<_DeletionServiceStatusChanged>( + _onDeletionServiceStatusChanged, + ); + + // 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); - on<_ConfirmDeleteHeadlineRequested>(_onConfirmDeleteHeadlineRequested); + on(_onClearRestoredHeadline); } final DataRepository _headlinesRepository; - Timer? _deleteTimer; + final PendingDeletionsService _pendingDeletionsService; + + /// Subscription to deletion events from the PendingDeletionsService. + late final StreamSubscription> + _deletionEventSubscription; @override - Future close() { - _deleteTimer?.cancel(); + Future close() async { + // Cancel the subscription to deletion events to prevent memory leaks. + await _deletionEventSubscription.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 +104,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 +120,16 @@ class ArchivedHeadlinesBloc final headlineToRestore = originalHeadlines[headlineIndex]; final updatedHeadlines = originalHeadlines..removeAt(headlineIndex); - emit(state.copyWith(headlines: updatedHeadlines)); + // Optimistically remove the headline from the UI. + emit( + state.copyWith( + headlines: updatedHeadlines, + lastPendingDeletionId: state.lastPendingDeletionId == event.id + ? null + : state.lastPendingDeletionId, + snackbarHeadlineTitle: null, + ), + ); try { final restoredHeadline = await _headlinesRepository.update( @@ -92,102 +138,124 @@ 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 + emit( + state.copyWith( + headlines: originalHeadlines, + exception: e, + lastPendingDeletionId: state.lastPendingDeletionId, + ), + ); } catch (e) { emit( state.copyWith( headlines: originalHeadlines, exception: UnknownException('An unexpected error occurred: $e'), + lastPendingDeletionId: state.lastPendingDeletionId, + ), + ); + } + } + + /// Handles deletion events from the [PendingDeletionsService]. + /// + /// 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 { + final id = event.event.id; + final status = event.event.status; + final item = event.event.item; + + if (status == DeletionStatus.confirmed) { + // 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( + lastPendingDeletionId: state.lastPendingDeletionId == id + ? null + : state.lastPendingDeletionId, + snackbarHeadlineTitle: null, ), ); + } 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( + insertionIndex != -1 ? insertionIndex : state.headlines.length, + item, + ); + emit( + state.copyWith( + headlines: updatedHeadlines, + lastPendingDeletionId: state.lastPendingDeletionId == id + ? null + : state.lastPendingDeletionId, + snackbarHeadlineTitle: null, + ), + ); + } } } + /// Handles the request to permanently delete an archived headline. + /// + /// This optimistically removes the headline from the UI and initiates a + /// timed deletion via the [PendingDeletionsService]. Future _onDeleteHeadlineForeverRequested( DeleteHeadlineForeverRequested event, Emitter emit, ) async { - _deleteTimer?.cancel(); - - final headlineIndex = state.headlines.indexWhere((h) => h.id == event.id); - if (headlineIndex == -1) return; + final headlineToDelete = state.headlines.firstWhere( + (h) => h.id == event.id, + ); - final headlineToDelete = state.headlines[headlineIndex]; + // Optimistically remove the headline from the UI. final updatedHeadlines = List.from(state.headlines) - ..removeAt(headlineIndex); + ..removeWhere((h) => h.id == event.id); emit( state.copyWith( headlines: updatedHeadlines, - lastDeletedHeadline: headlineToDelete, + lastPendingDeletionId: event.id, + snackbarHeadlineTitle: headlineToDelete.title, ), ); - _deleteTimer = Timer( - const Duration(seconds: 5), - () => add(_ConfirmDeleteHeadlineRequested(event.id)), + // Request deletion via the service. + _pendingDeletionsService.requestDeletion( + item: headlineToDelete, + repository: _headlinesRepository, + undoDuration: const Duration(seconds: 5), ); } - Future _onConfirmDeleteHeadlineRequested( - _ConfirmDeleteHeadlineRequested event, + /// 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 { - try { - await _headlinesRepository.delete(id: event.id); - emit(state.copyWith(lastDeletedHeadline: null)); - } on HttpException catch (e) { - // If deletion fails, restore the headline to the list - final originalHeadlines = List.from(state.headlines) - ..add(state.lastDeletedHeadline!); - emit( - state.copyWith( - headlines: originalHeadlines, - exception: e, - lastDeletedHeadline: null, - ), - ); - } catch (e) { - final originalHeadlines = List.from(state.headlines) - ..add(state.lastDeletedHeadline!); - emit( - state.copyWith( - headlines: originalHeadlines, - exception: UnknownException('An unexpected error occurred: $e'), - lastDeletedHeadline: null, - ), - ); - } + _pendingDeletionsService.undoDeletion(event.id); + // The _onDeletionServiceStatusChanged will handle re-adding to the list + // and updating pendingDeletions when DeletionStatus.undone is emitted. } - void _onUndoDeleteHeadlineRequested( - UndoDeleteHeadlineRequested event, + /// 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, ) { - _deleteTimer?.cancel(); - if (state.lastDeletedHeadline != null) { - final updatedHeadlines = List.from(state.headlines) - ..insert( - state.headlines.indexWhere( - (h) => h.updatedAt.isBefore( - state.lastDeletedHeadline!.updatedAt, - ), - ) != - -1 - ? state.headlines.indexWhere( - (h) => h.updatedAt.isBefore( - state.lastDeletedHeadline!.updatedAt, - ), - ) - : state.headlines.length, - state.lastDeletedHeadline!, - ); - emit( - state.copyWith( - headlines: updatedHeadlines, - lastDeletedHeadline: null, - ), - ); - } + emit(state.copyWith(restoredHeadline: null, snackbarHeadlineTitle: null)); } } diff --git a/lib/content_management/bloc/archived_headlines/archived_headlines_event.dart b/lib/content_management/bloc/archived_headlines/archived_headlines_event.dart index 1d164085..437d0ff9 100644 --- a/lib/content_management/bloc/archived_headlines/archived_headlines_event.dart +++ b/lib/content_management/bloc/archived_headlines/archived_headlines_event.dart @@ -28,27 +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 { - const UndoDeleteHeadlineRequested(); -} - -/// Internal event to confirm the permanent deletion of a headline after a delay. -final class _ConfirmDeleteHeadlineRequested extends ArchivedHeadlinesEvent { - const _ConfirmDeleteHeadlineRequested(this.id); + /// {@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]; } + +/// Event to clear the restored headline from the state. +final class ClearRestoredHeadline extends ArchivedHeadlinesEvent { + /// {@macro clear_restored_headline} + const ClearRestoredHeadline(); + + @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 => [event]; +} 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..dd8e44be 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,39 @@ class ArchivedHeadlinesState extends Equatable { this.hasMore = false, this.exception, this.restoredHeadline, - this.lastDeletedHeadline, + this.lastPendingDeletionId, + this.snackbarHeadlineTitle, }); + /// 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; + /// 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, List? headlines, @@ -35,16 +70,20 @@ class ArchivedHeadlinesState extends Equatable { bool? hasMore, HttpException? exception, Headline? restoredHeadline, - Headline? lastDeletedHeadline, + String? lastPendingDeletionId, + String? snackbarHeadlineTitle, }) { 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, + lastPendingDeletionId: lastPendingDeletionId, + snackbarHeadlineTitle: snackbarHeadlineTitle, ); } @@ -56,6 +95,7 @@ class ArchivedHeadlinesState extends Equatable { hasMore, exception, restoredHeadline, - lastDeletedHeadline, + lastPendingDeletionId, + 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 c10e4efb..a92fc289 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 'package:intl/intl.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -19,6 +20,7 @@ class ArchivedHeadlinesPage extends StatelessWidget { return BlocProvider( create: (context) => ArchivedHeadlinesBloc( headlinesRepository: context.read>(), + pendingDeletionsService: context.read(), )..add(const LoadArchivedHeadlinesRequested(limit: kDefaultRowsPerPage)), child: const _ArchivedHeadlinesView(), ); @@ -31,6 +33,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), @@ -39,18 +42,25 @@ class _ArchivedHeadlinesView extends StatelessWidget { padding: const EdgeInsets.all(AppSpacing.lg), child: BlocListener( listenWhen: (previous, current) => - previous.lastDeletedHeadline != current.lastDeletedHeadline || - previous.restoredHeadline != current.restoredHeadline, + previous.lastPendingDeletionId != current.lastPendingDeletionId || + previous.restoredHeadline != current.restoredHeadline || + previous.snackbarHeadlineTitle != current.snackbarHeadlineTitle, listener: (context, state) { if (state.restoredHeadline != null) { + // When a headline is restored, refresh the main headlines list. context.read().add( const LoadHeadlinesRequested(limit: kDefaultRowsPerPage), ); - } - if (state.lastDeletedHeadline != null) { - final truncatedTitle = state.lastDeletedHeadline!.title.truncate( - 30, + // Clear the restoredHeadline after it's been handled. + context.read().add( + const ClearRestoredHeadline(), ); + } + + // Show snackbar for pending deletions. + if (state.snackbarHeadlineTitle != null) { + final headlineId = state.lastPendingDeletionId!; + final truncatedTitle = state.snackbarHeadlineTitle!.truncate(30); ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( @@ -61,9 +71,8 @@ class _ArchivedHeadlinesView extends StatelessWidget { action: SnackBarAction( label: l10n.undo, onPressed: () { - context.read().add( - const UndoDeleteHeadlineRequested(), - ); + // Directly call undoDeletion on the service. + pendingDeletionsService.undoDeletion(headlineId); }, ), ), 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 new file mode 100644 index 00000000..2084811c --- /dev/null +++ b/lib/shared/services/pending_deletions_service.dart @@ -0,0 +1,184 @@ +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'; + +/// 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, {this.item}); + + /// The unique identifier of the item. + final String id; + + /// 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, item]; +} + +/// {@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)?.timer.cancel(); + } + + 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), + ); + } 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); + } + }); + + _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 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, including the item. + _deletionEventController.add( + DeletionEvent( + id, + DeletionStatus.undone, + item: pendingDeletion.item, + ), + ); + } 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 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]; +} 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));