diff --git a/analysis_options.yaml b/analysis_options.yaml index 35fe6699..9859707a 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -2,6 +2,7 @@ analyzer: errors: avoid_bool_literals_in_conditional_expressions: ignore avoid_catches_without_on_clauses: ignore + avoid_positional_boolean_parameters: ignore avoid_print: ignore avoid_redundant_argument_values: ignore deprecated_member_use: ignore diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 6186a35f..7ed3c7a1 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -12,6 +12,9 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/app/config/app_e import 'package:flutter_news_app_web_dashboard_full_source_code/app_configuration/bloc/app_configuration_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/authentication/bloc/authentication_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/content_management_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/headlines_filter/headlines_filter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/sources_filter/sources_filter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/topics_filter/topics_filter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/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'; @@ -119,11 +122,24 @@ class App extends StatelessWidget { .read>(), ), ), + BlocProvider( + create: (context) => HeadlinesFilterBloc(), + ), + BlocProvider( + create: (context) => TopicsFilterBloc(), + ), + BlocProvider( + create: (context) => SourcesFilterBloc(), + ), BlocProvider( create: (context) => ContentManagementBloc( headlinesRepository: context.read>(), topicsRepository: context.read>(), sourcesRepository: context.read>(), + headlinesFilterBloc: context.read(), + topicsFilterBloc: context.read(), + sourcesFilterBloc: context.read(), + pendingDeletionsService: context.read(), ), ), BlocProvider( diff --git a/lib/content_management/bloc/archived_headlines/archived_headlines_bloc.dart b/lib/content_management/bloc/archived_headlines/archived_headlines_bloc.dart deleted file mode 100644 index aa4e9106..00000000 --- a/lib/content_management/bloc/archived_headlines/archived_headlines_bloc.dart +++ /dev/null @@ -1,261 +0,0 @@ -import 'dart:async'; - -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(_onClearRestoredHeadline); - } - - final DataRepository _headlinesRepository; - final PendingDeletionsService _pendingDeletionsService; - - /// Subscription to deletion events from the PendingDeletionsService. - late final StreamSubscription> - _deletionEventSubscription; - - @override - 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, - ) async { - emit(state.copyWith(status: ArchivedHeadlinesStatus.loading)); - try { - final isPaginating = event.startAfterId != null; - final previousHeadlines = isPaginating ? state.headlines : []; - - final paginatedHeadlines = await _headlinesRepository.readAll( - filter: {'status': ContentStatus.archived.name}, - sort: [const SortOption('updatedAt', SortOrder.desc)], - pagination: PaginationOptions( - cursor: event.startAfterId, - limit: event.limit, - ), - ); - emit( - state.copyWith( - status: ArchivedHeadlinesStatus.success, - headlines: [...previousHeadlines, ...paginatedHeadlines.items], - cursor: paginatedHeadlines.cursor, - hasMore: paginatedHeadlines.hasMore, - ), - ); - } on HttpException catch (e) { - emit( - state.copyWith( - status: ArchivedHeadlinesStatus.failure, - exception: e, - ), - ); - } catch (e) { - emit( - state.copyWith( - status: ArchivedHeadlinesStatus.failure, - exception: UnknownException('An unexpected error occurred: $e'), - ), - ); - } - } - - /// 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, - ) async { - final originalHeadlines = List.from(state.headlines); - final headlineIndex = originalHeadlines.indexWhere((h) => h.id == event.id); - if (headlineIndex == -1) return; - - final headlineToRestore = originalHeadlines[headlineIndex]; - final updatedHeadlines = originalHeadlines..removeAt(headlineIndex); - - // 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( - id: event.id, - item: headlineToRestore.copyWith(status: ContentStatus.active), - ); - emit(state.copyWith(restoredHeadline: restoredHeadline)); - } on HttpException catch (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 { - final headlineToDelete = state.headlines.firstWhere( - (h) => h.id == event.id, - ); - - // Optimistically remove the headline from the UI. - final updatedHeadlines = List.from(state.headlines) - ..removeWhere((h) => h.id == event.id); - - emit( - state.copyWith( - headlines: updatedHeadlines, - lastPendingDeletionId: event.id, - snackbarHeadlineTitle: headlineToDelete.title, - ), - ); - - // Request deletion via the service. - _pendingDeletionsService.requestDeletion( - item: headlineToDelete, - repository: _headlinesRepository, - undoDuration: const Duration(seconds: 5), - ); - } - - /// 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, 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 deleted file mode 100644 index 437d0ff9..00000000 --- a/lib/content_management/bloc/archived_headlines/archived_headlines_event.dart +++ /dev/null @@ -1,72 +0,0 @@ -part of 'archived_headlines_bloc.dart'; - -sealed class ArchivedHeadlinesEvent extends Equatable { - const ArchivedHeadlinesEvent(); - - @override - List get props => []; -} - -/// Event to request loading of archived headlines. -final class LoadArchivedHeadlinesRequested extends ArchivedHeadlinesEvent { - const LoadArchivedHeadlinesRequested({this.startAfterId, this.limit}); - - final String? startAfterId; - final int? limit; - - @override - List get props => [startAfterId, limit]; -} - -/// Event to restore an archived headline. -final class RestoreHeadlineRequested extends ArchivedHeadlinesEvent { - const RestoreHeadlineRequested(this.id); - - final String id; - - @override - List get props => [id]; -} - -/// 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 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]; -} - -/// 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 deleted file mode 100644 index dd8e44be..00000000 --- a/lib/content_management/bloc/archived_headlines/archived_headlines_state.dart +++ /dev/null @@ -1,101 +0,0 @@ -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 [], - this.cursor, - this.hasMore = false, - this.exception, - this.restoredHeadline, - 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; - - /// 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, - String? cursor, - bool? hasMore, - HttpException? exception, - Headline? restoredHeadline, - 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, - lastPendingDeletionId: lastPendingDeletionId, - snackbarHeadlineTitle: snackbarHeadlineTitle, - ); - } - - @override - List get props => [ - status, - headlines, - cursor, - hasMore, - exception, - restoredHeadline, - lastPendingDeletionId, - snackbarHeadlineTitle, - ]; -} diff --git a/lib/content_management/bloc/archived_sources/archived_sources_bloc.dart b/lib/content_management/bloc/archived_sources/archived_sources_bloc.dart deleted file mode 100644 index 97758a9c..00000000 --- a/lib/content_management/bloc/archived_sources/archived_sources_bloc.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'package:bloc/bloc.dart'; -import 'package:core/core.dart'; -import 'package:data_repository/data_repository.dart'; -import 'package:equatable/equatable.dart'; - -part 'archived_sources_event.dart'; -part 'archived_sources_state.dart'; - -class ArchivedSourcesBloc - extends Bloc { - ArchivedSourcesBloc({ - required DataRepository sourcesRepository, - }) : _sourcesRepository = sourcesRepository, - super(const ArchivedSourcesState()) { - on(_onLoadArchivedSourcesRequested); - on(_onRestoreSourceRequested); - } - - final DataRepository _sourcesRepository; - - Future _onLoadArchivedSourcesRequested( - LoadArchivedSourcesRequested event, - Emitter emit, - ) async { - emit(state.copyWith(status: ArchivedSourcesStatus.loading)); - try { - final isPaginating = event.startAfterId != null; - final previousSources = isPaginating ? state.sources : []; - - final paginatedSources = await _sourcesRepository.readAll( - filter: {'status': ContentStatus.archived.name}, - sort: [const SortOption('updatedAt', SortOrder.desc)], - pagination: PaginationOptions( - cursor: event.startAfterId, - limit: event.limit, - ), - ); - emit( - state.copyWith( - status: ArchivedSourcesStatus.success, - sources: [...previousSources, ...paginatedSources.items], - cursor: paginatedSources.cursor, - hasMore: paginatedSources.hasMore, - ), - ); - } on HttpException catch (e) { - emit( - state.copyWith( - status: ArchivedSourcesStatus.failure, - exception: e, - ), - ); - } catch (e) { - emit( - state.copyWith( - status: ArchivedSourcesStatus.failure, - exception: UnknownException('An unexpected error occurred: $e'), - ), - ); - } - } - - Future _onRestoreSourceRequested( - RestoreSourceRequested event, - Emitter emit, - ) async { - final originalSources = List.from(state.sources); - final sourceIndex = originalSources.indexWhere((s) => s.id == event.id); - if (sourceIndex == -1) return; - - final sourceToRestore = originalSources[sourceIndex]; - final updatedSources = originalSources..removeAt(sourceIndex); - - emit(state.copyWith(sources: updatedSources)); - - try { - final restoredSource = await _sourcesRepository.update( - id: event.id, - item: sourceToRestore.copyWith(status: ContentStatus.active), - ); - emit(state.copyWith(restoredSource: restoredSource)); - } on HttpException catch (e) { - emit(state.copyWith(sources: originalSources, exception: e)); - } catch (e) { - emit( - state.copyWith( - sources: originalSources, - exception: UnknownException('An unexpected error occurred: $e'), - ), - ); - } - } -} diff --git a/lib/content_management/bloc/archived_sources/archived_sources_event.dart b/lib/content_management/bloc/archived_sources/archived_sources_event.dart deleted file mode 100644 index 9c77673f..00000000 --- a/lib/content_management/bloc/archived_sources/archived_sources_event.dart +++ /dev/null @@ -1,29 +0,0 @@ -part of 'archived_sources_bloc.dart'; - -sealed class ArchivedSourcesEvent extends Equatable { - const ArchivedSourcesEvent(); - - @override - List get props => []; -} - -/// Event to request loading of archived sources. -final class LoadArchivedSourcesRequested extends ArchivedSourcesEvent { - const LoadArchivedSourcesRequested({this.startAfterId, this.limit}); - - final String? startAfterId; - final int? limit; - - @override - List get props => [startAfterId, limit]; -} - -/// Event to restore an archived source. -final class RestoreSourceRequested extends ArchivedSourcesEvent { - const RestoreSourceRequested(this.id); - - final String id; - - @override - List get props => [id]; -} diff --git a/lib/content_management/bloc/archived_sources/archived_sources_state.dart b/lib/content_management/bloc/archived_sources/archived_sources_state.dart deleted file mode 100644 index 67e43b1e..00000000 --- a/lib/content_management/bloc/archived_sources/archived_sources_state.dart +++ /dev/null @@ -1,56 +0,0 @@ -part of 'archived_sources_bloc.dart'; - -/// Represents the status of archived content operations. -enum ArchivedSourcesStatus { - initial, - loading, - success, - failure, -} - -/// The state for the archived content feature. -class ArchivedSourcesState extends Equatable { - const ArchivedSourcesState({ - this.status = ArchivedSourcesStatus.initial, - this.sources = const [], - this.cursor, - this.hasMore = false, - this.exception, - this.restoredSource, - }); - - final ArchivedSourcesStatus status; - final List sources; - final String? cursor; - final bool hasMore; - final HttpException? exception; - final Source? restoredSource; - - ArchivedSourcesState copyWith({ - ArchivedSourcesStatus? status, - List? sources, - String? cursor, - bool? hasMore, - HttpException? exception, - Source? restoredSource, - }) { - return ArchivedSourcesState( - status: status ?? this.status, - sources: sources ?? this.sources, - cursor: cursor ?? this.cursor, - hasMore: hasMore ?? this.hasMore, - exception: exception ?? this.exception, - restoredSource: restoredSource, - ); - } - - @override - List get props => [ - status, - sources, - cursor, - hasMore, - exception, - restoredSource, - ]; -} diff --git a/lib/content_management/bloc/archived_topics/archived_topics_bloc.dart b/lib/content_management/bloc/archived_topics/archived_topics_bloc.dart deleted file mode 100644 index a991fad8..00000000 --- a/lib/content_management/bloc/archived_topics/archived_topics_bloc.dart +++ /dev/null @@ -1,93 +0,0 @@ -import 'package:bloc/bloc.dart'; -import 'package:core/core.dart'; -import 'package:data_repository/data_repository.dart'; -import 'package:equatable/equatable.dart'; - -part 'archived_topics_event.dart'; -part 'archived_topics_state.dart'; - -class ArchivedTopicsBloc - extends Bloc { - ArchivedTopicsBloc({ - required DataRepository topicsRepository, - }) : _topicsRepository = topicsRepository, - super(const ArchivedTopicsState()) { - on(_onLoadArchivedTopicsRequested); - on(_onRestoreTopicRequested); - } - - final DataRepository _topicsRepository; - - Future _onLoadArchivedTopicsRequested( - LoadArchivedTopicsRequested event, - Emitter emit, - ) async { - emit(state.copyWith(status: ArchivedTopicsStatus.loading)); - try { - final isPaginating = event.startAfterId != null; - final previousTopics = isPaginating ? state.topics : []; - - final paginatedTopics = await _topicsRepository.readAll( - filter: {'status': ContentStatus.archived.name}, - sort: [const SortOption('updatedAt', SortOrder.desc)], - pagination: PaginationOptions( - cursor: event.startAfterId, - limit: event.limit, - ), - ); - emit( - state.copyWith( - status: ArchivedTopicsStatus.success, - topics: [...previousTopics, ...paginatedTopics.items], - cursor: paginatedTopics.cursor, - hasMore: paginatedTopics.hasMore, - ), - ); - } on HttpException catch (e) { - emit( - state.copyWith( - status: ArchivedTopicsStatus.failure, - exception: e, - ), - ); - } catch (e) { - emit( - state.copyWith( - status: ArchivedTopicsStatus.failure, - exception: UnknownException('An unexpected error occurred: $e'), - ), - ); - } - } - - Future _onRestoreTopicRequested( - RestoreTopicRequested event, - Emitter emit, - ) async { - final originalTopics = List.from(state.topics); - final topicIndex = originalTopics.indexWhere((t) => t.id == event.id); - if (topicIndex == -1) return; - - final topicToRestore = originalTopics[topicIndex]; - final updatedTopics = originalTopics..removeAt(topicIndex); - - emit(state.copyWith(topics: updatedTopics)); - - try { - final restoredTopic = await _topicsRepository.update( - id: event.id, - item: topicToRestore.copyWith(status: ContentStatus.active), - ); - emit(state.copyWith(restoredTopic: restoredTopic)); - } on HttpException catch (e) { - emit(state.copyWith(topics: originalTopics, exception: e)); - } catch (e) { - emit( - state.copyWith( - topics: originalTopics, - exception: UnknownException('An unexpected error occurred: $e'), - ), - ); - } - } -} diff --git a/lib/content_management/bloc/archived_topics/archived_topics_event.dart b/lib/content_management/bloc/archived_topics/archived_topics_event.dart deleted file mode 100644 index b5208631..00000000 --- a/lib/content_management/bloc/archived_topics/archived_topics_event.dart +++ /dev/null @@ -1,29 +0,0 @@ -part of 'archived_topics_bloc.dart'; - -sealed class ArchivedTopicsEvent extends Equatable { - const ArchivedTopicsEvent(); - - @override - List get props => []; -} - -/// Event to request loading of archived topics. -final class LoadArchivedTopicsRequested extends ArchivedTopicsEvent { - const LoadArchivedTopicsRequested({this.startAfterId, this.limit}); - - final String? startAfterId; - final int? limit; - - @override - List get props => [startAfterId, limit]; -} - -/// Event to restore an archived topic. -final class RestoreTopicRequested extends ArchivedTopicsEvent { - const RestoreTopicRequested(this.id); - - final String id; - - @override - List get props => [id]; -} diff --git a/lib/content_management/bloc/archived_topics/archived_topics_state.dart b/lib/content_management/bloc/archived_topics/archived_topics_state.dart deleted file mode 100644 index 2afa306a..00000000 --- a/lib/content_management/bloc/archived_topics/archived_topics_state.dart +++ /dev/null @@ -1,56 +0,0 @@ -part of 'archived_topics_bloc.dart'; - -/// Represents the status of archived content operations. -enum ArchivedTopicsStatus { - initial, - loading, - success, - failure, -} - -/// The state for the archived content feature. -class ArchivedTopicsState extends Equatable { - const ArchivedTopicsState({ - this.status = ArchivedTopicsStatus.initial, - this.topics = const [], - this.cursor, - this.hasMore = false, - this.exception, - this.restoredTopic, - }); - - final ArchivedTopicsStatus status; - final List topics; - final String? cursor; - final bool hasMore; - final HttpException? exception; - final Topic? restoredTopic; - - ArchivedTopicsState copyWith({ - ArchivedTopicsStatus? status, - List? topics, - String? cursor, - bool? hasMore, - HttpException? exception, - Topic? restoredTopic, - }) { - return ArchivedTopicsState( - status: status ?? this.status, - topics: topics ?? this.topics, - cursor: cursor ?? this.cursor, - hasMore: hasMore ?? this.hasMore, - exception: exception ?? this.exception, - restoredTopic: restoredTopic, - ); - } - - @override - List get props => [ - status, - topics, - cursor, - hasMore, - exception, - restoredTopic, - ]; -} diff --git a/lib/content_management/bloc/content_management_bloc.dart b/lib/content_management/bloc/content_management_bloc.dart index 733f6db9..be6bc8e5 100644 --- a/lib/content_management/bloc/content_management_bloc.dart +++ b/lib/content_management/bloc/content_management_bloc.dart @@ -4,6 +4,11 @@ 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/content_management/bloc/headlines_filter/headlines_filter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/sources_filter/sources_filter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/topics_filter/topics_filter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/constants/app_constants.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/services/pending_deletions_service.dart'; import 'package:ui_kit/ui_kit.dart'; part 'content_management_event.dart'; @@ -27,25 +32,50 @@ class ContentManagementBloc required DataRepository headlinesRepository, required DataRepository topicsRepository, required DataRepository sourcesRepository, + required HeadlinesFilterBloc headlinesFilterBloc, + required TopicsFilterBloc topicsFilterBloc, + required SourcesFilterBloc sourcesFilterBloc, + required PendingDeletionsService pendingDeletionsService, }) : _headlinesRepository = headlinesRepository, _topicsRepository = topicsRepository, _sourcesRepository = sourcesRepository, + _headlinesFilterBloc = headlinesFilterBloc, + _topicsFilterBloc = topicsFilterBloc, + _sourcesFilterBloc = sourcesFilterBloc, + _pendingDeletionsService = pendingDeletionsService, super(const ContentManagementState()) { on(_onContentManagementTabChanged); + on(_onLoadHeadlinesRequested); on(_onArchiveHeadlineRequested); + on(_onPublishHeadlineRequested); + on(_onRestoreHeadlineRequested); + on(_onDeleteHeadlineForeverRequested); + on(_onUndoDeleteHeadlineRequested); + on(_onDeletionEventReceived); + on(_onLoadTopicsRequested); on(_onArchiveTopicRequested); + on(_onPublishTopicRequested); + on(_onRestoreTopicRequested); + on(_onDeleteTopicForeverRequested); + on(_onUndoDeleteTopicRequested); + on(_onLoadSourcesRequested); on(_onArchiveSourceRequested); + on(_onPublishSourceRequested); + on(_onRestoreSourceRequested); + on(_onDeleteSourceForeverRequested); + on(_onUndoDeleteSourceRequested); _headlineUpdateSubscription = _headlinesRepository.entityUpdated .where((type) => type == Headline) .listen((_) { add( - const LoadHeadlinesRequested( + LoadHeadlinesRequested( limit: kDefaultRowsPerPage, forceRefresh: true, + filter: buildHeadlinesFilterMap(_headlinesFilterBloc.state), ), ); }); @@ -54,9 +84,10 @@ class ContentManagementBloc .where((type) => type == Topic) .listen((_) { add( - const LoadTopicsRequested( + LoadTopicsRequested( limit: kDefaultRowsPerPage, forceRefresh: true, + filter: buildTopicsFilterMap(_topicsFilterBloc.state), ), ); }); @@ -65,30 +96,104 @@ class ContentManagementBloc .where((type) => type == Source) .listen((_) { add( - const LoadSourcesRequested( + LoadSourcesRequested( limit: kDefaultRowsPerPage, forceRefresh: true, + filter: buildSourcesFilterMap(_sourcesFilterBloc.state), ), ); }); + + _deletionEventsSubscription = _pendingDeletionsService.deletionEvents.listen( + (event) => add(DeletionEventReceived(event)), + ); } final DataRepository _headlinesRepository; final DataRepository _topicsRepository; final DataRepository _sourcesRepository; + final HeadlinesFilterBloc _headlinesFilterBloc; + final TopicsFilterBloc _topicsFilterBloc; + final SourcesFilterBloc _sourcesFilterBloc; + final PendingDeletionsService _pendingDeletionsService; late final StreamSubscription _headlineUpdateSubscription; late final StreamSubscription _topicUpdateSubscription; late final StreamSubscription _sourceUpdateSubscription; + late final StreamSubscription> _deletionEventsSubscription; @override Future close() { _headlineUpdateSubscription.cancel(); _topicUpdateSubscription.cancel(); _sourceUpdateSubscription.cancel(); + _deletionEventsSubscription.cancel(); return super.close(); } + /// Builds a filter map for headlines from the given filter state. + Map buildHeadlinesFilterMap(HeadlinesFilterState state) { + final filter = {}; + + if (state.searchQuery.isNotEmpty) { + filter['title'] = {r'$regex': state.searchQuery, r'$options': 'i'}; + } + + filter['status'] = state.selectedStatus.name; + + if (state.selectedSourceIds.isNotEmpty) { + filter['source.id'] = {r'$in': state.selectedSourceIds}; + } + if (state.selectedTopicIds.isNotEmpty) { + filter['topic.id'] = {r'$in': state.selectedTopicIds}; + } + if (state.selectedCountryIds.isNotEmpty) { + filter['eventCountry.id'] = {r'$in': state.selectedCountryIds}; + } + + return filter; + } + + /// Builds a filter map for topics from the given filter state. + Map buildTopicsFilterMap(TopicsFilterState state) { + final filter = {}; + + if (state.searchQuery.isNotEmpty) { + filter['name'] = {r'$regex': state.searchQuery, r'$options': 'i'}; + } + + filter['status'] = state.selectedStatus.name; + + return filter; + } + + /// Builds a filter map for sources from the given filter state. + Map buildSourcesFilterMap(SourcesFilterState state) { + final filter = {}; + + if (state.searchQuery.isNotEmpty) { + filter['name'] = {r'$regex': state.searchQuery, r'$options': 'i'}; + } + + filter['status'] = state.selectedStatus.name; + + if (state.selectedSourceTypes.isNotEmpty) { + filter['sourceType'] = { + r'$in': state.selectedSourceTypes.map((s) => s.name).toList(), + }; + } + if (state.selectedLanguageCodes.isNotEmpty) { + filter['language.code'] = {r'$in': state.selectedLanguageCodes}; + } + if (state.selectedHeadquartersCountryIds.isNotEmpty) { + filter['headquarters.id'] = { + r'$in': state.selectedHeadquartersCountryIds, + }; + } + + return filter; + } + void _onContentManagementTabChanged( ContentManagementTabChanged event, Emitter emit, @@ -101,11 +206,12 @@ class ContentManagementBloc Emitter emit, ) async { // If headlines are already loaded and it's not a pagination request, - // do not re-fetch. This prevents redundant API calls on tab changes. + // do not re-fetch unless forceRefresh is true or a filter is applied. if (state.headlinesStatus == ContentManagementStatus.success && state.headlines.isNotEmpty && event.startAfterId == null && - !event.forceRefresh) { + !event.forceRefresh && + event.filter == null) { return; } @@ -115,7 +221,7 @@ class ContentManagementBloc final previousHeadlines = isPaginating ? state.headlines : []; final paginatedHeadlines = await _headlinesRepository.readAll( - filter: {'status': ContentStatus.active.name}, + filter: event.filter ?? buildHeadlinesFilterMap(_headlinesFilterBloc.state), sort: [const SortOption('updatedAt', SortOrder.desc)], pagination: PaginationOptions( cursor: event.startAfterId, @@ -151,25 +257,96 @@ class ContentManagementBloc ArchiveHeadlineRequested event, Emitter emit, ) async { - // 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; - - final headlineToArchive = originalHeadlines[headlineIndex]; - final updatedHeadlines = originalHeadlines..removeAt(headlineIndex); + try { + final headlineToUpdate = state.headlines.firstWhere( + (h) => h.id == event.id, + ); + await _headlinesRepository.update( + id: event.id, + item: headlineToUpdate.copyWith(status: ContentStatus.archived), + ); + add( + LoadHeadlinesRequested( + limit: kDefaultRowsPerPage, + forceRefresh: true, + filter: buildHeadlinesFilterMap(_headlinesFilterBloc.state), + ), + ); + } on HttpException catch (e) { + emit( + state.copyWith( + headlinesStatus: ContentManagementStatus.failure, + exception: e, + ), + ); + } catch (e) { + emit( + state.copyWith( + headlinesStatus: ContentManagementStatus.failure, + exception: UnknownException('An unexpected error occurred: $e'), + ), + ); + } + } - emit(state.copyWith(headlines: updatedHeadlines)); + /// Handles the request to publish a draft headline. + Future _onPublishHeadlineRequested( + PublishHeadlineRequested event, + Emitter emit, + ) async { + try { + final headlineToUpdate = state.headlines.firstWhere( + (h) => h.id == event.id, + ); + await _headlinesRepository.update( + id: event.id, + item: headlineToUpdate.copyWith(status: ContentStatus.active), + ); + add( + LoadHeadlinesRequested( + limit: kDefaultRowsPerPage, + forceRefresh: true, + filter: buildHeadlinesFilterMap(_headlinesFilterBloc.state), + ), + ); + } on HttpException catch (e) { + emit( + state.copyWith( + headlinesStatus: ContentManagementStatus.failure, + exception: e, + ), + ); + } catch (e) { + emit( + state.copyWith( + headlinesStatus: ContentManagementStatus.failure, + exception: UnknownException('An unexpected error occurred: $e'), + ), + ); + } + } + /// Handles the request to restore an archived headline. + Future _onRestoreHeadlineRequested( + RestoreHeadlineRequested event, + Emitter emit, + ) async { try { + final headlineToUpdate = state.headlines.firstWhere( + (h) => h.id == event.id, + ); await _headlinesRepository.update( id: event.id, - item: headlineToArchive.copyWith(status: ContentStatus.archived), + item: headlineToUpdate.copyWith(status: ContentStatus.active), + ); + add( + LoadHeadlinesRequested( + limit: kDefaultRowsPerPage, + forceRefresh: true, + filter: buildHeadlinesFilterMap(_headlinesFilterBloc.state), + ), ); } on HttpException catch (e) { - // If the update fails, revert the change in the UI - emit(state.copyWith(headlines: originalHeadlines)); - // And then show the error emit( state.copyWith( headlinesStatus: ContentManagementStatus.failure, @@ -186,16 +363,52 @@ class ContentManagementBloc } } + /// Handles the request to permanently delete a headline. + Future _onDeleteHeadlineForeverRequested( + DeleteHeadlineForeverRequested event, + Emitter emit, + ) async { + final headlineToDelete = state.headlines.firstWhere( + (h) => h.id == event.id, + ); + + final updatedHeadlines = List.from(state.headlines) + ..removeWhere((h) => h.id == event.id); + + emit( + state.copyWith( + headlines: updatedHeadlines, + lastPendingDeletionId: event.id, + snackbarMessage: 'Headline "${headlineToDelete.title}" deleted.', + ), + ); + + _pendingDeletionsService.requestDeletion( + item: headlineToDelete, + repository: _headlinesRepository, + undoDuration: AppConstants.kSnackbarDuration, + ); + } + + /// Handles the request to undo a pending deletion of a headline. + void _onUndoDeleteHeadlineRequested( + UndoDeleteHeadlineRequested event, + Emitter emit, + ) { + _pendingDeletionsService.undoDeletion(event.id); + } + Future _onLoadTopicsRequested( LoadTopicsRequested event, Emitter emit, ) async { // If topics are already loaded and it's not a pagination request, - // do not re-fetch. This prevents redundant API calls on tab changes. + // do not re-fetch unless forceRefresh is true or a filter is applied. if (state.topicsStatus == ContentManagementStatus.success && state.topics.isNotEmpty && event.startAfterId == null && - !event.forceRefresh) { + !event.forceRefresh && + event.filter == null) { return; } @@ -205,7 +418,7 @@ class ContentManagementBloc final previousTopics = isPaginating ? state.topics : []; final paginatedTopics = await _topicsRepository.readAll( - filter: {'status': ContentStatus.active.name}, + filter: event.filter ?? buildTopicsFilterMap(_topicsFilterBloc.state), sort: [const SortOption('updatedAt', SortOrder.desc)], pagination: PaginationOptions( cursor: event.startAfterId, @@ -241,25 +454,90 @@ class ContentManagementBloc ArchiveTopicRequested event, Emitter emit, ) async { - // 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; - - final topicToArchive = originalTopics[topicIndex]; - final updatedTopics = originalTopics..removeAt(topicIndex); + try { + final topicToUpdate = state.topics.firstWhere((t) => t.id == event.id); + await _topicsRepository.update( + id: event.id, + item: topicToUpdate.copyWith(status: ContentStatus.archived), + ); + add( + LoadTopicsRequested( + limit: kDefaultRowsPerPage, + forceRefresh: true, + filter: buildTopicsFilterMap(_topicsFilterBloc.state), + ), + ); + } on HttpException catch (e) { + emit( + state.copyWith( + topicsStatus: ContentManagementStatus.failure, + exception: e, + ), + ); + } catch (e) { + emit( + state.copyWith( + topicsStatus: ContentManagementStatus.failure, + exception: UnknownException('An unexpected error occurred: $e'), + ), + ); + } + } - emit(state.copyWith(topics: updatedTopics)); + /// Handles the request to publish a draft topic. + Future _onPublishTopicRequested( + PublishTopicRequested event, + Emitter emit, + ) async { + try { + final topicToUpdate = state.topics.firstWhere((t) => t.id == event.id); + await _topicsRepository.update( + id: event.id, + item: topicToUpdate.copyWith(status: ContentStatus.active), + ); + add( + LoadTopicsRequested( + limit: kDefaultRowsPerPage, + forceRefresh: true, + filter: buildTopicsFilterMap(_topicsFilterBloc.state), + ), + ); + } on HttpException catch (e) { + emit( + state.copyWith( + topicsStatus: ContentManagementStatus.failure, + exception: e, + ), + ); + } catch (e) { + emit( + state.copyWith( + topicsStatus: ContentManagementStatus.failure, + exception: UnknownException('An unexpected error occurred: $e'), + ), + ); + } + } + /// Handles the request to restore an archived topic. + Future _onRestoreTopicRequested( + RestoreTopicRequested event, + Emitter emit, + ) async { try { + final topicToUpdate = state.topics.firstWhere((t) => t.id == event.id); await _topicsRepository.update( id: event.id, - item: topicToArchive.copyWith(status: ContentStatus.archived), + item: topicToUpdate.copyWith(status: ContentStatus.active), + ); + add( + LoadTopicsRequested( + limit: kDefaultRowsPerPage, + forceRefresh: true, + filter: buildTopicsFilterMap(_topicsFilterBloc.state), + ), ); } on HttpException catch (e) { - // If the update fails, revert the change in the UI - emit(state.copyWith(topics: originalTopics)); - // And then show the error emit( state.copyWith( topicsStatus: ContentManagementStatus.failure, @@ -276,16 +554,52 @@ class ContentManagementBloc } } + /// Handles the request to permanently delete a topic. + Future _onDeleteTopicForeverRequested( + DeleteTopicForeverRequested event, + Emitter emit, + ) async { + final topicToDelete = state.topics.firstWhere( + (t) => t.id == event.id, + ); + + final updatedTopics = List.from(state.topics) + ..removeWhere((t) => t.id == event.id); + + emit( + state.copyWith( + topics: updatedTopics, + lastPendingDeletionId: event.id, + snackbarMessage: 'Topic "${topicToDelete.name}" deleted.', + ), + ); + + _pendingDeletionsService.requestDeletion( + item: topicToDelete, + repository: _topicsRepository, + undoDuration: AppConstants.kSnackbarDuration, + ); + } + + /// Handles the request to undo a pending deletion of a topic. + void _onUndoDeleteTopicRequested( + UndoDeleteTopicRequested event, + Emitter emit, + ) { + _pendingDeletionsService.undoDeletion(event.id); + } + Future _onLoadSourcesRequested( LoadSourcesRequested event, Emitter emit, ) async { // If sources are already loaded and it's not a pagination request, - // do not re-fetch. This prevents redundant API calls on tab changes. + // do not re-fetch unless forceRefresh is true or a filter is applied. if (state.sourcesStatus == ContentManagementStatus.success && state.sources.isNotEmpty && event.startAfterId == null && - !event.forceRefresh) { + !event.forceRefresh && + event.filter == null) { return; } @@ -295,7 +609,8 @@ class ContentManagementBloc final previousSources = isPaginating ? state.sources : []; final paginatedSources = await _sourcesRepository.readAll( - filter: {'status': ContentStatus.active.name}, + filter: + event.filter ?? buildSourcesFilterMap(_sourcesFilterBloc.state), sort: [const SortOption('updatedAt', SortOrder.desc)], pagination: PaginationOptions( cursor: event.startAfterId, @@ -331,25 +646,90 @@ class ContentManagementBloc ArchiveSourceRequested event, Emitter emit, ) async { - // 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; - - final sourceToArchive = originalSources[sourceIndex]; - final updatedSources = originalSources..removeAt(sourceIndex); + try { + final sourceToUpdate = state.sources.firstWhere((s) => s.id == event.id); + await _sourcesRepository.update( + id: event.id, + item: sourceToUpdate.copyWith(status: ContentStatus.archived), + ); + add( + LoadSourcesRequested( + limit: kDefaultRowsPerPage, + forceRefresh: true, + filter: buildSourcesFilterMap(_sourcesFilterBloc.state), + ), + ); + } on HttpException catch (e) { + emit( + state.copyWith( + sourcesStatus: ContentManagementStatus.failure, + exception: e, + ), + ); + } catch (e) { + emit( + state.copyWith( + sourcesStatus: ContentManagementStatus.failure, + exception: UnknownException('An unexpected error occurred: $e'), + ), + ); + } + } - emit(state.copyWith(sources: updatedSources)); + /// Handles the request to publish a draft source. + Future _onPublishSourceRequested( + PublishSourceRequested event, + Emitter emit, + ) async { + try { + final sourceToUpdate = state.sources.firstWhere((s) => s.id == event.id); + await _sourcesRepository.update( + id: event.id, + item: sourceToUpdate.copyWith(status: ContentStatus.active), + ); + add( + LoadSourcesRequested( + limit: kDefaultRowsPerPage, + forceRefresh: true, + filter: buildSourcesFilterMap(_sourcesFilterBloc.state), + ), + ); + } on HttpException catch (e) { + emit( + state.copyWith( + sourcesStatus: ContentManagementStatus.failure, + exception: e, + ), + ); + } catch (e) { + emit( + state.copyWith( + sourcesStatus: ContentManagementStatus.failure, + exception: UnknownException('An unexpected error occurred: $e'), + ), + ); + } + } + /// Handles the request to restore an archived source. + Future _onRestoreSourceRequested( + RestoreSourceRequested event, + Emitter emit, + ) async { try { + final sourceToUpdate = state.sources.firstWhere((s) => s.id == event.id); await _sourcesRepository.update( id: event.id, - item: sourceToArchive.copyWith(status: ContentStatus.archived), + item: sourceToUpdate.copyWith(status: ContentStatus.active), + ); + add( + LoadSourcesRequested( + limit: kDefaultRowsPerPage, + forceRefresh: true, + filter: buildSourcesFilterMap(_sourcesFilterBloc.state), + ), ); } on HttpException catch (e) { - // If the update fails, revert the change in the UI - emit(state.copyWith(sources: originalSources)); - // And then show the error emit( state.copyWith( sourcesStatus: ContentManagementStatus.failure, @@ -365,4 +745,97 @@ class ContentManagementBloc ); } } + + /// Handles the request to permanently delete a source. + Future _onDeleteSourceForeverRequested( + DeleteSourceForeverRequested event, + Emitter emit, + ) async { + final sourceToDelete = state.sources.firstWhere( + (s) => s.id == event.id, + ); + + final updatedSources = List.from(state.sources) + ..removeWhere((s) => s.id == event.id); + + emit( + state.copyWith( + sources: updatedSources, + lastPendingDeletionId: event.id, + snackbarMessage: 'Source "${sourceToDelete.name}" deleted.', + ), + ); + + _pendingDeletionsService.requestDeletion( + item: sourceToDelete, + repository: _sourcesRepository, + undoDuration: AppConstants.kSnackbarDuration, + ); + } + + /// Handles the request to undo a pending deletion of a source. + void _onUndoDeleteSourceRequested( + UndoDeleteSourceRequested event, + Emitter emit, + ) { + _pendingDeletionsService.undoDeletion(event.id); + } + + /// Handles deletion events from the [PendingDeletionsService]. + /// + /// This method is responsible for updating the BLoC state based on whether + /// a deletion was confirmed or undone. + Future _onDeletionEventReceived( + DeletionEventReceived event, + Emitter emit, + ) async { + switch (event.event.status) { + case DeletionStatus.confirmed: + // If deletion is confirmed, clear pending status. + // The item was already optimistically removed from the list. + emit( + state.copyWith( + lastPendingDeletionId: null, + snackbarMessage: null, + ), + ); + case DeletionStatus.undone: + // If deletion is undone, re-add the item to the appropriate list. + final item = event.event.item; + if (item is Headline) { + final updatedHeadlines = List.from(state.headlines) + ..add(item) + ..sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + emit( + state.copyWith( + headlines: updatedHeadlines, + lastPendingDeletionId: null, + snackbarMessage: null, + ), + ); + } else if (item is Topic) { + final updatedTopics = List.from(state.topics) + ..add(item) + ..sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + emit( + state.copyWith( + topics: updatedTopics, + lastPendingDeletionId: null, + snackbarMessage: null, + ), + ); + } else if (item is Source) { + final updatedSources = List.from(state.sources) + ..add(item) + ..sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + emit( + state.copyWith( + sources: updatedSources, + lastPendingDeletionId: null, + snackbarMessage: null, + ), + ); + } + } + } } diff --git a/lib/content_management/bloc/content_management_event.dart b/lib/content_management/bloc/content_management_event.dart index ae7ca2cc..8fe8154b 100644 --- a/lib/content_management/bloc/content_management_event.dart +++ b/lib/content_management/bloc/content_management_event.dart @@ -7,128 +7,230 @@ sealed class ContentManagementEvent extends Equatable { List get props => []; } -/// {@template content_management_tab_changed} -/// Event to change the active content management tab. -/// {@endtemplate} +/// Event to notify the BLoC that the active tab has changed. final class ContentManagementTabChanged extends ContentManagementEvent { - /// {@macro content_management_tab_changed} const ContentManagementTabChanged(this.tab); - /// The new active tab. final ContentManagementTab tab; @override List get props => [tab]; } -/// {@template load_headlines_requested} /// Event to request loading of headlines. -/// {@endtemplate} final class LoadHeadlinesRequested extends ContentManagementEvent { - /// {@macro load_headlines_requested} const LoadHeadlinesRequested({ this.startAfterId, this.limit, this.forceRefresh = false, + this.filter, }); - /// Optional ID to start pagination after. final String? startAfterId; - - /// Optional maximum number of items to return. final int? limit; - - /// If true, forces a refresh of the data, bypassing the cache. final bool forceRefresh; + /// Optional filter to apply to the headlines query. + final Map? filter; + @override - List get props => [startAfterId, limit, forceRefresh]; + List get props => [startAfterId, limit, forceRefresh, filter]; } -/// {@template archive_headline_requested} -/// Event to request archiving of a headline. -/// {@endtemplate} +/// Event to archive a headline. final class ArchiveHeadlineRequested extends ContentManagementEvent { - /// {@macro archive_headline_requested} const ArchiveHeadlineRequested(this.id); - /// The ID of the headline to archive. final String id; @override List get props => [id]; } -/// {@template load_topics_requested} +/// Event received when a deletion event occurs in the PendingDeletionsService. +final class DeletionEventReceived extends ContentManagementEvent { + const DeletionEventReceived(this.event); + + final DeletionEvent event; + + @override + List get props => [event]; +} + +/// Event to publish a draft headline. +final class PublishHeadlineRequested extends ContentManagementEvent { + const PublishHeadlineRequested(this.id); + + final String id; + + @override + List get props => [id]; +} + +/// Event to restore an archived headline. +final class RestoreHeadlineRequested extends ContentManagementEvent { + const RestoreHeadlineRequested(this.id); + + final String id; + + @override + List get props => [id]; +} + +/// Event to request permanent deletion of a headline. +final class DeleteHeadlineForeverRequested extends ContentManagementEvent { + const DeleteHeadlineForeverRequested(this.id); + + final String id; + + @override + List get props => [id]; +} + +/// Event to undo a pending deletion of a headline. +final class UndoDeleteHeadlineRequested extends ContentManagementEvent { + const UndoDeleteHeadlineRequested(this.id); + + final String id; + + @override + List get props => [id]; +} + /// Event to request loading of topics. -/// {@endtemplate} final class LoadTopicsRequested extends ContentManagementEvent { - /// {@macro load_topics_requested} const LoadTopicsRequested({ this.startAfterId, this.limit, this.forceRefresh = false, + this.filter, }); - /// Optional ID to start pagination after. final String? startAfterId; - - /// Optional maximum number of items to return. final int? limit; - - /// If true, forces a refresh of the data, bypassing the cache. final bool forceRefresh; + /// Optional filter to apply to the topics query. + final Map? filter; + @override - List get props => [startAfterId, limit, forceRefresh]; + List get props => [startAfterId, limit, forceRefresh, filter]; } -/// {@template archive_topic_requested} -/// Event to request archiving of a topic. -/// {@endtemplate} +/// Event to archive a topic. final class ArchiveTopicRequested extends ContentManagementEvent { - /// {@macro archive_topic_requested} const ArchiveTopicRequested(this.id); - /// The ID of the topic to archive. final String id; @override List get props => [id]; } -/// {@template load_sources_requested} +/// Event to publish a draft topic. +final class PublishTopicRequested extends ContentManagementEvent { + const PublishTopicRequested(this.id); + + final String id; + + @override + List get props => [id]; +} + +/// Event to restore an archived topic. +final class RestoreTopicRequested extends ContentManagementEvent { + const RestoreTopicRequested(this.id); + + final String id; + + @override + List get props => [id]; +} + +/// Event to request permanent deletion of a topic. +final class DeleteTopicForeverRequested extends ContentManagementEvent { + const DeleteTopicForeverRequested(this.id); + + final String id; + + @override + List get props => [id]; +} + +/// Event to undo a pending deletion of a topic. +final class UndoDeleteTopicRequested extends ContentManagementEvent { + const UndoDeleteTopicRequested(this.id); + + final String id; + + @override + List get props => [id]; +} + /// Event to request loading of sources. -/// {@endtemplate} final class LoadSourcesRequested extends ContentManagementEvent { - /// {@macro load_sources_requested} const LoadSourcesRequested({ this.startAfterId, this.limit, this.forceRefresh = false, + this.filter, }); - /// Optional ID to start pagination after. final String? startAfterId; - - /// Optional maximum number of items to return. final int? limit; - - /// If true, forces a refresh of the data, bypassing the cache. final bool forceRefresh; + /// Optional filter to apply to the sources query. + final Map? filter; + @override - List get props => [startAfterId, limit, forceRefresh]; + List get props => [startAfterId, limit, forceRefresh, filter]; } -/// {@template archive_source_requested} -/// Event to request archiving of a source. -/// {@endtemplate} +/// Event to archive a source. final class ArchiveSourceRequested extends ContentManagementEvent { - /// {@macro archive_source_requested} const ArchiveSourceRequested(this.id); - /// The ID of the source to archive. + final String id; + + @override + List get props => [id]; +} + +/// Event to publish a draft source. +final class PublishSourceRequested extends ContentManagementEvent { + const PublishSourceRequested(this.id); + + final String id; + + @override + List get props => [id]; +} + +/// Event to restore an archived source. +final class RestoreSourceRequested extends ContentManagementEvent { + const RestoreSourceRequested(this.id); + + final String id; + + @override + List get props => [id]; +} + +/// Event to request permanent deletion of a source. +final class DeleteSourceForeverRequested extends ContentManagementEvent { + const DeleteSourceForeverRequested(this.id); + + final String id; + + @override + List get props => [id]; +} + +/// Event to undo a pending deletion of a source. +final class UndoDeleteSourceRequested extends ContentManagementEvent { + const UndoDeleteSourceRequested(this.id); + final String id; @override diff --git a/lib/content_management/bloc/content_management_state.dart b/lib/content_management/bloc/content_management_state.dart index c7b153ae..ca96559d 100644 --- a/lib/content_management/bloc/content_management_state.dart +++ b/lib/content_management/bloc/content_management_state.dart @@ -1,6 +1,6 @@ part of 'content_management_bloc.dart'; -/// Represents the status of content loading and operations. +/// Represents the status of content management operations. enum ContentManagementStatus { /// The operation is in its initial state. initial, @@ -15,7 +15,9 @@ enum ContentManagementStatus { failure, } -/// Defines the state for the content management feature. +/// {@template content_management_state} +/// The state for the content management feature. +/// {@endtemplate} class ContentManagementState extends Equatable { /// {@macro content_management_state} const ContentManagementState({ @@ -33,50 +35,60 @@ class ContentManagementState extends Equatable { this.sourcesCursor, this.sourcesHasMore = false, this.exception, + this.lastPendingDeletionId, + this.snackbarMessage, }); /// The currently active tab in the content management section. final ContentManagementTab activeTab; - /// Status of headline data operations. + /// The status of the headlines loading operation. final ContentManagementStatus headlinesStatus; - /// List of headlines. + /// The list of headlines currently displayed. final List headlines; - /// Cursor for headline pagination. + /// The cursor for fetching the next page of headlines. final String? headlinesCursor; - /// Indicates if there are more headlines to load. + /// Indicates if there are more headlines available to load. final bool headlinesHasMore; - /// Status of topic data operations. + /// The status of the topics loading operation. final ContentManagementStatus topicsStatus; - /// List of topics. + /// The list of topics currently displayed. final List topics; - /// Cursor for topic pagination. + /// The cursor for fetching the next page of topics. final String? topicsCursor; - /// Indicates if there are more topics to load. + /// Indicates if there are more topics available to load. final bool topicsHasMore; - /// Status of source data operations. + /// The status of the sources loading operation. final ContentManagementStatus sourcesStatus; - /// List of sources. + /// The list of sources currently displayed. final List sources; - /// Cursor for source pagination. + /// The cursor for fetching the next page of sources. final String? sourcesCursor; - /// Indicates if there are more sources to load. + /// Indicates if there are more sources available to load. final bool sourcesHasMore; - /// The error describing an operation failure, if any. + /// The exception encountered during a failed operation, if any. final HttpException? exception; + /// The ID of the item that was most recently added to pending deletions. + /// Used to trigger the snackbar display. + final String? lastPendingDeletionId; + + /// The message to display in the snackbar for pending deletions or other + /// transient messages. + final String? snackbarMessage; + /// Creates a copy of this [ContentManagementState] with updated values. ContentManagementState copyWith({ ContentManagementTab? activeTab, @@ -93,6 +105,8 @@ class ContentManagementState extends Equatable { String? sourcesCursor, bool? sourcesHasMore, HttpException? exception, + String? lastPendingDeletionId, + String? snackbarMessage, }) { return ContentManagementState( activeTab: activeTab ?? this.activeTab, @@ -108,7 +122,11 @@ class ContentManagementState extends Equatable { sources: sources ?? this.sources, sourcesCursor: sourcesCursor ?? this.sourcesCursor, sourcesHasMore: sourcesHasMore ?? this.sourcesHasMore, - exception: exception ?? this.exception, + exception: exception, // Explicitly set to null if not provided + lastPendingDeletionId: + lastPendingDeletionId, // Explicitly set to null if not provided + snackbarMessage: + snackbarMessage, // Explicitly set to null if not provided ); } @@ -127,5 +145,8 @@ class ContentManagementState extends Equatable { sources, sourcesCursor, sourcesHasMore, + exception, + lastPendingDeletionId, + snackbarMessage, ]; } diff --git a/lib/content_management/bloc/draft_headlines/draft_headlines_bloc.dart b/lib/content_management/bloc/draft_headlines/draft_headlines_bloc.dart deleted file mode 100644 index 7b8a618d..00000000 --- a/lib/content_management/bloc/draft_headlines/draft_headlines_bloc.dart +++ /dev/null @@ -1,262 +0,0 @@ -import 'dart:async'; - -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 'draft_headlines_event.dart'; -part 'draft_headlines_state.dart'; - -/// {@template draft_headlines_bloc} -/// A BLoC responsible for managing the state of draft headlines. -/// -/// It handles loading, publishing, and permanently deleting draft headlines, -/// including a temporary "undo" period for deletions. -/// {@endtemplate} -class DraftHeadlinesBloc - extends Bloc { - /// {@macro draft_headlines_bloc} - DraftHeadlinesBloc({ - required DataRepository headlinesRepository, - required PendingDeletionsService pendingDeletionsService, - }) : _headlinesRepository = headlinesRepository, - _pendingDeletionsService = pendingDeletionsService, - super(const DraftHeadlinesState()) { - on(_onLoadDraftHeadlinesRequested); - on(_onPublishDraftHeadlineRequested); - on<_DeletionServiceStatusChanged>( - _onDeletionServiceStatusChanged, - ); - on( - _onDeleteDraftHeadlineForeverRequested, - ); - on(_onUndoDeleteDraftHeadlineRequested); - on(_onClearPublishedHeadline); - - // 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)); - } - }, - ); - } - - final DataRepository _headlinesRepository; - final PendingDeletionsService _pendingDeletionsService; - - /// Subscription to deletion events from the PendingDeletionsService. - late final StreamSubscription> - _deletionEventSubscription; - - @override - Future close() async { - // Cancel the subscription to deletion events to prevent memory leaks. - await _deletionEventSubscription.cancel(); - return super.close(); - } - - /// Handles the request to load draft headlines. - /// - /// Fetches paginated draft headlines from the repository and updates the state. - Future _onLoadDraftHeadlinesRequested( - LoadDraftHeadlinesRequested event, - Emitter emit, - ) async { - emit(state.copyWith(status: DraftHeadlinesStatus.loading)); - try { - final isPaginating = event.startAfterId != null; - final previousHeadlines = isPaginating ? state.headlines : []; - - final paginatedHeadlines = await _headlinesRepository.readAll( - filter: {'status': ContentStatus.draft.name}, - sort: [const SortOption('updatedAt', SortOrder.desc)], - pagination: PaginationOptions( - cursor: event.startAfterId, - limit: event.limit, - ), - ); - emit( - state.copyWith( - status: DraftHeadlinesStatus.success, - headlines: [...previousHeadlines, ...paginatedHeadlines.items], - cursor: paginatedHeadlines.cursor, - hasMore: paginatedHeadlines.hasMore, - ), - ); - } on HttpException catch (e) { - emit( - state.copyWith( - status: DraftHeadlinesStatus.failure, - exception: e, - ), - ); - } catch (e) { - emit( - state.copyWith( - status: DraftHeadlinesStatus.failure, - exception: UnknownException('An unexpected error occurred: $e'), - ), - ); - } - } - - /// Handles the request to publish a draft 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 _onPublishDraftHeadlineRequested( - PublishDraftHeadlineRequested event, - Emitter emit, - ) async { - final originalHeadlines = state.headlines; - final headlineIndex = originalHeadlines.indexWhere((h) => h.id == event.id); - if (headlineIndex == -1) return; - final headlineToPublish = originalHeadlines[headlineIndex]; - final updatedHeadlines = List.from(originalHeadlines) - ..removeAt(headlineIndex); - - // Optimistically remove the headline from the UI. - emit( - state.copyWith( - headlines: updatedHeadlines, - lastPendingDeletionId: state.lastPendingDeletionId == event.id - ? null - : state.lastPendingDeletionId, - snackbarHeadlineTitle: null, - ), - ); - - try { - final publishedHeadline = await _headlinesRepository.update( - id: event.id, - item: headlineToPublish.copyWith(status: ContentStatus.active), - ); - emit(state.copyWith(publishedHeadline: publishedHeadline)); - } on HttpException catch (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 a draft headline. - /// - /// This optimistically removes the headline from the UI and initiates a - /// timed deletion via the [PendingDeletionsService]. - Future _onDeleteDraftHeadlineForeverRequested( - DeleteDraftHeadlineForeverRequested event, - Emitter emit, - ) async { - final headlineToDelete = state.headlines.firstWhere( - (h) => h.id == event.id, - ); - - // Optimistically remove the headline from the UI. - final updatedHeadlines = List.from(state.headlines) - ..removeWhere((h) => h.id == event.id); - - emit( - state.copyWith( - headlines: updatedHeadlines, - lastPendingDeletionId: event.id, - snackbarHeadlineTitle: headlineToDelete.title, - ), - ); - - // Request deletion via the service. - _pendingDeletionsService.requestDeletion( - item: headlineToDelete, - repository: _headlinesRepository, - undoDuration: const Duration(seconds: 5), - ); - } - - /// Handles the request to undo a pending deletion of a draft headline. - /// - /// This cancels the deletion timer in the [PendingDeletionsService]. - Future _onUndoDeleteDraftHeadlineRequested( - UndoDeleteDraftHeadlineRequested 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 published headline from the state. - /// - /// This is typically called after the UI has processed the published headline - /// and no longer needs it in the state. - void _onClearPublishedHeadline( - ClearPublishedHeadline event, - Emitter emit, - ) { - emit(state.copyWith(publishedHeadline: null, snackbarHeadlineTitle: null)); - } -} diff --git a/lib/content_management/bloc/draft_headlines/draft_headlines_event.dart b/lib/content_management/bloc/draft_headlines/draft_headlines_event.dart deleted file mode 100644 index 030fe44b..00000000 --- a/lib/content_management/bloc/draft_headlines/draft_headlines_event.dart +++ /dev/null @@ -1,72 +0,0 @@ -part of 'draft_headlines_bloc.dart'; - -sealed class DraftHeadlinesEvent extends Equatable { - const DraftHeadlinesEvent(); - - @override - List get props => []; -} - -/// Event to request loading of draft headlines. -final class LoadDraftHeadlinesRequested extends DraftHeadlinesEvent { - const LoadDraftHeadlinesRequested({this.startAfterId, this.limit}); - - final String? startAfterId; - final int? limit; - - @override - List get props => [startAfterId, limit]; -} - -/// Event to publish a draft headline. -final class PublishDraftHeadlineRequested extends DraftHeadlinesEvent { - const PublishDraftHeadlineRequested(this.id); - - final String id; - - @override - List get props => [id]; -} - -/// Event to request permanent deletion of a draft headline. -final class DeleteDraftHeadlineForeverRequested extends DraftHeadlinesEvent { - /// {@macro delete_draft_headline_forever_requested} - const DeleteDraftHeadlineForeverRequested(this.id); - - /// The ID of the headline to permanently delete. - final String id; - - @override - List get props => [id]; -} - -/// Event to undo a pending deletion of a draft headline. -final class UndoDeleteDraftHeadlineRequested extends DraftHeadlinesEvent { - /// {@macro undo_delete_draft_headline_requested} - const UndoDeleteDraftHeadlineRequested(this.id); - - /// The ID of the headline whose deletion should be undone. - final String id; - - @override - List get props => [id]; -} - -/// Event to clear the published headline from the state. -final class ClearPublishedHeadline extends DraftHeadlinesEvent { - /// {@macro clear_published_headline} - const ClearPublishedHeadline(); - - @override - List get props => []; -} - -/// Event to handle updates from the pending deletions service. -final class _DeletionServiceStatusChanged extends DraftHeadlinesEvent { - const _DeletionServiceStatusChanged(this.event); - - final DeletionEvent event; - - @override - List get props => [event]; -} diff --git a/lib/content_management/bloc/draft_headlines/draft_headlines_state.dart b/lib/content_management/bloc/draft_headlines/draft_headlines_state.dart deleted file mode 100644 index eb91075c..00000000 --- a/lib/content_management/bloc/draft_headlines/draft_headlines_state.dart +++ /dev/null @@ -1,103 +0,0 @@ -part of 'draft_headlines_bloc.dart'; - -/// Represents the status of draft content operations. -enum DraftHeadlinesStatus { - /// 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 draft_headlines_state} -/// The state for the draft content feature. -/// -/// Manages the list of draft headlines, pagination details, -/// and any pending deletion operations. -/// {@endtemplate} -class DraftHeadlinesState extends Equatable { - /// {@macro draft_headlines_state} - const DraftHeadlinesState({ - this.status = DraftHeadlinesStatus.initial, - this.headlines = const [], - this.cursor, - this.hasMore = false, - this.exception, - this.publishedHeadline, - this.lastPendingDeletionId, - this.snackbarHeadlineTitle, - }); - - /// The current status of the draft headlines operations. - final DraftHeadlinesStatus status; - - /// The list of draft headlines currently displayed. - final List headlines; - - /// The cursor for fetching the next page of draft headlines. - /// A `null` value indicates no more pages. - final String? cursor; - - /// Indicates if there are more draft 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 published, if any. - final Headline? publishedHeadline; - - /// 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 [DraftHeadlinesState] with updated values. - DraftHeadlinesState copyWith({ - DraftHeadlinesStatus? status, - List? headlines, - String? cursor, - bool? hasMore, - HttpException? exception, - Headline? publishedHeadline, - String? lastPendingDeletionId, - String? snackbarHeadlineTitle, - }) { - return DraftHeadlinesState( - status: status ?? this.status, - headlines: headlines ?? this.headlines, - cursor: cursor ?? this.cursor, - hasMore: hasMore ?? this.hasMore, - // Exception and publishedHeadline are explicitly set to null if not provided - // to ensure they are cleared after being handled. - exception: exception, - publishedHeadline: publishedHeadline, - lastPendingDeletionId: - lastPendingDeletionId ?? this.lastPendingDeletionId, - snackbarHeadlineTitle: - snackbarHeadlineTitle ?? this.snackbarHeadlineTitle, - ); - } - - @override - List get props => [ - status, - headlines, - cursor, - hasMore, - exception, - publishedHeadline, - lastPendingDeletionId, - snackbarHeadlineTitle, - ]; -} diff --git a/lib/content_management/bloc/draft_sources/draft_sources_bloc.dart b/lib/content_management/bloc/draft_sources/draft_sources_bloc.dart deleted file mode 100644 index 41659743..00000000 --- a/lib/content_management/bloc/draft_sources/draft_sources_bloc.dart +++ /dev/null @@ -1,261 +0,0 @@ -import 'dart:async'; - -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 'draft_sources_event.dart'; -part 'draft_sources_state.dart'; - -/// {@template draft_sources_bloc} -/// A BLoC responsible for managing the state of draft sources. -/// -/// It handles loading, publishing, and permanently deleting draft sources, -/// including a temporary "undo" period for deletions. -/// {@endtemplate} -class DraftSourcesBloc extends Bloc { - /// {@macro draft_sources_bloc} - DraftSourcesBloc({ - required DataRepository sourcesRepository, - required PendingDeletionsService pendingDeletionsService, - }) : _sourcesRepository = sourcesRepository, - _pendingDeletionsService = pendingDeletionsService, - super(const DraftSourcesState()) { - on(_onLoadDraftSourcesRequested); - on(_onPublishDraftSourceRequested); - on<_DeletionServiceStatusChanged>( - _onDeletionServiceStatusChanged, - ); - on( - _onDeleteDraftSourceForeverRequested, - ); - on(_onUndoDeleteDraftSourceRequested); - on(_onClearPublishedSource); - - // 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 Source) { - add(_DeletionServiceStatusChanged(event)); - } - }, - ); - } - - final DataRepository _sourcesRepository; - final PendingDeletionsService _pendingDeletionsService; - - /// Subscription to deletion events from the PendingDeletionsService. - late final StreamSubscription> - _deletionEventSubscription; - - @override - Future close() async { - // Cancel the subscription to deletion events to prevent memory leaks. - await _deletionEventSubscription.cancel(); - return super.close(); - } - - /// Handles the request to load draft sources. - /// - /// Fetches paginated draft sources from the repository and updates the state. - Future _onLoadDraftSourcesRequested( - LoadDraftSourcesRequested event, - Emitter emit, - ) async { - emit(state.copyWith(status: DraftSourcesStatus.loading)); - try { - final isPaginating = event.startAfterId != null; - final previousSources = isPaginating ? state.sources : []; - - final paginatedSources = await _sourcesRepository.readAll( - filter: {'status': ContentStatus.draft.name}, - sort: [const SortOption('updatedAt', SortOrder.desc)], - pagination: PaginationOptions( - cursor: event.startAfterId, - limit: event.limit, - ), - ); - emit( - state.copyWith( - status: DraftSourcesStatus.success, - sources: [...previousSources, ...paginatedSources.items], - cursor: paginatedSources.cursor, - hasMore: paginatedSources.hasMore, - ), - ); - } on HttpException catch (e) { - emit( - state.copyWith( - status: DraftSourcesStatus.failure, - exception: e, - ), - ); - } catch (e) { - emit( - state.copyWith( - status: DraftSourcesStatus.failure, - exception: UnknownException('An unexpected error occurred: $e'), - ), - ); - } - } - - /// Handles the request to publish a draft source. - /// - /// Optimistically removes the source from the UI, updates its status to active - /// in the repository, and then updates the state. If the source was pending - /// deletion, its pending deletion is cancelled. - Future _onPublishDraftSourceRequested( - PublishDraftSourceRequested event, - Emitter emit, - ) async { - final originalSources = state.sources; - final sourceIndex = originalSources.indexWhere((s) => s.id == event.id); - if (sourceIndex == -1) return; - final sourceToPublish = originalSources[sourceIndex]; - final updatedSources = List.from(originalSources) - ..removeAt(sourceIndex); - - // Optimistically remove the source from the UI. - emit( - state.copyWith( - sources: updatedSources, - lastPendingDeletionId: state.lastPendingDeletionId == event.id - ? null - : state.lastPendingDeletionId, - snackbarSourceTitle: null, - ), - ); - - try { - final publishedSource = await _sourcesRepository.update( - id: event.id, - item: sourceToPublish.copyWith(status: ContentStatus.active), - ); - emit(state.copyWith(publishedSource: publishedSource)); - } on HttpException catch (e) { - // If the update fails, revert the change in the UI - emit( - state.copyWith( - sources: originalSources, - exception: e, - lastPendingDeletionId: state.lastPendingDeletionId, - ), - ); - } catch (e) { - emit( - state.copyWith( - sources: originalSources, - 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 snackbarSourceTitle are cleared if this was the one. - emit( - state.copyWith( - lastPendingDeletionId: state.lastPendingDeletionId == id - ? null - : state.lastPendingDeletionId, - snackbarSourceTitle: null, - ), - ); - } else if (status == DeletionStatus.undone) { - // Deletion undone, restore the source to the main list. - if (item is Source) { - final insertionIndex = state.sources.indexWhere( - (s) => s.updatedAt.isBefore(item.updatedAt), - ); - final updatedSources = List.from(state.sources) - ..insert( - insertionIndex != -1 ? insertionIndex : state.sources.length, - item, - ); - emit( - state.copyWith( - sources: updatedSources, - lastPendingDeletionId: state.lastPendingDeletionId == id - ? null - : state.lastPendingDeletionId, - snackbarSourceTitle: null, - ), - ); - } - } - } - - /// Handles the request to permanently delete a draft source. - /// - /// This optimistically removes the source from the UI and initiates a - /// timed deletion via the [PendingDeletionsService]. - Future _onDeleteDraftSourceForeverRequested( - DeleteDraftSourceForeverRequested event, - Emitter emit, - ) async { - final sourceToDelete = state.sources.firstWhere( - (s) => s.id == event.id, - ); - - // Optimistically remove the source from the UI. - final updatedSources = List.from(state.sources) - ..removeWhere((s) => s.id == event.id); - - emit( - state.copyWith( - sources: updatedSources, - lastPendingDeletionId: event.id, - snackbarSourceTitle: sourceToDelete.name, - ), - ); - - // Request deletion via the service. - _pendingDeletionsService.requestDeletion( - item: sourceToDelete, - repository: _sourcesRepository, - undoDuration: const Duration(seconds: 5), - ); - } - - /// Handles the request to undo a pending deletion of a draft source. - /// - /// This cancels the deletion timer in the [PendingDeletionsService]. - Future _onUndoDeleteDraftSourceRequested( - UndoDeleteDraftSourceRequested 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 published source from the state. - /// - /// This is typically called after the UI has processed the published source - /// and no longer needs it in the state. - void _onClearPublishedSource( - ClearPublishedSource event, - Emitter emit, - ) { - emit(state.copyWith(publishedSource: null, snackbarSourceTitle: null)); - } -} diff --git a/lib/content_management/bloc/draft_sources/draft_sources_event.dart b/lib/content_management/bloc/draft_sources/draft_sources_event.dart deleted file mode 100644 index 34df8a59..00000000 --- a/lib/content_management/bloc/draft_sources/draft_sources_event.dart +++ /dev/null @@ -1,72 +0,0 @@ -part of 'draft_sources_bloc.dart'; - -sealed class DraftSourcesEvent extends Equatable { - const DraftSourcesEvent(); - - @override - List get props => []; -} - -/// Event to request loading of draft sources. -final class LoadDraftSourcesRequested extends DraftSourcesEvent { - const LoadDraftSourcesRequested({this.startAfterId, this.limit}); - - final String? startAfterId; - final int? limit; - - @override - List get props => [startAfterId, limit]; -} - -/// Event to publish a draft source. -final class PublishDraftSourceRequested extends DraftSourcesEvent { - const PublishDraftSourceRequested(this.id); - - final String id; - - @override - List get props => [id]; -} - -/// Event to request permanent deletion of a draft source. -final class DeleteDraftSourceForeverRequested extends DraftSourcesEvent { - /// {@macro delete_draft_source_forever_requested} - const DeleteDraftSourceForeverRequested(this.id); - - /// The ID of the source to permanently delete. - final String id; - - @override - List get props => [id]; -} - -/// Event to undo a pending deletion of a draft source. -final class UndoDeleteDraftSourceRequested extends DraftSourcesEvent { - /// {@macro undo_delete_draft_source_requested} - const UndoDeleteDraftSourceRequested(this.id); - - /// The ID of the source whose deletion should be undone. - final String id; - - @override - List get props => [id]; -} - -/// Event to clear the published source from the state. -final class ClearPublishedSource extends DraftSourcesEvent { - /// {@macro clear_published_source} - const ClearPublishedSource(); - - @override - List get props => []; -} - -/// Event to handle updates from the pending deletions service. -final class _DeletionServiceStatusChanged extends DraftSourcesEvent { - const _DeletionServiceStatusChanged(this.event); - - final DeletionEvent event; - - @override - List get props => [event]; -} diff --git a/lib/content_management/bloc/draft_sources/draft_sources_state.dart b/lib/content_management/bloc/draft_sources/draft_sources_state.dart deleted file mode 100644 index 8dc8562a..00000000 --- a/lib/content_management/bloc/draft_sources/draft_sources_state.dart +++ /dev/null @@ -1,102 +0,0 @@ -part of 'draft_sources_bloc.dart'; - -/// Represents the status of draft content operations. -enum DraftSourcesStatus { - /// 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 draft_sources_state} -/// The state for the draft content feature. -/// -/// Manages the list of draft sources, pagination details, -/// and any pending deletion operations. -/// {@endtemplate} -class DraftSourcesState extends Equatable { - /// {@macro draft_sources_state} - const DraftSourcesState({ - this.status = DraftSourcesStatus.initial, - this.sources = const [], - this.cursor, - this.hasMore = false, - this.exception, - this.publishedSource, - this.lastPendingDeletionId, - this.snackbarSourceTitle, - }); - - /// The current status of the draft sources operations. - final DraftSourcesStatus status; - - /// The list of draft sources currently displayed. - final List sources; - - /// The cursor for fetching the next page of draft sources. - /// A `null` value indicates no more pages. - final String? cursor; - - /// Indicates if there are more draft sources available to load. - final bool hasMore; - - /// The exception encountered during a failed operation, if any. - final HttpException? exception; - - /// The source that was most recently published, if any. - final Source? publishedSource; - - /// The ID of the source that was most recently added to pending deletions. - /// Used to trigger the snackbar display. - final String? lastPendingDeletionId; - - /// The title of the source 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? snackbarSourceTitle; - - /// Creates a copy of this [DraftSourcesState] with updated values. - DraftSourcesState copyWith({ - DraftSourcesStatus? status, - List? sources, - String? cursor, - bool? hasMore, - HttpException? exception, - Source? publishedSource, - String? lastPendingDeletionId, - String? snackbarSourceTitle, - }) { - return DraftSourcesState( - status: status ?? this.status, - sources: sources ?? this.sources, - cursor: cursor ?? this.cursor, - hasMore: hasMore ?? this.hasMore, - // Exception and publishedSource are explicitly set to null if not provided - // to ensure they are cleared after being handled. - exception: exception, - publishedSource: publishedSource, - lastPendingDeletionId: - lastPendingDeletionId ?? this.lastPendingDeletionId, - snackbarSourceTitle: snackbarSourceTitle ?? this.snackbarSourceTitle, - ); - } - - @override - List get props => [ - status, - sources, - cursor, - hasMore, - exception, - publishedSource, - lastPendingDeletionId, - snackbarSourceTitle, - ]; -} diff --git a/lib/content_management/bloc/draft_topics/draft_topics_bloc.dart b/lib/content_management/bloc/draft_topics/draft_topics_bloc.dart deleted file mode 100644 index def36564..00000000 --- a/lib/content_management/bloc/draft_topics/draft_topics_bloc.dart +++ /dev/null @@ -1,261 +0,0 @@ -import 'dart:async'; - -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 'draft_topics_event.dart'; -part 'draft_topics_state.dart'; - -/// {@template draft_topics_bloc} -/// A BLoC responsible for managing the state of draft topics. -/// -/// It handles loading, publishing, and permanently deleting draft topics, -/// including a temporary "undo" period for deletions. -/// {@endtemplate} -class DraftTopicsBloc extends Bloc { - /// {@macro draft_topics_bloc} - DraftTopicsBloc({ - required DataRepository topicsRepository, - required PendingDeletionsService pendingDeletionsService, - }) : _topicsRepository = topicsRepository, - _pendingDeletionsService = pendingDeletionsService, - super(const DraftTopicsState()) { - on(_onLoadDraftTopicsRequested); - on(_onPublishDraftTopicRequested); - on<_DeletionServiceStatusChanged>( - _onDeletionServiceStatusChanged, - ); - on( - _onDeleteDraftTopicForeverRequested, - ); - on(_onUndoDeleteDraftTopicRequested); - on(_onClearPublishedTopic); - - // 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 Topic) { - add(_DeletionServiceStatusChanged(event)); - } - }, - ); - } - - final DataRepository _topicsRepository; - final PendingDeletionsService _pendingDeletionsService; - - /// Subscription to deletion events from the PendingDeletionsService. - late final StreamSubscription> - _deletionEventSubscription; - - @override - Future close() async { - // Cancel the subscription to deletion events to prevent memory leaks. - await _deletionEventSubscription.cancel(); - return super.close(); - } - - /// Handles the request to load draft topics. - /// - /// Fetches paginated draft topics from the repository and updates the state. - Future _onLoadDraftTopicsRequested( - LoadDraftTopicsRequested event, - Emitter emit, - ) async { - emit(state.copyWith(status: DraftTopicsStatus.loading)); - try { - final isPaginating = event.startAfterId != null; - final previousTopics = isPaginating ? state.topics : []; - - final paginatedTopics = await _topicsRepository.readAll( - filter: {'status': ContentStatus.draft.name}, - sort: [const SortOption('updatedAt', SortOrder.desc)], - pagination: PaginationOptions( - cursor: event.startAfterId, - limit: event.limit, - ), - ); - emit( - state.copyWith( - status: DraftTopicsStatus.success, - topics: [...previousTopics, ...paginatedTopics.items], - cursor: paginatedTopics.cursor, - hasMore: paginatedTopics.hasMore, - ), - ); - } on HttpException catch (e) { - emit( - state.copyWith( - status: DraftTopicsStatus.failure, - exception: e, - ), - ); - } catch (e) { - emit( - state.copyWith( - status: DraftTopicsStatus.failure, - exception: UnknownException('An unexpected error occurred: $e'), - ), - ); - } - } - - /// Handles the request to publish a draft topic. - /// - /// Optimistically removes the topic from the UI, updates its status to active - /// in the repository, and then updates the state. If the topic was pending - /// deletion, its pending deletion is cancelled. - Future _onPublishDraftTopicRequested( - PublishDraftTopicRequested event, - Emitter emit, - ) async { - final originalTopics = state.topics; - final topicIndex = originalTopics.indexWhere((t) => t.id == event.id); - if (topicIndex == -1) return; - final topicToPublish = originalTopics[topicIndex]; - final updatedTopics = List.from(originalTopics) - ..removeAt(topicIndex); - - // Optimistically remove the topic from the UI. - emit( - state.copyWith( - topics: updatedTopics, - lastPendingDeletionId: state.lastPendingDeletionId == event.id - ? null - : state.lastPendingDeletionId, - snackbarTopicTitle: null, - ), - ); - - try { - final publishedTopic = await _topicsRepository.update( - id: event.id, - item: topicToPublish.copyWith(status: ContentStatus.active), - ); - emit(state.copyWith(publishedTopic: publishedTopic)); - } on HttpException catch (e) { - // If the update fails, revert the change in the UI - emit( - state.copyWith( - topics: originalTopics, - exception: e, - lastPendingDeletionId: state.lastPendingDeletionId, - ), - ); - } catch (e) { - emit( - state.copyWith( - topics: originalTopics, - 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 snackbarTopicTitle are cleared if this was the one. - emit( - state.copyWith( - lastPendingDeletionId: state.lastPendingDeletionId == id - ? null - : state.lastPendingDeletionId, - snackbarTopicTitle: null, - ), - ); - } else if (status == DeletionStatus.undone) { - // Deletion undone, restore the topic to the main list. - if (item is Topic) { - final insertionIndex = state.topics.indexWhere( - (t) => t.updatedAt.isBefore(item.updatedAt), - ); - final updatedTopics = List.from(state.topics) - ..insert( - insertionIndex != -1 ? insertionIndex : state.topics.length, - item, - ); - emit( - state.copyWith( - topics: updatedTopics, - lastPendingDeletionId: state.lastPendingDeletionId == id - ? null - : state.lastPendingDeletionId, - snackbarTopicTitle: null, - ), - ); - } - } - } - - /// Handles the request to permanently delete a draft topic. - /// - /// This optimistically removes the topic from the UI and initiates a - /// timed deletion via the [PendingDeletionsService]. - Future _onDeleteDraftTopicForeverRequested( - DeleteDraftTopicForeverRequested event, - Emitter emit, - ) async { - final topicToDelete = state.topics.firstWhere( - (t) => t.id == event.id, - ); - - // Optimistically remove the topic from the UI. - final updatedTopics = List.from(state.topics) - ..removeWhere((t) => t.id == event.id); - - emit( - state.copyWith( - topics: updatedTopics, - lastPendingDeletionId: event.id, - snackbarTopicTitle: topicToDelete.name, - ), - ); - - // Request deletion via the service. - _pendingDeletionsService.requestDeletion( - item: topicToDelete, - repository: _topicsRepository, - undoDuration: const Duration(seconds: 5), - ); - } - - /// Handles the request to undo a pending deletion of a draft topic. - /// - /// This cancels the deletion timer in the [PendingDeletionsService]. - Future _onUndoDeleteDraftTopicRequested( - UndoDeleteDraftTopicRequested 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 published topic from the state. - /// - /// This is typically called after the UI has processed the published topic - /// and no longer needs it in the state. - void _onClearPublishedTopic( - ClearPublishedTopic event, - Emitter emit, - ) { - emit(state.copyWith(publishedTopic: null, snackbarTopicTitle: null)); - } -} diff --git a/lib/content_management/bloc/draft_topics/draft_topics_event.dart b/lib/content_management/bloc/draft_topics/draft_topics_event.dart deleted file mode 100644 index c5e75b47..00000000 --- a/lib/content_management/bloc/draft_topics/draft_topics_event.dart +++ /dev/null @@ -1,72 +0,0 @@ -part of 'draft_topics_bloc.dart'; - -sealed class DraftTopicsEvent extends Equatable { - const DraftTopicsEvent(); - - @override - List get props => []; -} - -/// Event to request loading of draft topics. -final class LoadDraftTopicsRequested extends DraftTopicsEvent { - const LoadDraftTopicsRequested({this.startAfterId, this.limit}); - - final String? startAfterId; - final int? limit; - - @override - List get props => [startAfterId, limit]; -} - -/// Event to publish a draft topic. -final class PublishDraftTopicRequested extends DraftTopicsEvent { - const PublishDraftTopicRequested(this.id); - - final String id; - - @override - List get props => [id]; -} - -/// Event to request permanent deletion of a draft topic. -final class DeleteDraftTopicForeverRequested extends DraftTopicsEvent { - /// {@macro delete_draft_topic_forever_requested} - const DeleteDraftTopicForeverRequested(this.id); - - /// The ID of the topic to permanently delete. - final String id; - - @override - List get props => [id]; -} - -/// Event to undo a pending deletion of a draft topic. -final class UndoDeleteDraftTopicRequested extends DraftTopicsEvent { - /// {@macro undo_delete_draft_topic_requested} - const UndoDeleteDraftTopicRequested(this.id); - - /// The ID of the topic whose deletion should be undone. - final String id; - - @override - List get props => [id]; -} - -/// Event to clear the published topic from the state. -final class ClearPublishedTopic extends DraftTopicsEvent { - /// {@macro clear_published_topic} - const ClearPublishedTopic(); - - @override - List get props => []; -} - -/// Event to handle updates from the pending deletions service. -final class _DeletionServiceStatusChanged extends DraftTopicsEvent { - const _DeletionServiceStatusChanged(this.event); - - final DeletionEvent event; - - @override - List get props => [event]; -} diff --git a/lib/content_management/bloc/draft_topics/draft_topics_state.dart b/lib/content_management/bloc/draft_topics/draft_topics_state.dart deleted file mode 100644 index 07a30584..00000000 --- a/lib/content_management/bloc/draft_topics/draft_topics_state.dart +++ /dev/null @@ -1,102 +0,0 @@ -part of 'draft_topics_bloc.dart'; - -/// Represents the status of draft content operations. -enum DraftTopicsStatus { - /// 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 draft_topics_state} -/// The state for the draft content feature. -/// -/// Manages the list of draft topics, pagination details, -/// and any pending deletion operations. -/// {@endtemplate} -class DraftTopicsState extends Equatable { - /// {@macro draft_topics_state} - const DraftTopicsState({ - this.status = DraftTopicsStatus.initial, - this.topics = const [], - this.cursor, - this.hasMore = false, - this.exception, - this.publishedTopic, - this.lastPendingDeletionId, - this.snackbarTopicTitle, - }); - - /// The current status of the draft topics operations. - final DraftTopicsStatus status; - - /// The list of draft topics currently displayed. - final List topics; - - /// The cursor for fetching the next page of draft topics. - /// A `null` value indicates no more pages. - final String? cursor; - - /// Indicates if there are more draft topics available to load. - final bool hasMore; - - /// The exception encountered during a failed operation, if any. - final HttpException? exception; - - /// The topic that was most recently published, if any. - final Topic? publishedTopic; - - /// The ID of the topic that was most recently added to pending deletions. - /// Used to trigger the snackbar display. - final String? lastPendingDeletionId; - - /// The title of the topic 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? snackbarTopicTitle; - - /// Creates a copy of this [DraftTopicsState] with updated values. - DraftTopicsState copyWith({ - DraftTopicsStatus? status, - List? topics, - String? cursor, - bool? hasMore, - HttpException? exception, - Topic? publishedTopic, - String? lastPendingDeletionId, - String? snackbarTopicTitle, - }) { - return DraftTopicsState( - status: status ?? this.status, - topics: topics ?? this.topics, - cursor: cursor ?? this.cursor, - hasMore: hasMore ?? this.hasMore, - // Exception and publishedTopic are explicitly set to null if not provided - // to ensure they are cleared after being handled. - exception: exception, - publishedTopic: publishedTopic, - lastPendingDeletionId: - lastPendingDeletionId ?? this.lastPendingDeletionId, - snackbarTopicTitle: snackbarTopicTitle ?? this.snackbarTopicTitle, - ); - } - - @override - List get props => [ - status, - topics, - cursor, - hasMore, - exception, - publishedTopic, - lastPendingDeletionId, - snackbarTopicTitle, - ]; -} diff --git a/lib/content_management/bloc/headlines_filter/headlines_filter_bloc.dart b/lib/content_management/bloc/headlines_filter/headlines_filter_bloc.dart new file mode 100644 index 00000000..e0a14b54 --- /dev/null +++ b/lib/content_management/bloc/headlines_filter/headlines_filter_bloc.dart @@ -0,0 +1,103 @@ +import 'package:bloc/bloc.dart'; +import 'package:core/core.dart'; +import 'package:equatable/equatable.dart'; + +part 'headlines_filter_event.dart'; +part 'headlines_filter_state.dart'; + +/// {@template headlines_filter_bloc} +/// A BLoC that manages the state of the headlines filter UI. +/// +/// It handles user input for search queries, status selections, and other +/// filter criteria, and builds a filter map to be used by the data-fetching BLoC. +/// Filters are applied only when explicitly requested via [HeadlinesFilterApplied]. +/// {@endtemplate} +class HeadlinesFilterBloc + extends Bloc { + /// {@macro headlines_filter_bloc} + HeadlinesFilterBloc() : super(const HeadlinesFilterState()) { + on(_onHeadlinesSearchQueryChanged); + on(_onHeadlinesStatusFilterChanged); + on(_onHeadlinesSourceFilterChanged); + on(_onHeadlinesTopicFilterChanged); + on(_onHeadlinesCountryFilterChanged); + on(_onHeadlinesFilterApplied); + on(_onHeadlinesFilterReset); + } + + /// Handles changes to the search query text field. + void _onHeadlinesSearchQueryChanged( + HeadlinesSearchQueryChanged event, + Emitter emit, + ) { + emit(state.copyWith(searchQuery: event.query)); + } + + /// Handles changes to the selected content status. + /// + /// This updates the single selected status for the filter. + void _onHeadlinesStatusFilterChanged( + HeadlinesStatusFilterChanged event, + Emitter emit, + ) { + emit(state.copyWith(selectedStatus: event.status)); + } + + /// Handles changes to the selected source IDs. + /// + /// This updates the list of source IDs for the filter. + void _onHeadlinesSourceFilterChanged( + HeadlinesSourceFilterChanged event, + Emitter emit, + ) { + emit(state.copyWith(selectedSourceIds: event.sourceIds)); + } + + /// Handles changes to the selected topic IDs. + /// + /// This updates the list of topic IDs for the filter. + void _onHeadlinesTopicFilterChanged( + HeadlinesTopicFilterChanged event, + Emitter emit, + ) { + emit(state.copyWith(selectedTopicIds: event.topicIds)); + } + + /// Handles changes to the selected country IDs. + /// + /// This updates the list of country IDs for the filter. + void _onHeadlinesCountryFilterChanged( + HeadlinesCountryFilterChanged event, + Emitter emit, + ) { + emit(state.copyWith(selectedCountryIds: event.countryIds)); + } + + /// Handles the application of all current filter settings. + /// + /// This event is dispatched when the user explicitly confirms the filters + /// (e.g., by clicking an "Apply" button). It updates the BLoC's state + /// with the final filter values. + void _onHeadlinesFilterApplied( + HeadlinesFilterApplied event, + Emitter emit, + ) { + emit( + state.copyWith( + searchQuery: event.searchQuery, + selectedStatus: event.selectedStatus, + selectedSourceIds: event.selectedSourceIds, + selectedTopicIds: event.selectedTopicIds, + selectedCountryIds: event.selectedCountryIds, + ), + ); + } + + /// Handles the request to reset all filters to their initial state. + void _onHeadlinesFilterReset( + HeadlinesFilterReset event, + Emitter emit, + ) { + emit(const HeadlinesFilterState()); + } +} diff --git a/lib/content_management/bloc/headlines_filter/headlines_filter_event.dart b/lib/content_management/bloc/headlines_filter/headlines_filter_event.dart new file mode 100644 index 00000000..d4299379 --- /dev/null +++ b/lib/content_management/bloc/headlines_filter/headlines_filter_event.dart @@ -0,0 +1,89 @@ +part of 'headlines_filter_bloc.dart'; + +sealed class HeadlinesFilterEvent extends Equatable { + const HeadlinesFilterEvent(); + + @override + List get props => []; +} + +/// Event to notify the BLoC that the search query has changed. +final class HeadlinesSearchQueryChanged extends HeadlinesFilterEvent { + const HeadlinesSearchQueryChanged(this.query); + + final String query; + + @override + List get props => [query]; +} + +/// Event to notify the BLoC that the selected content status has changed. +final class HeadlinesStatusFilterChanged extends HeadlinesFilterEvent { + const HeadlinesStatusFilterChanged(this.status); + + final ContentStatus status; + + @override + List get props => [status]; +} + +/// Event to notify the BLoC that the selected source IDs have changed. +final class HeadlinesSourceFilterChanged extends HeadlinesFilterEvent { + const HeadlinesSourceFilterChanged(this.sourceIds); + + final List sourceIds; + + @override + List get props => [sourceIds]; +} + +/// Event to notify the BLoC that the selected topic IDs have changed. +final class HeadlinesTopicFilterChanged extends HeadlinesFilterEvent { + const HeadlinesTopicFilterChanged(this.topicIds); + + final List topicIds; + + @override + List get props => [topicIds]; +} + +/// Event to notify the BLoC that the selected country IDs have changed. +final class HeadlinesCountryFilterChanged extends HeadlinesFilterEvent { + const HeadlinesCountryFilterChanged(this.countryIds); + + final List countryIds; + + @override + List get props => [countryIds]; +} + +/// Event to request applying all current filters. +final class HeadlinesFilterApplied extends HeadlinesFilterEvent { + const HeadlinesFilterApplied({ + required this.searchQuery, + required this.selectedStatus, + required this.selectedSourceIds, + required this.selectedTopicIds, + required this.selectedCountryIds, + }); + + final String searchQuery; + final ContentStatus selectedStatus; + final List selectedSourceIds; + final List selectedTopicIds; + final List selectedCountryIds; + + @override + List get props => [ + searchQuery, + selectedStatus, + selectedSourceIds, + selectedTopicIds, + selectedCountryIds, + ]; +} + +/// Event to request resetting all filters to their initial state. +final class HeadlinesFilterReset extends HeadlinesFilterEvent { + const HeadlinesFilterReset(); +} diff --git a/lib/content_management/bloc/headlines_filter/headlines_filter_state.dart b/lib/content_management/bloc/headlines_filter/headlines_filter_state.dart new file mode 100644 index 00000000..7017a7ab --- /dev/null +++ b/lib/content_management/bloc/headlines_filter/headlines_filter_state.dart @@ -0,0 +1,61 @@ +part of 'headlines_filter_bloc.dart'; + +/// {@template headlines_filter_state} +/// The state for the headlines filter UI. +/// +/// Manages the search query and the set of selected content statuses +/// that the user wants to filter by. +/// {@endtemplate} +class HeadlinesFilterState extends Equatable { + /// {@macro headlines_filter_state} + const HeadlinesFilterState({ + this.searchQuery = '', + // Default to showing only active items. + this.selectedStatus = ContentStatus.active, + this.selectedSourceIds = const [], + this.selectedTopicIds = const [], + this.selectedCountryIds = const [], + }); + + /// The current text in the search query field. + final String searchQuery; + + /// The single content status to be included in the filter. + final ContentStatus selectedStatus; + + /// The list of source IDs to be included in the filter. + final List selectedSourceIds; + + /// The list of topic IDs to be included in the filter. + final List selectedTopicIds; + + /// The list of country IDs to be included in the filter. + final List selectedCountryIds; + + /// Creates a copy of this state with the given fields replaced with the + /// new values. + HeadlinesFilterState copyWith({ + String? searchQuery, + ContentStatus? selectedStatus, + List? selectedSourceIds, + List? selectedTopicIds, + List? selectedCountryIds, + }) { + return HeadlinesFilterState( + searchQuery: searchQuery ?? this.searchQuery, + selectedStatus: selectedStatus ?? this.selectedStatus, + selectedSourceIds: selectedSourceIds ?? this.selectedSourceIds, + selectedTopicIds: selectedTopicIds ?? this.selectedTopicIds, + selectedCountryIds: selectedCountryIds ?? this.selectedCountryIds, + ); + } + + @override + List get props => [ + searchQuery, + selectedStatus, + selectedSourceIds, + selectedTopicIds, + selectedCountryIds, + ]; +} diff --git a/lib/content_management/bloc/sources_filter/sources_filter_bloc.dart b/lib/content_management/bloc/sources_filter/sources_filter_bloc.dart new file mode 100644 index 00000000..c87ec826 --- /dev/null +++ b/lib/content_management/bloc/sources_filter/sources_filter_bloc.dart @@ -0,0 +1,102 @@ +import 'package:bloc/bloc.dart'; +import 'package:core/core.dart'; +import 'package:equatable/equatable.dart'; + +part 'sources_filter_event.dart'; +part 'sources_filter_state.dart'; + +/// {@template sources_filter_bloc} +/// A BLoC that manages the state of the sources filter UI. +/// +/// It handles user input for search queries, status selections, and other +/// filter criteria, and builds a filter map to be used by the data-fetching BLoC. +/// Filters are applied only when explicitly requested via [SourcesFilterApplied]. +/// {@endtemplate} +class SourcesFilterBloc extends Bloc { + /// {@macro sources_filter_bloc} + SourcesFilterBloc() : super(const SourcesFilterState()) { + on(_onSourcesSearchQueryChanged); + on(_onSourcesStatusFilterChanged); + on(_onSourcesSourceTypeFilterChanged); + on(_onSourcesLanguageFilterChanged); + on(_onSourcesHeadquartersFilterChanged); + on(_onSourcesFilterApplied); + on(_onSourcesFilterReset); + } + + /// Handles changes to the search query text field. + void _onSourcesSearchQueryChanged( + SourcesSearchQueryChanged event, + Emitter emit, + ) { + emit(state.copyWith(searchQuery: event.query)); + } + + /// Handles changes to the selected content status. + /// + /// This updates the single selected status for the filter. + void _onSourcesStatusFilterChanged( + SourcesStatusFilterChanged event, + Emitter emit, + ) { + emit(state.copyWith(selectedStatus: event.status)); + } + + /// Handles changes to the selected source types. + /// + /// This updates the list of source types for the filter. + void _onSourcesSourceTypeFilterChanged( + SourcesSourceTypeFilterChanged event, + Emitter emit, + ) { + emit(state.copyWith(selectedSourceTypes: event.sourceTypes)); + } + + /// Handles changes to the selected language codes. + /// + /// This updates the list of language codes for the filter. + void _onSourcesLanguageFilterChanged( + SourcesLanguageFilterChanged event, + Emitter emit, + ) { + emit(state.copyWith(selectedLanguageCodes: event.languageCodes)); + } + + /// Handles changes to the selected headquarters country IDs. + /// + /// This updates the list of headquarters country IDs for the filter. + void _onSourcesHeadquartersFilterChanged( + SourcesHeadquartersFilterChanged event, + Emitter emit, + ) { + emit(state.copyWith(selectedHeadquartersCountryIds: event.countryIds)); + } + + /// Handles the application of all current filter settings. + /// + /// This event is dispatched when the user explicitly confirms the filters + /// (e.g., by clicking an "Apply" button). It updates the BLoC's state + /// with the final filter values. + void _onSourcesFilterApplied( + SourcesFilterApplied event, + Emitter emit, + ) { + emit( + state.copyWith( + searchQuery: event.searchQuery, + selectedStatus: event.selectedStatus, + selectedSourceTypes: event.selectedSourceTypes, + selectedLanguageCodes: event.selectedLanguageCodes, + selectedHeadquartersCountryIds: event.selectedHeadquartersCountryIds, + ), + ); + } + + /// Handles the request to reset all filters to their initial state. + void _onSourcesFilterReset( + SourcesFilterReset event, + Emitter emit, + ) { + emit(const SourcesFilterState()); + } +} diff --git a/lib/content_management/bloc/sources_filter/sources_filter_event.dart b/lib/content_management/bloc/sources_filter/sources_filter_event.dart new file mode 100644 index 00000000..db2d1d05 --- /dev/null +++ b/lib/content_management/bloc/sources_filter/sources_filter_event.dart @@ -0,0 +1,89 @@ +part of 'sources_filter_bloc.dart'; + +sealed class SourcesFilterEvent extends Equatable { + const SourcesFilterEvent(); + + @override + List get props => []; +} + +/// Event to notify the BLoC that the search query has changed. +final class SourcesSearchQueryChanged extends SourcesFilterEvent { + const SourcesSearchQueryChanged(this.query); + + final String query; + + @override + List get props => [query]; +} + +/// Event to notify the BLoC that the selected content status has changed. +final class SourcesStatusFilterChanged extends SourcesFilterEvent { + const SourcesStatusFilterChanged(this.status); + + final ContentStatus status; + + @override + List get props => [status]; +} + +/// Event to notify the BLoC that the selected source types have changed. +final class SourcesSourceTypeFilterChanged extends SourcesFilterEvent { + const SourcesSourceTypeFilterChanged(this.sourceTypes); + + final List sourceTypes; + + @override + List get props => [sourceTypes]; +} + +/// Event to notify the BLoC that the selected language codes have changed. +final class SourcesLanguageFilterChanged extends SourcesFilterEvent { + const SourcesLanguageFilterChanged(this.languageCodes); + + final List languageCodes; + + @override + List get props => [languageCodes]; +} + +/// Event to notify the BLoC that the selected headquarters country IDs have changed. +final class SourcesHeadquartersFilterChanged extends SourcesFilterEvent { + const SourcesHeadquartersFilterChanged(this.countryIds); + + final List countryIds; + + @override + List get props => [countryIds]; +} + +/// Event to request applying all current filters. +final class SourcesFilterApplied extends SourcesFilterEvent { + const SourcesFilterApplied({ + required this.searchQuery, + required this.selectedStatus, + required this.selectedSourceTypes, + required this.selectedLanguageCodes, + required this.selectedHeadquartersCountryIds, + }); + + final String searchQuery; + final ContentStatus selectedStatus; + final List selectedSourceTypes; + final List selectedLanguageCodes; + final List selectedHeadquartersCountryIds; + + @override + List get props => [ + searchQuery, + selectedStatus, + selectedSourceTypes, + selectedLanguageCodes, + selectedHeadquartersCountryIds, + ]; +} + +/// Event to request resetting all filters to their initial state. +final class SourcesFilterReset extends SourcesFilterEvent { + const SourcesFilterReset(); +} diff --git a/lib/content_management/bloc/sources_filter/sources_filter_state.dart b/lib/content_management/bloc/sources_filter/sources_filter_state.dart new file mode 100644 index 00000000..81b3114a --- /dev/null +++ b/lib/content_management/bloc/sources_filter/sources_filter_state.dart @@ -0,0 +1,63 @@ +part of 'sources_filter_bloc.dart'; + +/// {@template sources_filter_state} +/// The state for the sources filter UI. +/// +/// Manages the search query and the set of selected content statuses +/// that the user wants to filter by. +/// {@endtemplate} +class SourcesFilterState extends Equatable { + /// {@macro sources_filter_state} + const SourcesFilterState({ + this.searchQuery = '', + // Default to showing only active items. + this.selectedStatus = ContentStatus.active, + this.selectedSourceTypes = const [], + this.selectedLanguageCodes = const [], + this.selectedHeadquartersCountryIds = const [], + }); + + /// The current text in the search query field. + final String searchQuery; + + /// The single content status to be included in the filter. + final ContentStatus selectedStatus; + + /// The list of source types to be included in the filter. + final List selectedSourceTypes; + + /// The list of language codes to be included in the filter. + final List selectedLanguageCodes; + + /// The list of headquarters country IDs to be included in the filter. + final List selectedHeadquartersCountryIds; + + /// Creates a copy of this state with the given fields replaced with the + /// new values. + SourcesFilterState copyWith({ + String? searchQuery, + ContentStatus? selectedStatus, + List? selectedSourceTypes, + List? selectedLanguageCodes, + List? selectedHeadquartersCountryIds, + }) { + return SourcesFilterState( + searchQuery: searchQuery ?? this.searchQuery, + selectedStatus: selectedStatus ?? this.selectedStatus, + selectedSourceTypes: selectedSourceTypes ?? this.selectedSourceTypes, + selectedLanguageCodes: + selectedLanguageCodes ?? this.selectedLanguageCodes, + selectedHeadquartersCountryIds: + selectedHeadquartersCountryIds ?? this.selectedHeadquartersCountryIds, + ); + } + + @override + List get props => [ + searchQuery, + selectedStatus, + selectedSourceTypes, + selectedLanguageCodes, + selectedHeadquartersCountryIds, + ]; +} diff --git a/lib/content_management/bloc/topics_filter/topics_filter_bloc.dart b/lib/content_management/bloc/topics_filter/topics_filter_bloc.dart new file mode 100644 index 00000000..4e40a178 --- /dev/null +++ b/lib/content_management/bloc/topics_filter/topics_filter_bloc.dart @@ -0,0 +1,66 @@ +import 'package:bloc/bloc.dart'; +import 'package:core/core.dart'; +import 'package:equatable/equatable.dart'; + +part 'topics_filter_event.dart'; +part 'topics_filter_state.dart'; + +/// {@template topics_filter_bloc} +/// A BLoC that manages the state of the topics filter UI. +/// +/// It handles user input for search queries and status selections, +/// and builds a filter map to be used by the data-fetching BLoC. +/// Filters are applied only when explicitly requested via [TopicsFilterApplied]. +/// {@endtemplate} +class TopicsFilterBloc extends Bloc { + /// {@macro topics_filter_bloc} + TopicsFilterBloc() : super(const TopicsFilterState()) { + on(_onTopicsSearchQueryChanged); + on(_onTopicsStatusFilterChanged); + on(_onTopicsFilterApplied); + on(_onTopicsFilterReset); + } + + /// Handles changes to the search query text field. + void _onTopicsSearchQueryChanged( + TopicsSearchQueryChanged event, + Emitter emit, + ) { + emit(state.copyWith(searchQuery: event.query)); + } + + /// Handles changes to the selected content status. + /// + /// This updates the single selected status for the filter. + void _onTopicsStatusFilterChanged( + TopicsStatusFilterChanged event, + Emitter emit, + ) { + emit(state.copyWith(selectedStatus: event.status)); + } + + /// Handles the application of all current filter settings. + /// + /// This event is dispatched when the user explicitly confirms the filters + /// (e.g., by clicking an "Apply" button). It updates the BLoC's state + /// with the final filter values. + void _onTopicsFilterApplied( + TopicsFilterApplied event, + Emitter emit, + ) { + emit( + state.copyWith( + searchQuery: event.searchQuery, + selectedStatus: event.selectedStatus, + ), + ); + } + + /// Handles the request to reset all filters to their initial state. + void _onTopicsFilterReset( + TopicsFilterReset event, + Emitter emit, + ) { + emit(const TopicsFilterState()); + } +} diff --git a/lib/content_management/bloc/topics_filter/topics_filter_event.dart b/lib/content_management/bloc/topics_filter/topics_filter_event.dart new file mode 100644 index 00000000..f1b3d877 --- /dev/null +++ b/lib/content_management/bloc/topics_filter/topics_filter_event.dart @@ -0,0 +1,47 @@ +part of 'topics_filter_bloc.dart'; + +sealed class TopicsFilterEvent extends Equatable { + const TopicsFilterEvent(); + + @override + List get props => []; +} + +/// Event to notify the BLoC that the search query has changed. +final class TopicsSearchQueryChanged extends TopicsFilterEvent { + const TopicsSearchQueryChanged(this.query); + + final String query; + + @override + List get props => [query]; +} + +/// Event to notify the BLoC that the selected content status has changed. +final class TopicsStatusFilterChanged extends TopicsFilterEvent { + const TopicsStatusFilterChanged(this.status); + + final ContentStatus status; + + @override + List get props => [status]; +} + +/// Event to request applying all current filters. +final class TopicsFilterApplied extends TopicsFilterEvent { + const TopicsFilterApplied({ + required this.searchQuery, + required this.selectedStatus, + }); + + final String searchQuery; + final ContentStatus selectedStatus; + + @override + List get props => [searchQuery, selectedStatus]; +} + +/// Event to request resetting all filters to their initial state. +final class TopicsFilterReset extends TopicsFilterEvent { + const TopicsFilterReset(); +} diff --git a/lib/content_management/bloc/topics_filter/topics_filter_state.dart b/lib/content_management/bloc/topics_filter/topics_filter_state.dart new file mode 100644 index 00000000..f5f107a0 --- /dev/null +++ b/lib/content_management/bloc/topics_filter/topics_filter_state.dart @@ -0,0 +1,37 @@ +part of 'topics_filter_bloc.dart'; + +/// {@template topics_filter_state} +/// The state for the topics filter UI. +/// +/// Manages the search query and the set of selected content statuses +/// that the user wants to filter by. +/// {@endtemplate} +class TopicsFilterState extends Equatable { + /// {@macro topics_filter_state} + const TopicsFilterState({ + this.searchQuery = '', + // Default to showing only active items. + this.selectedStatus = ContentStatus.active, + }); + + /// The current text in the search query field. + final String searchQuery; + + /// The single content status to be included in the filter. + final ContentStatus selectedStatus; + + /// Creates a copy of this state with the given fields replaced with the + /// new values. + TopicsFilterState copyWith({ + String? searchQuery, + ContentStatus? selectedStatus, + }) { + return TopicsFilterState( + searchQuery: searchQuery ?? this.searchQuery, + selectedStatus: selectedStatus ?? this.selectedStatus, + ); + } + + @override + List get props => [searchQuery, selectedStatus]; +} diff --git a/lib/content_management/view/archived_headlines_page.dart b/lib/content_management/view/archived_headlines_page.dart deleted file mode 100644 index a92fc289..00000000 --- a/lib/content_management/view/archived_headlines_page.dart +++ /dev/null @@ -1,245 +0,0 @@ -import 'package:core/core.dart'; -import 'package:data_repository/data_repository.dart'; -import 'package:data_table_2/data_table_2.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/archived_headlines/archived_headlines_bloc.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/content_management_bloc.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/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'; - -class ArchivedHeadlinesPage extends StatelessWidget { - const ArchivedHeadlinesPage({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => ArchivedHeadlinesBloc( - headlinesRepository: context.read>(), - pendingDeletionsService: context.read(), - )..add(const LoadArchivedHeadlinesRequested(limit: kDefaultRowsPerPage)), - child: const _ArchivedHeadlinesView(), - ); - } -} - -class _ArchivedHeadlinesView extends StatelessWidget { - const _ArchivedHeadlinesView(); - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizationsX(context).l10n; - final pendingDeletionsService = context.read(); - return Scaffold( - appBar: AppBar( - title: Text(l10n.archivedHeadlines), - ), - body: Padding( - padding: const EdgeInsets.all(AppSpacing.lg), - child: BlocListener( - listenWhen: (previous, current) => - 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), - ); - // 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( - SnackBar( - content: Text( - l10n.headlineDeleted(truncatedTitle), - ), - action: SnackBarAction( - label: l10n.undo, - onPressed: () { - // Directly call undoDeletion on the service. - pendingDeletionsService.undoDeletion(headlineId); - }, - ), - ), - ); - } - }, - child: BlocBuilder( - builder: (context, state) { - if (state.status == ArchivedHeadlinesStatus.loading && - state.headlines.isEmpty) { - return LoadingStateWidget( - icon: Icons.newspaper, - headline: l10n.loadingArchivedHeadlines, - subheadline: l10n.pleaseWait, - ); - } - - if (state.status == ArchivedHeadlinesStatus.failure) { - return FailureStateWidget( - exception: state.exception!, - onRetry: () => context.read().add( - const LoadArchivedHeadlinesRequested( - limit: kDefaultRowsPerPage, - ), - ), - ); - } - - if (state.headlines.isEmpty) { - return Center(child: Text(l10n.noArchivedHeadlinesFound)); - } - - return Column( - children: [ - if (state.status == ArchivedHeadlinesStatus.loading && - state.headlines.isNotEmpty) - const LinearProgressIndicator(), - Expanded( - child: PaginatedDataTable2( - columns: [ - DataColumn2( - label: Text(l10n.headlineTitle), - size: ColumnSize.L, - ), - DataColumn2( - label: Text(l10n.sourceName), - size: ColumnSize.M, - ), - DataColumn2( - label: Text(l10n.lastUpdated), - size: ColumnSize.M, - ), - DataColumn2( - label: Text(l10n.actions), - size: ColumnSize.S, - fixedWidth: 120, - ), - ], - source: _HeadlinesDataSource( - context: context, - headlines: state.headlines, - hasMore: state.hasMore, - l10n: l10n, - ), - rowsPerPage: kDefaultRowsPerPage, - availableRowsPerPage: const [kDefaultRowsPerPage], - onPageChanged: (pageIndex) { - final newOffset = pageIndex * kDefaultRowsPerPage; - if (newOffset >= state.headlines.length && - state.hasMore && - state.status != ArchivedHeadlinesStatus.loading) { - context.read().add( - LoadArchivedHeadlinesRequested( - startAfterId: state.cursor, - limit: kDefaultRowsPerPage, - ), - ); - } - }, - empty: Center(child: Text(l10n.noHeadlinesFound)), - showCheckboxColumn: false, - showFirstLastButtons: true, - fit: FlexFit.tight, - headingRowHeight: 56, - dataRowHeight: 56, - columnSpacing: AppSpacing.md, - horizontalMargin: AppSpacing.md, - ), - ), - ], - ); - }, - ), - ), - ), - ); - } -} - -class _HeadlinesDataSource extends DataTableSource { - _HeadlinesDataSource({ - required this.context, - required this.headlines, - required this.hasMore, - required this.l10n, - }); - - final BuildContext context; - final List headlines; - final bool hasMore; - final AppLocalizations l10n; - - @override - DataRow? getRow(int index) { - if (index >= headlines.length) { - return null; - } - final headline = headlines[index]; - return DataRow2( - cells: [ - DataCell( - Text( - headline.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - DataCell(Text(headline.source.name)), - DataCell( - Text( - DateFormat('dd-MM-yyyy').format(headline.updatedAt.toLocal()), - ), - ), - DataCell( - Row( - children: [ - IconButton( - icon: const Icon(Icons.restore), - tooltip: l10n.restore, - onPressed: () { - context.read().add( - RestoreHeadlineRequested(headline.id), - ); - }, - ), - IconButton( - icon: const Icon(Icons.delete_forever), - tooltip: l10n.deleteForever, - onPressed: () { - context.read().add( - DeleteHeadlineForeverRequested(headline.id), - ); - }, - ), - ], - ), - ), - ], - ); - } - - @override - bool get isRowCountApproximate => hasMore; - - @override - int get rowCount => headlines.length; - - @override - int get selectedRowCount => 0; -} diff --git a/lib/content_management/view/archived_sources_page.dart b/lib/content_management/view/archived_sources_page.dart deleted file mode 100644 index b9a5fdb8..00000000 --- a/lib/content_management/view/archived_sources_page.dart +++ /dev/null @@ -1,198 +0,0 @@ -import 'package:core/core.dart'; -import 'package:data_repository/data_repository.dart'; -import 'package:data_table_2/data_table_2.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/archived_sources/archived_sources_bloc.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/content_management_bloc.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; -import 'package:intl/intl.dart'; -import 'package:ui_kit/ui_kit.dart'; - -class ArchivedSourcesPage extends StatelessWidget { - const ArchivedSourcesPage({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => ArchivedSourcesBloc( - sourcesRepository: context.read>(), - )..add(const LoadArchivedSourcesRequested(limit: kDefaultRowsPerPage)), - child: const _ArchivedSourcesView(), - ); - } -} - -class _ArchivedSourcesView extends StatelessWidget { - const _ArchivedSourcesView(); - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizationsX(context).l10n; - return Scaffold( - appBar: AppBar( - title: Text(l10n.archivedSources), - ), - body: Padding( - padding: const EdgeInsets.all(AppSpacing.lg), - child: BlocListener( - listenWhen: (previous, current) => - previous.restoredSource != current.restoredSource, - listener: (context, state) { - if (state.restoredSource != null) { - context.read().add( - const LoadSourcesRequested(limit: kDefaultRowsPerPage), - ); - } - }, - child: BlocBuilder( - builder: (context, state) { - if (state.status == ArchivedSourcesStatus.loading && - state.sources.isEmpty) { - return LoadingStateWidget( - icon: Icons.source, - headline: l10n.loadingArchivedSources, - subheadline: l10n.pleaseWait, - ); - } - - if (state.status == ArchivedSourcesStatus.failure) { - return FailureStateWidget( - exception: state.exception!, - onRetry: () => context.read().add( - const LoadArchivedSourcesRequested( - limit: kDefaultRowsPerPage, - ), - ), - ); - } - - if (state.sources.isEmpty) { - return Center(child: Text(l10n.noArchivedSourcesFound)); - } - - return Column( - children: [ - if (state.status == ArchivedSourcesStatus.loading && - state.sources.isNotEmpty) - const LinearProgressIndicator(), - Expanded( - child: PaginatedDataTable2( - columns: [ - DataColumn2( - label: Text(l10n.sourceName), - size: ColumnSize.L, - ), - DataColumn2( - label: Text(l10n.lastUpdated), - size: ColumnSize.M, - ), - DataColumn2( - label: Text(l10n.actions), - size: ColumnSize.S, - fixedWidth: 120, - ), - ], - source: _SourcesDataSource( - context: context, - sources: state.sources, - hasMore: state.hasMore, - l10n: l10n, - ), - rowsPerPage: kDefaultRowsPerPage, - availableRowsPerPage: const [kDefaultRowsPerPage], - onPageChanged: (pageIndex) { - final newOffset = pageIndex * kDefaultRowsPerPage; - if (newOffset >= state.sources.length && - state.hasMore && - state.status != ArchivedSourcesStatus.loading) { - context.read().add( - LoadArchivedSourcesRequested( - startAfterId: state.cursor, - limit: kDefaultRowsPerPage, - ), - ); - } - }, - empty: Center(child: Text(l10n.noSourcesFound)), - showCheckboxColumn: false, - showFirstLastButtons: true, - fit: FlexFit.tight, - headingRowHeight: 56, - dataRowHeight: 56, - columnSpacing: AppSpacing.md, - horizontalMargin: AppSpacing.md, - ), - ), - ], - ); - }, - ), - ), - ), - ); - } -} - -class _SourcesDataSource extends DataTableSource { - _SourcesDataSource({ - required this.context, - required this.sources, - required this.hasMore, - required this.l10n, - }); - - final BuildContext context; - final List sources; - final bool hasMore; - final AppLocalizations l10n; - - @override - DataRow? getRow(int index) { - if (index >= sources.length) { - return null; - } - final source = sources[index]; - return DataRow2( - cells: [ - DataCell( - Text( - source.name, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - DataCell( - Text( - DateFormat('dd-MM-yyyy').format(source.updatedAt.toLocal()), - ), - ), - DataCell( - Row( - children: [ - IconButton( - icon: const Icon(Icons.restore), - tooltip: l10n.restore, - onPressed: () { - context.read().add( - RestoreSourceRequested(source.id), - ); - }, - ), - ], - ), - ), - ], - ); - } - - @override - bool get isRowCountApproximate => hasMore; - - @override - int get rowCount => sources.length; - - @override - int get selectedRowCount => 0; -} diff --git a/lib/content_management/view/archived_topics_page.dart b/lib/content_management/view/archived_topics_page.dart deleted file mode 100644 index 46db2de3..00000000 --- a/lib/content_management/view/archived_topics_page.dart +++ /dev/null @@ -1,198 +0,0 @@ -import 'package:core/core.dart'; -import 'package:data_repository/data_repository.dart'; -import 'package:data_table_2/data_table_2.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/archived_topics/archived_topics_bloc.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/content_management_bloc.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; -import 'package:intl/intl.dart'; -import 'package:ui_kit/ui_kit.dart'; - -class ArchivedTopicsPage extends StatelessWidget { - const ArchivedTopicsPage({super.key}); - - @override - Widget build(BuildContext context) { - return BlocProvider( - create: (context) => ArchivedTopicsBloc( - topicsRepository: context.read>(), - )..add(const LoadArchivedTopicsRequested(limit: kDefaultRowsPerPage)), - child: const _ArchivedTopicsView(), - ); - } -} - -class _ArchivedTopicsView extends StatelessWidget { - const _ArchivedTopicsView(); - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizationsX(context).l10n; - return Scaffold( - appBar: AppBar( - title: Text(l10n.archivedTopics), - ), - body: Padding( - padding: const EdgeInsets.all(AppSpacing.lg), - child: BlocListener( - listenWhen: (previous, current) => - previous.restoredTopic != current.restoredTopic, - listener: (context, state) { - if (state.restoredTopic != null) { - context.read().add( - const LoadTopicsRequested(limit: kDefaultRowsPerPage), - ); - } - }, - child: BlocBuilder( - builder: (context, state) { - if (state.status == ArchivedTopicsStatus.loading && - state.topics.isEmpty) { - return LoadingStateWidget( - icon: Icons.topic, - headline: l10n.loadingArchivedTopics, - subheadline: l10n.pleaseWait, - ); - } - - if (state.status == ArchivedTopicsStatus.failure) { - return FailureStateWidget( - exception: state.exception!, - onRetry: () => context.read().add( - const LoadArchivedTopicsRequested( - limit: kDefaultRowsPerPage, - ), - ), - ); - } - - if (state.topics.isEmpty) { - return Center(child: Text(l10n.noArchivedTopicsFound)); - } - - return Column( - children: [ - if (state.status == ArchivedTopicsStatus.loading && - state.topics.isNotEmpty) - const LinearProgressIndicator(), - Expanded( - child: PaginatedDataTable2( - columns: [ - DataColumn2( - label: Text(l10n.topicName), - size: ColumnSize.L, - ), - DataColumn2( - label: Text(l10n.lastUpdated), - size: ColumnSize.M, - ), - DataColumn2( - label: Text(l10n.actions), - size: ColumnSize.S, - fixedWidth: 120, - ), - ], - source: _TopicsDataSource( - context: context, - topics: state.topics, - hasMore: state.hasMore, - l10n: l10n, - ), - rowsPerPage: kDefaultRowsPerPage, - availableRowsPerPage: const [kDefaultRowsPerPage], - onPageChanged: (pageIndex) { - final newOffset = pageIndex * kDefaultRowsPerPage; - if (newOffset >= state.topics.length && - state.hasMore && - state.status != ArchivedTopicsStatus.loading) { - context.read().add( - LoadArchivedTopicsRequested( - startAfterId: state.cursor, - limit: kDefaultRowsPerPage, - ), - ); - } - }, - empty: Center(child: Text(l10n.noTopicsFound)), - showCheckboxColumn: false, - showFirstLastButtons: true, - fit: FlexFit.tight, - headingRowHeight: 56, - dataRowHeight: 56, - columnSpacing: AppSpacing.md, - horizontalMargin: AppSpacing.md, - ), - ), - ], - ); - }, - ), - ), - ), - ); - } -} - -class _TopicsDataSource extends DataTableSource { - _TopicsDataSource({ - required this.context, - required this.topics, - required this.hasMore, - required this.l10n, - }); - - final BuildContext context; - final List topics; - final bool hasMore; - final AppLocalizations l10n; - - @override - DataRow? getRow(int index) { - if (index >= topics.length) { - return null; - } - final topic = topics[index]; - return DataRow2( - cells: [ - DataCell( - Text( - topic.name, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - DataCell( - Text( - DateFormat('dd-MM-yyyy').format(topic.updatedAt.toLocal()), - ), - ), - DataCell( - Row( - children: [ - IconButton( - icon: const Icon(Icons.restore), - tooltip: l10n.restore, - onPressed: () { - context.read().add( - RestoreTopicRequested(topic.id), - ); - }, - ), - ], - ), - ), - ], - ); - } - - @override - bool get isRowCountApproximate => hasMore; - - @override - int get rowCount => topics.length; - - @override - int get selectedRowCount => 0; -} diff --git a/lib/content_management/view/content_management_page.dart b/lib/content_management/view/content_management_page.dart index 32502d10..87832e5b 100644 --- a/lib/content_management/view/content_management_page.dart +++ b/lib/content_management/view/content_management_page.dart @@ -1,6 +1,12 @@ +import 'package:collection/collection.dart'; // For deep equality check on filter maps +import 'package:core/core.dart'; +import 'package:data_repository/data_repository.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/content_management_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/headlines_filter/headlines_filter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/sources_filter/sources_filter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/topics_filter/topics_filter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/view/headlines_page.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/view/sources_page.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/view/topics_page.dart'; @@ -54,10 +60,117 @@ class _ContentManagementPageState extends State @override Widget build(BuildContext context) { final l10n = AppLocalizationsX(context).l10n; - return BlocListener( - listener: (context, state) { - // Optionally handle state changes, e.g., show snackbar for errors - }, + return MultiBlocListener( + listeners: [ + BlocListener( + listenWhen: (previous, current) => + previous.searchQuery != current.searchQuery || + previous.selectedStatus != current.selectedStatus || + !const DeepCollectionEquality() + .equals(previous.selectedSourceIds, current.selectedSourceIds) || + !const DeepCollectionEquality() + .equals(previous.selectedTopicIds, current.selectedTopicIds) || + !const DeepCollectionEquality().equals( + previous.selectedCountryIds, + current.selectedCountryIds, + ), + listener: (context, state) { + context.read().add( + LoadHeadlinesRequested( + filter: context + .read() + .buildHeadlinesFilterMap(state), + forceRefresh: true, + ), + ); + }, + ), + BlocListener( + listenWhen: (previous, current) => + previous.searchQuery != current.searchQuery || + previous.selectedStatus != current.selectedStatus, + listener: (context, state) { + context.read().add( + LoadTopicsRequested( + filter: context + .read() + .buildTopicsFilterMap(state), + forceRefresh: true, + ), + ); + }, + ), + BlocListener( + listenWhen: (previous, current) => + previous.searchQuery != current.searchQuery || + previous.selectedStatus != current.selectedStatus || + !const DeepCollectionEquality().equals( + previous.selectedSourceTypes, + current.selectedSourceTypes, + ) || + !const DeepCollectionEquality().equals( + previous.selectedLanguageCodes, + current.selectedLanguageCodes, + ) || + !const DeepCollectionEquality().equals( + previous.selectedHeadquartersCountryIds, + current.selectedHeadquartersCountryIds, + ), + listener: (context, state) { + context.read().add( + LoadSourcesRequested( + filter: context + .read() + .buildSourcesFilterMap(state), + forceRefresh: true, + ), + ); + }, + ), + BlocListener( + listenWhen: (previous, current) => + previous.snackbarMessage != current.snackbarMessage && + current.snackbarMessage != null, + listener: (context, state) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(state.snackbarMessage!), + action: SnackBarAction( + label: l10n.undo, + onPressed: () { + final activeTab = state.activeTab; + final lastPendingDeletionId = state.lastPendingDeletionId; + if (lastPendingDeletionId != null) { + switch (activeTab) { + case ContentManagementTab.headlines: + context.read().add( + UndoDeleteHeadlineRequested( + lastPendingDeletionId, + ), + ); + case ContentManagementTab.topics: + context.read().add( + UndoDeleteTopicRequested( + lastPendingDeletionId, + ), + ); + case ContentManagementTab.sources: + context.read().add( + UndoDeleteSourceRequested( + lastPendingDeletionId, + ), + ); + } + } + }, + ), + ), + ); + }, + ), + ], child: Scaffold( appBar: AppBar( title: Text(l10n.contentManagement), @@ -96,62 +209,54 @@ class _ContentManagementPageState extends State ), actions: [ IconButton( - icon: const Icon(Icons.drafts_outlined), - tooltip: l10n.draftsIconTooltip, + icon: const Icon(Icons.filter_list), + tooltip: l10n.filter, onPressed: () { - final currentTab = context - .read() - .state - .activeTab; - switch (currentTab) { - case ContentManagementTab.headlines: - context.goNamed(Routes.draftHeadlinesName); - case ContentManagementTab.topics: - context.goNamed(Routes.draftTopicsName); - case ContentManagementTab.sources: - context.goNamed(Routes.draftSourcesName); - } - }, - ), - IconButton( - icon: const Icon(Icons.archive_outlined), - tooltip: l10n.archivedItems, - onPressed: () { - final currentTab = context - .read() - .state - .activeTab; - switch (currentTab) { - case ContentManagementTab.headlines: - context.goNamed(Routes.archivedHeadlinesName); - case ContentManagementTab.topics: - context.goNamed(Routes.archivedTopicsName); - case ContentManagementTab.sources: - context.goNamed(Routes.archivedSourcesName); - } - }, - ), - IconButton( - icon: const Icon(Icons.add_outlined), - tooltip: l10n.addNewItem, - onPressed: () { - final currentTab = context - .read() - .state - .activeTab; - switch (currentTab) { - case ContentManagementTab.headlines: - context.goNamed(Routes.createHeadlineName); - case ContentManagementTab.topics: - context.goNamed(Routes.createTopicName); - case ContentManagementTab.sources: - context.goNamed(Routes.createSourceName); - } + final contentManagementBloc = context + .read(); + final topicsRepository = context.read>(); + final sourcesRepository = context + .read>(); + final countriesRepository = context + .read>(); + final languagesRepository = context + .read>(); + + // Construct arguments map to pass to the filter dialog route + final arguments = { + 'activeTab': contentManagementBloc.state.activeTab, + 'sourcesRepository': sourcesRepository, + 'topicsRepository': topicsRepository, + 'countriesRepository': countriesRepository, + 'languagesRepository': languagesRepository, + 'headlinesFilterState': context.read().state, + 'topicsFilterState': context.read().state, + 'sourcesFilterState': context.read().state, + }; + + // Push the filter dialog as a new route + context.pushNamed(Routes.filterDialogName, extra: arguments); }, ), - const SizedBox(width: AppSpacing.md), ], ), + floatingActionButton: FloatingActionButton( + onPressed: () { + final currentTab = context + .read() + .state + .activeTab; + switch (currentTab) { + case ContentManagementTab.headlines: + context.goNamed(Routes.createHeadlineName); + case ContentManagementTab.topics: + context.goNamed(Routes.createTopicName); + case ContentManagementTab.sources: + context.goNamed(Routes.createSourceName); + } + }, + child: const Icon(Icons.add), + ), body: TabBarView( controller: _tabController, children: const [ diff --git a/lib/content_management/view/create_headline_page.dart b/lib/content_management/view/create_headline_page.dart index eaf21cfa..ced81804 100644 --- a/lib/content_management/view/create_headline_page.dart +++ b/lib/content_management/view/create_headline_page.dart @@ -208,12 +208,14 @@ class _CreateHeadlineViewState extends State<_CreateHeadlineView> { const SizedBox(height: AppSpacing.lg), SearchableSelectionInput( label: l10n.sourceName, - selectedItem: state.source, + selectedItems: state.source != null + ? [state.source!] + : [], itemBuilder: (context, source) => Text(source.name), itemToString: (source) => source.name, - onChanged: (value) => context + onChanged: (items) => context .read() - .add(CreateHeadlineSourceChanged(value)), + .add(CreateHeadlineSourceChanged(items?.first)), repository: context.read>(), filterBuilder: (searchTerm) => searchTerm == null ? {} @@ -232,12 +234,12 @@ class _CreateHeadlineViewState extends State<_CreateHeadlineView> { const SizedBox(height: AppSpacing.lg), SearchableSelectionInput( label: l10n.topicName, - selectedItem: state.topic, + selectedItems: state.topic != null ? [state.topic!] : [], itemBuilder: (context, topic) => Text(topic.name), itemToString: (topic) => topic.name, - onChanged: (value) => context + onChanged: (items) => context .read() - .add(CreateHeadlineTopicChanged(value)), + .add(CreateHeadlineTopicChanged(items?.first)), repository: context.read>(), filterBuilder: (searchTerm) => searchTerm == null ? {} @@ -256,7 +258,9 @@ class _CreateHeadlineViewState extends State<_CreateHeadlineView> { const SizedBox(height: AppSpacing.lg), SearchableSelectionInput( label: l10n.countryName, - selectedItem: state.eventCountry, + selectedItems: state.eventCountry != null + ? [state.eventCountry!] + : [], itemBuilder: (context, country) => Row( children: [ SizedBox( @@ -274,9 +278,9 @@ class _CreateHeadlineViewState extends State<_CreateHeadlineView> { ], ), itemToString: (country) => country.name, - onChanged: (value) => context + onChanged: (items) => context .read() - .add(CreateHeadlineCountryChanged(value)), + .add(CreateHeadlineCountryChanged(items?.first)), repository: context.read>(), filterBuilder: (searchTerm) => searchTerm == null ? {} diff --git a/lib/content_management/view/create_source_page.dart b/lib/content_management/view/create_source_page.dart index c6626426..1d67abf4 100644 --- a/lib/content_management/view/create_source_page.dart +++ b/lib/content_management/view/create_source_page.dart @@ -213,12 +213,14 @@ class _CreateSourceViewState extends State<_CreateSourceView> { const SizedBox(height: AppSpacing.lg), SearchableSelectionInput( label: l10n.language, - selectedItem: state.language, + selectedItems: state.language != null + ? [state.language!] + : [], itemBuilder: (context, language) => Text(language.name), itemToString: (language) => language.name, - onChanged: (value) => context + onChanged: (items) => context .read() - .add(CreateSourceLanguageChanged(value)), + .add(CreateSourceLanguageChanged(items?.first)), repository: context.read>(), filterBuilder: (searchTerm) => searchTerm == null ? {} @@ -236,19 +238,23 @@ class _CreateSourceViewState extends State<_CreateSourceView> { const SizedBox(height: AppSpacing.lg), SearchableSelectionInput( label: l10n.sourceType, - selectedItem: state.sourceType, + selectedItems: state.sourceType != null + ? [state.sourceType!] + : [], staticItems: SourceType.values.toList(), itemBuilder: (context, type) => Text(type.localizedName(l10n)), itemToString: (type) => type.localizedName(l10n), - onChanged: (value) => context + onChanged: (items) => context .read() - .add(CreateSourceTypeChanged(value)), + .add(CreateSourceTypeChanged(items?.first)), ), const SizedBox(height: AppSpacing.lg), SearchableSelectionInput( label: l10n.headquarters, - selectedItem: state.headquarters, + selectedItems: state.headquarters != null + ? [state.headquarters!] + : [], itemBuilder: (context, country) => Row( children: [ SizedBox( @@ -266,9 +272,9 @@ class _CreateSourceViewState extends State<_CreateSourceView> { ], ), itemToString: (country) => country.name, - onChanged: (value) => context + onChanged: (items) => context .read() - .add(CreateSourceHeadquartersChanged(value)), + .add(CreateSourceHeadquartersChanged(items?.first)), repository: context.read>(), filterBuilder: (searchTerm) => searchTerm == null ? {} diff --git a/lib/content_management/view/draft_headlines_page.dart b/lib/content_management/view/draft_headlines_page.dart deleted file mode 100644 index 08ca2880..00000000 --- a/lib/content_management/view/draft_headlines_page.dart +++ /dev/null @@ -1,277 +0,0 @@ -import 'package:core/core.dart'; -import 'package:data_repository/data_repository.dart'; -import 'package:data_table_2/data_table_2.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/content_management_bloc.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/draft_headlines/draft_headlines_bloc.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/router/routes.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:go_router/go_router.dart'; -import 'package:intl/intl.dart'; -import 'package:ui_kit/ui_kit.dart'; - -/// {@template draft_headlines_page} -/// A page for displaying and managing draft headlines in a tabular format. -/// {@endtemplate} -class DraftHeadlinesPage extends StatelessWidget { - /// {@macro draft_headlines_page} - const DraftHeadlinesPage({super.key}); - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizationsX(context).l10n; - final pendingDeletionsService = context.read(); - return Scaffold( - appBar: AppBar( - title: Text(l10n.draftHeadlines), - ), - body: BlocProvider( - create: (context) => DraftHeadlinesBloc( - headlinesRepository: context.read>(), - pendingDeletionsService: pendingDeletionsService, - )..add(const LoadDraftHeadlinesRequested(limit: kDefaultRowsPerPage)), - child: BlocListener( - listenWhen: (previous, current) => - previous.lastPendingDeletionId != current.lastPendingDeletionId || - previous.publishedHeadline != current.publishedHeadline || - previous.snackbarHeadlineTitle != current.snackbarHeadlineTitle, - listener: (context, state) { - if (state.publishedHeadline != null) { - // When a headline is published, refresh the main headlines list. - context.read().add( - const LoadHeadlinesRequested(limit: kDefaultRowsPerPage), - ); - // Clear the publishedHeadline after it's been handled. - context.read().add( - const ClearPublishedHeadline(), - ); - } - - // Show snackbar for pending deletions. - 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( - builder: (context, state) { - if (state.status == DraftHeadlinesStatus.loading && - state.headlines.isEmpty) { - return LoadingStateWidget( - icon: Icons.edit_note, - headline: l10n.loadingDraftHeadlines, - subheadline: l10n.pleaseWait, - ); - } - - if (state.status == DraftHeadlinesStatus.failure) { - return FailureStateWidget( - exception: state.exception!, - onRetry: () => context.read().add( - const LoadDraftHeadlinesRequested( - limit: kDefaultRowsPerPage, - ), - ), - ); - } - - if (state.headlines.isEmpty) { - return Center(child: Text(l10n.noDraftHeadlinesFound)); - } - - return Column( - children: [ - if (state.status == DraftHeadlinesStatus.loading && - state.headlines.isNotEmpty) - const LinearProgressIndicator(), - Expanded( - child: PaginatedDataTable2( - columns: [ - DataColumn2( - label: Text(l10n.headlineTitle), - size: ColumnSize.L, - ), - DataColumn2( - label: Text(l10n.sourceName), - size: ColumnSize.M, - ), - DataColumn2( - label: Text(l10n.lastUpdated), - size: ColumnSize.M, - ), - DataColumn2( - label: Text(l10n.actions), - size: ColumnSize.S, - ), - ], - source: _DraftHeadlinesDataSource( - context: context, - headlines: state.headlines, - hasMore: state.hasMore, - l10n: l10n, - ), - rowsPerPage: kDefaultRowsPerPage, - availableRowsPerPage: const [kDefaultRowsPerPage], - onPageChanged: (pageIndex) { - final newOffset = pageIndex * kDefaultRowsPerPage; - if (newOffset >= state.headlines.length && - state.hasMore && - state.status != DraftHeadlinesStatus.loading) { - context.read().add( - LoadDraftHeadlinesRequested( - startAfterId: state.cursor, - limit: kDefaultRowsPerPage, - ), - ); - } - }, - empty: Center(child: Text(l10n.noHeadlinesFound)), - showCheckboxColumn: false, - showFirstLastButtons: true, - fit: FlexFit.tight, - headingRowHeight: 56, - dataRowHeight: 56, - columnSpacing: AppSpacing.md, - horizontalMargin: AppSpacing.md, - ), - ), - ], - ); - }, - ), - ), - ), - ); - } -} - -class _DraftHeadlinesDataSource extends DataTableSource { - _DraftHeadlinesDataSource({ - required this.context, - required this.headlines, - required this.hasMore, - required this.l10n, - }); - - final BuildContext context; - final List headlines; - final bool hasMore; - final AppLocalizations l10n; - - @override - DataRow? getRow(int index) { - if (index >= headlines.length) { - return null; - } - final headline = headlines[index]; - return DataRow2( - onSelectChanged: (selected) { - if (selected ?? false) { - context.goNamed( - Routes.editHeadlineName, - pathParameters: {'id': headline.id}, - ); - } - }, - cells: [ - DataCell( - Text( - headline.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - DataCell(Text(headline.source.name)), - DataCell( - Text( - DateFormat('dd-MM-yyyy').format(headline.updatedAt.toLocal()), - ), - ), - DataCell( - Row( - children: [ - // Primary action: Publish button - IconButton( - icon: const Icon(Icons.publish), - tooltip: l10n.publish, - onPressed: () { - context.read().add( - PublishDraftHeadlineRequested(headline.id), - ); - }, - ), - // Secondary actions: Edit and Delete via PopupMenuButton - PopupMenuButton( - icon: const Icon(Icons.more_vert), - tooltip: l10n.moreActions, - onSelected: (value) { - if (value == 'edit') { - context.goNamed( - Routes.editHeadlineName, - pathParameters: {'id': headline.id}, - ); - } else if (value == 'delete') { - context.read().add( - DeleteDraftHeadlineForeverRequested(headline.id), - ); - } - }, - itemBuilder: (BuildContext context) => >[ - PopupMenuItem( - value: 'edit', - child: Row( - children: [ - const Icon(Icons.edit), - const SizedBox(width: AppSpacing.sm), - Text(l10n.editHeadline), - ], - ), - ), - PopupMenuItem( - value: 'delete', - child: Row( - children: [ - const Icon(Icons.delete_forever), - const SizedBox(width: AppSpacing.sm), - Text(l10n.deleteForever), - ], - ), - ), - ], - ), - ], - ), - ), - ], - ); - } - - @override - bool get isRowCountApproximate => hasMore; - - @override - int get rowCount => headlines.length; - - @override - int get selectedRowCount => 0; -} diff --git a/lib/content_management/view/draft_sources_page.dart b/lib/content_management/view/draft_sources_page.dart deleted file mode 100644 index 119fc2a0..00000000 --- a/lib/content_management/view/draft_sources_page.dart +++ /dev/null @@ -1,272 +0,0 @@ -import 'package:core/core.dart'; -import 'package:data_repository/data_repository.dart'; -import 'package:data_table_2/data_table_2.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/content_management_bloc.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/draft_sources/draft_sources_bloc.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/router/routes.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:go_router/go_router.dart'; -import 'package:intl/intl.dart'; -import 'package:ui_kit/ui_kit.dart'; - -/// {@template draft_sources_page} -/// A page for displaying and managing draft sources in a tabular format. -/// {@endtemplate} -class DraftSourcesPage extends StatelessWidget { - /// {@macro draft_sources_page} - const DraftSourcesPage({super.key}); - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizationsX(context).l10n; - final pendingDeletionsService = context.read(); - return Scaffold( - appBar: AppBar( - title: Text(l10n.draftSources), - ), - body: BlocProvider( - create: (context) => DraftSourcesBloc( - sourcesRepository: context.read>(), - pendingDeletionsService: pendingDeletionsService, - )..add(const LoadDraftSourcesRequested(limit: kDefaultRowsPerPage)), - child: BlocListener( - listenWhen: (previous, current) => - previous.lastPendingDeletionId != current.lastPendingDeletionId || - previous.publishedSource != current.publishedSource || - previous.snackbarSourceTitle != current.snackbarSourceTitle, - listener: (context, state) { - if (state.publishedSource != null) { - // When a source is published, refresh the main sources list. - context.read().add( - const LoadSourcesRequested(limit: kDefaultRowsPerPage), - ); - // Clear the publishedSource after it's been handled. - context.read().add( - const ClearPublishedSource(), - ); - } - - // Show snackbar for pending deletions. - if (state.snackbarSourceTitle != null) { - final sourceId = state.lastPendingDeletionId!; - final truncatedTitle = state.snackbarSourceTitle!.truncate(30); - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - content: Text( - l10n.sourceDeleted(truncatedTitle), - ), - action: SnackBarAction( - label: l10n.undo, - onPressed: () { - // Directly call undoDeletion on the service. - pendingDeletionsService.undoDeletion(sourceId); - }, - ), - ), - ); - } - }, - child: BlocBuilder( - builder: (context, state) { - if (state.status == DraftSourcesStatus.loading && - state.sources.isEmpty) { - return LoadingStateWidget( - icon: Icons.edit_note, - headline: l10n.loadingDraftSources, - subheadline: l10n.pleaseWait, - ); - } - - if (state.status == DraftSourcesStatus.failure) { - return FailureStateWidget( - exception: state.exception!, - onRetry: () => context.read().add( - const LoadDraftSourcesRequested( - limit: kDefaultRowsPerPage, - ), - ), - ); - } - - if (state.sources.isEmpty) { - return Center(child: Text(l10n.noDraftSourcesFound)); - } - - return Column( - children: [ - if (state.status == DraftSourcesStatus.loading && - state.sources.isNotEmpty) - const LinearProgressIndicator(), - Expanded( - child: PaginatedDataTable2( - columns: [ - DataColumn2( - label: Text(l10n.sourceName), - size: ColumnSize.L, - ), - DataColumn2( - label: Text(l10n.lastUpdated), - size: ColumnSize.M, - ), - DataColumn2( - label: Text(l10n.actions), - size: ColumnSize.S, - ), - ], - source: _DraftSourcesDataSource( - context: context, - sources: state.sources, - hasMore: state.hasMore, - l10n: l10n, - ), - rowsPerPage: kDefaultRowsPerPage, - availableRowsPerPage: const [kDefaultRowsPerPage], - onPageChanged: (pageIndex) { - final newOffset = pageIndex * kDefaultRowsPerPage; - if (newOffset >= state.sources.length && - state.hasMore && - state.status != DraftSourcesStatus.loading) { - context.read().add( - LoadDraftSourcesRequested( - startAfterId: state.cursor, - limit: kDefaultRowsPerPage, - ), - ); - } - }, - empty: Center(child: Text(l10n.noSourcesFound)), - showCheckboxColumn: false, - showFirstLastButtons: true, - fit: FlexFit.tight, - headingRowHeight: 56, - dataRowHeight: 56, - columnSpacing: AppSpacing.md, - horizontalMargin: AppSpacing.md, - ), - ), - ], - ); - }, - ), - ), - ), - ); - } -} - -class _DraftSourcesDataSource extends DataTableSource { - _DraftSourcesDataSource({ - required this.context, - required this.sources, - required this.hasMore, - required this.l10n, - }); - - final BuildContext context; - final List sources; - final bool hasMore; - final AppLocalizations l10n; - - @override - DataRow? getRow(int index) { - if (index >= sources.length) { - return null; - } - final source = sources[index]; - return DataRow2( - onSelectChanged: (selected) { - if (selected ?? false) { - context.goNamed( - Routes.editSourceName, - pathParameters: {'id': source.id}, - ); - } - }, - cells: [ - DataCell( - Text( - source.name, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - DataCell( - Text( - DateFormat('dd-MM-yyyy').format(source.updatedAt.toLocal()), - ), - ), - DataCell( - Row( - children: [ - // Primary action: Publish button - IconButton( - icon: const Icon(Icons.publish), - tooltip: l10n.publish, - onPressed: () { - context.read().add( - PublishDraftSourceRequested(source.id), - ); - }, - ), - // Secondary actions: Edit and Delete via PopupMenuButton - PopupMenuButton( - icon: const Icon(Icons.more_vert), - tooltip: l10n.moreActions, - onSelected: (value) { - if (value == 'edit') { - context.goNamed( - Routes.editSourceName, - pathParameters: {'id': source.id}, - ); - } else if (value == 'delete') { - context.read().add( - DeleteDraftSourceForeverRequested(source.id), - ); - } - }, - itemBuilder: (BuildContext context) => >[ - PopupMenuItem( - value: 'edit', - child: Row( - children: [ - const Icon(Icons.edit), - const SizedBox(width: AppSpacing.sm), - Text(l10n.editSource), - ], - ), - ), - PopupMenuItem( - value: 'delete', - child: Row( - children: [ - const Icon(Icons.delete_forever), - const SizedBox(width: AppSpacing.sm), - Text(l10n.deleteForever), - ], - ), - ), - ], - ), - ], - ), - ), - ], - ); - } - - @override - bool get isRowCountApproximate => hasMore; - - @override - int get rowCount => sources.length; - - @override - int get selectedRowCount => 0; -} diff --git a/lib/content_management/view/draft_topics_page.dart b/lib/content_management/view/draft_topics_page.dart deleted file mode 100644 index 6a4bb77e..00000000 --- a/lib/content_management/view/draft_topics_page.dart +++ /dev/null @@ -1,272 +0,0 @@ -import 'package:core/core.dart'; -import 'package:data_repository/data_repository.dart'; -import 'package:data_table_2/data_table_2.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/content_management_bloc.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/draft_topics/draft_topics_bloc.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/router/routes.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:go_router/go_router.dart'; -import 'package:intl/intl.dart'; -import 'package:ui_kit/ui_kit.dart'; - -/// {@template draft_topics_page} -/// A page for displaying and managing draft topics in a tabular format. -/// {@endtemplate} -class DraftTopicsPage extends StatelessWidget { - /// {@macro draft_topics_page} - const DraftTopicsPage({super.key}); - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizationsX(context).l10n; - final pendingDeletionsService = context.read(); - return Scaffold( - appBar: AppBar( - title: Text(l10n.draftTopics), - ), - body: BlocProvider( - create: (context) => DraftTopicsBloc( - topicsRepository: context.read>(), - pendingDeletionsService: pendingDeletionsService, - )..add(const LoadDraftTopicsRequested(limit: kDefaultRowsPerPage)), - child: BlocListener( - listenWhen: (previous, current) => - previous.lastPendingDeletionId != current.lastPendingDeletionId || - previous.publishedTopic != current.publishedTopic || - previous.snackbarTopicTitle != current.snackbarTopicTitle, - listener: (context, state) { - if (state.publishedTopic != null) { - // When a topic is published, refresh the main topics list. - context.read().add( - const LoadTopicsRequested(limit: kDefaultRowsPerPage), - ); - // Clear the publishedTopic after it's been handled. - context.read().add( - const ClearPublishedTopic(), - ); - } - - // Show snackbar for pending deletions. - if (state.snackbarTopicTitle != null) { - final topicId = state.lastPendingDeletionId!; - final truncatedTitle = state.snackbarTopicTitle!.truncate(30); - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar( - SnackBar( - content: Text( - l10n.topicDeleted(truncatedTitle), - ), - action: SnackBarAction( - label: l10n.undo, - onPressed: () { - // Directly call undoDeletion on the service. - pendingDeletionsService.undoDeletion(topicId); - }, - ), - ), - ); - } - }, - child: BlocBuilder( - builder: (context, state) { - if (state.status == DraftTopicsStatus.loading && - state.topics.isEmpty) { - return LoadingStateWidget( - icon: Icons.edit_note, - headline: l10n.loadingDraftTopics, - subheadline: l10n.pleaseWait, - ); - } - - if (state.status == DraftTopicsStatus.failure) { - return FailureStateWidget( - exception: state.exception!, - onRetry: () => context.read().add( - const LoadDraftTopicsRequested( - limit: kDefaultRowsPerPage, - ), - ), - ); - } - - if (state.topics.isEmpty) { - return Center(child: Text(l10n.noDraftTopicsFound)); - } - - return Column( - children: [ - if (state.status == DraftTopicsStatus.loading && - state.topics.isNotEmpty) - const LinearProgressIndicator(), - Expanded( - child: PaginatedDataTable2( - columns: [ - DataColumn2( - label: Text(l10n.topicName), - size: ColumnSize.L, - ), - DataColumn2( - label: Text(l10n.lastUpdated), - size: ColumnSize.M, - ), - DataColumn2( - label: Text(l10n.actions), - size: ColumnSize.S, - ), - ], - source: _DraftTopicsDataSource( - context: context, - topics: state.topics, - hasMore: state.hasMore, - l10n: l10n, - ), - rowsPerPage: kDefaultRowsPerPage, - availableRowsPerPage: const [kDefaultRowsPerPage], - onPageChanged: (pageIndex) { - final newOffset = pageIndex * kDefaultRowsPerPage; - if (newOffset >= state.topics.length && - state.hasMore && - state.status != DraftTopicsStatus.loading) { - context.read().add( - LoadDraftTopicsRequested( - startAfterId: state.cursor, - limit: kDefaultRowsPerPage, - ), - ); - } - }, - empty: Center(child: Text(l10n.noTopicsFound)), - showCheckboxColumn: false, - showFirstLastButtons: true, - fit: FlexFit.tight, - headingRowHeight: 56, - dataRowHeight: 56, - columnSpacing: AppSpacing.md, - horizontalMargin: AppSpacing.md, - ), - ), - ], - ); - }, - ), - ), - ), - ); - } -} - -class _DraftTopicsDataSource extends DataTableSource { - _DraftTopicsDataSource({ - required this.context, - required this.topics, - required this.hasMore, - required this.l10n, - }); - - final BuildContext context; - final List topics; - final bool hasMore; - final AppLocalizations l10n; - - @override - DataRow? getRow(int index) { - if (index >= topics.length) { - return null; - } - final topic = topics[index]; - return DataRow2( - onSelectChanged: (selected) { - if (selected ?? false) { - context.goNamed( - Routes.editTopicName, - pathParameters: {'id': topic.id}, - ); - } - }, - cells: [ - DataCell( - Text( - topic.name, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - DataCell( - Text( - DateFormat('dd-MM-yyyy').format(topic.updatedAt.toLocal()), - ), - ), - DataCell( - Row( - children: [ - // Primary action: Publish button - IconButton( - icon: const Icon(Icons.publish), - tooltip: l10n.publish, - onPressed: () { - context.read().add( - PublishDraftTopicRequested(topic.id), - ); - }, - ), - // Secondary actions: Edit and Delete via PopupMenuButton - PopupMenuButton( - icon: const Icon(Icons.more_vert), - tooltip: l10n.moreActions, - onSelected: (value) { - if (value == 'edit') { - context.goNamed( - Routes.editTopicName, - pathParameters: {'id': topic.id}, - ); - } else if (value == 'delete') { - context.read().add( - DeleteDraftTopicForeverRequested(topic.id), - ); - } - }, - itemBuilder: (BuildContext context) => >[ - PopupMenuItem( - value: 'edit', - child: Row( - children: [ - const Icon(Icons.edit), - const SizedBox(width: AppSpacing.sm), - Text(l10n.editTopic), - ], - ), - ), - PopupMenuItem( - value: 'delete', - child: Row( - children: [ - const Icon(Icons.delete_forever), - const SizedBox(width: AppSpacing.sm), - Text(l10n.deleteForever), - ], - ), - ), - ], - ), - ], - ), - ), - ], - ); - } - - @override - bool get isRowCountApproximate => hasMore; - - @override - int get rowCount => topics.length; - - @override - int get selectedRowCount => 0; -} diff --git a/lib/content_management/view/edit_headline_page.dart b/lib/content_management/view/edit_headline_page.dart index 9812452c..06aee388 100644 --- a/lib/content_management/view/edit_headline_page.dart +++ b/lib/content_management/view/edit_headline_page.dart @@ -223,12 +223,14 @@ class _EditHeadlineViewState extends State<_EditHeadlineView> { const SizedBox(height: AppSpacing.lg), SearchableSelectionInput( label: l10n.sourceName, - selectedItem: state.source, + selectedItems: state.source != null + ? [state.source!] + : [], itemBuilder: (context, source) => Text(source.name), itemToString: (source) => source.name, - onChanged: (value) => context + onChanged: (items) => context .read() - .add(EditHeadlineSourceChanged(value)), + .add(EditHeadlineSourceChanged(items?.first)), repository: context.read>(), filterBuilder: (searchTerm) => searchTerm == null ? {} @@ -247,12 +249,12 @@ class _EditHeadlineViewState extends State<_EditHeadlineView> { const SizedBox(height: AppSpacing.lg), SearchableSelectionInput( label: l10n.topicName, - selectedItem: state.topic, + selectedItems: state.topic != null ? [state.topic!] : [], itemBuilder: (context, topic) => Text(topic.name), itemToString: (topic) => topic.name, - onChanged: (value) => context + onChanged: (items) => context .read() - .add(EditHeadlineTopicChanged(value)), + .add(EditHeadlineTopicChanged(items?.first)), repository: context.read>(), filterBuilder: (searchTerm) => searchTerm == null ? {} @@ -271,7 +273,9 @@ class _EditHeadlineViewState extends State<_EditHeadlineView> { const SizedBox(height: AppSpacing.lg), SearchableSelectionInput( label: l10n.countryName, - selectedItem: state.eventCountry, + selectedItems: state.eventCountry != null + ? [state.eventCountry!] + : [], itemBuilder: (context, country) => Row( children: [ SizedBox( @@ -289,9 +293,9 @@ class _EditHeadlineViewState extends State<_EditHeadlineView> { ], ), itemToString: (country) => country.name, - onChanged: (value) => context + onChanged: (items) => context .read() - .add(EditHeadlineCountryChanged(value)), + .add(EditHeadlineCountryChanged(items?.first)), repository: context.read>(), filterBuilder: (searchTerm) => searchTerm == null ? {} diff --git a/lib/content_management/view/edit_source_page.dart b/lib/content_management/view/edit_source_page.dart index 39454e8c..c426a3d6 100644 --- a/lib/content_management/view/edit_source_page.dart +++ b/lib/content_management/view/edit_source_page.dart @@ -203,11 +203,13 @@ class _EditSourceViewState extends State<_EditSourceView> { const SizedBox(height: AppSpacing.lg), SearchableSelectionInput( label: l10n.language, - selectedItem: state.language, + selectedItems: state.language != null + ? [state.language!] + : [], itemBuilder: (context, language) => Text(language.name), itemToString: (language) => language.name, - onChanged: (value) => context.read().add( - EditSourceLanguageChanged(value), + onChanged: (items) => context.read().add( + EditSourceLanguageChanged(items?.first), ), repository: context.read>(), filterBuilder: (searchTerm) => searchTerm == null @@ -226,19 +228,23 @@ class _EditSourceViewState extends State<_EditSourceView> { const SizedBox(height: AppSpacing.lg), SearchableSelectionInput( label: l10n.sourceType, - selectedItem: state.sourceType, + selectedItems: state.sourceType != null + ? [state.sourceType!] + : [], staticItems: SourceType.values.toList(), itemBuilder: (context, type) => Text(type.localizedName(l10n)), itemToString: (type) => type.localizedName(l10n), - onChanged: (value) => context.read().add( - EditSourceTypeChanged(value), + onChanged: (items) => context.read().add( + EditSourceTypeChanged(items?.first), ), ), const SizedBox(height: AppSpacing.lg), SearchableSelectionInput( label: l10n.headquarters, - selectedItem: state.headquarters, + selectedItems: state.headquarters != null + ? [state.headquarters!] + : [], itemBuilder: (context, country) => Row( children: [ SizedBox( @@ -256,8 +262,8 @@ class _EditSourceViewState extends State<_EditSourceView> { ], ), itemToString: (country) => country.name, - onChanged: (value) => context.read().add( - EditSourceHeadquartersChanged(value), + onChanged: (items) => context.read().add( + EditSourceHeadquartersChanged(items?.first), ), repository: context.read>(), filterBuilder: (searchTerm) => searchTerm == null diff --git a/lib/content_management/view/headlines_page.dart b/lib/content_management/view/headlines_page.dart index adc5c5fb..b87dd327 100644 --- a/lib/content_management/view/headlines_page.dart +++ b/lib/content_management/view/headlines_page.dart @@ -3,6 +3,8 @@ import 'package:data_table_2/data_table_2.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/content_management_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/headlines_filter/headlines_filter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/widgets/content_action_buttons.dart'; // Import the new widget 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/router/routes.dart'; @@ -25,11 +27,26 @@ class _HeadlinesPageState extends State { @override void initState() { super.initState(); + // Initial load of headlines, applying the default filter from HeadlinesFilterBloc context.read().add( - const LoadHeadlinesRequested(limit: kDefaultRowsPerPage), + LoadHeadlinesRequested( + limit: kDefaultRowsPerPage, + filter: context + .read() + .buildHeadlinesFilterMap(context.read().state), + ), ); } + /// Checks if any filters are currently active in the HeadlinesFilterBloc. + bool _areFiltersActive(HeadlinesFilterState state) { + return state.searchQuery.isNotEmpty || + state.selectedStatus != ContentStatus.active || + state.selectedSourceIds.isNotEmpty || + state.selectedTopicIds.isNotEmpty || + state.selectedCountryIds.isNotEmpty; + } + @override Widget build(BuildContext context) { final l10n = AppLocalizationsX(context).l10n; @@ -37,6 +54,10 @@ class _HeadlinesPageState extends State { padding: const EdgeInsets.all(AppSpacing.lg), child: BlocBuilder( builder: (context, state) { + final headlinesFilterState = + context.watch().state; + final filtersActive = _areFiltersActive(headlinesFilterState); + if (state.headlinesStatus == ContentManagementStatus.loading && state.headlines.isEmpty) { return LoadingStateWidget( @@ -50,12 +71,43 @@ class _HeadlinesPageState extends State { return FailureStateWidget( exception: state.exception!, onRetry: () => context.read().add( - const LoadHeadlinesRequested(limit: kDefaultRowsPerPage), + LoadHeadlinesRequested( + limit: kDefaultRowsPerPage, + forceRefresh: true, + filter: context + .read() + .buildHeadlinesFilterMap( + context.read().state, + ), + ), ), ); } if (state.headlines.isEmpty) { + if (filtersActive) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + l10n.noResultsWithCurrentFilters, + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.lg), + ElevatedButton( + onPressed: () { + context.read().add( + const HeadlinesFilterReset(), + ); + }, + child: Text(l10n.resetFiltersButtonText), + ), + ], + ), + ); + } return Center(child: Text(l10n.noHeadlinesFound)); } @@ -65,55 +117,67 @@ class _HeadlinesPageState extends State { state.headlines.isNotEmpty) const LinearProgressIndicator(), Expanded( - child: PaginatedDataTable2( - columns: [ - DataColumn2( - label: Text(l10n.headlineTitle), - size: ColumnSize.L, - ), - DataColumn2( - label: Text(l10n.sourceName), - size: ColumnSize.S, - ), - DataColumn2( - label: Text(l10n.lastUpdated), - size: ColumnSize.S, - ), - DataColumn2( - label: Text(l10n.actions), - size: ColumnSize.S, - ), - ], - source: _HeadlinesDataSource( - context: context, - headlines: state.headlines, - hasMore: state.headlinesHasMore, - l10n: l10n, - ), - rowsPerPage: kDefaultRowsPerPage, - availableRowsPerPage: const [kDefaultRowsPerPage], - onPageChanged: (pageIndex) { - final newOffset = pageIndex * kDefaultRowsPerPage; - if (newOffset >= state.headlines.length && - state.headlinesHasMore && - state.headlinesStatus != - ContentManagementStatus.loading) { - context.read().add( - LoadHeadlinesRequested( - startAfterId: state.headlinesCursor, - limit: kDefaultRowsPerPage, + child: LayoutBuilder( + builder: (context, constraints) { + final isMobile = constraints.maxWidth < 600; + return PaginatedDataTable2( + columns: [ + DataColumn2( + label: Text(l10n.headlineTitle), + size: ColumnSize.L, + ), + if (!isMobile) // Conditionally show Source Name + DataColumn2( + label: Text(l10n.sourceName), + size: ColumnSize.S, + ), + DataColumn2( + label: Text(l10n.lastUpdated), + size: ColumnSize.S, ), - ); - } + DataColumn2( + label: Text(l10n.actions), + size: ColumnSize.S, + ), + ], + source: _HeadlinesDataSource( + context: context, + headlines: state.headlines, + hasMore: state.headlinesHasMore, + l10n: l10n, + isMobile: isMobile, // Pass isMobile to data source + ), + rowsPerPage: kDefaultRowsPerPage, + availableRowsPerPage: const [kDefaultRowsPerPage], + onPageChanged: (pageIndex) { + final newOffset = pageIndex * kDefaultRowsPerPage; + if (newOffset >= state.headlines.length && + state.headlinesHasMore && + state.headlinesStatus != + ContentManagementStatus.loading) { + context.read().add( + LoadHeadlinesRequested( + startAfterId: state.headlinesCursor, + limit: kDefaultRowsPerPage, + filter: context + .read() + .buildHeadlinesFilterMap( + context.read().state, + ), + ), + ); + } + }, + empty: Center(child: Text(l10n.noHeadlinesFound)), + showCheckboxColumn: false, + showFirstLastButtons: true, + fit: FlexFit.tight, + headingRowHeight: 56, + dataRowHeight: 56, + columnSpacing: AppSpacing.md, + horizontalMargin: AppSpacing.md, + ); }, - empty: Center(child: Text(l10n.noHeadlinesFound)), - showCheckboxColumn: false, - showFirstLastButtons: true, - fit: FlexFit.tight, - headingRowHeight: 56, - dataRowHeight: 56, - columnSpacing: AppSpacing.md, - horizontalMargin: AppSpacing.md, ), ), ], @@ -130,12 +194,14 @@ class _HeadlinesDataSource extends DataTableSource { required this.headlines, required this.hasMore, required this.l10n, + required this.isMobile, // New parameter }); final BuildContext context; final List headlines; final bool hasMore; final AppLocalizations l10n; + final bool isMobile; // New parameter @override DataRow? getRow(int index) { @@ -160,34 +226,17 @@ class _HeadlinesDataSource extends DataTableSource { overflow: TextOverflow.ellipsis, ), ), - DataCell(Text(headline.source.name)), + if (!isMobile) // Conditionally show Source Name + DataCell(Text(headline.source.name)), DataCell( Text( DateFormat('dd-MM-yyyy').format(headline.updatedAt.toLocal()), ), ), DataCell( - Row( - children: [ - IconButton( - icon: const Icon(Icons.edit), - onPressed: () { - context.goNamed( - Routes.editHeadlineName, - pathParameters: {'id': headline.id}, - ); - }, - ), - IconButton( - icon: const Icon(Icons.archive), - tooltip: l10n.archive, - onPressed: () { - context.read().add( - ArchiveHeadlineRequested(headline.id), - ); - }, - ), - ], + ContentActionButtons( + item: headline, + l10n: l10n, ), ), ], diff --git a/lib/content_management/view/sources_page.dart b/lib/content_management/view/sources_page.dart index f8c46d2f..15760450 100644 --- a/lib/content_management/view/sources_page.dart +++ b/lib/content_management/view/sources_page.dart @@ -3,10 +3,12 @@ import 'package:data_table_2/data_table_2.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/content_management_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/sources_filter/sources_filter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/widgets/content_action_buttons.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/router/routes.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/shared/extensions/source_type_l10n.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/extensions/extensions.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:ui_kit/ui_kit.dart'; @@ -26,11 +28,26 @@ class _SourcesPageState extends State { @override void initState() { super.initState(); + // Initial load of sources, applying the default filter from SourcesFilterBloc context.read().add( - const LoadSourcesRequested(limit: kDefaultRowsPerPage), + LoadSourcesRequested( + limit: kDefaultRowsPerPage, + filter: context + .read() + .buildSourcesFilterMap(context.read().state), + ), ); } + /// Checks if any filters are currently active in the SourcesFilterBloc. + bool _areFiltersActive(SourcesFilterState state) { + return state.searchQuery.isNotEmpty || + state.selectedStatus != ContentStatus.active || + state.selectedSourceTypes.isNotEmpty || + state.selectedLanguageCodes.isNotEmpty || + state.selectedHeadquartersCountryIds.isNotEmpty; + } + @override Widget build(BuildContext context) { final l10n = AppLocalizationsX(context).l10n; @@ -38,10 +55,13 @@ class _SourcesPageState extends State { padding: const EdgeInsets.all(AppSpacing.lg), child: BlocBuilder( builder: (context, state) { + final sourcesFilterState = context.watch().state; + final filtersActive = _areFiltersActive(sourcesFilterState); + if (state.sourcesStatus == ContentManagementStatus.loading && state.sources.isEmpty) { return LoadingStateWidget( - icon: Icons.source, + icon: Icons.rss_feed, headline: l10n.loadingSources, subheadline: l10n.pleaseWait, ); @@ -51,12 +71,43 @@ class _SourcesPageState extends State { return FailureStateWidget( exception: state.exception!, onRetry: () => context.read().add( - const LoadSourcesRequested(limit: kDefaultRowsPerPage), + LoadSourcesRequested( + limit: kDefaultRowsPerPage, + forceRefresh: true, + filter: context + .read() + .buildSourcesFilterMap( + context.read().state, + ), + ), ), ); } if (state.sources.isEmpty) { + if (filtersActive) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + l10n.noResultsWithCurrentFilters, + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.lg), + ElevatedButton( + onPressed: () { + context.read().add( + const SourcesFilterReset(), + ); + }, + child: Text(l10n.resetFiltersButtonText), + ), + ], + ), + ); + } return Center(child: Text(l10n.noSourcesFound)); } @@ -66,55 +117,66 @@ class _SourcesPageState extends State { state.sources.isNotEmpty) const LinearProgressIndicator(), Expanded( - child: PaginatedDataTable2( - columns: [ - DataColumn2( - label: Text(l10n.sourceName), - size: ColumnSize.L, - ), - DataColumn2( - label: Text(l10n.sourceType), - size: ColumnSize.S, - ), - DataColumn2( - label: Text(l10n.lastUpdated), - size: ColumnSize.S, - ), - DataColumn2( - label: Text(l10n.actions), - size: ColumnSize.S, - ), - ], - source: _SourcesDataSource( - context: context, - sources: state.sources, - hasMore: state.sourcesHasMore, - l10n: l10n, - ), - rowsPerPage: kDefaultRowsPerPage, - availableRowsPerPage: const [kDefaultRowsPerPage], - onPageChanged: (pageIndex) { - final newOffset = pageIndex * kDefaultRowsPerPage; - if (newOffset >= state.sources.length && - state.sourcesHasMore && - state.sourcesStatus != - ContentManagementStatus.loading) { - context.read().add( - LoadSourcesRequested( - startAfterId: state.sourcesCursor, - limit: kDefaultRowsPerPage, + child: LayoutBuilder( + builder: (context, constraints) { + final isMobile = constraints.maxWidth < 600; + return PaginatedDataTable2( + columns: [ + DataColumn2( + label: Text(l10n.sourceName), + size: ColumnSize.L, ), - ); - } + DataColumn2( + label: Text(l10n.sourceType), + size: ColumnSize.S, + ), + DataColumn2( + label: Text(l10n.lastUpdated), + size: ColumnSize.S, + ), + DataColumn2( + label: Text(l10n.actions), + size: ColumnSize.S, + ), + ], + source: _SourcesDataSource( + context: context, + sources: state.sources, + hasMore: state.sourcesHasMore, + l10n: l10n, + isMobile: isMobile, + ), + rowsPerPage: kDefaultRowsPerPage, + availableRowsPerPage: const [kDefaultRowsPerPage], + onPageChanged: (pageIndex) { + final newOffset = pageIndex * kDefaultRowsPerPage; + if (newOffset >= state.sources.length && + state.sourcesHasMore && + state.sourcesStatus != + ContentManagementStatus.loading) { + context.read().add( + LoadSourcesRequested( + startAfterId: state.sourcesCursor, + limit: kDefaultRowsPerPage, + filter: context + .read() + .buildSourcesFilterMap( + context.read().state, + ), + ), + ); + } + }, + empty: Center(child: Text(l10n.noSourcesFound)), + showCheckboxColumn: false, + showFirstLastButtons: true, + fit: FlexFit.tight, + headingRowHeight: 56, + dataRowHeight: 56, + columnSpacing: AppSpacing.md, + horizontalMargin: AppSpacing.md, + ); }, - empty: Center(child: Text(l10n.noSourcesFound)), - showCheckboxColumn: false, - showFirstLastButtons: true, - fit: FlexFit.tight, - headingRowHeight: 56, - dataRowHeight: 56, - columnSpacing: AppSpacing.md, - horizontalMargin: AppSpacing.md, ), ), ], @@ -131,12 +193,14 @@ class _SourcesDataSource extends DataTableSource { required this.sources, required this.hasMore, required this.l10n, + required this.isMobile, }); final BuildContext context; final List sources; final bool hasMore; final AppLocalizations l10n; + final bool isMobile; @override DataRow? getRow(int index) { @@ -161,37 +225,22 @@ class _SourcesDataSource extends DataTableSource { overflow: TextOverflow.ellipsis, ), ), - DataCell(Text(source.sourceType.localizedName(l10n))), DataCell( Text( - // TODO(fulleni): Make date format configurable by admin. + source.sourceType.localizedName(l10n), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + DataCell( + Text( DateFormat('dd-MM-yyyy').format(source.updatedAt.toLocal()), ), ), DataCell( - Row( - children: [ - IconButton( - icon: const Icon(Icons.edit), - onPressed: () { - // Navigate to edit page - context.goNamed( - Routes.editSourceName, - pathParameters: {'id': source.id}, - ); - }, - ), - IconButton( - icon: const Icon(Icons.archive), - tooltip: l10n.archive, - onPressed: () { - // Dispatch delete event - context.read().add( - ArchiveSourceRequested(source.id), - ); - }, - ), - ], + ContentActionButtons( + item: source, + l10n: l10n, ), ), ], diff --git a/lib/content_management/view/topics_page.dart b/lib/content_management/view/topics_page.dart index 35c6be2a..d5795e53 100644 --- a/lib/content_management/view/topics_page.dart +++ b/lib/content_management/view/topics_page.dart @@ -3,6 +3,8 @@ import 'package:data_table_2/data_table_2.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/content_management_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/topics_filter/topics_filter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/widgets/content_action_buttons.dart'; // Import the new widget 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/router/routes.dart'; @@ -25,11 +27,23 @@ class _TopicPageState extends State { @override void initState() { super.initState(); + // Initial load of topics, applying the default filter from TopicsFilterBloc context.read().add( - const LoadTopicsRequested(limit: kDefaultRowsPerPage), + LoadTopicsRequested( + limit: kDefaultRowsPerPage, + filter: context + .read() + .buildTopicsFilterMap(context.read().state), + ), ); } + /// Checks if any filters are currently active in the TopicsFilterBloc. + bool _areFiltersActive(TopicsFilterState state) { + return state.searchQuery.isNotEmpty || + state.selectedStatus != ContentStatus.active; + } + @override Widget build(BuildContext context) { final l10n = AppLocalizationsX(context).l10n; @@ -37,6 +51,9 @@ class _TopicPageState extends State { padding: const EdgeInsets.all(AppSpacing.lg), child: BlocBuilder( builder: (context, state) { + final topicsFilterState = context.watch().state; + final filtersActive = _areFiltersActive(topicsFilterState); + if (state.topicsStatus == ContentManagementStatus.loading && state.topics.isEmpty) { return LoadingStateWidget( @@ -50,12 +67,43 @@ class _TopicPageState extends State { return FailureStateWidget( exception: state.exception!, onRetry: () => context.read().add( - const LoadTopicsRequested(limit: kDefaultRowsPerPage), + LoadTopicsRequested( + limit: kDefaultRowsPerPage, + forceRefresh: true, + filter: context + .read() + .buildTopicsFilterMap( + context.read().state, + ), + ), ), ); } if (state.topics.isEmpty) { + if (filtersActive) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + l10n.noResultsWithCurrentFilters, + style: Theme.of(context).textTheme.titleMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: AppSpacing.lg), + ElevatedButton( + onPressed: () { + context.read().add( + const TopicsFilterReset(), + ); + }, + child: Text(l10n.resetFiltersButtonText), + ), + ], + ), + ); + } return Center(child: Text(l10n.noTopicsFound)); } @@ -65,50 +113,62 @@ class _TopicPageState extends State { state.topics.isNotEmpty) const LinearProgressIndicator(), Expanded( - child: PaginatedDataTable2( - columns: [ - DataColumn2( - label: Text(l10n.topicName), - size: ColumnSize.L, - ), - DataColumn2( - label: Text(l10n.lastUpdated), - size: ColumnSize.S, - ), - DataColumn2( - label: Text(l10n.actions), - size: ColumnSize.S, - ), - ], - source: _TopicsDataSource( - context: context, - topics: state.topics, - hasMore: state.topicsHasMore, - l10n: l10n, - ), - rowsPerPage: kDefaultRowsPerPage, - availableRowsPerPage: const [kDefaultRowsPerPage], - onPageChanged: (pageIndex) { - final newOffset = pageIndex * kDefaultRowsPerPage; - if (newOffset >= state.topics.length && - state.topicsHasMore && - state.topicsStatus != ContentManagementStatus.loading) { - context.read().add( - LoadTopicsRequested( - startAfterId: state.topicsCursor, - limit: kDefaultRowsPerPage, + child: LayoutBuilder( + builder: (context, constraints) { + final isMobile = constraints.maxWidth < 600; + return PaginatedDataTable2( + columns: [ + DataColumn2( + label: Text(l10n.topicName), + size: ColumnSize.L, + ), + DataColumn2( + label: Text(l10n.lastUpdated), + size: ColumnSize.S, + ), + DataColumn2( + label: Text(l10n.actions), + size: ColumnSize.S, ), - ); - } + ], + source: _TopicsDataSource( + context: context, + topics: state.topics, + hasMore: state.topicsHasMore, + l10n: l10n, + isMobile: isMobile, // Pass isMobile to data source + ), + rowsPerPage: kDefaultRowsPerPage, + availableRowsPerPage: const [kDefaultRowsPerPage], + onPageChanged: (pageIndex) { + final newOffset = pageIndex * kDefaultRowsPerPage; + if (newOffset >= state.topics.length && + state.topicsHasMore && + state.topicsStatus != + ContentManagementStatus.loading) { + context.read().add( + LoadTopicsRequested( + startAfterId: state.topicsCursor, + limit: kDefaultRowsPerPage, + filter: context + .read() + .buildTopicsFilterMap( + context.read().state, + ), + ), + ); + } + }, + empty: Center(child: Text(l10n.noTopicsFound)), + showCheckboxColumn: false, + showFirstLastButtons: true, + fit: FlexFit.tight, + headingRowHeight: 56, + dataRowHeight: 56, + columnSpacing: AppSpacing.md, + horizontalMargin: AppSpacing.md, + ); }, - empty: Center(child: Text(l10n.noTopicsFound)), - showCheckboxColumn: false, - showFirstLastButtons: true, - fit: FlexFit.tight, - headingRowHeight: 56, - dataRowHeight: 56, - columnSpacing: AppSpacing.md, - horizontalMargin: AppSpacing.md, ), ), ], @@ -125,12 +185,14 @@ class _TopicsDataSource extends DataTableSource { required this.topics, required this.hasMore, required this.l10n, + required this.isMobile, // New parameter }); final BuildContext context; final List topics; final bool hasMore; final AppLocalizations l10n; + final bool isMobile; // New parameter @override DataRow? getRow(int index) { @@ -162,29 +224,9 @@ class _TopicsDataSource extends DataTableSource { ), ), DataCell( - Row( - children: [ - IconButton( - icon: const Icon(Icons.edit), - onPressed: () { - // Navigate to edit page - context.goNamed( - Routes.editTopicName, - pathParameters: {'id': topic.id}, - ); - }, - ), - IconButton( - icon: const Icon(Icons.archive), - tooltip: l10n.archive, - onPressed: () { - // Dispatch delete event - context.read().add( - ArchiveTopicRequested(topic.id), - ); - }, - ), - ], + ContentActionButtons( + item: topic, + l10n: l10n, ), ), ], diff --git a/lib/content_management/widgets/content_action_buttons.dart b/lib/content_management/widgets/content_action_buttons.dart new file mode 100644 index 00000000..dd9a7285 --- /dev/null +++ b/lib/content_management/widgets/content_action_buttons.dart @@ -0,0 +1,227 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/content_management_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/router/routes.dart'; +import 'package:go_router/go_router.dart'; +import 'package:ui_kit/ui_kit.dart'; + +/// {@template content_action_buttons} +/// A widget that displays action buttons for content management items +/// (Headlines, Topics, Sources) based on their [ContentStatus]. +/// +/// It shows a maximum of two primary icons, with additional actions +/// accessible via a dropdown menu. +/// {@endtemplate} +class ContentActionButtons extends StatelessWidget { + /// {@macro content_action_buttons} + const ContentActionButtons({ + required this.item, + required this.l10n, + super.key, + }); + + /// The content item for which to display actions. + final FeedItem item; + + /// The localized strings for the application. + final AppLocalizations l10n; + + @override + Widget build(BuildContext context) { + final visibleActions = []; + final overflowMenuItems = >[]; + + // Determine item ID and status + String itemId; + ContentStatus status; + + if (item is Headline) { + itemId = (item as Headline).id; + status = (item as Headline).status; + } else if (item is Topic) { + itemId = (item as Topic).id; + status = (item as Topic).status; + } else if (item is Source) { + itemId = (item as Source).id; + status = (item as Source).status; + } else { + return const SizedBox.shrink(); // Should not happen with current FeedItem types + } + + // Action 1: Edit (always visible as the first action) + visibleActions.add( + IconButton( + icon: const Icon(Icons.edit), + tooltip: l10n.edit, + onPressed: () { + String routeName; + switch (item.type) { + case 'headline': + routeName = Routes.editHeadlineName; + case 'topic': + routeName = Routes.editTopicName; + case 'source': + routeName = Routes.editSourceName; + default: + return; + } + context.goNamed( + routeName, + pathParameters: {'id': itemId}, + ); + }, + ), + ); + + // Determine contextual action and add to overflow + switch (status) { + case ContentStatus.draft: + overflowMenuItems.add( + PopupMenuItem( + value: 'publish', + child: Row( + children: [ + const Icon(Icons.publish), + const SizedBox(width: AppSpacing.sm), + Text(l10n.publish), + ], + ), + ), + ); + // Delete is allowed for all draft items + overflowMenuItems.add( + PopupMenuItem( + value: 'delete', + child: Row( + children: [ + const Icon(Icons.delete_forever), + const SizedBox(width: AppSpacing.sm), + Text(l10n.deleteForever), + ], + ), + ), + ); + case ContentStatus.active: + overflowMenuItems.add( + PopupMenuItem( + value: 'archive', + child: Row( + children: [ + const Icon(Icons.archive), + const SizedBox(width: AppSpacing.sm), + Text(l10n.archive), + ], + ), + ), + ); + // Delete is NOT allowed for active items + case ContentStatus.archived: + overflowMenuItems.add( + PopupMenuItem( + value: 'restore', + child: Row( + children: [ + const Icon(Icons.unarchive), + const SizedBox(width: AppSpacing.sm), + Text(l10n.restore), + ], + ), + ), + ); + // Delete is only allowed for archived headlines + if (item is Headline) { + overflowMenuItems.add( + PopupMenuItem( + value: 'delete', + child: Row( + children: [ + const Icon(Icons.delete_forever), + const SizedBox(width: AppSpacing.sm), + Text(l10n.deleteForever), + ], + ), + ), + ); + } + } + + // The ellipsis button is always shown if there are any overflow actions. + // Given the current logic, there will always be at least one overflow item + // (publish/archive/restore for draft/active/archived, and delete for draft/archived headlines). + // So, we will always show the ellipsis. + visibleActions.add( + PopupMenuButton( + icon: const Icon(Icons.more_vert), + tooltip: l10n.moreActions, + onSelected: (value) { + switch (value) { + case 'publish': + if (item is Headline) { + context.read().add( + PublishHeadlineRequested(itemId), + ); + } else if (item is Topic) { + context.read().add( + PublishTopicRequested(itemId), + ); + } else if (item is Source) { + context.read().add( + PublishSourceRequested(itemId), + ); + } + case 'archive': + if (item is Headline) { + context.read().add( + ArchiveHeadlineRequested(itemId), + ); + } else if (item is Topic) { + context.read().add( + ArchiveTopicRequested(itemId), + ); + } else if (item is Source) { + context.read().add( + ArchiveSourceRequested(itemId), + ); + } + case 'restore': + if (item is Headline) { + context.read().add( + RestoreHeadlineRequested(itemId), + ); + } else if (item is Topic) { + context.read().add( + RestoreTopicRequested(itemId), + ); + } else if (item is Source) { + context.read().add( + RestoreSourceRequested(itemId), + ); + } + case 'delete': + if (item is Headline) { + context.read().add( + DeleteHeadlineForeverRequested(itemId), + ); + } else if (item is Topic) { + context.read().add( + DeleteTopicForeverRequested(itemId), + ); + } else if (item is Source) { + context.read().add( + DeleteSourceForeverRequested(itemId), + ); + } + } + }, + itemBuilder: (BuildContext context) => overflowMenuItems, + ), + ); + + return Row( + mainAxisSize: MainAxisSize.min, + children: visibleActions, + ); + } +} diff --git a/lib/content_management/widgets/filter_dialog/bloc/filter_dialog_bloc.dart b/lib/content_management/widgets/filter_dialog/bloc/filter_dialog_bloc.dart new file mode 100644 index 00000000..a1b1e94b --- /dev/null +++ b/lib/content_management/widgets/filter_dialog/bloc/filter_dialog_bloc.dart @@ -0,0 +1,253 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:bloc_concurrency/bloc_concurrency.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/content_management/bloc/content_management_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/headlines_filter/headlines_filter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/sources_filter/sources_filter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/topics_filter/topics_filter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/widgets/filter_dialog/filter_dialog.dart' + show FilterDialog; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/constants/constants.dart'; +import 'package:rxdart/rxdart.dart'; + +part 'filter_dialog_event.dart'; +part 'filter_dialog_state.dart'; + +/// A transformer to debounce events, typically used for search input. +EventTransformer debounce(Duration duration) { + return (events, mapper) => events.debounceTime(duration).flatMap(mapper); +} + +/// {@template filter_dialog_bloc} +/// A BLoC that manages the state and logic for the [FilterDialog]. +/// +/// This BLoC handles the temporary filter selections, fetches available +/// filter options (sources, topics, countries, languages), and provides +/// the necessary state for the UI to render the filter dialog. +/// {@endtemplate} +class FilterDialogBloc extends Bloc { + /// {@macro filter_dialog_bloc} + FilterDialogBloc({ + required DataRepository sourcesRepository, + required DataRepository topicsRepository, + required DataRepository countriesRepository, + required DataRepository languagesRepository, + required ContentManagementTab activeTab, + }) : _sourcesRepository = sourcesRepository, + _topicsRepository = topicsRepository, + _countriesRepository = countriesRepository, + _languagesRepository = languagesRepository, + super(FilterDialogState(activeTab: activeTab)) { + on(_onFilterDialogInitialized); + on( + _onFilterOptionsLoadRequested, + transformer: restartable(), + ); + on( + _onFilterDialogSearchQueryChanged, + transformer: debounce(const Duration(milliseconds: 300)), + ); + on(_onFilterDialogStatusChanged); + on( + _onFilterDialogHeadlinesSourceIdsChanged, + ); + on( + _onFilterDialogHeadlinesTopicIdsChanged, + ); + on( + _onFilterDialogHeadlinesCountryIdsChanged, + ); + on(_onFilterDialogSourceTypesChanged); + on( + _onFilterDialogLanguageCodesChanged, + ); + on( + _onFilterDialogHeadquartersCountryIdsChanged, + ); + on(_onFilterDialogReset); + } + + final DataRepository _sourcesRepository; + final DataRepository _topicsRepository; + final DataRepository _countriesRepository; + final DataRepository _languagesRepository; + + /// Initializes the filter dialog's state from the current filter BLoCs. + void _onFilterDialogInitialized( + FilterDialogInitialized event, + Emitter emit, + ) { + emit(state.copyWith(activeTab: event.activeTab)); + + switch (event.activeTab) { + case ContentManagementTab.headlines: + final headlinesState = event.headlinesFilterState; + if (headlinesState != null) { + emit( + state.copyWith( + searchQuery: headlinesState.searchQuery, + selectedStatus: headlinesState.selectedStatus, + selectedSourceIds: headlinesState.selectedSourceIds, + selectedTopicIds: headlinesState.selectedTopicIds, + selectedCountryIds: headlinesState.selectedCountryIds, + ), + ); + } + case ContentManagementTab.topics: + final topicsState = event.topicsFilterState; + if (topicsState != null) { + emit( + state.copyWith( + searchQuery: topicsState.searchQuery, + selectedStatus: topicsState.selectedStatus, + ), + ); + } + case ContentManagementTab.sources: + final sourcesState = event.sourcesFilterState; + if (sourcesState != null) { + emit( + state.copyWith( + searchQuery: sourcesState.searchQuery, + selectedStatus: sourcesState.selectedStatus, + selectedSourceTypes: sourcesState.selectedSourceTypes, + selectedLanguageCodes: sourcesState.selectedLanguageCodes, + selectedHeadquartersCountryIds: + sourcesState.selectedHeadquartersCountryIds, + ), + ); + } + } + add(const FilterOptionsLoadRequested()); + } + + /// Loads available filter options (sources, topics, countries, languages). + Future _onFilterOptionsLoadRequested( + FilterOptionsLoadRequested event, + Emitter emit, + ) async { + emit(state.copyWith(filterOptionsStatus: FilterDialogStatus.loading)); + try { + final sourcesResponse = await _sourcesRepository.readAll( + pagination: const PaginationOptions( + limit: AppConstants.kMaxItemsPerRequest, + ), + ); + final topicsResponse = await _topicsRepository.readAll( + pagination: const PaginationOptions( + limit: AppConstants.kMaxItemsPerRequest, + ), + ); + final countriesResponse = await _countriesRepository.readAll( + pagination: const PaginationOptions( + limit: AppConstants.kMaxItemsPerRequest, + ), + ); + final languagesResponse = await _languagesRepository.readAll( + pagination: const PaginationOptions( + limit: AppConstants.kMaxItemsPerRequest, + ), + ); + + emit( + state.copyWith( + filterOptionsStatus: FilterDialogStatus.success, + availableSources: sourcesResponse.items, + availableTopics: topicsResponse.items, + availableCountries: countriesResponse.items, + availableLanguages: languagesResponse.items, + ), + ); + } on HttpException catch (e) { + emit( + state.copyWith( + filterOptionsStatus: FilterDialogStatus.failure, + exception: e, + ), + ); + } catch (e) { + emit( + state.copyWith( + filterOptionsStatus: FilterDialogStatus.failure, + exception: UnknownException('An unexpected error occurred: $e'), + ), + ); + } + } + + /// Updates the temporary search query. + void _onFilterDialogSearchQueryChanged( + FilterDialogSearchQueryChanged event, + Emitter emit, + ) { + emit(state.copyWith(searchQuery: event.query)); + } + + /// Updates the temporary selected content status. + void _onFilterDialogStatusChanged( + FilterDialogStatusChanged event, + Emitter emit, + ) { + emit(state.copyWith(selectedStatus: event.status)); + } + + /// Updates the temporary selected source IDs for headlines. + void _onFilterDialogHeadlinesSourceIdsChanged( + FilterDialogHeadlinesSourceIdsChanged event, + Emitter emit, + ) { + emit(state.copyWith(selectedSourceIds: event.sourceIds)); + } + + /// Updates the temporary selected topic IDs for headlines. + void _onFilterDialogHeadlinesTopicIdsChanged( + FilterDialogHeadlinesTopicIdsChanged event, + Emitter emit, + ) { + emit(state.copyWith(selectedTopicIds: event.topicIds)); + } + + /// Updates the temporary selected country IDs for headlines. + void _onFilterDialogHeadlinesCountryIdsChanged( + FilterDialogHeadlinesCountryIdsChanged event, + Emitter emit, + ) { + emit(state.copyWith(selectedCountryIds: event.countryIds)); + } + + /// Updates the temporary selected source types for sources. + void _onFilterDialogSourceTypesChanged( + FilterDialogSourceTypesChanged event, + Emitter emit, + ) { + emit(state.copyWith(selectedSourceTypes: event.sourceTypes)); + } + + /// Updates the temporary selected language codes for sources. + void _onFilterDialogLanguageCodesChanged( + FilterDialogLanguageCodesChanged event, + Emitter emit, + ) { + emit(state.copyWith(selectedLanguageCodes: event.languageCodes)); + } + + /// Updates the temporary selected headquarters country IDs for sources. + void _onFilterDialogHeadquartersCountryIdsChanged( + FilterDialogHeadquartersCountryIdsChanged event, + Emitter emit, + ) { + emit(state.copyWith(selectedHeadquartersCountryIds: event.countryIds)); + } + + /// Resets all temporary filter selections in the dialog to their initial state. + void _onFilterDialogReset( + FilterDialogReset event, + Emitter emit, + ) { + emit(FilterDialogState(activeTab: state.activeTab)); + } +} diff --git a/lib/content_management/widgets/filter_dialog/bloc/filter_dialog_event.dart b/lib/content_management/widgets/filter_dialog/bloc/filter_dialog_event.dart new file mode 100644 index 00000000..f6ecb976 --- /dev/null +++ b/lib/content_management/widgets/filter_dialog/bloc/filter_dialog_event.dart @@ -0,0 +1,124 @@ +part of 'filter_dialog_bloc.dart'; + +/// Base class for all events related to the [FilterDialogBloc]. +sealed class FilterDialogEvent extends Equatable { + const FilterDialogEvent(); + + @override + List get props => []; +} + +/// Event to initialize the filter dialog's state from the current filter BLoCs. +final class FilterDialogInitialized extends FilterDialogEvent { + const FilterDialogInitialized({ + required this.activeTab, + this.headlinesFilterState, + this.topicsFilterState, + this.sourcesFilterState, + }); + + final ContentManagementTab activeTab; + final HeadlinesFilterState? headlinesFilterState; + final TopicsFilterState? topicsFilterState; + final SourcesFilterState? sourcesFilterState; + + @override + List get props => [ + activeTab, + headlinesFilterState, + topicsFilterState, + sourcesFilterState, + ]; +} + +/// Event to load filter options (e.g., sources, topics, countries, languages). +final class FilterOptionsLoadRequested extends FilterDialogEvent { + const FilterOptionsLoadRequested(); +} + +/// Event to update the temporary search query. +final class FilterDialogSearchQueryChanged extends FilterDialogEvent { + const FilterDialogSearchQueryChanged(this.query); + + final String query; + + @override + List get props => [query]; +} + +/// Event to update the temporary selected content status. +final class FilterDialogStatusChanged extends FilterDialogEvent { + const FilterDialogStatusChanged(this.status); + + final ContentStatus status; + + @override + List get props => [status]; +} + +/// Event to update the temporary selected source IDs for headlines. +final class FilterDialogHeadlinesSourceIdsChanged extends FilterDialogEvent { + const FilterDialogHeadlinesSourceIdsChanged(this.sourceIds); + + final List sourceIds; + + @override + List get props => [sourceIds]; +} + +/// Event to update the temporary selected topic IDs for headlines. +final class FilterDialogHeadlinesTopicIdsChanged extends FilterDialogEvent { + const FilterDialogHeadlinesTopicIdsChanged(this.topicIds); + + final List topicIds; + + @override + List get props => [topicIds]; +} + +/// Event to update the temporary selected country IDs for headlines. +final class FilterDialogHeadlinesCountryIdsChanged extends FilterDialogEvent { + const FilterDialogHeadlinesCountryIdsChanged(this.countryIds); + + final List countryIds; + + @override + List get props => [countryIds]; +} + +/// Event to update the temporary selected source types for sources. +final class FilterDialogSourceTypesChanged extends FilterDialogEvent { + const FilterDialogSourceTypesChanged(this.sourceTypes); + + final List sourceTypes; + + @override + List get props => [sourceTypes]; +} + +/// Event to update the temporary selected language codes for sources. +final class FilterDialogLanguageCodesChanged extends FilterDialogEvent { + const FilterDialogLanguageCodesChanged(this.languageCodes); + + final List languageCodes; + + @override + List get props => [languageCodes]; +} + +/// Event to update the temporary selected headquarters country IDs for sources. +final class FilterDialogHeadquartersCountryIdsChanged + extends FilterDialogEvent { + const FilterDialogHeadquartersCountryIdsChanged(this.countryIds); + + final List countryIds; + + @override + List get props => [countryIds]; +} + +/// Event to reset all temporary filter selections in the dialog to their +/// initial state. +final class FilterDialogReset extends FilterDialogEvent { + const FilterDialogReset(); +} diff --git a/lib/content_management/widgets/filter_dialog/bloc/filter_dialog_state.dart b/lib/content_management/widgets/filter_dialog/bloc/filter_dialog_state.dart new file mode 100644 index 00000000..da727489 --- /dev/null +++ b/lib/content_management/widgets/filter_dialog/bloc/filter_dialog_state.dart @@ -0,0 +1,150 @@ +part of 'filter_dialog_bloc.dart'; + +/// Represents the status of the filter dialog's operations. +enum FilterDialogStatus { + /// 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 filter_dialog_state} +/// The state for the [FilterDialogBloc]. +/// {@endtemplate} +final class FilterDialogState extends Equatable { + /// {@macro filter_dialog_state} + const FilterDialogState({ + required this.activeTab, + this.status = FilterDialogStatus.initial, + this.filterOptionsStatus = FilterDialogStatus.initial, + this.exception, + this.searchQuery = '', + this.selectedStatus = ContentStatus.active, + this.selectedSourceIds = const [], + this.selectedTopicIds = const [], + this.selectedCountryIds = const [], + this.selectedSourceTypes = const [], + this.selectedLanguageCodes = const [], + this.selectedHeadquartersCountryIds = const [], + this.availableSources = const [], + this.availableTopics = const [], + this.availableCountries = const [], + this.availableLanguages = const [], + }); + + /// The current status of the filter dialog's main operations. + final FilterDialogStatus status; + + /// The status of loading filter options (e.g., sources, topics). + final FilterDialogStatus filterOptionsStatus; + + /// The exception encountered during a failed operation, if any. + final HttpException? exception; + + /// The currently active content management tab. + final ContentManagementTab activeTab; + + /// The current text in the search query field. + final String searchQuery; + + /// The single content status to be included in the filter. + final ContentStatus selectedStatus; + + /// The list of source IDs to be included in the filter for headlines. + final List selectedSourceIds; + + /// The list of topic IDs to be included in the filter for headlines. + final List selectedTopicIds; + + /// The list of country IDs to be included in the filter for headlines. + final List selectedCountryIds; + + /// The list of source types to be included in the filter for sources. + final List selectedSourceTypes; + + /// The list of language codes to be included in the filter for sources. + final List selectedLanguageCodes; + + /// The list of headquarters country IDs to be included in the filter for sources. + final List selectedHeadquartersCountryIds; + + /// The list of available sources for selection. + final List availableSources; + + /// The list of available topics for selection. + final List availableTopics; + + /// The list of available countries for selection. + final List availableCountries; + + /// The list of available languages for selection. + final List availableLanguages; + + /// Creates a copy of this [FilterDialogState] with updated values. + FilterDialogState copyWith({ + FilterDialogStatus? status, + FilterDialogStatus? filterOptionsStatus, + HttpException? exception, + ContentManagementTab? activeTab, + String? searchQuery, + ContentStatus? selectedStatus, + List? selectedSourceIds, + List? selectedTopicIds, + List? selectedCountryIds, + List? selectedSourceTypes, + List? selectedLanguageCodes, + List? selectedHeadquartersCountryIds, + List? availableSources, + List? availableTopics, + List? availableCountries, + List? availableLanguages, + }) { + return FilterDialogState( + status: status ?? this.status, + filterOptionsStatus: filterOptionsStatus ?? this.filterOptionsStatus, + exception: exception, + activeTab: activeTab ?? this.activeTab, + searchQuery: searchQuery ?? this.searchQuery, + selectedStatus: selectedStatus ?? this.selectedStatus, + selectedSourceIds: selectedSourceIds ?? this.selectedSourceIds, + selectedTopicIds: selectedTopicIds ?? this.selectedTopicIds, + selectedCountryIds: selectedCountryIds ?? this.selectedCountryIds, + selectedSourceTypes: selectedSourceTypes ?? this.selectedSourceTypes, + selectedLanguageCodes: + selectedLanguageCodes ?? this.selectedLanguageCodes, + selectedHeadquartersCountryIds: + selectedHeadquartersCountryIds ?? this.selectedHeadquartersCountryIds, + availableSources: availableSources ?? this.availableSources, + availableTopics: availableTopics ?? this.availableTopics, + availableCountries: availableCountries ?? this.availableCountries, + availableLanguages: availableLanguages ?? this.availableLanguages, + ); + } + + @override + List get props => [ + status, + filterOptionsStatus, + exception, + activeTab, + searchQuery, + selectedStatus, + selectedSourceIds, + selectedTopicIds, + selectedCountryIds, + selectedSourceTypes, + selectedLanguageCodes, + selectedHeadquartersCountryIds, + availableSources, + availableTopics, + availableCountries, + availableLanguages, + ]; +} diff --git a/lib/content_management/widgets/filter_dialog/filter_dialog.dart b/lib/content_management/widgets/filter_dialog/filter_dialog.dart new file mode 100644 index 00000000..051e3ccc --- /dev/null +++ b/lib/content_management/widgets/filter_dialog/filter_dialog.dart @@ -0,0 +1,526 @@ +import 'package:core/core.dart'; +import 'package:data_repository/data_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/content_management_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/headlines_filter/headlines_filter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/sources_filter/sources_filter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/topics_filter/topics_filter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/widgets/filter_dialog/bloc/filter_dialog_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/app_localizations.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/l10n/l10n.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/constants/app_constants.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/extensions/content_status_l10n.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/extensions/source_type_l10n.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/searchable_selection_input.dart'; +import 'package:ui_kit/ui_kit.dart'; + +/// {@template filter_dialog} +/// A full-screen dialog for applying filters to content management lists. +/// +/// This dialog provides a search text field and filter chips for content status, +/// as well as searchable selection inputs for other filter criteria. +/// It is designed to be generic and work with different filter BLoCs +/// (e.g., [HeadlinesFilterBloc], [TopicsFilterBloc], [SourcesFilterBloc]). +/// {@endtemplate} +class FilterDialog extends StatefulWidget { + /// {@macro filter_dialog} + const FilterDialog({ + required this.activeTab, + required this.sourcesRepository, + required this.topicsRepository, + required this.countriesRepository, + required this.languagesRepository, + super.key, + }); + + /// The currently active content management tab, used to determine which + /// filter BLoC to interact with. + final ContentManagementTab activeTab; + + /// The repository for fetching [Source] items. + final DataRepository sourcesRepository; + + /// The repository for fetching [Topic] items. + final DataRepository topicsRepository; + + /// The repository for fetching [Country] items. + final DataRepository countriesRepository; + + /// The repository for fetching [Language] items. + final DataRepository languagesRepository; + + @override + State createState() => _FilterDialogState(); +} + +class _FilterDialogState extends State { + late TextEditingController _searchController; + + @override + void initState() { + super.initState(); + _searchController = TextEditingController(); + // Initialize the FilterDialogBloc with current filter states. + // The FilterDialogBloc is now provided by a parent widget (the GoRouter route), + // so we can safely access it here. + _loadInitialFilterState(); + } + + /// Loads the initial filter state from the appropriate BLoC and dispatches + /// it to the FilterDialogBloc. + void _loadInitialFilterState() { + // Access the FilterDialogBloc directly from the context, as it's now + // provided higher up in the widget tree. + final filterDialogBloc = context.read(); + + final headlinesState = context.read().state; + final topicsState = context.read().state; + final sourcesState = context.read().state; + + filterDialogBloc.add( + FilterDialogInitialized( + activeTab: widget.activeTab, + headlinesFilterState: headlinesState, + topicsFilterState: topicsState, + sourcesFilterState: sourcesState, + ), + ); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizationsX(context).l10n; + final theme = Theme.of(context); + + // The BlocProvider for FilterDialogBloc is now handled by the GoRouter route, + // so we can directly use BlocBuilder here. + return BlocBuilder( + builder: (context, filterDialogState) { + _searchController.text = filterDialogState.searchQuery; + return Scaffold( + appBar: AppBar( + title: Text(_getDialogTitle(l10n)), + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + tooltip: l10n.resetFiltersButtonText, + onPressed: () { + // Dispatch reset event + context.read().add( + const FilterDialogReset(), + ); + // After reset, get the new state and apply filters + final resetState = + context.read().state.copyWith( + searchQuery: '', + selectedStatus: ContentStatus.active, + selectedSourceIds: [], + selectedTopicIds: [], + selectedCountryIds: [], + selectedSourceTypes: [], + selectedLanguageCodes: [], + selectedHeadquartersCountryIds: [], + ); + _dispatchFilterApplied(resetState); + Navigator.of(context).pop(); + }, + ), + IconButton( + icon: const Icon(Icons.check), + tooltip: l10n.applyFilters, + onPressed: () { + _dispatchFilterApplied(filterDialogState); + Navigator.of(context).pop(); + }, + ), + ], + ), + body: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _searchController, + decoration: InputDecoration( + labelText: l10n.search, + hintText: _getSearchHint(l10n), + prefixIcon: const Icon(Icons.search), + border: const OutlineInputBorder(), + ), + onChanged: (query) { + context.read().add( + FilterDialogSearchQueryChanged(query), + ); + }, + ), + const SizedBox(height: AppSpacing.lg), + Text( + l10n.status, + style: theme.textTheme.titleMedium, + ), + const SizedBox(height: AppSpacing.sm), + _buildStatusFilterChips(l10n, theme, filterDialogState), + const SizedBox(height: AppSpacing.lg), + _buildAdditionalFilters(l10n, filterDialogState), + ], + ), + ), + ), + ); + }, + ); + } + + /// Returns the appropriate dialog title based on the active tab. + String _getDialogTitle(AppLocalizations l10n) { + switch (widget.activeTab) { + case ContentManagementTab.headlines: + return l10n.filterHeadlines; + case ContentManagementTab.topics: + return l10n.filterTopics; + case ContentManagementTab.sources: + return l10n.filterSources; + } + } + + /// Returns the appropriate search hint based on the active tab. + String _getSearchHint(AppLocalizations l10n) { + switch (widget.activeTab) { + case ContentManagementTab.headlines: + return l10n.searchByHeadlineTitle; + case ContentManagementTab.topics: + return l10n.searchByTopicName; + case ContentManagementTab.sources: + return l10n.searchBySourceName; + } + } + + /// Builds the status filter chips based on the active tab's filter state. + Widget _buildStatusFilterChips( + AppLocalizations l10n, + ThemeData theme, + FilterDialogState filterDialogState, + ) { + return Wrap( + spacing: AppSpacing.sm, + children: ContentStatus.values.map((status) { + return ChoiceChip( + label: Text(status.l10n(context)), + selected: filterDialogState.selectedStatus == status, + onSelected: (isSelected) { + if (isSelected) { + context.read().add( + FilterDialogStatusChanged(status), + ); + } + }, + selectedColor: theme.colorScheme.primaryContainer, + labelStyle: TextStyle( + color: filterDialogState.selectedStatus == status + ? theme.colorScheme.onPrimaryContainer + : theme.colorScheme.onSurface, + ), + ); + }).toList(), + ); + } + + /// Builds additional filter widgets based on the active tab. + Widget _buildAdditionalFilters( + AppLocalizations l10n, + FilterDialogState filterDialogState, + ) { + switch (widget.activeTab) { + case ContentManagementTab.headlines: + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: AppSpacing.lg), + SearchableSelectionInput( + label: l10n.sources, + hintText: l10n.selectSources, + isMultiSelect: true, + selectedItems: filterDialogState.selectedSourceIds + .map( + (id) => filterDialogState.availableSources.firstWhere( + (source) => source.id == id, + orElse: () => Source( + id: id, + name: '', + description: '', + url: '', + sourceType: SourceType.other, + language: Language( + id: '', + code: '', + name: '', + nativeName: '', + createdAt: dummyDate, + updatedAt: dummyDate, + status: ContentStatus.active, + ), + headquarters: Country( + id: '', + isoCode: '', + name: '', + flagUrl: '', + createdAt: dummyDate, + updatedAt: dummyDate, + status: ContentStatus.active, + ), + createdAt: dummyDate, + updatedAt: dummyDate, + status: ContentStatus.active, + ), + ), + ) + .toList(), + itemBuilder: (context, item) => Text(item.name), + itemToString: (item) => item.name, + onChanged: (items) { + context.read().add( + FilterDialogHeadlinesSourceIdsChanged( + items?.map((e) => e.id).toList() ?? [], + ), + ); + }, + repository: widget.sourcesRepository, + filterBuilder: (searchTerm) => { + if (searchTerm != null && searchTerm.isNotEmpty) + 'name': {r'$regex': searchTerm, r'$options': 'i'}, + }, + sortOptions: const [SortOption('name', SortOrder.asc)], + limit: AppConstants.kDefaultRowsPerPage, + includeInactiveSelectedItem: true, + ), + const SizedBox(height: AppSpacing.lg), + SearchableSelectionInput( + label: l10n.topics, + hintText: l10n.selectTopics, + isMultiSelect: true, + selectedItems: filterDialogState.selectedTopicIds + .map( + (id) => filterDialogState.availableTopics.firstWhere( + (topic) => topic.id == id, + orElse: () => Topic( + id: id, + name: '', + description: '', + iconUrl: '', + createdAt: dummyDate, + updatedAt: dummyDate, + status: ContentStatus.active, + ), + ), + ) + .toList(), + itemBuilder: (context, item) => Text(item.name), + itemToString: (item) => item.name, + onChanged: (items) { + context.read().add( + FilterDialogHeadlinesTopicIdsChanged( + items?.map((e) => e.id).toList() ?? [], + ), + ); + }, + repository: widget.topicsRepository, + filterBuilder: (searchTerm) => { + if (searchTerm != null && searchTerm.isNotEmpty) + 'name': {r'$regex': searchTerm, r'$options': 'i'}, + }, + sortOptions: const [SortOption('name', SortOrder.asc)], + limit: AppConstants.kDefaultRowsPerPage, + includeInactiveSelectedItem: true, + ), + const SizedBox(height: AppSpacing.lg), + SearchableSelectionInput( + label: l10n.countries, + hintText: l10n.selectCountries, + isMultiSelect: true, + selectedItems: filterDialogState.selectedCountryIds + .map( + (id) => filterDialogState.availableCountries.firstWhere( + (country) => country.id == id, + orElse: () => Country( + id: '', + isoCode: '', + name: '', + flagUrl: '', + createdAt: dummyDate, + updatedAt: dummyDate, + status: ContentStatus.active, + ), + ), + ) + .toList(), + itemBuilder: (context, item) => Text(item.name), + itemToString: (item) => item.name, + onChanged: (items) { + context.read().add( + FilterDialogHeadlinesCountryIdsChanged( + items?.map((e) => e.id).toList() ?? [], + ), + ); + }, + repository: widget.countriesRepository, + filterBuilder: (searchTerm) => { + if (searchTerm != null && searchTerm.isNotEmpty) + 'name': {r'$regex': searchTerm, r'$options': 'i'}, + }, + sortOptions: const [SortOption('name', SortOrder.asc)], + limit: AppConstants.kDefaultRowsPerPage, + includeInactiveSelectedItem: true, + ), + ], + ); + case ContentManagementTab.topics: + return const SizedBox.shrink(); // No additional filters for topics + case ContentManagementTab.sources: + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: AppSpacing.lg), + SearchableSelectionInput( + label: l10n.sourceType, + hintText: l10n.selectSourceTypes, + isMultiSelect: true, + selectedItems: filterDialogState.selectedSourceTypes, + itemBuilder: (context, item) => Text(item.localizedName(l10n)), + itemToString: (item) => item.localizedName(l10n), + onChanged: (items) { + context.read().add( + FilterDialogSourceTypesChanged(items ?? []), + ); + }, + staticItems: SourceType.values, + ), + const SizedBox(height: AppSpacing.lg), + SearchableSelectionInput( + label: l10n.language, + hintText: l10n.selectLanguages, + isMultiSelect: true, + selectedItems: filterDialogState.selectedLanguageCodes + .map( + (code) => filterDialogState.availableLanguages.firstWhere( + (language) => language.code == code, + orElse: () => Language( + id: '', + code: '', + name: '', + nativeName: '', + createdAt: dummyDate, + updatedAt: dummyDate, + status: ContentStatus.active, + ), + ), + ) + .toList(), + itemBuilder: (context, item) => Text(item.name), + itemToString: (item) => item.name, + onChanged: (items) { + context.read().add( + FilterDialogLanguageCodesChanged( + items?.map((e) => e.code).toList() ?? [], + ), + ); + }, + repository: widget.languagesRepository, + filterBuilder: (searchTerm) => { + if (searchTerm != null && searchTerm.isNotEmpty) + 'name': {r'$regex': searchTerm, r'$options': 'i'}, + }, + sortOptions: const [SortOption('name', SortOrder.asc)], + limit: AppConstants.kDefaultRowsPerPage, + includeInactiveSelectedItem: true, + ), + const SizedBox(height: AppSpacing.lg), + SearchableSelectionInput( + label: l10n.headquarters, + hintText: l10n.selectHeadquarters, + isMultiSelect: true, + selectedItems: filterDialogState.selectedHeadquartersCountryIds + .map( + (id) => filterDialogState.availableCountries.firstWhere( + (country) => country.id == id, + orElse: () => Country( + id: '', + isoCode: '', + name: '', + flagUrl: '', + createdAt: dummyDate, + updatedAt: dummyDate, + status: ContentStatus.active, + ), + ), + ) + .toList(), + itemBuilder: (context, item) => Text(item.name), + itemToString: (item) => item.name, + onChanged: (items) { + context.read().add( + FilterDialogHeadquartersCountryIdsChanged( + items?.map((e) => e.id).toList() ?? [], + ), + ); + }, + repository: widget.countriesRepository, + filterBuilder: (searchTerm) => { + if (searchTerm != null && searchTerm.isNotEmpty) + 'name': {r'$regex': searchTerm, r'$options': 'i'}, + }, + sortOptions: const [SortOption('name', SortOrder.asc)], + limit: AppConstants.kDefaultRowsPerPage, + includeInactiveSelectedItem: true, + ), + ], + ); + } + } + + /// Dispatches the filter applied event to the appropriate BLoC. + void _dispatchFilterApplied(FilterDialogState filterDialogState) { + switch (widget.activeTab) { + case ContentManagementTab.headlines: + context.read().add( + HeadlinesFilterApplied( + searchQuery: filterDialogState.searchQuery, + selectedStatus: filterDialogState.selectedStatus, + selectedSourceIds: filterDialogState.selectedSourceIds, + selectedTopicIds: filterDialogState.selectedTopicIds, + selectedCountryIds: filterDialogState.selectedCountryIds, + ), + ); + case ContentManagementTab.topics: + context.read().add( + TopicsFilterApplied( + searchQuery: filterDialogState.searchQuery, + selectedStatus: filterDialogState.selectedStatus, + ), + ); + case ContentManagementTab.sources: + context.read().add( + SourcesFilterApplied( + searchQuery: filterDialogState.searchQuery, + selectedStatus: filterDialogState.selectedStatus, + selectedSourceTypes: filterDialogState.selectedSourceTypes, + selectedLanguageCodes: filterDialogState.selectedLanguageCodes, + selectedHeadquartersCountryIds: + filterDialogState.selectedHeadquartersCountryIds, + ), + ); + } + } +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 22260fd1..a774c5d1 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -986,6 +986,12 @@ abstract class AppLocalizations { /// **'Select the application language.'** String get languageDescription; + /// Tooltip for the edit button + /// + /// In en, this message translates to: + /// **'Edit'** + String get edit; + /// Option for English language /// /// In en, this message translates to: @@ -1898,6 +1904,12 @@ abstract class AppLocalizations { /// **'Close'** String get close; + /// Button text to apply changes or selections + /// + /// In en, this message translates to: + /// **'Apply'** + String get apply; + /// Label for checkbox to control visibility of a decorator for a specific role /// /// In en, this message translates to: @@ -2599,6 +2611,108 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'More Actions'** String get moreActions; + + /// Tooltip for the filter icon button. + /// + /// In en, this message translates to: + /// **'Filter'** + String get filter; + + /// Text for the button to apply filters. + /// + /// In en, this message translates to: + /// **'Apply Filters'** + String get applyFilters; + + /// Title for the filter dialog when filtering headlines. + /// + /// In en, this message translates to: + /// **'Filter Headlines'** + String get filterHeadlines; + + /// Title for the filter dialog when filtering topics. + /// + /// In en, this message translates to: + /// **'Filter Topics'** + String get filterTopics; + + /// Title for the filter dialog when filtering sources. + /// + /// In en, this message translates to: + /// **'Filter Sources'** + String get filterSources; + + /// Hint text for the headline search field. + /// + /// In en, this message translates to: + /// **'Search by headline title...'** + String get searchByHeadlineTitle; + + /// Hint text for the topic search field. + /// + /// In en, this message translates to: + /// **'Search by topic name...'** + String get searchByTopicName; + + /// Hint text for the source search field. + /// + /// In en, this message translates to: + /// **'Search by source name...'** + String get searchBySourceName; + + /// Hint text for selecting sources in a filter dialog. + /// + /// In en, this message translates to: + /// **'Select Sources'** + String get selectSources; + + /// Hint text for selecting topics in a filter dialog. + /// + /// In en, this message translates to: + /// **'Select Topics'** + String get selectTopics; + + /// Label for countries filter. + /// + /// In en, this message translates to: + /// **'Countries'** + String get countries; + + /// Hint text for selecting countries in a filter dialog. + /// + /// In en, this message translates to: + /// **'Select Countries'** + String get selectCountries; + + /// Hint text for selecting source types in a filter dialog. + /// + /// In en, this message translates to: + /// **'Select Source Types'** + String get selectSourceTypes; + + /// Hint text for selecting languages in a filter dialog. + /// + /// In en, this message translates to: + /// **'Select Languages'** + String get selectLanguages; + + /// Hint text for selecting headquarters in a filter dialog. + /// + /// In en, this message translates to: + /// **'Select Headquarters'** + String get selectHeadquarters; + + /// Text for the button to reset filters to their default state. + /// + /// In en, this message translates to: + /// **'Reset Filters'** + String get resetFiltersButtonText; + + /// Message displayed when no results are found due to active filters, prompting the user to reset them. + /// + /// In en, this message translates to: + /// **'No results found with current filters. Try resetting them.'** + String get noResultsWithCurrentFilters; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index 77daf864..b91f5f65 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -521,6 +521,9 @@ class AppLocalizationsAr extends AppLocalizations { @override String get languageDescription => 'اختر لغة التطبيق.'; + @override + String get edit => 'تعديل'; + @override String get englishLanguage => 'الإنجليزية'; @@ -1001,6 +1004,9 @@ class AppLocalizationsAr extends AppLocalizations { @override String get close => 'إغلاق'; + @override + String get apply => 'تطبيق'; + @override String visibleToRoleLabel(String roleName) { return 'مرئي لـ $roleName'; @@ -1390,4 +1396,56 @@ class AppLocalizationsAr extends AppLocalizations { @override String get moreActions => 'المزيد من الإجراءات'; + + @override + String get filter => 'تصفية'; + + @override + String get applyFilters => 'تطبيق الفلاتر'; + + @override + String get filterHeadlines => 'تصفية العناوين'; + + @override + String get filterTopics => 'تصفية المواضيع'; + + @override + String get filterSources => 'تصفية المصادر'; + + @override + String get searchByHeadlineTitle => 'البحث بعنوان الخبر...'; + + @override + String get searchByTopicName => 'البحث باسم الموضوع...'; + + @override + String get searchBySourceName => 'البحث باسم المصدر...'; + + @override + String get selectSources => 'اختر المصادر'; + + @override + String get selectTopics => 'اختر المواضيع'; + + @override + String get countries => 'البلدان'; + + @override + String get selectCountries => 'اختر البلدان'; + + @override + String get selectSourceTypes => 'اختر أنواع المصادر'; + + @override + String get selectLanguages => 'اختر اللغات'; + + @override + String get selectHeadquarters => 'اختر المقر الرئيسي'; + + @override + String get resetFiltersButtonText => 'إعادة تعيين الفلاتر'; + + @override + String get noResultsWithCurrentFilters => + 'لم يتم العثور على نتائج باستخدام الفلاتر الحالية. حاول إعادة تعيينها.'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 98ab1f3b..649118a2 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -520,6 +520,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get languageDescription => 'Select the application language.'; + @override + String get edit => 'Edit'; + @override String get englishLanguage => 'English'; @@ -999,6 +1002,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get close => 'Close'; + @override + String get apply => 'Apply'; + @override String visibleToRoleLabel(String roleName) { return 'Visible to $roleName'; @@ -1395,4 +1401,56 @@ class AppLocalizationsEn extends AppLocalizations { @override String get moreActions => 'More Actions'; + + @override + String get filter => 'Filter'; + + @override + String get applyFilters => 'Apply Filters'; + + @override + String get filterHeadlines => 'Filter Headlines'; + + @override + String get filterTopics => 'Filter Topics'; + + @override + String get filterSources => 'Filter Sources'; + + @override + String get searchByHeadlineTitle => 'Search by headline title...'; + + @override + String get searchByTopicName => 'Search by topic name...'; + + @override + String get searchBySourceName => 'Search by source name...'; + + @override + String get selectSources => 'Select Sources'; + + @override + String get selectTopics => 'Select Topics'; + + @override + String get countries => 'Countries'; + + @override + String get selectCountries => 'Select Countries'; + + @override + String get selectSourceTypes => 'Select Source Types'; + + @override + String get selectLanguages => 'Select Languages'; + + @override + String get selectHeadquarters => 'Select Headquarters'; + + @override + String get resetFiltersButtonText => 'Reset Filters'; + + @override + String get noResultsWithCurrentFilters => + 'No results found with current filters. Try resetting them.'; } diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 8bc5135d..24878602 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -1257,6 +1257,10 @@ "@close": { "description": "نص زر إغلاق نافذة منبثقة أو تراكب" }, + "apply": "تطبيق", + "@apply": { + "description": "نص زر لتطبيق التغييرات أو التحديدات" + }, "visibleToRoleLabel": "مرئي لـ {roleName}", "@visibleToRoleLabel": { "description": "تسمية مربع الاختيار للتحكم في رؤية الزينة لدور معين", @@ -1759,5 +1763,77 @@ "moreActions": "المزيد من الإجراءات", "@moreActions": { "description": "تلميح الزر الذي يفتح قائمة تحتوي على المزيد من الإجراءات لصف في الجدول." + }, + "filter": "تصفية", + "@filter": { + "description": "تلميح زر أيقونة التصفية." + }, + "applyFilters": "تطبيق الفلاتر", + "@applyFilters": { + "description": "نص زر تطبيق الفلاتر." + }, + "filterHeadlines": "تصفية العناوين", + "@filterHeadlines": { + "description": "عنوان مربع حوار التصفية عند تصفية العناوين." + }, + "filterTopics": "تصفية المواضيع", + "@filterTopics": { + "description": "عنوان مربع حوار التصفية عند تصفية المواضيع." + }, + "filterSources": "تصفية المصادر", + "@filterSources": { + "description": "عنوان مربع حوار التصفية عند تصفية المصادر." + }, + "searchByHeadlineTitle": "البحث بعنوان الخبر...", + "@searchByHeadlineTitle": { + "description": "نص تلميح حقل البحث عن العناوين." + }, + "searchByTopicName": "البحث باسم الموضوع...", + "@searchByTopicName": { + "description": "نص تلميح حقل البحث عن المواضيع." + }, + "searchBySourceName": "البحث باسم المصدر...", + "@searchBySourceName": { + "description": "نص تلميح حقل البحث عن المصادر." + }, + "selectSources": "اختر المصادر", + "@selectSources": { + "description": "نص تلميح لاختيار المصادر في مربع حوار التصفية." + }, + "selectTopics": "اختر المواضيع", + "@selectTopics": { + "description": "نص تلميح لاختيار المواضيع في مربع حوار التصفية." + }, + "countries": "البلدان", + "@countries": { + "description": "تسمية لفلتر البلدان." + }, + "selectCountries": "اختر البلدان", + "@selectCountries": { + "description": "نص تلميح لاختيار البلدان في مربع حوار التصفية." + }, + "selectSourceTypes": "اختر أنواع المصادر", + "@selectSourceTypes": { + "description": "نص تلميح لاختيار أنواع المصادر في مربع حوار التصفية." + }, + "selectLanguages": "اختر اللغات", + "@selectLanguages": { + "description": "نص تلميح لاختيار اللغات في مربع حوار التصفية." + }, + "selectHeadquarters": "اختر المقر الرئيسي", + "@selectHeadquarters": { + "description": "نص تلميح لاختيار المقر الرئيسي في مربع حوار التصفية." + }, + "edit": "تعديل", + "@edit": { + "description": "Tooltip for the edit button" + }, + "resetFiltersButtonText": "إعادة تعيين الفلاتر", + "@resetFiltersButtonText": { + "description": "Text for the button to reset filters to their default state." + }, + "noResultsWithCurrentFilters": "لم يتم العثور على نتائج باستخدام الفلاتر الحالية. حاول إعادة تعيينها.", + "@noResultsWithCurrentFilters": { + "description": "Message displayed when no results are found due to active filters, prompting the user to reset them." } } \ No newline at end of file diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 544aaa72..9b420d45 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -622,6 +622,10 @@ "@languageDescription": { "description": "Description for language setting" }, + "edit": "Edit", + "@edit": { + "description": "Tooltip for the edit button" + }, "englishLanguage": "English", "@englishLanguage": { "description": "Option for English language" @@ -1253,6 +1257,10 @@ "@close": { "description": "Button text to close a modal or overlay" }, + "apply": "Apply", + "@apply": { + "description": "Button text to apply changes or selections" + }, "visibleToRoleLabel": "Visible to {roleName}", "@visibleToRoleLabel": { "description": "Label for checkbox to control visibility of a decorator for a specific role", @@ -1755,5 +1763,73 @@ "moreActions": "More Actions", "@moreActions": { "description": "Tooltip for the button that opens a menu with more actions for a table row." + }, + "filter": "Filter", + "@filter": { + "description": "Tooltip for the filter icon button." + }, + "applyFilters": "Apply Filters", + "@applyFilters": { + "description": "Text for the button to apply filters." + }, + "filterHeadlines": "Filter Headlines", + "@filterHeadlines": { + "description": "Title for the filter dialog when filtering headlines." + }, + "filterTopics": "Filter Topics", + "@filterTopics": { + "description": "Title for the filter dialog when filtering topics." + }, + "filterSources": "Filter Sources", + "@filterSources": { + "description": "Title for the filter dialog when filtering sources." + }, + "searchByHeadlineTitle": "Search by headline title...", + "@searchByHeadlineTitle": { + "description": "Hint text for the headline search field." + }, + "searchByTopicName": "Search by topic name...", + "@searchByTopicName": { + "description": "Hint text for the topic search field." + }, + "searchBySourceName": "Search by source name...", + "@searchBySourceName": { + "description": "Hint text for the source search field." + }, + "selectSources": "Select Sources", + "@selectSources": { + "description": "Hint text for selecting sources in a filter dialog." + }, + "selectTopics": "Select Topics", + "@selectTopics": { + "description": "Hint text for selecting topics in a filter dialog." + }, + "countries": "Countries", + "@countries": { + "description": "Label for countries filter." + }, + "selectCountries": "Select Countries", + "@selectCountries": { + "description": "Hint text for selecting countries in a filter dialog." + }, + "selectSourceTypes": "Select Source Types", + "@selectSourceTypes": { + "description": "Hint text for selecting source types in a filter dialog." + }, + "selectLanguages": "Select Languages", + "@selectLanguages": { + "description": "Hint text for selecting languages in a filter dialog." + }, + "selectHeadquarters": "Select Headquarters", + "@selectHeadquarters": { + "description": "Hint text for selecting headquarters in a filter dialog." + }, + "resetFiltersButtonText": "Reset Filters", + "@resetFiltersButtonText": { + "description": "Text for the button to reset filters to their default state." + }, + "noResultsWithCurrentFilters": "No results found with current filters. Try resetting them.", + "@noResultsWithCurrentFilters": { + "description": "Message displayed when no results are found due to active filters, prompting the user to reset them." } } \ No newline at end of file diff --git a/lib/router/router.dart b/lib/router/router.dart index 36245ef4..af67c388 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -1,4 +1,6 @@ import 'package:auth_repository/auth_repository.dart'; +import 'package:core/core.dart' hide AppStatus; // Hide AppStatus from core.dart +import 'package:data_repository/data_repository.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/app/bloc/app_bloc.dart'; @@ -10,19 +12,19 @@ import 'package:flutter_news_app_web_dashboard_full_source_code/authentication/b import 'package:flutter_news_app_web_dashboard_full_source_code/authentication/view/authentication_page.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/authentication/view/email_code_verification_page.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/authentication/view/request_code_page.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/view/archived_headlines_page.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/view/archived_sources_page.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/view/archived_topics_page.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/content_management_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/headlines_filter/headlines_filter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/sources_filter/sources_filter_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/bloc/topics_filter/topics_filter_bloc.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/view/content_management_page.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/view/create_headline_page.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/view/create_source_page.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/view/create_topic_page.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/view/draft_headlines_page.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/view/draft_sources_page.dart'; -import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/view/draft_topics_page.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/view/edit_headline_page.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/view/edit_source_page.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/view/edit_topic_page.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/widgets/filter_dialog/bloc/filter_dialog_bloc.dart'; +import 'package:flutter_news_app_web_dashboard_full_source_code/content_management/widgets/filter_dialog/filter_dialog.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/local_ads_management/view/archived_local_ads_page.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/local_ads_management/view/create_local_banner_ad_page.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/local_ads_management/view/create_local_interstitial_ad_page.dart'; @@ -214,36 +216,6 @@ GoRouter createRouter({ return EditSourcePage(sourceId: sourceId); }, ), - GoRoute( - path: Routes.archivedHeadlines, - name: Routes.archivedHeadlinesName, - builder: (context, state) => const ArchivedHeadlinesPage(), - ), - GoRoute( - path: Routes.archivedTopics, - name: Routes.archivedTopicsName, - builder: (context, state) => const ArchivedTopicsPage(), - ), - GoRoute( - path: Routes.archivedSources, - name: Routes.archivedSourcesName, - builder: (context, state) => const ArchivedSourcesPage(), - ), - GoRoute( - path: Routes.draftHeadlines, - name: Routes.draftHeadlinesName, - builder: (context, state) => const DraftHeadlinesPage(), - ), - GoRoute( - path: Routes.draftSources, - name: Routes.draftSourcesName, - builder: (context, state) => const DraftSourcesPage(), - ), - GoRoute( - path: Routes.draftTopics, - name: Routes.draftTopicsName, - builder: (context, state) => const DraftTopicsPage(), - ), // Moved searchableSelection as a sub-route of content-management GoRoute( path: Routes.searchableSelection, @@ -256,6 +228,65 @@ GoRouter createRouter({ ); }, ), + // New route for the FilterDialog + GoRoute( + path: Routes.filterDialog, + name: Routes.filterDialogName, + pageBuilder: (context, state) { + final args = state.extra! as Map; + final activeTab = + args['activeTab'] as ContentManagementTab; + final sourcesRepository = + args['sourcesRepository'] as DataRepository; + final topicsRepository = + args['topicsRepository'] as DataRepository; + final countriesRepository = + args['countriesRepository'] + as DataRepository; + final languagesRepository = + args['languagesRepository'] + as DataRepository; + + return MaterialPage( + fullscreenDialog: true, + child: BlocProvider( + create: (providerContext) { + final filterDialogBloc = + FilterDialogBloc( + activeTab: activeTab, + sourcesRepository: sourcesRepository, + topicsRepository: topicsRepository, + countriesRepository: countriesRepository, + languagesRepository: languagesRepository, + ) + // Dispatch initial state after creation + ..add( + FilterDialogInitialized( + activeTab: activeTab, + headlinesFilterState: providerContext + .read() + .state, + topicsFilterState: providerContext + .read() + .state, + sourcesFilterState: providerContext + .read() + .state, + ), + ); + return filterDialogBloc; + }, + child: FilterDialog( + activeTab: activeTab, + sourcesRepository: sourcesRepository, + topicsRepository: topicsRepository, + countriesRepository: countriesRepository, + languagesRepository: languagesRepository, + ), + ), + ); + }, + ), ], ), ], diff --git a/lib/router/routes.dart b/lib/router/routes.dart index 9e0ccd3d..1b6ae0f5 100644 --- a/lib/router/routes.dart +++ b/lib/router/routes.dart @@ -44,42 +44,6 @@ abstract final class Routes { /// The name for the content management section route. static const String contentManagementName = 'contentManagement'; - /// The path for the archived headlines page. - static const String archivedHeadlines = 'archived-headlines'; - - /// The name for the archived headlines page route. - static const String archivedHeadlinesName = 'archivedHeadlines'; - - /// The path for the archived topics page. - static const String archivedTopics = 'archived-topics'; - - /// The name for the archived topics page route. - static const String archivedTopicsName = 'archivedTopics'; - - /// The path for the archived sources page. - static const String archivedSources = 'archived-sources'; - - /// The name for the archived sources page route. - static const String archivedSourcesName = 'archivedSources'; - - /// The path for the draft headlines page. - static const String draftHeadlines = 'draft-headlines'; - - /// The name for the draft headlines page route. - static const String draftHeadlinesName = 'draftHeadlines'; - - /// The path for the draft topics page. - static const String draftTopics = 'draft-topics'; - - /// The name for the draft topics page route. - static const String draftTopicsName = 'draftTopics'; - - /// The path for the draft sources page. - static const String draftSources = 'draft-sources'; - - /// The name for the draft sources page route. - static const String draftSourcesName = 'draftSources'; - /// The path for creating a new headline. static const String createHeadline = 'create-headline'; @@ -129,11 +93,17 @@ abstract final class Routes { static const String settingsName = 'settings'; /// The path for the generic searchable selection page. - static const String searchableSelection = '/searchable-selection'; + static const String searchableSelection = 'searchable-selection'; /// The name for the generic searchable selection page route. static const String searchableSelectionName = 'searchableSelection'; + /// The path for the filter dialog. + static const String filterDialog = 'filter-dialog'; + + /// The name for the filter dialog route. + static const String filterDialogName = 'filterDialog'; + /// The path for the local ads management page. static const String localAdsManagement = '/local-ads-management'; diff --git a/lib/shared/constants/app_constants.dart b/lib/shared/constants/app_constants.dart index 01f34bde..b975677c 100644 --- a/lib/shared/constants/app_constants.dart +++ b/lib/shared/constants/app_constants.dart @@ -14,4 +14,17 @@ abstract final class AppConstants { /// The default card radius used across the application. static const double kCardRadius = 8; + + /// The default number of rows per page for paginated tables. + static const int kDefaultRowsPerPage = 10; + + /// The maximum number of items to fetch in a single API request for filter options. + static const int kMaxItemsPerRequest = 25; + + /// The duration for which a snackbar message is displayed, + /// also used as the undo duration for pending deletions. + static const Duration kSnackbarDuration = Duration(seconds: 5); } + +/// A dummy [DateTime] used for placeholder models in UI. +final dummyDate = DateTime.fromMillisecondsSinceEpoch(0); diff --git a/lib/shared/widgets/searchable_selection_input.dart b/lib/shared/widgets/searchable_selection_input.dart index 8db320ac..b1eb139a 100644 --- a/lib/shared/widgets/searchable_selection_input.dart +++ b/lib/shared/widgets/searchable_selection_input.dart @@ -10,14 +10,14 @@ import 'package:go_router/go_router.dart'; /// {@template searchable_selection_input} /// A custom input widget that, when tapped, navigates to a full-page -/// searchable selection screen to allow the user to select an item of type [T]. +/// searchable selection screen to allow the user to select one or more items of type [T]. /// /// This widget replaces the functionality of a traditional dropdown with a /// more robust, page-based selection experience, supporting search and /// pagination on the selection page. /// /// It handles the conversion of generic type [T] to [Object] for passing -/// arguments via `GoRouter` and then casts the selected item back to [T]. +/// arguments via `GoRouter` and then casts the selected item(s) back to type [T]. /// {@endtemplate} class SearchableSelectionInput extends StatefulWidget { /// {@macro searchable_selection_input} @@ -26,13 +26,15 @@ class SearchableSelectionInput extends StatefulWidget { required this.itemBuilder, required this.itemToString, required this.onChanged, - this.selectedItem, + this.selectedItems, // Changed to List? this.repository, this.filterBuilder, this.sortOptions, this.limit, this.staticItems, this.includeInactiveSelectedItem = false, + this.isMultiSelect = false, // New parameter + this.hintText, // New parameter super.key, }) : assert( (repository != null && @@ -46,13 +48,13 @@ class SearchableSelectionInput extends StatefulWidget { /// The label text for the input field. final String label; - /// The currently selected item. - final T? selectedItem; + /// The currently selected item(s). + final List? selectedItems; // Changed to List? - /// If true, the [selectedItem] will be included in the fetched results - /// even if it does not match the current filter criteria (e.g., if it's - /// an inactive item that was previously selected). This is useful for edit - /// pages where the previously selected item should always be visible. + /// If true, the [selectedItems] will be included in the fetched results + /// even if they do not match the current filter criteria (e.g., if they are + /// inactive items that were previously selected). This is useful for edit + /// pages where the previously selected item(s) should always be visible. final bool includeInactiveSelectedItem; /// A builder function to customize the display of each item in the list. @@ -63,8 +65,8 @@ class SearchableSelectionInput extends StatefulWidget { /// display in the input field and for search filtering. final String Function(T item) itemToString; - /// Callback when an item is selected or cleared. - final ValueChanged onChanged; + /// Callback when item(s) are selected or cleared. + final ValueChanged?> onChanged; // Changed to ValueChanged?> /// The [DataRepository] to use for fetching items (if not using static items). /// The generic type of the repository must match [T]. @@ -83,6 +85,13 @@ class SearchableSelectionInput extends StatefulWidget { /// The items in this list must be of type [T]. final List? staticItems; + /// If true, the selection page will allow multiple items to be selected. + /// Defaults to false for single selection. + final bool isMultiSelect; + + /// Optional hint text for the input field. + final String? hintText; + @override State> createState() => _SearchableSelectionInputState(); @@ -97,31 +106,35 @@ class _SearchableSelectionInputState @override void initState() { super.initState(); - // Initialize the text controller with the selected item's string representation. + // Initialize the text controller with the selected item(s)'s string representation. _updateTextController(); } @override void didUpdateWidget(covariant SearchableSelectionInput oldWidget) { super.didUpdateWidget(oldWidget); - // Update the text controller if the selected item changes externally. - if (widget.selectedItem != oldWidget.selectedItem) { + // Update the text controller if the selected item(s) change externally. + if (widget.selectedItems != oldWidget.selectedItems) { _updateTextController(); } } - /// Updates the text controller's text based on the current selected item. + /// Updates the text controller's text based on the current selected item(s). void _updateTextController() { - _textController.text = widget.selectedItem != null - ? widget.itemToString(widget.selectedItem as T) - : ''; + if (widget.selectedItems != null && widget.selectedItems!.isNotEmpty) { + _textController.text = widget.selectedItems! + .map((item) => widget.itemToString(item)) + .join(', '); + } else { + _textController.clear(); + } } /// Opens the [SearchableSelectionPage] as a new route. /// /// It constructs [SelectionPageArguments] by converting generic functions /// and lists to operate on [Object] to bypass `GoRouter`'s generic limitations. - /// After selection, it casts the result back to type [T]. + /// After selection, it casts the selected item(s) back to type [T]. Future _openSelectionPage() async { // Determine whether to use repository-based fetching or static items. final SelectionPageArguments arguments; @@ -131,13 +144,13 @@ class _SearchableSelectionInputState itemType: T, itemBuilder: (context, item) => widget.itemBuilder(context, item as T), itemToString: (item) => widget.itemToString(item as T), - initialSelectedItem: widget.selectedItem, + initialSelectedItems: widget.selectedItems?.cast(), repository: widget.repository! as DataRepository, filterBuilder: widget.filterBuilder, sortOptions: widget.sortOptions, limit: widget.limit, - // Pass the flag to the selection page arguments. includeInactiveSelectedItem: widget.includeInactiveSelectedItem, + isMultiSelect: widget.isMultiSelect, ); } else { arguments = SelectionPageArguments( @@ -145,22 +158,24 @@ class _SearchableSelectionInputState itemType: T, itemBuilder: (context, item) => widget.itemBuilder(context, item as T), itemToString: (item) => widget.itemToString(item as T), - initialSelectedItem: widget.selectedItem, + initialSelectedItems: widget.selectedItems?.cast(), staticItems: widget.staticItems! as List, - // Pass the flag to the selection page arguments. includeInactiveSelectedItem: widget.includeInactiveSelectedItem, + isMultiSelect: widget.isMultiSelect, ); } // Push the searchable selection page and await a result. - final selectedItem = await context.pushNamed( + final result = await context.pushNamed?>( Routes.searchableSelectionName, extra: arguments, ); - // If an item was selected, notify the parent widget. - if (selectedItem != null) { - widget.onChanged(selectedItem as T); + // If item(s) were selected, notify the parent widget. + if (result != null) { + widget.onChanged(result.cast()); + } else { + widget.onChanged(null); // Clear selection if nothing was chosen } } @@ -172,13 +187,15 @@ class _SearchableSelectionInputState readOnly: true, decoration: InputDecoration( labelText: widget.label, + hintText: widget.hintText, // Use new hintText parameter border: Theme.of(context).inputDecorationTheme.border, suffixIcon: IconButton( icon: const Icon(Icons.arrow_drop_down), onPressed: _openSelectionPage, ), - // Show a clear button if an item is currently selected. - prefixIcon: widget.selectedItem != null + // Show a clear button if item(s) are currently selected. + prefixIcon: + (widget.selectedItems != null && widget.selectedItems!.isNotEmpty) ? IconButton( icon: const Icon(Icons.clear), tooltip: l10n.clearSelection, 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 4ec8f5df..f27e100c 100644 --- a/lib/shared/widgets/selection_page/bloc/searchable_selection_bloc.dart +++ b/lib/shared/widgets/selection_page/bloc/searchable_selection_bloc.dart @@ -5,7 +5,6 @@ import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:core/core.dart'; import 'package:data_repository/data_repository.dart'; import 'package:equatable/equatable.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter_news_app_web_dashboard_full_source_code/shared/widgets/selection_page/selection_page_arguments.dart'; import 'package:rxdart/rxdart.dart'; @@ -22,6 +21,7 @@ EventTransformer debounce(Duration duration) { /// /// This BLoC handles fetching data from a [DataRepository] or a static list, /// applying search filters with debouncing, and managing pagination. +/// It supports both single and multi-selection modes. /// /// Note: This BLoC operates on [Object] due to GoRouter's limitations with /// passing generic types. Items are expected to be cast to their specific @@ -35,7 +35,7 @@ class SearchableSelectionBloc }) : _arguments = arguments, super( SearchableSelectionState( - selectedItem: arguments.initialSelectedItem, + selectedItems: arguments.initialSelectedItems ?? const [], ), ) { on( @@ -50,7 +50,8 @@ class SearchableSelectionBloc _onLoadMoreRequested, transformer: sequential(), ); - on(_onSetSelectedItem); + on(_onSetSelectedItems); + on(_onToggleItem); // Initial load add(const SearchableSelectionLoadRequested()); @@ -105,15 +106,15 @@ class SearchableSelectionBloc cursor = response.cursor; hasMore = response.hasMore; - // If includeInactiveSelectedItem is true and initialSelectedItem is provided, - // ensure it's in the list, even if it doesn't match the current filter. + // If includeInactiveSelectedItem is true and initialSelectedItems are provided, + // ensure they are in the list, even if they don't match the current filter. if (_arguments.includeInactiveSelectedItem && - _arguments.initialSelectedItem != null && - !fetchedItems.contains(_arguments.initialSelectedItem)) { - fetchedItems = [ - _arguments.initialSelectedItem!, - ...fetchedItems, - ]; + _arguments.initialSelectedItems != null && + _arguments.initialSelectedItems!.isNotEmpty) { + final itemsToAdd = _arguments.initialSelectedItems! + .where((item) => !fetchedItems.contains(item)) + .toList(); + fetchedItems = [...itemsToAdd, ...fetchedItems]; } } else { // This case should ideally not be reached due to the assert in arguments @@ -205,16 +206,16 @@ class SearchableSelectionBloc var newItems = [...state.items, ...response.items]; - // If includeInactiveSelectedItem is true and initialSelectedItem is provided, - // ensure it's in the list, even if it doesn't match the current filter. - // This check is only needed if it wasn't already added in the initial load. + // If includeInactiveSelectedItem is true and initialSelectedItems are provided, + // ensure they are in the list, even if they don't match the current filter. + // This check is only needed if they weren't already added in the initial load. if (_arguments.includeInactiveSelectedItem && - _arguments.initialSelectedItem != null && - !newItems.contains(_arguments.initialSelectedItem)) { - newItems = [ - _arguments.initialSelectedItem!, - ...newItems, - ]; + _arguments.initialSelectedItems != null && + _arguments.initialSelectedItems!.isNotEmpty) { + final itemsToAdd = _arguments.initialSelectedItems! + .where((item) => !newItems.contains(item)) + .toList(); + newItems = [...itemsToAdd, ...newItems]; } emit( @@ -242,10 +243,28 @@ class SearchableSelectionBloc } } - void _onSetSelectedItem( - SearchableSelectionSetSelectedItem event, + /// Handles setting the list of selected items. + void _onSetSelectedItems( + SearchableSelectionSetSelectedItems event, + Emitter emit, + ) { + emit(state.copyWith(selectedItems: event.items)); + } + + /// Handles toggling a single item's selection status. + /// + /// This is used in multi-select mode to add or remove an item from the + /// currently selected list. + void _onToggleItem( + SearchableSelectionToggleItem event, Emitter emit, ) { - emit(state.copyWith(selectedItem: () => event.item)); + final currentSelectedItems = List.from(state.selectedItems); + if (currentSelectedItems.contains(event.item)) { + currentSelectedItems.remove(event.item); + } else { + currentSelectedItems.add(event.item); + } + emit(state.copyWith(selectedItems: currentSelectedItems)); } } diff --git a/lib/shared/widgets/selection_page/bloc/searchable_selection_event.dart b/lib/shared/widgets/selection_page/bloc/searchable_selection_event.dart index 05244ea5..8570f8ce 100644 --- a/lib/shared/widgets/selection_page/bloc/searchable_selection_event.dart +++ b/lib/shared/widgets/selection_page/bloc/searchable_selection_event.dart @@ -31,13 +31,26 @@ final class SearchableSelectionLoadMoreRequested const SearchableSelectionLoadMoreRequested(); } -/// Event to set the selected item. -final class SearchableSelectionSetSelectedItem +/// Event to set the selected items (for single or multi-selection). +final class SearchableSelectionSetSelectedItems extends SearchableSelectionEvent { - const SearchableSelectionSetSelectedItem(this.item); + const SearchableSelectionSetSelectedItems(this.items); - /// The item to set as selected. - final Object? item; + /// The list of items to set as selected. + final List items; + + @override + List get props => [items]; +} + +/// Event to toggle the selection status of a single item. +/// +/// Used in multi-select mode to add or remove an item from the selected list. +final class SearchableSelectionToggleItem extends SearchableSelectionEvent { + const SearchableSelectionToggleItem(this.item); + + /// The item to toggle. + final Object item; @override List get props => [item]; diff --git a/lib/shared/widgets/selection_page/bloc/searchable_selection_state.dart b/lib/shared/widgets/selection_page/bloc/searchable_selection_state.dart index 3a8263ab..a59c1b12 100644 --- a/lib/shared/widgets/selection_page/bloc/searchable_selection_state.dart +++ b/lib/shared/widgets/selection_page/bloc/searchable_selection_state.dart @@ -23,7 +23,7 @@ final class SearchableSelectionState extends Equatable { const SearchableSelectionState({ this.status = SearchableSelectionStatus.initial, this.items = const [], - this.selectedItem, + this.selectedItems = const [], // Changed to List this.searchTerm = '', this.cursor, this.hasMore = true, @@ -36,8 +36,8 @@ final class SearchableSelectionState extends Equatable { /// The list of currently loaded items. final List items; - /// The currently selected item. - final Object? selectedItem; + /// The currently selected items. + final List selectedItems; // Changed to List /// The current search term applied to the items. final String searchTerm; @@ -55,7 +55,7 @@ final class SearchableSelectionState extends Equatable { SearchableSelectionState copyWith({ SearchableSelectionStatus? status, List? items, - ValueGetter? selectedItem, + List? selectedItems, // Changed to List String? searchTerm, String? cursor, bool? hasMore, @@ -64,7 +64,7 @@ final class SearchableSelectionState extends Equatable { return SearchableSelectionState( status: status ?? this.status, items: items ?? this.items, - selectedItem: selectedItem != null ? selectedItem() : this.selectedItem, + selectedItems: selectedItems ?? this.selectedItems, // Updated searchTerm: searchTerm ?? this.searchTerm, cursor: cursor ?? this.cursor, hasMore: hasMore ?? this.hasMore, @@ -76,7 +76,7 @@ final class SearchableSelectionState extends Equatable { List get props => [ status, items, - selectedItem, + selectedItems, // Updated searchTerm, cursor, hasMore, diff --git a/lib/shared/widgets/selection_page/searchable_selection_page.dart b/lib/shared/widgets/selection_page/searchable_selection_page.dart index 12007af6..fe9cf6d2 100644 --- a/lib/shared/widgets/selection_page/searchable_selection_page.dart +++ b/lib/shared/widgets/selection_page/searchable_selection_page.dart @@ -71,6 +71,11 @@ class _SearchableSelectionViewState extends State<_SearchableSelectionView> { /// Controller for the scrollable list, used to detect when to load more items. final ScrollController _scrollController = ScrollController(); + /// Temporary local state for selected items, used when in multi-select mode. + /// This list is modified directly by user taps and only returned when "Apply" + /// is pressed. + late List _tempSelectedItems; + @override void initState() { super.initState(); @@ -82,6 +87,11 @@ class _SearchableSelectionViewState extends State<_SearchableSelectionView> { .read() .state .searchTerm; + + // Initialize temporary selected items from the BLoC's initial state. + _tempSelectedItems = List.from( + context.read().state.selectedItems, + ); } @override @@ -115,6 +125,17 @@ class _SearchableSelectionViewState extends State<_SearchableSelectionView> { icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop(), ), + actions: [ + if (widget.arguments.isMultiSelect) + IconButton( + icon: const Icon(Icons.check), + tooltip: l10n.apply, // Assuming l10n.apply exists + onPressed: () { + // Return the locally managed selected items. + Navigator.of(context).pop(_tempSelectedItems); + }, + ), + ], bottom: PreferredSize( preferredSize: const Size.fromHeight(kToolbarHeight), child: Padding( @@ -201,17 +222,26 @@ class _SearchableSelectionViewState extends State<_SearchableSelectionView> { // Build the title using the provided itemBuilder. title: itemBuilder(context, item), onTap: () { - // Dispatch an event to the BLoC to set the selected item. - context.read().add( - SearchableSelectionSetSelectedItem(item), - ); - // Pop the page with the selected item as the result. - Navigator.of(context).pop(item); + if (widget.arguments.isMultiSelect) { + setState(() { + if (_tempSelectedItems.contains(item)) { + _tempSelectedItems.remove(item); + } else { + _tempSelectedItems.add(item); + } + }); + } else { + // For single select, set the item and pop immediately. + context.read().add( + SearchableSelectionSetSelectedItems([item]), + ); + Navigator.of(context).pop([item]); + } }, - // Highlight the selected item. - selected: item == state.selectedItem, - // Show a checkmark for the selected item. - trailing: item == state.selectedItem + // Highlight the selected item(s). + selected: _tempSelectedItems.contains(item), + // Show a checkmark for the selected item(s). + trailing: _tempSelectedItems.contains(item) ? Icon(Icons.check, color: theme.colorScheme.primary) : null, ); diff --git a/lib/shared/widgets/selection_page/selection_page_arguments.dart b/lib/shared/widgets/selection_page/selection_page_arguments.dart index 26bfaa08..d85cb2e8 100644 --- a/lib/shared/widgets/selection_page/selection_page_arguments.dart +++ b/lib/shared/widgets/selection_page/selection_page_arguments.dart @@ -31,8 +31,9 @@ class SelectionPageArguments extends Equatable { this.sortOptions, this.limit, this.staticItems, - this.initialSelectedItem, + this.initialSelectedItems, // Changed to List? this.includeInactiveSelectedItem = false, + this.isMultiSelect = false, // New parameter }) : assert( (repository != null && filterBuilder != null && @@ -78,16 +79,20 @@ class SelectionPageArguments extends Equatable { /// Items are of type [Object] and must be cast to [itemType] before use. final List? staticItems; - /// The item that is initially selected when the page opens. - /// The item is of type [Object] and must be cast to [itemType] before use. - final Object? initialSelectedItem; + /// The items that are initially selected when the page opens. + /// The items are of type [Object] and must be cast to [itemType] before use. + final List? initialSelectedItems; // Changed to List? - /// If true, the [initialSelectedItem] will be included in the fetched results - /// even if it does not match the current filter criteria (e.g., if it's - /// an inactive item that was previously selected). This is useful for edit - /// pages where the previously selected item should always be visible. + /// If true, the [initialSelectedItems] will be included in the fetched results + /// even if they do not match the current filter criteria (e.g., if they are + /// inactive items that were previously selected). This is useful for edit + /// pages where the previously selected item(s) should always be visible. final bool includeInactiveSelectedItem; + /// If true, the selection page will allow multiple items to be selected. + /// Defaults to false for single selection. + final bool isMultiSelect; // New parameter + @override List get props => [ title, @@ -99,7 +104,8 @@ class SelectionPageArguments extends Equatable { sortOptions, limit, staticItems, - initialSelectedItem, + initialSelectedItems, // Updated includeInactiveSelectedItem, + isMultiSelect, // New parameter ]; } diff --git a/pubspec.lock b/pubspec.lock index 13f57887..ab302b18 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -78,7 +78,7 @@ packages: source: hosted version: "1.1.2" collection: - dependency: transitive + dependency: "direct main" description: name: collection sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" diff --git a/pubspec.yaml b/pubspec.yaml index 8f542996..47536a06 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -25,6 +25,7 @@ dependencies: ref: 1f1272b586b045903c164be5a39d95383809ba09 bloc: ^9.0.0 bloc_concurrency: ^0.3.0 + collection: ^1.19.1 core: git: url: https://github.com/flutter-news-app-full-source-code/core.git