diff --git a/lib/content_management/bloc/archived_headlines/archived_headlines_bloc.dart b/lib/content_management/bloc/archived_headlines/archived_headlines_bloc.dart new file mode 100644 index 0000000..d2dccfa --- /dev/null +++ b/lib/content_management/bloc/archived_headlines/archived_headlines_bloc.dart @@ -0,0 +1,118 @@ +import 'package:bloc/bloc.dart'; +import 'package:core/core.dart'; +import 'package:data_repository/data_repository.dart'; +import 'package:equatable/equatable.dart'; + +part 'archived_headlines_event.dart'; +part 'archived_headlines_state.dart'; + +class ArchivedHeadlinesBloc + extends Bloc { + ArchivedHeadlinesBloc({ + required DataRepository headlinesRepository, + }) : _headlinesRepository = headlinesRepository, + super(const ArchivedHeadlinesState()) { + on(_onLoadArchivedHeadlinesRequested); + on(_onRestoreHeadlineRequested); + on(_onDeleteHeadlineForeverRequested); + } + + final DataRepository _headlinesRepository; + + 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'), + ), + ); + } + } + + 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); + + emit(state.copyWith(headlines: updatedHeadlines)); + + try { + await _headlinesRepository.update( + id: event.id, + item: headlineToRestore.copyWith(status: ContentStatus.active), + ); + } on HttpException catch (e) { + emit(state.copyWith(headlines: originalHeadlines, exception: e)); + } catch (e) { + emit( + state.copyWith( + headlines: originalHeadlines, + exception: UnknownException('An unexpected error occurred: $e'), + ), + ); + } + } + + Future _onDeleteHeadlineForeverRequested( + DeleteHeadlineForeverRequested event, + Emitter emit, + ) async { + final originalHeadlines = List.from(state.headlines); + final headlineIndex = originalHeadlines.indexWhere((h) => h.id == event.id); + if (headlineIndex == -1) return; + + final updatedHeadlines = originalHeadlines..removeAt(headlineIndex); + emit(state.copyWith(headlines: updatedHeadlines)); + + try { + await _headlinesRepository.delete(id: event.id); + } on HttpException catch (e) { + emit(state.copyWith(headlines: originalHeadlines, exception: e)); + } catch (e) { + emit( + state.copyWith( + headlines: originalHeadlines, + exception: UnknownException('An unexpected error occurred: $e'), + ), + ); + } + } +} diff --git a/lib/content_management/bloc/archived_headlines/archived_headlines_event.dart b/lib/content_management/bloc/archived_headlines/archived_headlines_event.dart new file mode 100644 index 0000000..e191954 --- /dev/null +++ b/lib/content_management/bloc/archived_headlines/archived_headlines_event.dart @@ -0,0 +1,39 @@ +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 permanently delete an archived headline. +final class DeleteHeadlineForeverRequested extends ArchivedHeadlinesEvent { + const DeleteHeadlineForeverRequested(this.id); + + final String id; + + @override + List get props => [id]; +} diff --git a/lib/content_management/bloc/archived_headlines/archived_headlines_state.dart b/lib/content_management/bloc/archived_headlines/archived_headlines_state.dart new file mode 100644 index 0000000..1f90001 --- /dev/null +++ b/lib/content_management/bloc/archived_headlines/archived_headlines_state.dart @@ -0,0 +1,51 @@ +part of 'archived_headlines_bloc.dart'; + +/// Represents the status of archived content operations. +enum ArchivedHeadlinesStatus { + initial, + loading, + success, + failure, +} + +/// The state for the archived content feature. +class ArchivedHeadlinesState extends Equatable { + const ArchivedHeadlinesState({ + this.status = ArchivedHeadlinesStatus.initial, + this.headlines = const [], + this.cursor, + this.hasMore = false, + this.exception, + }); + + final ArchivedHeadlinesStatus status; + final List headlines; + final String? cursor; + final bool hasMore; + final HttpException? exception; + + ArchivedHeadlinesState copyWith({ + ArchivedHeadlinesStatus? status, + List? headlines, + String? cursor, + bool? hasMore, + HttpException? exception, + }) { + return ArchivedHeadlinesState( + status: status ?? this.status, + headlines: headlines ?? this.headlines, + cursor: cursor ?? this.cursor, + hasMore: hasMore ?? this.hasMore, + exception: exception ?? this.exception, + ); + } + + @override + List get props => [ + status, + headlines, + cursor, + hasMore, + exception, + ]; +} diff --git a/lib/content_management/bloc/archived_sources/archived_sources_bloc.dart b/lib/content_management/bloc/archived_sources/archived_sources_bloc.dart new file mode 100644 index 0000000..6ceaf53 --- /dev/null +++ b/lib/content_management/bloc/archived_sources/archived_sources_bloc.dart @@ -0,0 +1,92 @@ +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 { + await _sourcesRepository.update( + id: event.id, + item: sourceToRestore.copyWith(status: ContentStatus.active), + ); + } 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 new file mode 100644 index 0000000..9c77673 --- /dev/null +++ b/lib/content_management/bloc/archived_sources/archived_sources_event.dart @@ -0,0 +1,29 @@ +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 new file mode 100644 index 0000000..ded6188 --- /dev/null +++ b/lib/content_management/bloc/archived_sources/archived_sources_state.dart @@ -0,0 +1,51 @@ +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, + }); + + final ArchivedSourcesStatus status; + final List sources; + final String? cursor; + final bool hasMore; + final HttpException? exception; + + ArchivedSourcesState copyWith({ + ArchivedSourcesStatus? status, + List? sources, + String? cursor, + bool? hasMore, + HttpException? exception, + }) { + return ArchivedSourcesState( + status: status ?? this.status, + sources: sources ?? this.sources, + cursor: cursor ?? this.cursor, + hasMore: hasMore ?? this.hasMore, + exception: exception ?? this.exception, + ); + } + + @override + List get props => [ + status, + sources, + cursor, + hasMore, + exception, + ]; +} diff --git a/lib/content_management/bloc/archived_topics/archived_topics_bloc.dart b/lib/content_management/bloc/archived_topics/archived_topics_bloc.dart new file mode 100644 index 0000000..6cb1f19 --- /dev/null +++ b/lib/content_management/bloc/archived_topics/archived_topics_bloc.dart @@ -0,0 +1,92 @@ +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 { + await _topicsRepository.update( + id: event.id, + item: topicToRestore.copyWith(status: ContentStatus.active), + ); + } 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 new file mode 100644 index 0000000..b520863 --- /dev/null +++ b/lib/content_management/bloc/archived_topics/archived_topics_event.dart @@ -0,0 +1,29 @@ +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 new file mode 100644 index 0000000..4ac2199 --- /dev/null +++ b/lib/content_management/bloc/archived_topics/archived_topics_state.dart @@ -0,0 +1,51 @@ +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, + }); + + final ArchivedTopicsStatus status; + final List topics; + final String? cursor; + final bool hasMore; + final HttpException? exception; + + ArchivedTopicsState copyWith({ + ArchivedTopicsStatus? status, + List? topics, + String? cursor, + bool? hasMore, + HttpException? exception, + }) { + return ArchivedTopicsState( + status: status ?? this.status, + topics: topics ?? this.topics, + cursor: cursor ?? this.cursor, + hasMore: hasMore ?? this.hasMore, + exception: exception ?? this.exception, + ); + } + + @override + List get props => [ + status, + topics, + cursor, + hasMore, + exception, + ]; +} diff --git a/lib/content_management/bloc/content_management_bloc.dart b/lib/content_management/bloc/content_management_bloc.dart index c47e7c2..12a0c5e 100644 --- a/lib/content_management/bloc/content_management_bloc.dart +++ b/lib/content_management/bloc/content_management_bloc.dart @@ -31,13 +31,13 @@ class ContentManagementBloc on(_onContentManagementTabChanged); on(_onLoadHeadlinesRequested); on(_onHeadlineUpdated); - on(_onDeleteHeadlineRequested); + on(_onArchiveHeadlineRequested); on(_onLoadTopicsRequested); on(_onTopicUpdated); - on(_onDeleteTopicRequested); + on(_onArchiveTopicRequested); on(_onLoadSourcesRequested); on(_onSourceUpdated); - on(_onDeleteSourceRequested); + on(_onArchiveSourceRequested); } final DataRepository _headlinesRepository; @@ -92,17 +92,29 @@ class ContentManagementBloc } } - Future _onDeleteHeadlineRequested( - DeleteHeadlineRequested event, + Future _onArchiveHeadlineRequested( + 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; // Headline not found + + final headlineToArchive = originalHeadlines[headlineIndex]; + final updatedHeadlines = originalHeadlines..removeAt(headlineIndex); + + emit(state.copyWith(headlines: updatedHeadlines)); + try { - await _headlinesRepository.delete(id: event.id); - final updatedHeadlines = state.headlines - .where((h) => h.id != event.id) - .toList(); - emit(state.copyWith(headlines: updatedHeadlines)); + await _headlinesRepository.update( + id: event.id, + item: headlineToArchive.copyWith(status: ContentStatus.archived), + ); } 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, @@ -172,17 +184,29 @@ class ContentManagementBloc } } - Future _onDeleteTopicRequested( - DeleteTopicRequested event, + Future _onArchiveTopicRequested( + 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; // Topic not found + + final topicToArchive = originalTopics[topicIndex]; + final updatedTopics = originalTopics..removeAt(topicIndex); + + emit(state.copyWith(topics: updatedTopics)); + try { - await _topicsRepository.delete(id: event.id); - final updatedTopics = state.topics - .where((c) => c.id != event.id) - .toList(); - emit(state.copyWith(topics: updatedTopics)); + await _topicsRepository.update( + id: event.id, + item: topicToArchive.copyWith(status: ContentStatus.archived), + ); } 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, @@ -252,17 +276,29 @@ class ContentManagementBloc } } - Future _onDeleteSourceRequested( - DeleteSourceRequested event, + Future _onArchiveSourceRequested( + 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; // Source not found + + final sourceToArchive = originalSources[sourceIndex]; + final updatedSources = originalSources..removeAt(sourceIndex); + + emit(state.copyWith(sources: updatedSources)); + try { - await _sourcesRepository.delete(id: event.id); - final updatedSources = state.sources - .where((s) => s.id != event.id) - .toList(); - emit(state.copyWith(sources: updatedSources)); + await _sourcesRepository.update( + id: event.id, + item: sourceToArchive.copyWith(status: ContentStatus.archived), + ); } 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, diff --git a/lib/content_management/bloc/content_management_event.dart b/lib/content_management/bloc/content_management_event.dart index e9fb3ca..3e1d95b 100644 --- a/lib/content_management/bloc/content_management_event.dart +++ b/lib/content_management/bloc/content_management_event.dart @@ -38,14 +38,14 @@ final class LoadHeadlinesRequested extends ContentManagementEvent { List get props => [startAfterId, limit]; } -/// {@template delete_headline_requested} -/// Event to request deletion of a headline. +/// {@template archive_headline_requested} +/// Event to request archiving of a headline. /// {@endtemplate} -final class DeleteHeadlineRequested extends ContentManagementEvent { - /// {@macro delete_headline_requested} - const DeleteHeadlineRequested(this.id); +final class ArchiveHeadlineRequested extends ContentManagementEvent { + /// {@macro archive_headline_requested} + const ArchiveHeadlineRequested(this.id); - /// The ID of the headline to delete. + /// The ID of the headline to archive. final String id; @override @@ -83,14 +83,14 @@ final class LoadTopicsRequested extends ContentManagementEvent { List get props => [startAfterId, limit]; } -/// {@template delete_topic_requested} -/// Event to request deletion of a topic. +/// {@template archive_topic_requested} +/// Event to request archiving of a topic. /// {@endtemplate} -final class DeleteTopicRequested extends ContentManagementEvent { - /// {@macro delete_topic_requested} - const DeleteTopicRequested(this.id); +final class ArchiveTopicRequested extends ContentManagementEvent { + /// {@macro archive_topic_requested} + const ArchiveTopicRequested(this.id); - /// The ID of the topic to delete. + /// The ID of the topic to archive. final String id; @override @@ -128,14 +128,14 @@ final class LoadSourcesRequested extends ContentManagementEvent { List get props => [startAfterId, limit]; } -/// {@template delete_source_requested} -/// Event to request deletion of a source. +/// {@template archive_source_requested} +/// Event to request archiving of a source. /// {@endtemplate} -final class DeleteSourceRequested extends ContentManagementEvent { - /// {@macro delete_source_requested} - const DeleteSourceRequested(this.id); +final class ArchiveSourceRequested extends ContentManagementEvent { + /// {@macro archive_source_requested} + const ArchiveSourceRequested(this.id); - /// The ID of the source to delete. + /// The ID of the source to archive. final String id; @override diff --git a/lib/content_management/view/archived_headlines_page.dart b/lib/content_management/view/archived_headlines_page.dart new file mode 100644 index 0000000..b0886e1 --- /dev/null +++ b/lib/content_management/view/archived_headlines_page.dart @@ -0,0 +1,200 @@ +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/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 ArchivedHeadlinesPage extends StatelessWidget { + const ArchivedHeadlinesPage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => ArchivedHeadlinesBloc( + headlinesRepository: 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; + return Scaffold( + appBar: AppBar( + title: Text(l10n.archivedHeadlines), + ), + body: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + 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 new file mode 100644 index 0000000..1a56338 --- /dev/null +++ b/lib/content_management/view/archived_sources_page.dart @@ -0,0 +1,186 @@ +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/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: 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 new file mode 100644 index 0000000..a86ff72 --- /dev/null +++ b/lib/content_management/view/archived_topics_page.dart @@ -0,0 +1,186 @@ +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/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: 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 a5cb580..1ded64f 100644 --- a/lib/content_management/view/content_management_page.dart +++ b/lib/content_management/view/content_management_page.dart @@ -92,9 +92,25 @@ class _ContentManagementPageState extends State ), ), actions: [ + IconButton( + icon: const Icon(Icons.inventory_2_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), - tooltip: 'Add New Item', // Consider localizing this tooltip + tooltip: l10n.addNewItem, onPressed: () { final currentTab = context .read() diff --git a/lib/content_management/view/headlines_page.dart b/lib/content_management/view/headlines_page.dart index 8555d38..fd84750 100644 --- a/lib/content_management/view/headlines_page.dart +++ b/lib/content_management/view/headlines_page.dart @@ -27,8 +27,8 @@ class _HeadlinesPageState extends State { void initState() { super.initState(); context.read().add( - const LoadHeadlinesRequested(limit: kDefaultRowsPerPage), - ); + const LoadHeadlinesRequested(limit: kDefaultRowsPerPage), + ); } @override @@ -51,8 +51,8 @@ class _HeadlinesPageState extends State { return FailureStateWidget( exception: state.exception!, onRetry: () => context.read().add( - const LoadHeadlinesRequested(limit: kDefaultRowsPerPage), - ), + const LoadHeadlinesRequested(limit: kDefaultRowsPerPage), + ), ); } @@ -60,49 +60,66 @@ class _HeadlinesPageState extends State { return Center(child: Text(l10n.noHeadlinesFound)); } - return PaginatedDataTable2( - columns: [ - DataColumn2(label: Text(l10n.headlineTitle), size: ColumnSize.L), - DataColumn2(label: Text(l10n.sourceName), size: ColumnSize.M), - DataColumn2(label: Text(l10n.status), size: ColumnSize.S), - DataColumn2(label: Text(l10n.lastUpdated), size: ColumnSize.M), - DataColumn2( - label: Text(l10n.actions), - size: ColumnSize.S, - fixedWidth: 120, + return Column( + children: [ + if (state.headlinesStatus == ContentManagementStatus.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.status), size: ColumnSize.S), + 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.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, + ), + ); + } + }, + empty: Center(child: Text(l10n.noHeadlinesFound)), + showCheckboxColumn: false, + showFirstLastButtons: true, + fit: FlexFit.tight, + headingRowHeight: 56, + dataRowHeight: 56, + columnSpacing: AppSpacing.md, + horizontalMargin: AppSpacing.md, + ), ), ], - source: _HeadlinesDataSource( - context: context, - headlines: state.headlines, - isLoading: - state.headlinesStatus == ContentManagementStatus.loading, - 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, - ), - ); - } - }, - empty: Center(child: Text(l10n.noHeadlinesFound)), - showCheckboxColumn: false, - showFirstLastButtons: true, - fit: FlexFit.tight, - headingRowHeight: 56, - dataRowHeight: 56, - columnSpacing: AppSpacing.md, - horizontalMargin: AppSpacing.md, ); }, ), @@ -114,30 +131,18 @@ class _HeadlinesDataSource extends DataTableSource { _HeadlinesDataSource({ required this.context, required this.headlines, - required this.isLoading, required this.hasMore, required this.l10n, }); final BuildContext context; final List headlines; - final bool isLoading; final bool hasMore; final AppLocalizations l10n; @override DataRow? getRow(int index) { if (index >= headlines.length) { - // This can happen if hasMore is true and the user is on the last page. - // If we are loading, show a spinner. Otherwise, we've reached the end. - if (isLoading) { - return DataRow2( - cells: List.generate( - 5, - (_) => const DataCell(Center(child: CircularProgressIndicator())), - ), - ); - } return null; } final headline = headlines[index]; @@ -151,7 +156,13 @@ class _HeadlinesDataSource extends DataTableSource { } }, cells: [ - DataCell(Text(headline.title)), + DataCell( + Text( + headline.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), DataCell(Text(headline.source.name)), DataCell(Text(headline.status.l10n(context))), DataCell( @@ -174,12 +185,12 @@ class _HeadlinesDataSource extends DataTableSource { }, ), IconButton( - icon: const Icon(Icons.delete), + icon: const Icon(Icons.archive), + tooltip: l10n.archive, onPressed: () { - // Dispatch delete event context.read().add( - DeleteHeadlineRequested(headline.id), - ); + ArchiveHeadlineRequested(headline.id), + ); }, ), ], @@ -194,16 +205,6 @@ class _HeadlinesDataSource extends DataTableSource { @override int get rowCount { - // If we have more items to fetch, we add 1 to the current length. - // This signals to PaginatedDataTable2 that there is at least one more page, - // which enables the 'next page' button. - if (hasMore) { - // When loading, we show an extra row for the spinner. - // Otherwise, we just indicate that there are more rows. - return isLoading - ? headlines.length + 1 - : headlines.length + kDefaultRowsPerPage; - } return headlines.length; } diff --git a/lib/content_management/view/sources_page.dart b/lib/content_management/view/sources_page.dart index 1e60840..9c14cc8 100644 --- a/lib/content_management/view/sources_page.dart +++ b/lib/content_management/view/sources_page.dart @@ -61,48 +61,66 @@ class _SourcesPageState extends State { return Center(child: Text(l10n.noSourcesFound)); } - return PaginatedDataTable2( - columns: [ - DataColumn2(label: Text(l10n.sourceName), size: ColumnSize.L), - DataColumn2(label: Text(l10n.sourceType), size: ColumnSize.M), - DataColumn2(label: Text(l10n.status), size: ColumnSize.S), - DataColumn2(label: Text(l10n.lastUpdated), size: ColumnSize.M), - DataColumn2( - label: Text(l10n.actions), - size: ColumnSize.S, - fixedWidth: 120, + return Column( + children: [ + if (state.sourcesStatus == ContentManagementStatus.loading && + state.sources.isNotEmpty) + const LinearProgressIndicator(), + Expanded( + child: PaginatedDataTable2( + columns: [ + DataColumn2( + label: Text(l10n.sourceName), + size: ColumnSize.L, + ), + DataColumn2( + label: Text(l10n.sourceType), + size: ColumnSize.M, + ), + DataColumn2(label: Text(l10n.status), size: ColumnSize.S), + 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.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, + ), + ); + } + }, + empty: Center(child: Text(l10n.noSourcesFound)), + showCheckboxColumn: false, + showFirstLastButtons: true, + fit: FlexFit.tight, + headingRowHeight: 56, + dataRowHeight: 56, + columnSpacing: AppSpacing.md, + horizontalMargin: AppSpacing.md, + ), ), ], - source: _SourcesDataSource( - context: context, - sources: state.sources, - isLoading: state.sourcesStatus == ContentManagementStatus.loading, - 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, - ), - ); - } - }, - empty: Center(child: Text(l10n.noSourcesFound)), - showCheckboxColumn: false, - showFirstLastButtons: true, - fit: FlexFit.tight, - headingRowHeight: 56, - dataRowHeight: 56, - columnSpacing: AppSpacing.md, - horizontalMargin: AppSpacing.md, ); }, ), @@ -114,29 +132,18 @@ class _SourcesDataSource extends DataTableSource { _SourcesDataSource({ required this.context, required this.sources, - required this.isLoading, required this.hasMore, required this.l10n, }); final BuildContext context; final List sources; - final bool isLoading; final bool hasMore; final AppLocalizations l10n; @override DataRow? getRow(int index) { if (index >= sources.length) { - // This can happen if hasMore is true and the user is on the last page. - // If we are loading, show a spinner. Otherwise, we've reached the end. - if (isLoading) { - return DataRow2( - cells: List.generate(5, (_) { - return const DataCell(Center(child: CircularProgressIndicator())); - }), - ); - } return null; } final source = sources[index]; @@ -150,7 +157,13 @@ class _SourcesDataSource extends DataTableSource { } }, cells: [ - DataCell(Text(source.name)), + DataCell( + Text( + source.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), DataCell(Text(source.sourceType.localizedName(l10n))), DataCell(Text(source.status.l10n(context))), DataCell( @@ -173,12 +186,13 @@ class _SourcesDataSource extends DataTableSource { }, ), IconButton( - icon: const Icon(Icons.delete), + icon: const Icon(Icons.archive), + tooltip: l10n.archive, onPressed: () { // Dispatch delete event context.read().add( - DeleteSourceRequested(source.id), - ); + ArchiveSourceRequested(source.id), + ); }, ), ], @@ -193,16 +207,6 @@ class _SourcesDataSource extends DataTableSource { @override int get rowCount { - // If we have more items to fetch, we add 1 to the current length. - // This signals to PaginatedDataTable2 that there is at least one more page, - // which enables the 'next page' button. - if (hasMore) { - // When loading, we show an extra row for the spinner. - // Otherwise, we just indicate that there are more rows. - return isLoading - ? sources.length + 1 - : sources.length + kDefaultRowsPerPage; - } return sources.length; } diff --git a/lib/content_management/view/topics_page.dart b/lib/content_management/view/topics_page.dart index af06e00..c9b6439 100644 --- a/lib/content_management/view/topics_page.dart +++ b/lib/content_management/view/topics_page.dart @@ -27,8 +27,8 @@ class _TopicPageState extends State { void initState() { super.initState(); context.read().add( - const LoadTopicsRequested(limit: kDefaultRowsPerPage), - ); + const LoadTopicsRequested(limit: kDefaultRowsPerPage), + ); } @override @@ -51,8 +51,8 @@ class _TopicPageState extends State { return FailureStateWidget( exception: state.exception!, onRetry: () => context.read().add( - const LoadTopicsRequested(limit: kDefaultRowsPerPage), - ), + const LoadTopicsRequested(limit: kDefaultRowsPerPage), + ), ); } @@ -60,47 +60,61 @@ class _TopicPageState extends State { return Center(child: Text(l10n.noTopicsFound)); } - return PaginatedDataTable2( - columns: [ - DataColumn2(label: Text(l10n.topicName), size: ColumnSize.L), - DataColumn2(label: Text(l10n.status), size: ColumnSize.S), - DataColumn2(label: Text(l10n.lastUpdated), size: ColumnSize.M), - DataColumn2( - label: Text(l10n.actions), - size: ColumnSize.S, - fixedWidth: 120, + return Column( + children: [ + if (state.topicsStatus == ContentManagementStatus.loading && + state.topics.isNotEmpty) + const LinearProgressIndicator(), + Expanded( + child: PaginatedDataTable2( + columns: [ + DataColumn2( + label: Text(l10n.topicName), + size: ColumnSize.L, + ), + DataColumn2(label: Text(l10n.status), size: ColumnSize.S), + 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.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, + ), + ); + } + }, + empty: Center(child: Text(l10n.noTopicsFound)), + showCheckboxColumn: false, + showFirstLastButtons: true, + fit: FlexFit.tight, + headingRowHeight: 56, + dataRowHeight: 56, + columnSpacing: AppSpacing.md, + horizontalMargin: AppSpacing.md, + ), ), ], - source: _TopicsDataSource( - context: context, - topics: state.topics, - isLoading: state.topicsStatus == ContentManagementStatus.loading, - 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, - ), - ); - } - }, - empty: Center(child: Text(l10n.noTopicsFound)), - showCheckboxColumn: false, - showFirstLastButtons: true, - fit: FlexFit.tight, - headingRowHeight: 56, - dataRowHeight: 56, - columnSpacing: AppSpacing.md, - horizontalMargin: AppSpacing.md, ); }, ), @@ -112,30 +126,18 @@ class _TopicsDataSource extends DataTableSource { _TopicsDataSource({ required this.context, required this.topics, - required this.isLoading, required this.hasMore, required this.l10n, }); final BuildContext context; final List topics; - final bool isLoading; final bool hasMore; final AppLocalizations l10n; @override DataRow? getRow(int index) { if (index >= topics.length) { - // This can happen if hasMore is true and the user is on the last page. - // If we are loading, show a spinner. Otherwise, we've reached the end. - if (isLoading) { - return DataRow2( - cells: List.generate( - 4, - (_) => const DataCell(Center(child: CircularProgressIndicator())), - ), - ); - } return null; } final topic = topics[index]; @@ -149,7 +151,13 @@ class _TopicsDataSource extends DataTableSource { } }, cells: [ - DataCell(Text(topic.name)), + DataCell( + Text( + topic.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), DataCell(Text(topic.status.l10n(context))), DataCell( Text( @@ -171,12 +179,13 @@ class _TopicsDataSource extends DataTableSource { }, ), IconButton( - icon: const Icon(Icons.delete), + icon: const Icon(Icons.archive), + tooltip: l10n.archive, onPressed: () { // Dispatch delete event context.read().add( - DeleteTopicRequested(topic.id), - ); + ArchiveTopicRequested(topic.id), + ); }, ), ], @@ -191,16 +200,6 @@ class _TopicsDataSource extends DataTableSource { @override int get rowCount { - // If we have more items to fetch, we add 1 to the current length. - // This signals to PaginatedDataTable2 that there is at least one more page, - // which enables the 'next page' button. - if (hasMore) { - // When loading, we show an extra row for the spinner. - // Otherwise, we just indicate that there are more rows. - return isLoading - ? topics.length + 1 - : topics.length + kDefaultRowsPerPage; - } return topics.length; } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index ef0eb1f..56a9275 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1669,6 +1669,90 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Select a country'** String get countryPickerSelectCountryLabel; + + /// Title for the Archived Headlines page + /// + /// In en, this message translates to: + /// **'Archived Headlines'** + String get archivedHeadlines; + + /// Headline for loading state of archived headlines + /// + /// In en, this message translates to: + /// **'Loading Archived Headlines'** + String get loadingArchivedHeadlines; + + /// Message when no archived headlines are found + /// + /// In en, this message translates to: + /// **'No archived headlines found.'** + String get noArchivedHeadlinesFound; + + /// Tooltip for the restore button + /// + /// In en, this message translates to: + /// **'Restore'** + String get restore; + + /// Tooltip for the delete forever button + /// + /// In en, this message translates to: + /// **'Delete Forever'** + String get deleteForever; + + /// Title for the Archived Topics page + /// + /// In en, this message translates to: + /// **'Archived Topics'** + String get archivedTopics; + + /// Headline for loading state of archived topics + /// + /// In en, this message translates to: + /// **'Loading Archived Topics'** + String get loadingArchivedTopics; + + /// Message when no archived topics are found + /// + /// In en, this message translates to: + /// **'No archived topics found.'** + String get noArchivedTopicsFound; + + /// Title for the Archived Sources page + /// + /// In en, this message translates to: + /// **'Archived Sources'** + String get archivedSources; + + /// Headline for loading state of archived sources + /// + /// In en, this message translates to: + /// **'Loading Archived Sources'** + String get loadingArchivedSources; + + /// Message when no archived sources are found + /// + /// In en, this message translates to: + /// **'No archived sources found.'** + String get noArchivedSourcesFound; + + /// Tooltip for the archived items button + /// + /// In en, this message translates to: + /// **'Archived Items'** + String get archivedItems; + + /// Tooltip for the add new item button + /// + /// In en, this message translates to: + /// **'Add New Item'** + String get addNewItem; + + /// Tooltip for the archive button + /// + /// In en, this message translates to: + /// **'Archive'** + String get archive; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index 9aaa4b2..1583d68 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -880,4 +880,46 @@ class AppLocalizationsAr extends AppLocalizations { @override String get countryPickerSelectCountryLabel => 'اختر دولة'; + + @override + String get archivedHeadlines => 'العناوين المؤرشفة'; + + @override + String get loadingArchivedHeadlines => 'جاري تحميل العناوين المؤرشفة'; + + @override + String get noArchivedHeadlinesFound => 'لم يتم العثور على عناوين مؤرشفة.'; + + @override + String get restore => 'استعادة'; + + @override + String get deleteForever => 'حذف نهائي'; + + @override + String get archivedTopics => 'المواضيع المؤرشفة'; + + @override + String get loadingArchivedTopics => 'جاري تحميل المواضيع المؤرشفة'; + + @override + String get noArchivedTopicsFound => 'لم يتم العثور على مواضيع مؤرشفة.'; + + @override + String get archivedSources => 'المصادر المؤرشفة'; + + @override + String get loadingArchivedSources => 'جاري تحميل المصادر المؤرشفة'; + + @override + String get noArchivedSourcesFound => 'لم يتم العثور على مصادر مؤرشفة.'; + + @override + String get archivedItems => 'العناصر المؤرشفة'; + + @override + String get addNewItem => 'إضافة عنصر جديد'; + + @override + String get archive => 'أرشفة'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index d905389..77a372a 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -879,4 +879,46 @@ class AppLocalizationsEn extends AppLocalizations { @override String get countryPickerSelectCountryLabel => 'Select a country'; + + @override + String get archivedHeadlines => 'Archived Headlines'; + + @override + String get loadingArchivedHeadlines => 'Loading Archived Headlines'; + + @override + String get noArchivedHeadlinesFound => 'No archived headlines found.'; + + @override + String get restore => 'Restore'; + + @override + String get deleteForever => 'Delete Forever'; + + @override + String get archivedTopics => 'Archived Topics'; + + @override + String get loadingArchivedTopics => 'Loading Archived Topics'; + + @override + String get noArchivedTopicsFound => 'No archived topics found.'; + + @override + String get archivedSources => 'Archived Sources'; + + @override + String get loadingArchivedSources => 'Loading Archived Sources'; + + @override + String get noArchivedSourcesFound => 'No archived sources found.'; + + @override + String get archivedItems => 'Archived Items'; + + @override + String get addNewItem => 'Add New Item'; + + @override + String get archive => 'Archive'; } diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 795602d..71d801b 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -1093,5 +1093,61 @@ "countryPickerSelectCountryLabel": "اختر دولة", "@countryPickerSelectCountryLabel": { "description": "التسمية المعروضة عند عدم اختيار أي دولة في حقل نموذج منتقي البلد" + }, + "archivedHeadlines": "العناوين المؤرشفة", + "@archivedHeadlines": { + "description": "عنوان صفحة العناوين المؤرشفة" + }, + "loadingArchivedHeadlines": "جاري تحميل العناوين المؤرشفة", + "@loadingArchivedHeadlines": { + "description": "عنوان حالة تحميل العناوين المؤرشفة" + }, + "noArchivedHeadlinesFound": "لم يتم العثور على عناوين مؤرشفة.", + "@noArchivedHeadlinesFound": { + "description": "رسالة عند عدم العثور على عناوين مؤرشفة" + }, + "restore": "استعادة", + "@restore": { + "description": "تلميح لزر الاستعادة" + }, + "deleteForever": "حذف نهائي", + "@deleteForever": { + "description": "تلميح لزر الحذف النهائي" + }, + "archivedTopics": "المواضيع المؤرشفة", + "@archivedTopics": { + "description": "عنوان صفحة المواضيع المؤرشفة" + }, + "loadingArchivedTopics": "جاري تحميل المواضيع المؤرشفة", + "@loadingArchivedTopics": { + "description": "عنوان حالة تحميل المواضيع المؤرشفة" + }, + "noArchivedTopicsFound": "لم يتم العثور على مواضيع مؤرشفة.", + "@noArchivedTopicsFound": { + "description": "رسالة عند عدم العثور على مواضيع مؤرشفة" + }, + "archivedSources": "المصادر المؤرشفة", + "@archivedSources": { + "description": "عنوان صفحة المصادر المؤرشفة" + }, + "loadingArchivedSources": "جاري تحميل المصادر المؤرشفة", + "@loadingArchivedSources": { + "description": "عنوان حالة تحميل المصادر المؤرشفة" + }, + "noArchivedSourcesFound": "لم يتم العثور على مصادر مؤرشفة.", + "@noArchivedSourcesFound": { + "description": "رسالة عند عدم العثور على مصادر مؤرشفة" + }, + "archivedItems": "العناصر المؤرشفة", + "@archivedItems": { + "description": "تلميح لزر العناصر المؤرشفة" + }, + "addNewItem": "إضافة عنصر جديد", + "@addNewItem": { + "description": "تلميح لزر إضافة عنصر جديد" + }, + "archive": "أرشفة", + "@archive": { + "description": "تلميح لزر الأرشفة" } } diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 330be9d..2c1a93f 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1093,5 +1093,61 @@ "countryPickerSelectCountryLabel": "Select a country", "@countryPickerSelectCountryLabel": { "description": "Label displayed when no country is selected in the picker form field" + }, + "archivedHeadlines": "Archived Headlines", + "@archivedHeadlines": { + "description": "Title for the Archived Headlines page" + }, + "loadingArchivedHeadlines": "Loading Archived Headlines", + "@loadingArchivedHeadlines": { + "description": "Headline for loading state of archived headlines" + }, + "noArchivedHeadlinesFound": "No archived headlines found.", + "@noArchivedHeadlinesFound": { + "description": "Message when no archived headlines are found" + }, + "restore": "Restore", + "@restore": { + "description": "Tooltip for the restore button" + }, + "deleteForever": "Delete Forever", + "@deleteForever": { + "description": "Tooltip for the delete forever button" + }, + "archivedTopics": "Archived Topics", + "@archivedTopics": { + "description": "Title for the Archived Topics page" + }, + "loadingArchivedTopics": "Loading Archived Topics", + "@loadingArchivedTopics": { + "description": "Headline for loading state of archived topics" + }, + "noArchivedTopicsFound": "No archived topics found.", + "@noArchivedTopicsFound": { + "description": "Message when no archived topics are found" + }, + "archivedSources": "Archived Sources", + "@archivedSources": { + "description": "Title for the Archived Sources page" + }, + "loadingArchivedSources": "Loading Archived Sources", + "@loadingArchivedSources": { + "description": "Headline for loading state of archived sources" + }, + "noArchivedSourcesFound": "No archived sources found.", + "@noArchivedSourcesFound": { + "description": "Message when no archived sources are found" + }, + "archivedItems": "Archived Items", + "@archivedItems": { + "description": "Tooltip for the archived items button" + }, + "addNewItem": "Add New Item", + "@addNewItem": { + "description": "Tooltip for the add new item button" + }, + "archive": "Archive", + "@archive": { + "description": "Tooltip for the archive button" } } diff --git a/lib/router/router.dart b/lib/router/router.dart index 942e7f7..151a075 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -196,6 +196,24 @@ GoRouter createRouter({ return EditSourcePage(sourceId: id); }, ), + GoRoute( + path: Routes.archivedHeadlines, + name: Routes.archivedHeadlinesName, + builder: (context, state) => + const Placeholder(), + ), + GoRoute( + path: Routes.archivedTopics, + name: Routes.archivedTopicsName, + builder: (context, state) => + const Placeholder(), + ), + GoRoute( + path: Routes.archivedSources, + name: Routes.archivedSourcesName, + builder: (context, state) => + const Placeholder(), + ), ], ), ], diff --git a/lib/router/routes.dart b/lib/router/routes.dart index 88346a0..60598b8 100644 --- a/lib/router/routes.dart +++ b/lib/router/routes.dart @@ -44,6 +44,24 @@ 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 creating a new headline. static const String createHeadline = 'create-headline';