diff --git a/analysis_options.yaml b/analysis_options.yaml index efeb30f..2cc6fd2 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -2,9 +2,11 @@ analyzer: errors: avoid_catches_without_on_clauses: ignore avoid_print: ignore + avoid_redundant_argument_values: ignore deprecated_member_use: ignore document_ignores: ignore lines_longer_than_80_chars: ignore + unnecessary_null_checks: ignore include: package:very_good_analysis/analysis_options.9.0.0.yaml linter: rules: diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index 9ffc5d0..dd99561 100644 --- a/lib/app/bloc/app_bloc.dart +++ b/lib/app/bloc/app_bloc.dart @@ -1,3 +1,5 @@ +// ignore_for_file: unused_field + import 'dart:async'; import 'package:bloc/bloc.dart'; diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index c34cd57..a2ccb9a 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -10,6 +10,7 @@ import 'package:ht_dashboard/app/bloc/app_bloc.dart'; import 'package:ht_dashboard/app/config/app_environment.dart'; import 'package:ht_dashboard/app_configuration/bloc/app_configuration_bloc.dart'; import 'package:ht_dashboard/authentication/bloc/authentication_bloc.dart'; +import 'package:ht_dashboard/content_management/bloc/content_management_bloc.dart'; import 'package:ht_dashboard/l10n/app_localizations.dart'; import 'package:ht_dashboard/router/router.dart'; // Import for app_theme.dart @@ -90,6 +91,13 @@ class App extends StatelessWidget { appConfigRepository: context.read>(), ), ), + BlocProvider( + create: (context) => ContentManagementBloc( + headlinesRepository: context.read>(), + categoriesRepository: context.read>(), + sourcesRepository: context.read>(), + ), + ), ], child: _AppView( htAuthenticationRepository: _htAuthenticationRepository, diff --git a/lib/app_configuration/view/app_configuration_page.dart b/lib/app_configuration/view/app_configuration_page.dart index 50bb318..31b9d1f 100644 --- a/lib/app_configuration/view/app_configuration_page.dart +++ b/lib/app_configuration/view/app_configuration_page.dart @@ -211,7 +211,7 @@ class _AppConfigurationPageState extends State { onPressed: isDirty ? () async { final confirmed = await _showConfirmationDialog(context); - if (confirmed && appConfig != null) { + if (context.mounted && confirmed && appConfig != null) { context.read().add( AppConfigurationUpdated(appConfig), ); diff --git a/lib/content_management/bloc/content_management_bloc.dart b/lib/content_management/bloc/content_management_bloc.dart new file mode 100644 index 0000000..f725ed8 --- /dev/null +++ b/lib/content_management/bloc/content_management_bloc.dart @@ -0,0 +1,395 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:ht_data_repository/ht_data_repository.dart'; +import 'package:ht_shared/ht_shared.dart'; + +part 'content_management_event.dart'; +part 'content_management_state.dart'; + +/// Defines the tabs available in the content management section. +enum ContentManagementTab { + /// Represents the Headlines tab. + headlines, + + /// Represents the Categories tab. + categories, + + /// Represents the Sources tab. + sources, +} + +class ContentManagementBloc + extends Bloc { + ContentManagementBloc({ + required HtDataRepository headlinesRepository, + required HtDataRepository categoriesRepository, + required HtDataRepository sourcesRepository, + }) : _headlinesRepository = headlinesRepository, + _categoriesRepository = categoriesRepository, + _sourcesRepository = sourcesRepository, + super(const ContentManagementState()) { + on(_onContentManagementTabChanged); + on(_onLoadHeadlinesRequested); + on(_onCreateHeadlineRequested); + on(_onUpdateHeadlineRequested); + on(_onDeleteHeadlineRequested); + on(_onLoadCategoriesRequested); + on(_onCreateCategoryRequested); + on(_onUpdateCategoryRequested); + on(_onDeleteCategoryRequested); + on(_onLoadSourcesRequested); + on(_onCreateSourceRequested); + on(_onUpdateSourceRequested); + on(_onOnDeleteSourceRequested); + } + + final HtDataRepository _headlinesRepository; + final HtDataRepository _categoriesRepository; + final HtDataRepository _sourcesRepository; + + void _onContentManagementTabChanged( + ContentManagementTabChanged event, + Emitter emit, + ) { + emit(state.copyWith(activeTab: event.tab)); + } + + Future _onLoadHeadlinesRequested( + LoadHeadlinesRequested event, + Emitter emit, + ) async { + emit(state.copyWith(headlinesStatus: ContentManagementStatus.loading)); + try { + final paginatedHeadlines = await _headlinesRepository.readAll( + startAfterId: event.startAfterId, + limit: event.limit, + ); + emit( + state.copyWith( + headlinesStatus: ContentManagementStatus.success, + headlines: paginatedHeadlines.items, + headlinesCursor: paginatedHeadlines.cursor, + headlinesHasMore: paginatedHeadlines.hasMore, + ), + ); + } on HtHttpException catch (e) { + emit( + state.copyWith( + headlinesStatus: ContentManagementStatus.failure, + errorMessage: e.message, + ), + ); + } catch (e) { + emit( + state.copyWith( + headlinesStatus: ContentManagementStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + Future _onCreateHeadlineRequested( + CreateHeadlineRequested event, + Emitter emit, + ) async { + emit(state.copyWith(headlinesStatus: ContentManagementStatus.loading)); + try { + await _headlinesRepository.create(item: event.headline); + // Reload headlines after creation + add(const LoadHeadlinesRequested()); + } on HtHttpException catch (e) { + emit( + state.copyWith( + headlinesStatus: ContentManagementStatus.failure, + errorMessage: e.message, + ), + ); + } catch (e) { + emit( + state.copyWith( + headlinesStatus: ContentManagementStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + Future _onUpdateHeadlineRequested( + UpdateHeadlineRequested event, + Emitter emit, + ) async { + emit(state.copyWith(headlinesStatus: ContentManagementStatus.loading)); + try { + await _headlinesRepository.update(id: event.id, item: event.headline); + // Reload headlines after update + add(const LoadHeadlinesRequested()); + } on HtHttpException catch (e) { + emit( + state.copyWith( + headlinesStatus: ContentManagementStatus.failure, + errorMessage: e.message, + ), + ); + } catch (e) { + emit( + state.copyWith( + headlinesStatus: ContentManagementStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + Future _onDeleteHeadlineRequested( + DeleteHeadlineRequested event, + Emitter emit, + ) async { + emit(state.copyWith(headlinesStatus: ContentManagementStatus.loading)); + try { + await _headlinesRepository.delete(id: event.id); + // Reload headlines after deletion + add(const LoadHeadlinesRequested()); + } on HtHttpException catch (e) { + emit( + state.copyWith( + headlinesStatus: ContentManagementStatus.failure, + errorMessage: e.message, + ), + ); + } catch (e) { + emit( + state.copyWith( + headlinesStatus: ContentManagementStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + Future _onLoadCategoriesRequested( + LoadCategoriesRequested event, + Emitter emit, + ) async { + emit(state.copyWith(categoriesStatus: ContentManagementStatus.loading)); + try { + final paginatedCategories = await _categoriesRepository.readAll( + startAfterId: event.startAfterId, + limit: event.limit, + ); + emit( + state.copyWith( + categoriesStatus: ContentManagementStatus.success, + categories: paginatedCategories.items, + categoriesCursor: paginatedCategories.cursor, + categoriesHasMore: paginatedCategories.hasMore, + ), + ); + } on HtHttpException catch (e) { + emit( + state.copyWith( + categoriesStatus: ContentManagementStatus.failure, + errorMessage: e.message, + ), + ); + } catch (e) { + emit( + state.copyWith( + categoriesStatus: ContentManagementStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + Future _onCreateCategoryRequested( + CreateCategoryRequested event, + Emitter emit, + ) async { + emit(state.copyWith(categoriesStatus: ContentManagementStatus.loading)); + try { + await _categoriesRepository.create(item: event.category); + // Reload categories after creation + add(const LoadCategoriesRequested()); + } on HtHttpException catch (e) { + emit( + state.copyWith( + categoriesStatus: ContentManagementStatus.failure, + errorMessage: e.message, + ), + ); + } catch (e) { + emit( + state.copyWith( + categoriesStatus: ContentManagementStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + Future _onUpdateCategoryRequested( + UpdateCategoryRequested event, + Emitter emit, + ) async { + emit(state.copyWith(categoriesStatus: ContentManagementStatus.loading)); + try { + await _categoriesRepository.update(id: event.id, item: event.category); + // Reload categories after update + add(const LoadCategoriesRequested()); + } on HtHttpException catch (e) { + emit( + state.copyWith( + categoriesStatus: ContentManagementStatus.failure, + errorMessage: e.message, + ), + ); + } catch (e) { + emit( + state.copyWith( + categoriesStatus: ContentManagementStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + Future _onDeleteCategoryRequested( + DeleteCategoryRequested event, + Emitter emit, + ) async { + emit(state.copyWith(categoriesStatus: ContentManagementStatus.loading)); + try { + await _categoriesRepository.delete(id: event.id); + // Reload categories after deletion + add(const LoadCategoriesRequested()); + } on HtHttpException catch (e) { + emit( + state.copyWith( + categoriesStatus: ContentManagementStatus.failure, + errorMessage: e.message, + ), + ); + } catch (e) { + emit( + state.copyWith( + categoriesStatus: ContentManagementStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + Future _onLoadSourcesRequested( + LoadSourcesRequested event, + Emitter emit, + ) async { + emit(state.copyWith(sourcesStatus: ContentManagementStatus.loading)); + try { + final paginatedSources = await _sourcesRepository.readAll( + startAfterId: event.startAfterId, + limit: event.limit, + ); + emit( + state.copyWith( + sourcesStatus: ContentManagementStatus.success, + sources: paginatedSources.items, + sourcesCursor: paginatedSources.cursor, + sourcesHasMore: paginatedSources.hasMore, + ), + ); + } on HtHttpException catch (e) { + emit( + state.copyWith( + sourcesStatus: ContentManagementStatus.failure, + errorMessage: e.message, + ), + ); + } catch (e) { + emit( + state.copyWith( + sourcesStatus: ContentManagementStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + Future _onCreateSourceRequested( + CreateSourceRequested event, + Emitter emit, + ) async { + emit(state.copyWith(sourcesStatus: ContentManagementStatus.loading)); + try { + await _sourcesRepository.create(item: event.source); + // Reload sources after creation + add(const LoadSourcesRequested()); + } on HtHttpException catch (e) { + emit( + state.copyWith( + sourcesStatus: ContentManagementStatus.failure, + errorMessage: e.message, + ), + ); + } catch (e) { + emit( + state.copyWith( + sourcesStatus: ContentManagementStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + Future _onUpdateSourceRequested( + UpdateSourceRequested event, + Emitter emit, + ) async { + emit(state.copyWith(sourcesStatus: ContentManagementStatus.loading)); + try { + await _sourcesRepository.update(id: event.id, item: event.source); + // Reload sources after update + add(const LoadSourcesRequested()); + } on HtHttpException catch (e) { + emit( + state.copyWith( + sourcesStatus: ContentManagementStatus.failure, + errorMessage: e.message, + ), + ); + } catch (e) { + emit( + state.copyWith( + sourcesStatus: ContentManagementStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + Future _onOnDeleteSourceRequested( + DeleteSourceRequested event, + Emitter emit, + ) async { + emit(state.copyWith(sourcesStatus: ContentManagementStatus.loading)); + try { + await _sourcesRepository.delete(id: event.id); + // Reload sources after deletion + add(const LoadSourcesRequested()); + } on HtHttpException catch (e) { + emit( + state.copyWith( + sourcesStatus: ContentManagementStatus.failure, + errorMessage: e.message, + ), + ); + } catch (e) { + emit( + state.copyWith( + sourcesStatus: ContentManagementStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } +} diff --git a/lib/content_management/bloc/content_management_event.dart b/lib/content_management/bloc/content_management_event.dart new file mode 100644 index 0000000..3debb97 --- /dev/null +++ b/lib/content_management/bloc/content_management_event.dart @@ -0,0 +1,208 @@ +part of 'content_management_bloc.dart'; + +sealed class ContentManagementEvent extends Equatable { + const ContentManagementEvent(); + + @override + List get props => []; +} + +/// {@template content_management_tab_changed} +/// Event to change the active content management tab. +/// {@endtemplate} +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}); + + /// Optional ID to start pagination after. + final String? startAfterId; + + /// Optional maximum number of items to return. + final int? limit; + + @override + List get props => [startAfterId, limit]; +} + +/// {@template create_headline_requested} +/// Event to request creation of a new headline. +/// {@endtemplate} +final class CreateHeadlineRequested extends ContentManagementEvent { + /// {@macro create_headline_requested} + const CreateHeadlineRequested(this.headline); + + /// The headline to create. + final Headline headline; + + @override + List get props => [headline]; +} + +/// {@template update_headline_requested} +/// Event to request update of an existing headline. +/// {@endtemplate} +final class UpdateHeadlineRequested extends ContentManagementEvent { + /// {@macro update_headline_requested} + const UpdateHeadlineRequested({required this.id, required this.headline}); + + /// The ID of the headline to update. + final String id; + + /// The updated headline data. + final Headline headline; + + @override + List get props => [id, headline]; +} + +/// {@template delete_headline_requested} +/// Event to request deletion of a headline. +/// {@endtemplate} +final class DeleteHeadlineRequested extends ContentManagementEvent { + /// {@macro delete_headline_requested} + const DeleteHeadlineRequested(this.id); + + /// The ID of the headline to delete. + final String id; + + @override + List get props => [id]; +} + +/// {@template load_categories_requested} +/// Event to request loading of categories. +/// {@endtemplate} +final class LoadCategoriesRequested extends ContentManagementEvent { + /// {@macro load_categories_requested} + const LoadCategoriesRequested({this.startAfterId, this.limit}); + + /// Optional ID to start pagination after. + final String? startAfterId; + + /// Optional maximum number of items to return. + final int? limit; + + @override + List get props => [startAfterId, limit]; +} + +/// {@template create_category_requested} +/// Event to request creation of a new category. +/// {@endtemplate} +final class CreateCategoryRequested extends ContentManagementEvent { + /// {@macro create_category_requested} + const CreateCategoryRequested(this.category); + + /// The category to create. + final Category category; + + @override + List get props => [category]; +} + +/// {@template update_category_requested} +/// Event to request update of an existing category. +/// {@endtemplate} +final class UpdateCategoryRequested extends ContentManagementEvent { + /// {@macro update_category_requested} + const UpdateCategoryRequested({required this.id, required this.category}); + + /// The ID of the category to update. + final String id; + + /// The updated category data. + final Category category; + + @override + List get props => [id, category]; +} + +/// {@template delete_category_requested} +/// Event to request deletion of a category. +/// {@endtemplate} +final class DeleteCategoryRequested extends ContentManagementEvent { + /// {@macro delete_category_requested} + const DeleteCategoryRequested(this.id); + + /// The ID of the category to delete. + final String id; + + @override + List get props => [id]; +} + +/// {@template load_sources_requested} +/// Event to request loading of sources. +/// {@endtemplate} +final class LoadSourcesRequested extends ContentManagementEvent { + /// {@macro load_sources_requested} + const LoadSourcesRequested({this.startAfterId, this.limit}); + + /// Optional ID to start pagination after. + final String? startAfterId; + + /// Optional maximum number of items to return. + final int? limit; + + @override + List get props => [startAfterId, limit]; +} + +/// {@template create_source_requested} +/// Event to request creation of a new source. +/// {@endtemplate} +final class CreateSourceRequested extends ContentManagementEvent { + /// {@macro create_source_requested} + const CreateSourceRequested(this.source); + + /// The source to create. + final Source source; + + @override + List get props => [source]; +} + +/// {@template update_source_requested} +/// Event to request update of an existing source. +/// {@endtemplate} +final class UpdateSourceRequested extends ContentManagementEvent { + /// {@macro update_source_requested} + const UpdateSourceRequested({required this.id, required this.source}); + + /// The ID of the source to update. + final String id; + + /// The updated source data. + final Source source; + + @override + List get props => [id, source]; +} + +/// {@template delete_source_requested} +/// Event to request deletion of a source. +/// {@endtemplate} +final class DeleteSourceRequested extends ContentManagementEvent { + /// {@macro delete_source_requested} + const DeleteSourceRequested(this.id); + + /// The ID of the source to delete. + final String id; + + @override + List get props => [id]; +} diff --git a/lib/content_management/bloc/content_management_state.dart b/lib/content_management/bloc/content_management_state.dart new file mode 100644 index 0000000..f603193 --- /dev/null +++ b/lib/content_management/bloc/content_management_state.dart @@ -0,0 +1,133 @@ +part of 'content_management_bloc.dart'; + + +/// Represents the status of content loading and operations. +enum ContentManagementStatus { + /// 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, +} + +/// Defines the state for the content management feature. +class ContentManagementState extends Equatable { + /// {@macro content_management_state} + const ContentManagementState({ + this.activeTab = ContentManagementTab.headlines, + this.headlinesStatus = ContentManagementStatus.initial, + this.headlines = const [], + this.headlinesCursor, + this.headlinesHasMore = false, + this.categoriesStatus = ContentManagementStatus.initial, + this.categories = const [], + this.categoriesCursor, + this.categoriesHasMore = false, + this.sourcesStatus = ContentManagementStatus.initial, + this.sources = const [], + this.sourcesCursor, + this.sourcesHasMore = false, + this.errorMessage, + }); + + /// The currently active tab in the content management section. + final ContentManagementTab activeTab; + + /// Status of headline data operations. + final ContentManagementStatus headlinesStatus; + + /// List of headlines. + final List headlines; + + /// Cursor for headline pagination. + final String? headlinesCursor; + + /// Indicates if there are more headlines to load. + final bool headlinesHasMore; + + /// Status of category data operations. + final ContentManagementStatus categoriesStatus; + + /// List of categories. + final List categories; + + /// Cursor for category pagination. + final String? categoriesCursor; + + /// Indicates if there are more categories to load. + final bool categoriesHasMore; + + /// Status of source data operations. + final ContentManagementStatus sourcesStatus; + + /// List of sources. + final List sources; + + /// Cursor for source pagination. + final String? sourcesCursor; + + /// Indicates if there are more sources to load. + final bool sourcesHasMore; + + /// Error message if an operation fails. + final String? errorMessage; + + /// Creates a copy of this [ContentManagementState] with updated values. + ContentManagementState copyWith({ + ContentManagementTab? activeTab, + ContentManagementStatus? headlinesStatus, + List? headlines, + String? headlinesCursor, + bool? headlinesHasMore, + ContentManagementStatus? categoriesStatus, + List? categories, + String? categoriesCursor, + bool? categoriesHasMore, + ContentManagementStatus? sourcesStatus, + List? sources, + String? sourcesCursor, + bool? sourcesHasMore, + String? errorMessage, + }) { + return ContentManagementState( + activeTab: activeTab ?? this.activeTab, + headlinesStatus: headlinesStatus ?? this.headlinesStatus, + headlines: headlines ?? this.headlines, + headlinesCursor: headlinesCursor ?? this.headlinesCursor, + headlinesHasMore: headlinesHasMore ?? this.headlinesHasMore, + categoriesStatus: categoriesStatus ?? this.categoriesStatus, + categories: categories ?? this.categories, + categoriesCursor: categoriesCursor ?? this.categoriesCursor, + categoriesHasMore: categoriesHasMore ?? this.categoriesHasMore, + sourcesStatus: sourcesStatus ?? this.sourcesStatus, + sources: sources ?? this.sources, + sourcesCursor: sourcesCursor ?? this.sourcesCursor, + sourcesHasMore: sourcesHasMore ?? this.sourcesHasMore, + errorMessage: errorMessage, + ); + } + + @override + List get props => [ + activeTab, + headlinesStatus, + headlines, + headlinesCursor, + headlinesHasMore, + categoriesStatus, + categories, + categoriesCursor, + categoriesHasMore, + sourcesStatus, + sources, + sourcesCursor, + sourcesHasMore, + errorMessage, + ]; +} diff --git a/lib/content_management/bloc/create_headline/create_headline_bloc.dart b/lib/content_management/bloc/create_headline/create_headline_bloc.dart new file mode 100644 index 0000000..8d03524 --- /dev/null +++ b/lib/content_management/bloc/create_headline/create_headline_bloc.dart @@ -0,0 +1,152 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart' hide Category; +import 'package:ht_data_repository/ht_data_repository.dart'; +import 'package:ht_shared/ht_shared.dart'; + +part 'create_headline_event.dart'; +part 'create_headline_state.dart'; + +/// A BLoC to manage the state of creating a new headline. +class CreateHeadlineBloc + extends Bloc { + /// {@macro create_headline_bloc} + CreateHeadlineBloc({ + required HtDataRepository headlinesRepository, + required HtDataRepository sourcesRepository, + required HtDataRepository categoriesRepository, + }) : _headlinesRepository = headlinesRepository, + _sourcesRepository = sourcesRepository, + _categoriesRepository = categoriesRepository, + super(const CreateHeadlineState()) { + on(_onDataLoaded); + on(_onTitleChanged); + on(_onDescriptionChanged); + on(_onUrlChanged); + on(_onImageUrlChanged); + on(_onSourceChanged); + on(_onCategoryChanged); + on(_onSubmitted); + } + + final HtDataRepository _headlinesRepository; + final HtDataRepository _sourcesRepository; + final HtDataRepository _categoriesRepository; + + Future _onDataLoaded( + CreateHeadlineDataLoaded event, + Emitter emit, + ) async { + emit(state.copyWith(status: CreateHeadlineStatus.loading)); + try { + final [sourcesResponse, categoriesResponse] = await Future.wait([ + _sourcesRepository.readAll(), + _categoriesRepository.readAll(), + ]); + + final sources = (sourcesResponse as PaginatedResponse).items; + final categories = + (categoriesResponse as PaginatedResponse).items; + + emit( + state.copyWith( + status: CreateHeadlineStatus.initial, + sources: sources, + categories: categories, + ), + ); + } on HtHttpException catch (e) { + emit( + state.copyWith( + status: CreateHeadlineStatus.failure, + errorMessage: e.message, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: CreateHeadlineStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + void _onTitleChanged( + CreateHeadlineTitleChanged event, + Emitter emit, + ) { + emit(state.copyWith(title: event.title)); + } + + void _onDescriptionChanged( + CreateHeadlineDescriptionChanged event, + Emitter emit, + ) { + emit(state.copyWith(description: event.description)); + } + + void _onUrlChanged( + CreateHeadlineUrlChanged event, + Emitter emit, + ) { + emit(state.copyWith(url: event.url)); + } + + void _onImageUrlChanged( + CreateHeadlineImageUrlChanged event, + Emitter emit, + ) { + emit(state.copyWith(imageUrl: event.imageUrl)); + } + + void _onSourceChanged( + CreateHeadlineSourceChanged event, + Emitter emit, + ) { + emit(state.copyWith(source: () => event.source)); + } + + void _onCategoryChanged( + CreateHeadlineCategoryChanged event, + Emitter emit, + ) { + emit(state.copyWith(category: () => event.category)); + } + + Future _onSubmitted( + CreateHeadlineSubmitted event, + Emitter emit, + ) async { + if (!state.isFormValid) return; + + emit(state.copyWith(status: CreateHeadlineStatus.submitting)); + try { + final newHeadline = Headline( + title: state.title, + description: state.description.isNotEmpty ? state.description : null, + url: state.url.isNotEmpty ? state.url : null, + imageUrl: state.imageUrl.isNotEmpty ? state.imageUrl : null, + source: state.source, + category: state.category, + ); + + await _headlinesRepository.create(item: newHeadline); + emit(state.copyWith(status: CreateHeadlineStatus.success)); + } on HtHttpException catch (e) { + emit( + state.copyWith( + status: CreateHeadlineStatus.failure, + errorMessage: e.message, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: CreateHeadlineStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } +} diff --git a/lib/content_management/bloc/create_headline/create_headline_event.dart b/lib/content_management/bloc/create_headline/create_headline_event.dart new file mode 100644 index 0000000..de06564 --- /dev/null +++ b/lib/content_management/bloc/create_headline/create_headline_event.dart @@ -0,0 +1,68 @@ +part of 'create_headline_bloc.dart'; + +/// Base class for all events related to the [CreateHeadlineBloc]. +abstract class CreateHeadlineEvent extends Equatable { + const CreateHeadlineEvent(); + + @override + List get props => []; +} + +/// Event to signal that the data for dropdowns should be loaded. +class CreateHeadlineDataLoaded extends CreateHeadlineEvent { + const CreateHeadlineDataLoaded(); +} + +/// Event for when the headline's title is changed. +class CreateHeadlineTitleChanged extends CreateHeadlineEvent { + const CreateHeadlineTitleChanged(this.title); + final String title; + @override + List get props => [title]; +} + +/// Event for when the headline's description is changed. +class CreateHeadlineDescriptionChanged extends CreateHeadlineEvent { + const CreateHeadlineDescriptionChanged(this.description); + final String description; + @override + List get props => [description]; +} + +/// Event for when the headline's URL is changed. +class CreateHeadlineUrlChanged extends CreateHeadlineEvent { + const CreateHeadlineUrlChanged(this.url); + final String url; + @override + List get props => [url]; +} + +/// Event for when the headline's image URL is changed. +class CreateHeadlineImageUrlChanged extends CreateHeadlineEvent { + const CreateHeadlineImageUrlChanged(this.imageUrl); + final String imageUrl; + @override + List get props => [imageUrl]; +} + +/// Event for when the headline's source is changed. +class CreateHeadlineSourceChanged extends CreateHeadlineEvent { + const CreateHeadlineSourceChanged(this.source); + final Source? source; + @override + List get props => [source]; +} + +/// Event for when the headline's category is changed. +class CreateHeadlineCategoryChanged extends CreateHeadlineEvent { + const CreateHeadlineCategoryChanged(this.category); + final Category? category; + @override + List get props => [category]; +} + +/// Event to signal that the form should be submitted. +class CreateHeadlineSubmitted extends CreateHeadlineEvent { + const CreateHeadlineSubmitted(); +} + diff --git a/lib/content_management/bloc/create_headline/create_headline_state.dart b/lib/content_management/bloc/create_headline/create_headline_state.dart new file mode 100644 index 0000000..c0ed1ae --- /dev/null +++ b/lib/content_management/bloc/create_headline/create_headline_state.dart @@ -0,0 +1,90 @@ +part of 'create_headline_bloc.dart'; + +/// Represents the status of the create headline operation. +enum CreateHeadlineStatus { + /// Initial state, before any data is loaded. + initial, + + /// Data is being loaded. + loading, + + /// An operation completed successfully. + success, + + /// An error occurred. + failure, + + /// The form is being submitted. + submitting, +} + +/// The state for the [CreateHeadlineBloc]. +final class CreateHeadlineState extends Equatable { + const CreateHeadlineState({ + this.status = CreateHeadlineStatus.initial, + this.title = '', + this.description = '', + this.url = '', + this.imageUrl = '', + this.source, + this.category, + this.sources = const [], + this.categories = const [], + this.errorMessage, + }); + + final CreateHeadlineStatus status; + final String title; + final String description; + final String url; + final String imageUrl; + final Source? source; + final Category? category; + final List sources; + final List categories; + final String? errorMessage; + + /// Returns true if the form is valid and can be submitted. + bool get isFormValid => title.isNotEmpty; + + CreateHeadlineState copyWith({ + CreateHeadlineStatus? status, + String? title, + String? description, + String? url, + String? imageUrl, + ValueGetter? source, + ValueGetter? category, + List? sources, + List? categories, + String? errorMessage, + }) { + return CreateHeadlineState( + status: status ?? this.status, + title: title ?? this.title, + description: description ?? this.description, + url: url ?? this.url, + imageUrl: imageUrl ?? this.imageUrl, + source: source != null ? source() : this.source, + category: category != null ? category() : this.category, + sources: sources ?? this.sources, + categories: categories ?? this.categories, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + @override + List get props => [ + status, + title, + description, + url, + imageUrl, + source, + category, + sources, + categories, + errorMessage, + ]; +} + diff --git a/lib/content_management/bloc/edit_category/edit_category_bloc.dart b/lib/content_management/bloc/edit_category/edit_category_bloc.dart new file mode 100644 index 0000000..6266e16 --- /dev/null +++ b/lib/content_management/bloc/edit_category/edit_category_bloc.dart @@ -0,0 +1,146 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:ht_data_repository/ht_data_repository.dart'; +import 'package:ht_shared/ht_shared.dart'; + +part 'edit_category_event.dart'; +part 'edit_category_state.dart'; + +/// A BLoC to manage the state of editing a single category. +class EditCategoryBloc extends Bloc { + /// {@macro edit_category_bloc} + EditCategoryBloc({ + required HtDataRepository categoriesRepository, + required String categoryId, + }) : _categoriesRepository = categoriesRepository, + _categoryId = categoryId, + super(const EditCategoryState()) { + on(_onLoaded); + on(_onNameChanged); + on(_onDescriptionChanged); + on(_onIconUrlChanged); + on(_onSubmitted); + } + + final HtDataRepository _categoriesRepository; + final String _categoryId; + + Future _onLoaded( + EditCategoryLoaded event, + Emitter emit, + ) async { + emit(state.copyWith(status: EditCategoryStatus.loading)); + try { + final category = await _categoriesRepository.read(id: _categoryId); + emit( + state.copyWith( + status: EditCategoryStatus.initial, + initialCategory: category, + name: category.name, + description: category.description ?? '', + iconUrl: category.iconUrl ?? '', + ), + ); + } on HtHttpException catch (e) { + emit( + state.copyWith( + status: EditCategoryStatus.failure, + errorMessage: e.message, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: EditCategoryStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + void _onNameChanged( + EditCategoryNameChanged event, + Emitter emit, + ) { + emit( + state.copyWith( + name: event.name, + // Reset status to allow for re-submission after a failure. + status: EditCategoryStatus.initial, + ), + ); + } + + void _onDescriptionChanged( + EditCategoryDescriptionChanged event, + Emitter emit, + ) { + emit( + state.copyWith( + description: event.description, + status: EditCategoryStatus.initial, + ), + ); + } + + void _onIconUrlChanged( + EditCategoryIconUrlChanged event, + Emitter emit, + ) { + emit( + state.copyWith( + iconUrl: event.iconUrl, + status: EditCategoryStatus.initial, + ), + ); + } + + Future _onSubmitted( + EditCategorySubmitted event, + Emitter emit, + ) async { + if (!state.isFormValid) return; + + // Safely access the initial category to prevent null errors. + final initialCategory = state.initialCategory; + if (initialCategory == null) { + emit( + state.copyWith( + status: EditCategoryStatus.failure, + errorMessage: 'Cannot update: Original category data not loaded.', + ), + ); + return; + } + + emit(state.copyWith(status: EditCategoryStatus.submitting)); + try { + // Use null for empty optional fields, which is cleaner for APIs. + final updatedCategory = initialCategory.copyWith( + name: state.name, + description: state.description.isNotEmpty ? state.description : null, + iconUrl: state.iconUrl.isNotEmpty ? state.iconUrl : null, + ); + + await _categoriesRepository.update( + id: _categoryId, + item: updatedCategory, + ); + emit(state.copyWith(status: EditCategoryStatus.success)); + } on HtHttpException catch (e) { + emit( + state.copyWith( + status: EditCategoryStatus.failure, + errorMessage: e.message, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: EditCategoryStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } +} diff --git a/lib/content_management/bloc/edit_category/edit_category_event.dart b/lib/content_management/bloc/edit_category/edit_category_event.dart new file mode 100644 index 0000000..cea54d4 --- /dev/null +++ b/lib/content_management/bloc/edit_category/edit_category_event.dart @@ -0,0 +1,49 @@ +part of 'edit_category_bloc.dart'; + +/// Base class for all events related to the [EditCategoryBloc]. +sealed class EditCategoryEvent extends Equatable { + const EditCategoryEvent(); + + @override + List get props => []; +} + +/// Event to load the initial category data for editing. +final class EditCategoryLoaded extends EditCategoryEvent { + const EditCategoryLoaded(); +} + +/// Event triggered when the category name input changes. +final class EditCategoryNameChanged extends EditCategoryEvent { + const EditCategoryNameChanged(this.name); + + final String name; + + @override + List get props => [name]; +} + +/// Event triggered when the category description input changes. +final class EditCategoryDescriptionChanged extends EditCategoryEvent { + const EditCategoryDescriptionChanged(this.description); + + final String description; + + @override + List get props => [description]; +} + +/// Event triggered when the category icon URL input changes. +final class EditCategoryIconUrlChanged extends EditCategoryEvent { + const EditCategoryIconUrlChanged(this.iconUrl); + + final String iconUrl; + + @override + List get props => [iconUrl]; +} + +/// Event to submit the edited category data. +final class EditCategorySubmitted extends EditCategoryEvent { + const EditCategorySubmitted(); +} diff --git a/lib/content_management/bloc/edit_category/edit_category_state.dart b/lib/content_management/bloc/edit_category/edit_category_state.dart new file mode 100644 index 0000000..9f6537f --- /dev/null +++ b/lib/content_management/bloc/edit_category/edit_category_state.dart @@ -0,0 +1,64 @@ +part of 'edit_category_bloc.dart'; + +/// Represents the status of the edit category operation. +enum EditCategoryStatus { + /// Initial state, before any data is loaded. + initial, + + /// Data is being loaded. + loading, + + /// Data has been successfully loaded or an operation completed. + success, + + /// An error occurred. + failure, + + /// The form is being submitted. + submitting, +} + +/// The state for the [EditCategoryBloc]. +final class EditCategoryState extends Equatable { + const EditCategoryState({ + this.status = EditCategoryStatus.initial, + this.initialCategory, + this.name = '', + this.description = '', + this.iconUrl = '', + this.errorMessage, + }); + + final EditCategoryStatus status; + final Category? initialCategory; + final String name; + final String description; + final String iconUrl; + final String? errorMessage; + + /// Returns true if the form is valid and can be submitted. + bool get isFormValid => name.isNotEmpty; + + EditCategoryState copyWith({ + EditCategoryStatus? status, + Category? initialCategory, + String? name, + String? description, + String? iconUrl, + String? errorMessage, + }) { + return EditCategoryState( + status: status ?? this.status, + initialCategory: initialCategory ?? this.initialCategory, + name: name ?? this.name, + description: description ?? this.description, + iconUrl: iconUrl ?? this.iconUrl, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + @override + List get props => + [status, initialCategory, name, description, iconUrl, errorMessage]; +} + diff --git a/lib/content_management/bloc/edit_headline/edit_headline_bloc.dart b/lib/content_management/bloc/edit_headline/edit_headline_bloc.dart new file mode 100644 index 0000000..da2fee1 --- /dev/null +++ b/lib/content_management/bloc/edit_headline/edit_headline_bloc.dart @@ -0,0 +1,200 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart' hide Category; +import 'package:ht_data_repository/ht_data_repository.dart'; +import 'package:ht_shared/ht_shared.dart'; + +part 'edit_headline_event.dart'; +part 'edit_headline_state.dart'; + +/// A BLoC to manage the state of editing a single headline. +class EditHeadlineBloc extends Bloc { + /// {@macro edit_headline_bloc} + EditHeadlineBloc({ + required HtDataRepository headlinesRepository, + required HtDataRepository sourcesRepository, + required HtDataRepository categoriesRepository, + required String headlineId, + }) : _headlinesRepository = headlinesRepository, + _sourcesRepository = sourcesRepository, + _categoriesRepository = categoriesRepository, + _headlineId = headlineId, + super(const EditHeadlineState()) { + on(_onLoaded); + on(_onTitleChanged); + on(_onDescriptionChanged); + on(_onUrlChanged); + on(_onImageUrlChanged); + on(_onSourceChanged); + on(_onCategoryChanged); + on(_onSubmitted); + } + + final HtDataRepository _headlinesRepository; + final HtDataRepository _sourcesRepository; + final HtDataRepository _categoriesRepository; + final String _headlineId; + + Future _onLoaded( + EditHeadlineLoaded event, + Emitter emit, + ) async { + emit(state.copyWith(status: EditHeadlineStatus.loading)); + try { + final [ + headlineResponse, + sourcesResponse, + categoriesResponse, + ] = await Future.wait([ + _headlinesRepository.read(id: _headlineId), + _sourcesRepository.readAll(), + _categoriesRepository.readAll(), + ]); + + final headline = headlineResponse as Headline; + final sources = (sourcesResponse as PaginatedResponse).items; + final categories = + (categoriesResponse as PaginatedResponse).items; + + emit( + state.copyWith( + status: EditHeadlineStatus.initial, + initialHeadline: headline, + title: headline.title, + description: headline.description ?? '', + url: headline.url ?? '', + imageUrl: headline.imageUrl ?? '', + source: () => headline.source, + category: () => headline.category, + sources: sources, + categories: categories, + ), + ); + } on HtHttpException catch (e) { + emit( + state.copyWith( + status: EditHeadlineStatus.failure, + errorMessage: e.message, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: EditHeadlineStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + void _onTitleChanged( + EditHeadlineTitleChanged event, + Emitter emit, + ) { + emit( + state.copyWith(title: event.title, status: EditHeadlineStatus.initial), + ); + } + + void _onDescriptionChanged( + EditHeadlineDescriptionChanged event, + Emitter emit, + ) { + emit( + state.copyWith( + description: event.description, + status: EditHeadlineStatus.initial, + ), + ); + } + + void _onUrlChanged( + EditHeadlineUrlChanged event, + Emitter emit, + ) { + emit(state.copyWith(url: event.url, status: EditHeadlineStatus.initial)); + } + + void _onImageUrlChanged( + EditHeadlineImageUrlChanged event, + Emitter emit, + ) { + emit( + state.copyWith( + imageUrl: event.imageUrl, + status: EditHeadlineStatus.initial, + ), + ); + } + + void _onSourceChanged( + EditHeadlineSourceChanged event, + Emitter emit, + ) { + emit( + state.copyWith( + source: () => event.source, + status: EditHeadlineStatus.initial, + ), + ); + } + + void _onCategoryChanged( + EditHeadlineCategoryChanged event, + Emitter emit, + ) { + emit( + state.copyWith( + category: () => event.category, + status: EditHeadlineStatus.initial, + ), + ); + } + + Future _onSubmitted( + EditHeadlineSubmitted event, + Emitter emit, + ) async { + if (!state.isFormValid) return; + + final initialHeadline = state.initialHeadline; + if (initialHeadline == null) { + emit( + state.copyWith( + status: EditHeadlineStatus.failure, + errorMessage: 'Cannot update: Original headline data not loaded.', + ), + ); + return; + } + + emit(state.copyWith(status: EditHeadlineStatus.submitting)); + try { + final updatedHeadline = initialHeadline.copyWith( + title: state.title, + description: state.description.isNotEmpty ? state.description : null, + url: state.url.isNotEmpty ? state.url : null, + imageUrl: state.imageUrl.isNotEmpty ? state.imageUrl : null, + source: state.source, + category: state.category, + ); + + await _headlinesRepository.update(id: _headlineId, item: updatedHeadline); + emit(state.copyWith(status: EditHeadlineStatus.success)); + } on HtHttpException catch (e) { + emit( + state.copyWith( + status: EditHeadlineStatus.failure, + errorMessage: e.message, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: EditHeadlineStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } +} diff --git a/lib/content_management/bloc/edit_headline/edit_headline_event.dart b/lib/content_management/bloc/edit_headline/edit_headline_event.dart new file mode 100644 index 0000000..8b71944 --- /dev/null +++ b/lib/content_management/bloc/edit_headline/edit_headline_event.dart @@ -0,0 +1,68 @@ +part of 'edit_headline_bloc.dart'; + +/// Base class for all events related to the [EditHeadlineBloc]. +abstract class EditHeadlineEvent extends Equatable { + const EditHeadlineEvent(); + + @override + List get props => []; +} + +/// Event to signal that the headline data should be loaded. +class EditHeadlineLoaded extends EditHeadlineEvent { + const EditHeadlineLoaded(); +} + +/// Event for when the headline's title is changed. +class EditHeadlineTitleChanged extends EditHeadlineEvent { + const EditHeadlineTitleChanged(this.title); + final String title; + @override + List get props => [title]; +} + +/// Event for when the headline's description is changed. +class EditHeadlineDescriptionChanged extends EditHeadlineEvent { + const EditHeadlineDescriptionChanged(this.description); + final String description; + @override + List get props => [description]; +} + +/// Event for when the headline's URL is changed. +class EditHeadlineUrlChanged extends EditHeadlineEvent { + const EditHeadlineUrlChanged(this.url); + final String url; + @override + List get props => [url]; +} + +/// Event for when the headline's image URL is changed. +class EditHeadlineImageUrlChanged extends EditHeadlineEvent { + const EditHeadlineImageUrlChanged(this.imageUrl); + final String imageUrl; + @override + List get props => [imageUrl]; +} + +/// Event for when the headline's source is changed. +class EditHeadlineSourceChanged extends EditHeadlineEvent { + const EditHeadlineSourceChanged(this.source); + final Source? source; + @override + List get props => [source]; +} + +/// Event for when the headline's category is changed. +class EditHeadlineCategoryChanged extends EditHeadlineEvent { + const EditHeadlineCategoryChanged(this.category); + final Category? category; + @override + List get props => [category]; +} + +/// Event to signal that the form should be submitted. +class EditHeadlineSubmitted extends EditHeadlineEvent { + const EditHeadlineSubmitted(); +} + diff --git a/lib/content_management/bloc/edit_headline/edit_headline_state.dart b/lib/content_management/bloc/edit_headline/edit_headline_state.dart new file mode 100644 index 0000000..c2463c7 --- /dev/null +++ b/lib/content_management/bloc/edit_headline/edit_headline_state.dart @@ -0,0 +1,94 @@ +part of 'edit_headline_bloc.dart'; + +/// Represents the status of the edit headline operation. +enum EditHeadlineStatus { + /// Initial state, before any data is loaded. + initial, + + /// Data is being loaded. + loading, + + /// An operation completed successfully. + success, + + /// An error occurred. + failure, + + /// The form is being submitted. + submitting, +} + +/// The state for the [EditHeadlineBloc]. +final class EditHeadlineState extends Equatable { + const EditHeadlineState({ + this.status = EditHeadlineStatus.initial, + this.initialHeadline, + this.title = '', + this.description = '', + this.url = '', + this.imageUrl = '', + this.source, + this.category, + this.sources = const [], + this.categories = const [], + this.errorMessage, + }); + + final EditHeadlineStatus status; + final Headline? initialHeadline; + final String title; + final String description; + final String url; + final String imageUrl; + final Source? source; + final Category? category; + final List sources; + final List categories; + final String? errorMessage; + + /// Returns true if the form is valid and can be submitted. + bool get isFormValid => title.isNotEmpty; + + EditHeadlineState copyWith({ + EditHeadlineStatus? status, + Headline? initialHeadline, + String? title, + String? description, + String? url, + String? imageUrl, + ValueGetter? source, + ValueGetter? category, + List? sources, + List? categories, + String? errorMessage, + }) { + return EditHeadlineState( + status: status ?? this.status, + initialHeadline: initialHeadline ?? this.initialHeadline, + title: title ?? this.title, + description: description ?? this.description, + url: url ?? this.url, + imageUrl: imageUrl ?? this.imageUrl, + source: source != null ? source() : this.source, + category: category != null ? category() : this.category, + sources: sources ?? this.sources, + categories: categories ?? this.categories, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + @override + List get props => [ + status, + initialHeadline, + title, + description, + url, + imageUrl, + source, + category, + sources, + categories, + errorMessage, + ]; +} diff --git a/lib/content_management/bloc/edit_source/edit_source_bloc.dart b/lib/content_management/bloc/edit_source/edit_source_bloc.dart new file mode 100644 index 0000000..f00cf3c --- /dev/null +++ b/lib/content_management/bloc/edit_source/edit_source_bloc.dart @@ -0,0 +1,218 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; +import 'package:ht_dashboard/l10n/app_localizations.dart'; +import 'package:ht_data_repository/ht_data_repository.dart'; +import 'package:ht_http_client/ht_http_client.dart'; +import 'package:ht_shared/ht_shared.dart'; +import 'package:ht_dashboard/l10n/l10n.dart'; + +part 'edit_source_event.dart'; +part 'edit_source_state.dart'; + +/// A BLoC to manage the state of editing a single source. +class EditSourceBloc extends Bloc { + /// {@macro edit_source_bloc} + EditSourceBloc({ + required HtDataRepository sourcesRepository, + required HtDataRepository countriesRepository, + required String sourceId, + }) : _sourcesRepository = sourcesRepository, + _countriesRepository = countriesRepository, + _sourceId = sourceId, + super(const EditSourceState()) { + on(_onLoaded); + on(_onNameChanged); + on(_onDescriptionChanged); + on(_onUrlChanged); + on(_onSourceTypeChanged); + on(_onLanguageChanged); + on(_onHeadquartersChanged); + on(_onSubmitted); + } + + final HtDataRepository _sourcesRepository; + final HtDataRepository _countriesRepository; + final String _sourceId; + + Future _onLoaded( + EditSourceLoaded event, + Emitter emit, + ) async { + emit(state.copyWith(status: EditSourceStatus.loading)); + try { + final [sourceResponse, countriesResponse] = await Future.wait([ + _sourcesRepository.read(id: _sourceId), + _countriesRepository.readAll(), + ]); + final source = sourceResponse as Source; + final countries = (countriesResponse as PaginatedResponse).items; + emit( + state.copyWith( + status: EditSourceStatus.initial, + initialSource: source, + name: source.name, + description: source.description ?? '', + url: source.url ?? '', + sourceType: () => source.sourceType, + language: source.language ?? '', + headquarters: () => source.headquarters, + countries: countries, + ), + ); + } on HtHttpException catch (e) { + emit( + state.copyWith( + status: EditSourceStatus.failure, + errorMessage: e.message, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: EditSourceStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + void _onNameChanged( + EditSourceNameChanged event, + Emitter emit, + ) { + emit(state.copyWith(name: event.name, status: EditSourceStatus.initial)); + } + + void _onDescriptionChanged( + EditSourceDescriptionChanged event, + Emitter emit, + ) { + emit( + state.copyWith( + description: event.description, + status: EditSourceStatus.initial, + ), + ); + } + + void _onUrlChanged( + EditSourceUrlChanged event, + Emitter emit, + ) { + emit(state.copyWith(url: event.url, status: EditSourceStatus.initial)); + } + + void _onSourceTypeChanged( + EditSourceTypeChanged event, + Emitter emit, + ) { + emit( + state.copyWith( + sourceType: () => event.sourceType, + status: EditSourceStatus.initial, + ), + ); + } + + void _onLanguageChanged( + EditSourceLanguageChanged event, + Emitter emit, + ) { + emit( + state.copyWith( + language: event.language, + status: EditSourceStatus.initial, + ), + ); + } + + void _onHeadquartersChanged( + EditSourceHeadquartersChanged event, + Emitter emit, + ) { + emit( + state.copyWith( + headquarters: () => event.headquarters, + status: EditSourceStatus.initial, + ), + ); + } + + Future _onSubmitted( + EditSourceSubmitted event, + Emitter emit, + ) async { + if (!state.isFormValid) return; + + final initialSource = state.initialSource; + if (initialSource == null) { + emit( + state.copyWith( + status: EditSourceStatus.failure, + errorMessage: 'Cannot update: Original source data not loaded.', + ), + ); + return; + } + + emit(state.copyWith(status: EditSourceStatus.submitting)); + try { + final updatedSource = initialSource.copyWith( + name: state.name, + description: state.description.isNotEmpty ? state.description : null, + url: state.url.isNotEmpty ? state.url : null, + sourceType: state.sourceType, + language: state.language.isNotEmpty ? state.language : null, + headquarters: state.headquarters, + ); + + await _sourcesRepository.update(id: _sourceId, item: updatedSource); + emit(state.copyWith(status: EditSourceStatus.success)); + } on HtHttpException catch (e) { + emit( + state.copyWith( + status: EditSourceStatus.failure, + errorMessage: e.message, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: EditSourceStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } +} + +/// Adds localization support to the [SourceType] enum. +extension SourceTypeL10n on SourceType { + /// Returns the localized name for the source type. + /// + /// This requires an [AppLocalizations] instance, which is typically + /// retrieved from the build context. + String localizedName(AppLocalizations l10n) { + switch (this) { + case SourceType.newsAgency: + return l10n.sourceTypeNewsAgency; + case SourceType.localNewsOutlet: + return l10n.sourceTypeLocalNewsOutlet; + case SourceType.nationalNewsOutlet: + return l10n.sourceTypeNationalNewsOutlet; + case SourceType.internationalNewsOutlet: + return l10n.sourceTypeInternationalNewsOutlet; + case SourceType.specializedPublisher: + return l10n.sourceTypeSpecializedPublisher; + case SourceType.blog: + return l10n.sourceTypeBlog; + case SourceType.governmentSource: + return l10n.sourceTypeGovernmentSource; + case SourceType.aggregator: + return l10n.sourceTypeAggregator; + case SourceType.other: + return l10n.sourceTypeOther; + } + } +} diff --git a/lib/content_management/bloc/edit_source/edit_source_event.dart b/lib/content_management/bloc/edit_source/edit_source_event.dart new file mode 100644 index 0000000..678e4ef --- /dev/null +++ b/lib/content_management/bloc/edit_source/edit_source_event.dart @@ -0,0 +1,79 @@ +part of 'edit_source_bloc.dart'; + +/// Base class for all events related to the [EditSourceBloc]. +sealed class EditSourceEvent extends Equatable { + const EditSourceEvent(); + + @override + List get props => []; +} + +/// Event to load the initial source data for editing. +final class EditSourceLoaded extends EditSourceEvent { + const EditSourceLoaded(); +} + +/// Event triggered when the source name input changes. +final class EditSourceNameChanged extends EditSourceEvent { + const EditSourceNameChanged(this.name); + + final String name; + + @override + List get props => [name]; +} + +/// Event triggered when the source description input changes. +final class EditSourceDescriptionChanged extends EditSourceEvent { + const EditSourceDescriptionChanged(this.description); + + final String description; + + @override + List get props => [description]; +} + +/// Event triggered when the source URL input changes. +final class EditSourceUrlChanged extends EditSourceEvent { + const EditSourceUrlChanged(this.url); + + final String url; + + @override + List get props => [url]; +} + +/// Event triggered when the source type input changes. +final class EditSourceTypeChanged extends EditSourceEvent { + const EditSourceTypeChanged(this.sourceType); + + final SourceType? sourceType; + + @override + List get props => [sourceType]; +} + +/// Event triggered when the source language input changes. +final class EditSourceLanguageChanged extends EditSourceEvent { + const EditSourceLanguageChanged(this.language); + + final String language; + + @override + List get props => [language]; +} + +/// Event triggered when the source headquarters input changes. +final class EditSourceHeadquartersChanged extends EditSourceEvent { + const EditSourceHeadquartersChanged(this.headquarters); + + final Country? headquarters; + + @override + List get props => [headquarters]; +} + +/// Event to submit the edited source data. +final class EditSourceSubmitted extends EditSourceEvent { + const EditSourceSubmitted(); +} diff --git a/lib/content_management/bloc/edit_source/edit_source_state.dart b/lib/content_management/bloc/edit_source/edit_source_state.dart new file mode 100644 index 0000000..950cb29 --- /dev/null +++ b/lib/content_management/bloc/edit_source/edit_source_state.dart @@ -0,0 +1,89 @@ +part of 'edit_source_bloc.dart'; + +/// Represents the status of the edit source operation. +enum EditSourceStatus { + /// Initial state, before any data is loaded. + initial, + + /// Data is being loaded. + loading, + + /// An operation completed successfully. + success, + + /// An error occurred. + failure, + + /// The form is being submitted. + submitting, +} + +/// The state for the [EditSourceBloc]. +final class EditSourceState extends Equatable { + const EditSourceState({ + this.status = EditSourceStatus.initial, + this.initialSource, + this.name = '', + this.description = '', + this.url = '', + this.sourceType, + this.language = '', + this.headquarters, + this.countries = const [], + this.errorMessage, + }); + + final EditSourceStatus status; + final Source? initialSource; + final String name; + final String description; + final String url; + final SourceType? sourceType; + final String language; + final Country? headquarters; + final List countries; + final String? errorMessage; + + /// Returns true if the form is valid and can be submitted. + bool get isFormValid => name.isNotEmpty; + + EditSourceState copyWith({ + EditSourceStatus? status, + Source? initialSource, + String? name, + String? description, + String? url, + ValueGetter? sourceType, + String? language, + ValueGetter? headquarters, + List? countries, + String? errorMessage, + }) { + return EditSourceState( + status: status ?? this.status, + initialSource: initialSource ?? this.initialSource, + name: name ?? this.name, + description: description ?? this.description, + url: url ?? this.url, + sourceType: sourceType != null ? sourceType() : this.sourceType, + language: language ?? this.language, + headquarters: headquarters != null ? headquarters() : this.headquarters, + countries: countries ?? this.countries, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + @override + List get props => [ + status, + initialSource, + name, + description, + url, + sourceType, + language, + headquarters, + countries, + errorMessage, + ]; +} diff --git a/lib/content_management/view/categories_page.dart b/lib/content_management/view/categories_page.dart index b32c027..34d0605 100644 --- a/lib/content_management/view/categories_page.dart +++ b/lib/content_management/view/categories_page.dart @@ -1,16 +1,174 @@ +import 'package:data_table_2/data_table_2.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:ht_dashboard/content_management/bloc/content_management_bloc.dart'; +import 'package:ht_dashboard/l10n/app_localizations.dart'; +import 'package:ht_dashboard/l10n/l10n.dart'; +import 'package:ht_dashboard/router/routes.dart'; +import 'package:ht_dashboard/shared/constants/app_spacing.dart'; +import 'package:ht_dashboard/shared/widgets/failure_state_widget.dart'; +import 'package:ht_dashboard/shared/widgets/loading_state_widget.dart'; +import 'package:ht_shared/ht_shared.dart'; /// {@template categories_page} -/// A placeholder page for Categories. +/// A page for displaying and managing Categories in a tabular format. /// {@endtemplate} -class CategoriesPage extends StatelessWidget { +class CategoriesPage extends StatefulWidget { /// {@macro categories_page} const CategoriesPage({super.key}); + @override + State createState() => _CategoriesPageState(); +} + +class _CategoriesPageState extends State { + static const int _rowsPerPage = 10; + + @override + void initState() { + super.initState(); + context.read().add( + const LoadCategoriesRequested(limit: _rowsPerPage), + ); + } + @override Widget build(BuildContext context) { - return const Center( - child: Text('Categories Page'), + final l10n = context.l10n; + return Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: BlocBuilder( + builder: (context, state) { + if (state.categoriesStatus == ContentManagementStatus.loading && + state.categories.isEmpty) { + return LoadingStateWidget( + icon: Icons.category, + headline: l10n.loadingCategories, + subheadline: l10n.pleaseWait, + ); + } + + if (state.categoriesStatus == ContentManagementStatus.failure) { + return FailureStateWidget( + message: state.errorMessage ?? l10n.unknownError, + onRetry: () => context.read().add( + const LoadCategoriesRequested(limit: _rowsPerPage), + ), + ); + } + + if (state.categories.isEmpty) { + return Center( + child: Text(l10n.noCategoriesFound), + ); + } + + return PaginatedDataTable2( + columns: [ + DataColumn2( + label: Text(l10n.categoryName), + size: ColumnSize.L, + ), + DataColumn2( + label: Text(l10n.description), + size: ColumnSize.M, + ), + DataColumn2( + label: Text(l10n.actions), + size: ColumnSize.S, + ), + ], + source: _CategoriesDataSource( + context: context, + categories: state.categories, + l10n: l10n, + ), + rowsPerPage: _rowsPerPage, + availableRowsPerPage: const [_rowsPerPage], + onPageChanged: (pageIndex) { + final newOffset = pageIndex * _rowsPerPage; + if (newOffset >= state.categories.length && + state.categoriesHasMore) { + context.read().add( + LoadCategoriesRequested( + startAfterId: state.categoriesCursor, + limit: _rowsPerPage, + ), + ); + } + }, + empty: Center(child: Text(l10n.noCategoriesFound)), + showCheckboxColumn: false, + showFirstLastButtons: true, + fit: FlexFit.tight, + headingRowHeight: 56, + dataRowHeight: 56, + columnSpacing: AppSpacing.md, + horizontalMargin: AppSpacing.md, + ); + }, + ), ); } } + +class _CategoriesDataSource extends DataTableSource { + _CategoriesDataSource({ + required this.context, + required this.categories, + required this.l10n, + }); + + final BuildContext context; + final List categories; + final AppLocalizations l10n; + + @override + DataRow? getRow(int index) { + if (index >= categories.length) { + return null; + } + final category = categories[index]; + return DataRow2( + cells: [ + DataCell(Text(category.name)), + DataCell(Text(category.description ?? l10n.notAvailable)), + DataCell( + Row( + children: [ + IconButton( + icon: const Icon(Icons.edit), + onPressed: () { + // Navigate to edit page + context.goNamed( + Routes.editCategoryName, // Assuming an edit route exists + pathParameters: {'id': category.id}, + ); + }, + ), + IconButton( + icon: const Icon(Icons.delete), + onPressed: () { + // Dispatch delete event + context.read().add( + DeleteCategoryRequested(category.id), + ); + }, + ), + ], + ), + ), + ], + ); + } + + @override + bool get isRowCountApproximate => false; + + @override + int get rowCount => categories.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 c52f67d..3f9749f 100644 --- a/lib/content_management/view/content_management_page.dart +++ b/lib/content_management/view/content_management_page.dart @@ -1,8 +1,12 @@ import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:ht_dashboard/content_management/bloc/content_management_bloc.dart'; import 'package:ht_dashboard/content_management/view/categories_page.dart'; import 'package:ht_dashboard/content_management/view/headlines_page.dart'; import 'package:ht_dashboard/content_management/view/sources_page.dart'; import 'package:ht_dashboard/l10n/l10n.dart'; +import 'package:ht_dashboard/router/routes.dart'; import 'package:ht_dashboard/shared/constants/app_spacing.dart'; /// {@template content_management_page} @@ -24,59 +28,97 @@ class _ContentManagementPageState extends State void initState() { super.initState(); _tabController = TabController(length: 3, vsync: this); + _tabController.addListener(_onTabChanged); } @override void dispose() { - _tabController.dispose(); + _tabController + ..removeListener(_onTabChanged) + ..dispose(); super.dispose(); } + void _onTabChanged() { + if (!_tabController.indexIsChanging) { + final tab = ContentManagementTab.values[_tabController.index]; + context.read().add( + ContentManagementTabChanged(tab), + ); + } + } + @override Widget build(BuildContext context) { final l10n = context.l10n; - return Scaffold( - appBar: AppBar( - title: Text(l10n.contentManagement), - bottom: PreferredSize( - preferredSize: const Size.fromHeight(kTextTabBarHeight + AppSpacing.lg), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only( - left: AppSpacing.lg, - right: AppSpacing.lg, - bottom: AppSpacing.lg, - ), - child: Text( - l10n.contentManagementPageDescription, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, + return BlocListener( + listener: (context, state) { + // Optionally handle state changes, e.g., show snackbar for errors + }, + child: Scaffold( + appBar: AppBar( + title: Text(l10n.contentManagement), + bottom: PreferredSize( + preferredSize: const Size.fromHeight( + kTextTabBarHeight + AppSpacing.lg, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only( + left: AppSpacing.lg, + right: AppSpacing.lg, + bottom: AppSpacing.lg, + ), + child: Text( + l10n.contentManagementPageDescription, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), ), - ), - TabBar( - controller: _tabController, - tabAlignment: TabAlignment.start, - isScrollable: true, - tabs: [ - Tab(text: l10n.headlines), - Tab(text: l10n.categories), - Tab(text: l10n.sources), - ], - ), - ], + TabBar( + controller: _tabController, + tabAlignment: TabAlignment.start, + isScrollable: true, + tabs: [ + Tab(text: l10n.headlines), + Tab(text: l10n.categories), + Tab(text: l10n.sources), + ], + ), + ], + ), ), + actions: [ + IconButton( + icon: const Icon(Icons.add), + tooltip: 'Add New Item', // Consider localizing this tooltip + onPressed: () { + final currentTab = + context.read().state.activeTab; + switch (currentTab) { + case ContentManagementTab.headlines: + context.goNamed(Routes.createHeadlineName); + case ContentManagementTab.categories: + context.goNamed(Routes.createCategoryName); + case ContentManagementTab.sources: + context.goNamed(Routes.createSourceName); + } + }, + ), + const SizedBox(width: AppSpacing.md), + ], + ), + body: TabBarView( + controller: _tabController, + children: const [ + HeadlinesPage(), + CategoriesPage(), + SourcesPage(), + ], ), - ), - body: TabBarView( - controller: _tabController, - children: const [ - HeadlinesPage(), - CategoriesPage(), - SourcesPage(), - ], ), ); } diff --git a/lib/content_management/view/create_headline_page.dart b/lib/content_management/view/create_headline_page.dart new file mode 100644 index 0000000..18e4cfc --- /dev/null +++ b/lib/content_management/view/create_headline_page.dart @@ -0,0 +1,223 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:ht_dashboard/content_management/bloc/content_management_bloc.dart'; +import 'package:ht_dashboard/content_management/bloc/create_headline/create_headline_bloc.dart'; +import 'package:ht_dashboard/l10n/l10n.dart'; +import 'package:ht_dashboard/shared/shared.dart'; +import 'package:ht_data_repository/ht_data_repository.dart'; +import 'package:ht_shared/ht_shared.dart'; + +/// {@template create_headline_page} +/// A page for creating a new headline. +/// It uses a [BlocProvider] to create and provide a [CreateHeadlineBloc]. +/// {@endtemplate} +class CreateHeadlinePage extends StatelessWidget { + /// {@macro create_headline_page} + const CreateHeadlinePage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => CreateHeadlineBloc( + headlinesRepository: context.read>(), + sourcesRepository: context.read>(), + categoriesRepository: context.read>(), + )..add(const CreateHeadlineDataLoaded()), + child: const _CreateHeadlineView(), + ); + } +} + +class _CreateHeadlineView extends StatefulWidget { + const _CreateHeadlineView(); + + @override + State<_CreateHeadlineView> createState() => _CreateHeadlineViewState(); +} + +class _CreateHeadlineViewState extends State<_CreateHeadlineView> { + final _formKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Scaffold( + appBar: AppBar( + title: Text(l10n.createHeadline), + actions: [ + BlocBuilder( + builder: (context, state) { + if (state.status == CreateHeadlineStatus.submitting) { + return const Padding( + padding: EdgeInsets.only(right: AppSpacing.lg), + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 3), + ), + ); + } + return IconButton( + icon: const Icon(Icons.save), + tooltip: l10n.saveChanges, + onPressed: state.isFormValid + ? () => context.read().add( + const CreateHeadlineSubmitted(), + ) + : null, + ); + }, + ), + ], + ), + body: BlocConsumer( + listenWhen: (previous, current) => previous.status != current.status, + listener: (context, state) { + if (state.status == CreateHeadlineStatus.success && + ModalRoute.of(context)!.isCurrent) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(l10n.headlineCreatedSuccessfully), + ), + ); + context.read().add( + const LoadHeadlinesRequested(), + ); + context.pop(); + } + if (state.status == CreateHeadlineStatus.failure) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(state.errorMessage ?? l10n.unknownError), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + }, + builder: (context, state) { + if (state.status == CreateHeadlineStatus.loading) { + return LoadingStateWidget( + icon: Icons.newspaper, + headline: l10n.loadingData, + subheadline: l10n.pleaseWait, + ); + } + + if (state.status == CreateHeadlineStatus.failure && + state.sources.isEmpty && + state.categories.isEmpty) { + return FailureStateWidget( + message: state.errorMessage ?? l10n.unknownError, + onRetry: () => context.read().add( + const CreateHeadlineDataLoaded(), + ), + ); + } + + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + initialValue: state.title, + decoration: InputDecoration( + labelText: l10n.headlineTitle, + border: const OutlineInputBorder(), + ), + onChanged: (value) => context + .read() + .add(CreateHeadlineTitleChanged(value)), + ), + const SizedBox(height: AppSpacing.lg), + TextFormField( + initialValue: state.description, + decoration: InputDecoration( + labelText: l10n.description, + border: const OutlineInputBorder(), + ), + maxLines: 3, + onChanged: (value) => context + .read() + .add(CreateHeadlineDescriptionChanged(value)), + ), + const SizedBox(height: AppSpacing.lg), + TextFormField( + initialValue: state.url, + decoration: InputDecoration( + labelText: l10n.sourceUrl, + border: const OutlineInputBorder(), + ), + onChanged: (value) => context + .read() + .add(CreateHeadlineUrlChanged(value)), + ), + const SizedBox(height: AppSpacing.lg), + TextFormField( + initialValue: state.imageUrl, + decoration: InputDecoration( + labelText: l10n.imageUrl, + border: const OutlineInputBorder(), + ), + onChanged: (value) => context + .read() + .add(CreateHeadlineImageUrlChanged(value)), + ), + const SizedBox(height: AppSpacing.lg), + DropdownButtonFormField( + value: state.source, + decoration: InputDecoration( + labelText: l10n.sourceName, + border: const OutlineInputBorder(), + ), + items: [ + DropdownMenuItem(value: null, child: Text(l10n.none)), + ...state.sources.map( + (source) => DropdownMenuItem( + value: source, + child: Text(source.name), + ), + ), + ], + onChanged: (value) => context + .read() + .add(CreateHeadlineSourceChanged(value)), + ), + const SizedBox(height: AppSpacing.lg), + DropdownButtonFormField( + value: state.category, + decoration: InputDecoration( + labelText: l10n.categoryName, + border: const OutlineInputBorder(), + ), + items: [ + DropdownMenuItem(value: null, child: Text(l10n.none)), + ...state.categories.map( + (category) => DropdownMenuItem( + value: category, + child: Text(category.name), + ), + ), + ], + onChanged: (value) => context + .read() + .add(CreateHeadlineCategoryChanged(value)), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/content_management/view/edit_category_page.dart b/lib/content_management/view/edit_category_page.dart new file mode 100644 index 0000000..1c977a5 --- /dev/null +++ b/lib/content_management/view/edit_category_page.dart @@ -0,0 +1,201 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:ht_dashboard/content_management/bloc/content_management_bloc.dart'; +import 'package:ht_dashboard/content_management/bloc/edit_category/edit_category_bloc.dart'; +import 'package:ht_dashboard/l10n/l10n.dart'; +import 'package:ht_dashboard/shared/shared.dart'; +import 'package:ht_data_repository/ht_data_repository.dart'; +import 'package:ht_shared/ht_shared.dart'; + +/// {@template edit_category_page} +/// A page for editing an existing category. +/// It uses a [BlocProvider] to create and provide an [EditCategoryBloc]. +/// {@endtemplate} +class EditCategoryPage extends StatelessWidget { + /// {@macro edit_category_page} + const EditCategoryPage({required this.categoryId, super.key}); + + /// The ID of the category to be edited. + final String categoryId; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => EditCategoryBloc( + categoriesRepository: context.read>(), + categoryId: categoryId, + )..add(const EditCategoryLoaded()), + child: const _EditCategoryView(), + ); + } +} + +class _EditCategoryView extends StatefulWidget { + const _EditCategoryView(); + + @override + State<_EditCategoryView> createState() => _EditCategoryViewState(); +} + +class _EditCategoryViewState extends State<_EditCategoryView> { + final _formKey = GlobalKey(); + late final TextEditingController _nameController; + late final TextEditingController _descriptionController; + late final TextEditingController _iconUrlController; + + @override + void initState() { + super.initState(); + final state = context.read().state; + _nameController = TextEditingController(text: state.name); + _descriptionController = TextEditingController(text: state.description); + _iconUrlController = TextEditingController(text: state.iconUrl); + } + + @override + void dispose() { + _nameController.dispose(); + _descriptionController.dispose(); + _iconUrlController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Scaffold( + appBar: AppBar( + title: Text(l10n.editCategory), + actions: [ + BlocBuilder( + builder: (context, state) { + if (state.status == EditCategoryStatus.submitting) { + return const Padding( + padding: EdgeInsets.only(right: AppSpacing.lg), + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 3), + ), + ); + } + return IconButton( + icon: const Icon(Icons.save), + tooltip: l10n.saveChanges, + onPressed: state.isFormValid + ? () => context.read().add( + const EditCategorySubmitted(), + ) + : null, + ); + }, + ), + ], + ), + body: BlocConsumer( + listenWhen: (previous, current) => + previous.status != current.status || + previous.initialCategory != current.initialCategory, + listener: (context, state) { + if (state.status == EditCategoryStatus.success && + state.initialCategory != null && + ModalRoute.of(context)!.isCurrent) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + // TODO(l10n): Localize this message. + const SnackBar(content: Text('Category updated successfully.')), + ); + context.read().add( + const LoadCategoriesRequested(), + ); + context.pop(); + } + if (state.status == EditCategoryStatus.failure) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(state.errorMessage ?? l10n.unknownError), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + if (state.initialCategory != null) { + _nameController.text = state.name; + _descriptionController.text = state.description; + _iconUrlController.text = state.iconUrl; + } + }, + builder: (context, state) { + if (state.status == EditCategoryStatus.loading) { + return LoadingStateWidget( + icon: Icons.category, + // TODO(l10n): Localize this message. + headline: 'Loading Category...', + subheadline: l10n.pleaseWait, + ); + } + + if (state.status == EditCategoryStatus.failure && + state.initialCategory == null) { + return FailureStateWidget( + message: state.errorMessage ?? l10n.unknownError, + onRetry: () => context.read().add( + const EditCategoryLoaded(), + ), + ); + } + + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: _nameController, + decoration: InputDecoration( + labelText: l10n.categoryName, + border: const OutlineInputBorder(), + ), + onChanged: (value) => context + .read() + .add(EditCategoryNameChanged(value)), + ), + const SizedBox(height: AppSpacing.lg), + TextFormField( + controller: _descriptionController, + decoration: InputDecoration( + labelText: l10n.description, + border: const OutlineInputBorder(), + ), + maxLines: 3, + onChanged: (value) => context + .read() + .add(EditCategoryDescriptionChanged(value)), + ), + const SizedBox(height: AppSpacing.lg), + TextFormField( + controller: _iconUrlController, + decoration: InputDecoration( + labelText: l10n.iconUrl, + border: const OutlineInputBorder(), + ), + onChanged: (value) => context + .read() + .add(EditCategoryIconUrlChanged(value)), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/content_management/view/edit_headline_page.dart b/lib/content_management/view/edit_headline_page.dart new file mode 100644 index 0000000..e78394b --- /dev/null +++ b/lib/content_management/view/edit_headline_page.dart @@ -0,0 +1,282 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:ht_dashboard/content_management/bloc/content_management_bloc.dart'; +import 'package:ht_dashboard/content_management/bloc/edit_headline/edit_headline_bloc.dart'; +import 'package:ht_dashboard/l10n/l10n.dart'; +import 'package:ht_dashboard/shared/shared.dart'; +import 'package:ht_data_repository/ht_data_repository.dart'; +import 'package:ht_shared/ht_shared.dart'; + +/// {@template edit_headline_page} +/// A page for editing an existing headline. +/// It uses a [BlocProvider] to create and provide an [EditHeadlineBloc]. +/// {@endtemplate} +class EditHeadlinePage extends StatelessWidget { + /// {@macro edit_headline_page} + const EditHeadlinePage({required this.headlineId, super.key}); + + /// The ID of the headline to be edited. + final String headlineId; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => EditHeadlineBloc( + headlinesRepository: context.read>(), + sourcesRepository: context.read>(), + categoriesRepository: context.read>(), + headlineId: headlineId, + )..add(const EditHeadlineLoaded()), + child: const _EditHeadlineView(), + ); + } +} + +class _EditHeadlineView extends StatefulWidget { + const _EditHeadlineView(); + + @override + State<_EditHeadlineView> createState() => _EditHeadlineViewState(); +} + +class _EditHeadlineViewState extends State<_EditHeadlineView> { + final _formKey = GlobalKey(); + late final TextEditingController _titleController; + late final TextEditingController _descriptionController; + late final TextEditingController _urlController; + late final TextEditingController _imageUrlController; + + @override + void initState() { + super.initState(); + final state = context.read().state; + _titleController = TextEditingController(text: state.title); + _descriptionController = TextEditingController(text: state.description); + _urlController = TextEditingController(text: state.url); + _imageUrlController = TextEditingController(text: state.imageUrl); + } + + @override + void dispose() { + _titleController.dispose(); + _descriptionController.dispose(); + _urlController.dispose(); + _imageUrlController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Scaffold( + appBar: AppBar( + title: Text(l10n.editHeadline), + actions: [ + BlocBuilder( + builder: (context, state) { + if (state.status == EditHeadlineStatus.submitting) { + return const Padding( + padding: EdgeInsets.only(right: AppSpacing.lg), + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 3), + ), + ); + } + return IconButton( + icon: const Icon(Icons.save), + tooltip: l10n.saveChanges, + onPressed: state.isFormValid + ? () => context.read().add( + const EditHeadlineSubmitted(), + ) + : null, + ); + }, + ), + ], + ), + body: BlocConsumer( + listenWhen: (previous, current) => + previous.status != current.status || + previous.initialHeadline != current.initialHeadline, + listener: (context, state) { + if (state.status == EditHeadlineStatus.success && + state.initialHeadline != null && + ModalRoute.of(context)!.isCurrent) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(l10n.headlineUpdatedSuccessfully), + ), + ); + context.read().add( + const LoadHeadlinesRequested(), + ); + context.pop(); + } + if (state.status == EditHeadlineStatus.failure) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(state.errorMessage ?? l10n.unknownError), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + if (state.initialHeadline != null) { + _titleController.text = state.title; + _descriptionController.text = state.description; + _urlController.text = state.url; + _imageUrlController.text = state.imageUrl; + } + }, + builder: (context, state) { + if (state.status == EditHeadlineStatus.loading) { + return LoadingStateWidget( + icon: Icons.newspaper, + headline: l10n.loadingHeadline, + subheadline: l10n.pleaseWait, + ); + } + + if (state.status == EditHeadlineStatus.failure && + state.initialHeadline == null) { + return FailureStateWidget( + message: state.errorMessage ?? l10n.unknownError, + onRetry: () => context.read().add( + const EditHeadlineLoaded(), + ), + ); + } + + // Find the correct instances from the lists to ensure + // the Dropdowns can display the selections correctly. + Source? selectedSource; + if (state.source != null) { + try { + selectedSource = state.sources.firstWhere( + (s) => s.id == state.source!.id, + ); + } catch (_) { + selectedSource = null; + } + } + + Category? selectedCategory; + if (state.category != null) { + try { + selectedCategory = state.categories.firstWhere( + (c) => c.id == state.category!.id, + ); + } catch (_) { + selectedCategory = null; + } + } + + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: _titleController, + decoration: InputDecoration( + labelText: l10n.headlineTitle, + border: const OutlineInputBorder(), + ), + onChanged: (value) => context + .read() + .add(EditHeadlineTitleChanged(value)), + ), + const SizedBox(height: AppSpacing.lg), + TextFormField( + controller: _descriptionController, + decoration: InputDecoration( + labelText: l10n.description, + border: const OutlineInputBorder(), + ), + maxLines: 3, + onChanged: (value) => context + .read() + .add(EditHeadlineDescriptionChanged(value)), + ), + const SizedBox(height: AppSpacing.lg), + TextFormField( + controller: _urlController, + decoration: InputDecoration( + labelText: l10n.sourceUrl, + border: const OutlineInputBorder(), + ), + onChanged: (value) => context + .read() + .add(EditHeadlineUrlChanged(value)), + ), + const SizedBox(height: AppSpacing.lg), + TextFormField( + controller: _imageUrlController, + decoration: InputDecoration( + labelText: l10n.imageUrl, + border: const OutlineInputBorder(), + ), + onChanged: (value) => context + .read() + .add(EditHeadlineImageUrlChanged(value)), + ), + const SizedBox(height: AppSpacing.lg), + DropdownButtonFormField( + value: selectedSource, + decoration: InputDecoration( + labelText: l10n.sourceName, + border: const OutlineInputBorder(), + ), + items: [ + DropdownMenuItem(value: null, child: Text(l10n.none)), + ...state.sources.map( + (source) => DropdownMenuItem( + value: source, + child: Text(source.name), + ), + ), + ], + onChanged: (value) => context + .read() + .add(EditHeadlineSourceChanged(value)), + ), + const SizedBox(height: AppSpacing.lg), + DropdownButtonFormField( + value: selectedCategory, + decoration: InputDecoration( + labelText: l10n.categoryName, + border: const OutlineInputBorder(), + ), + items: [ + DropdownMenuItem(value: null, child: Text(l10n.none)), + ...state.categories.map( + (category) => DropdownMenuItem( + value: category, + child: Text(category.name), + ), + ), + ], + onChanged: (value) => context + .read() + .add(EditHeadlineCategoryChanged(value)), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/content_management/view/edit_source_page.dart b/lib/content_management/view/edit_source_page.dart new file mode 100644 index 0000000..387b65c --- /dev/null +++ b/lib/content_management/view/edit_source_page.dart @@ -0,0 +1,267 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:ht_dashboard/content_management/bloc/content_management_bloc.dart'; +import 'package:ht_dashboard/content_management/bloc/edit_source/edit_source_bloc.dart'; +import 'package:ht_dashboard/l10n/l10n.dart'; +import 'package:ht_dashboard/shared/shared.dart'; +import 'package:ht_data_repository/ht_data_repository.dart'; +import 'package:ht_shared/ht_shared.dart'; + +/// {@template edit_source_page} +/// A page for editing an existing source. +/// It uses a [BlocProvider] to create and provide an [EditSourceBloc]. +/// {@endtemplate} +class EditSourcePage extends StatelessWidget { + /// {@macro edit_source_page} + const EditSourcePage({required this.sourceId, super.key}); + + /// The ID of the source to be edited. + final String sourceId; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => EditSourceBloc( + sourcesRepository: context.read>(), + countriesRepository: context.read>(), + sourceId: sourceId, + )..add(const EditSourceLoaded()), + child: const _EditSourceView(), + ); + } +} + +class _EditSourceView extends StatefulWidget { + const _EditSourceView(); + + @override + State<_EditSourceView> createState() => _EditSourceViewState(); +} + +class _EditSourceViewState extends State<_EditSourceView> { + final _formKey = GlobalKey(); + late final TextEditingController _nameController; + late final TextEditingController _descriptionController; + late final TextEditingController _urlController; + late final TextEditingController _languageController; + + @override + void initState() { + super.initState(); + final state = context.read().state; + _nameController = TextEditingController(text: state.name); + _descriptionController = TextEditingController(text: state.description); + _urlController = TextEditingController(text: state.url); + _languageController = TextEditingController(text: state.language); + } + + @override + void dispose() { + _nameController.dispose(); + _descriptionController.dispose(); + _urlController.dispose(); + _languageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Scaffold( + appBar: AppBar( + title: Text(l10n.editSource), + actions: [ + BlocBuilder( + builder: (context, state) { + if (state.status == EditSourceStatus.submitting) { + return const Padding( + padding: EdgeInsets.only(right: AppSpacing.lg), + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 3), + ), + ); + } + return IconButton( + icon: const Icon(Icons.save), + tooltip: l10n.saveChanges, + onPressed: state.isFormValid + ? () => context.read().add( + const EditSourceSubmitted(), + ) + : null, + ); + }, + ), + ], + ), + body: BlocConsumer( + listenWhen: (previous, current) => + previous.status != current.status || + previous.initialSource != current.initialSource, + listener: (context, state) { + if (state.status == EditSourceStatus.success && + state.initialSource != null && + ModalRoute.of(context)!.isCurrent) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(l10n.sourceUpdatedSuccessfully)), + ); + context.read().add( + const LoadSourcesRequested(), + ); + context.pop(); + } + if (state.status == EditSourceStatus.failure) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(state.errorMessage ?? l10n.unknownError), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + if (state.initialSource != null) { + _nameController.text = state.name; + _descriptionController.text = state.description; + _urlController.text = state.url; + _languageController.text = state.language; + } + }, + builder: (context, state) { + if (state.status == EditSourceStatus.loading) { + return LoadingStateWidget( + icon: Icons.source, + headline: l10n.loadingSource, + subheadline: l10n.pleaseWait, + ); + } + + if (state.status == EditSourceStatus.failure && + state.initialSource == null) { + return FailureStateWidget( + message: state.errorMessage ?? l10n.unknownError, + onRetry: () => + context.read().add(const EditSourceLoaded()), + ); + } + + // Find the correct Country instance from the list to ensure + // the Dropdown can display the selection correctly. + Country? selectedHeadquarters; + if (state.headquarters != null) { + try { + selectedHeadquarters = state.countries.firstWhere( + (c) => c.id == state.headquarters!.id, + ); + } catch (_) { + selectedHeadquarters = null; + } + } + + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: _nameController, + decoration: InputDecoration( + labelText: l10n.sourceName, + border: const OutlineInputBorder(), + ), + onChanged: (value) => context.read().add( + EditSourceNameChanged(value), + ), + ), + const SizedBox(height: AppSpacing.lg), + TextFormField( + controller: _descriptionController, + decoration: InputDecoration( + labelText: l10n.description, + border: const OutlineInputBorder(), + ), + maxLines: 3, + onChanged: (value) => context.read().add( + EditSourceDescriptionChanged(value), + ), + ), + const SizedBox(height: AppSpacing.lg), + TextFormField( + controller: _urlController, + decoration: InputDecoration( + labelText: l10n.sourceUrl, + border: const OutlineInputBorder(), + ), + onChanged: (value) => context.read().add( + EditSourceUrlChanged(value), + ), + ), + const SizedBox(height: AppSpacing.lg), + TextFormField( + controller: _languageController, + decoration: InputDecoration( + labelText: l10n.language, + border: const OutlineInputBorder(), + ), + onChanged: (value) => context.read().add( + EditSourceLanguageChanged(value), + ), + ), + const SizedBox(height: AppSpacing.lg), + DropdownButtonFormField( + value: state.sourceType, + decoration: InputDecoration( + labelText: l10n.sourceType, + border: const OutlineInputBorder(), + ), + items: [ + DropdownMenuItem(value: null, child: Text(l10n.none)), + ...SourceType.values.map( + (type) => DropdownMenuItem( + value: type, + child: Text(type.localizedName(l10n)), + ), + ), + ], + onChanged: (value) => context.read().add( + EditSourceTypeChanged(value), + ), + ), + const SizedBox(height: AppSpacing.lg), + DropdownButtonFormField( + value: selectedHeadquarters, + decoration: InputDecoration( + labelText: l10n.headquarters, + border: const OutlineInputBorder(), + ), + items: [ + DropdownMenuItem(value: null, child: Text(l10n.none)), + ...state.countries.map( + (country) => DropdownMenuItem( + value: country, + child: Text(country.name), + ), + ), + ], + onChanged: (value) => context.read().add( + EditSourceHeadquartersChanged(value), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/content_management/view/headlines_page.dart b/lib/content_management/view/headlines_page.dart index 851a966..51d0339 100644 --- a/lib/content_management/view/headlines_page.dart +++ b/lib/content_management/view/headlines_page.dart @@ -1,16 +1,186 @@ +import 'package:data_table_2/data_table_2.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:ht_dashboard/content_management/bloc/content_management_bloc.dart'; +import 'package:ht_dashboard/l10n/app_localizations.dart'; // Corrected import +import 'package:ht_dashboard/l10n/l10n.dart'; +import 'package:ht_dashboard/router/routes.dart'; +import 'package:ht_dashboard/shared/constants/app_spacing.dart'; +import 'package:ht_dashboard/shared/utils/date_formatter.dart'; +import 'package:ht_dashboard/shared/widgets/failure_state_widget.dart'; +import 'package:ht_dashboard/shared/widgets/loading_state_widget.dart'; +import 'package:ht_shared/ht_shared.dart'; /// {@template headlines_page} -/// A placeholder page for Headlines. +/// A page for displaying and managing Headlines in a tabular format. /// {@endtemplate} -class HeadlinesPage extends StatelessWidget { +class HeadlinesPage extends StatefulWidget { /// {@macro headlines_page} const HeadlinesPage({super.key}); + @override + State createState() => _HeadlinesPageState(); +} + +class _HeadlinesPageState extends State { + static const int _rowsPerPage = 10; + + @override + void initState() { + super.initState(); + context.read().add( + const LoadHeadlinesRequested(limit: _rowsPerPage), + ); + } + @override Widget build(BuildContext context) { - return const Center( - child: Text('Headlines Page'), + final l10n = context.l10n; + return Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: BlocBuilder( + builder: (context, state) { + if (state.headlinesStatus == ContentManagementStatus.loading && + state.headlines.isEmpty) { + return LoadingStateWidget( + icon: Icons.newspaper, + headline: l10n.loadingHeadlines, + subheadline: l10n.pleaseWait, + ); + } + + if (state.headlinesStatus == ContentManagementStatus.failure) { + return FailureStateWidget( + message: state.errorMessage ?? l10n.unknownError, + onRetry: () => context.read().add( + const LoadHeadlinesRequested(limit: _rowsPerPage), + ), + ); + } + + if (state.headlines.isEmpty) { + 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.publishedAt), + size: ColumnSize.S, + ), + DataColumn2( + label: Text(l10n.actions), + size: ColumnSize.S, + ), + ], + source: _HeadlinesDataSource( + context: context, + headlines: state.headlines, + l10n: l10n, + ), + rowsPerPage: _rowsPerPage, + availableRowsPerPage: const [_rowsPerPage], + onPageChanged: (pageIndex) { + final newOffset = pageIndex * _rowsPerPage; + if (newOffset >= state.headlines.length && + state.headlinesHasMore) { + context.read().add( + LoadHeadlinesRequested( + startAfterId: state.headlinesCursor, + limit: _rowsPerPage, + ), + ); + } + }, + 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.l10n, + }); + + final BuildContext context; + final List headlines; + 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)), + DataCell(Text(headline.source?.name ?? l10n.unknown)), + DataCell( + Text( + headline.publishedAt != null + ? DateFormatter.formatDate(headline.publishedAt!) + : l10n.unknown, + ), + ), + DataCell( + Row( + children: [ + IconButton( + icon: const Icon(Icons.edit), + onPressed: () { + // Navigate to edit page + context.goNamed( + Routes.editHeadlineName, + pathParameters: {'id': headline.id}, + ); + }, + ), + IconButton( + icon: const Icon(Icons.delete), + onPressed: () { + // Dispatch delete event + context.read().add( + DeleteHeadlineRequested(headline.id), + ); + }, + ), + ], + ), + ), + ], + ); + } + + @override + bool get isRowCountApproximate => false; + + @override + int get rowCount => headlines.length; + + @override + int get selectedRowCount => 0; +} diff --git a/lib/content_management/view/sources_page.dart b/lib/content_management/view/sources_page.dart index 3f216a7..93623e2 100644 --- a/lib/content_management/view/sources_page.dart +++ b/lib/content_management/view/sources_page.dart @@ -1,16 +1,178 @@ +import 'package:data_table_2/data_table_2.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; +import 'package:ht_dashboard/content_management/bloc/content_management_bloc.dart'; +import 'package:ht_dashboard/l10n/app_localizations.dart'; +import 'package:ht_dashboard/l10n/l10n.dart'; +import 'package:ht_dashboard/router/routes.dart'; +import 'package:ht_dashboard/shared/constants/app_spacing.dart'; +import 'package:ht_dashboard/shared/widgets/failure_state_widget.dart'; +import 'package:ht_dashboard/shared/widgets/loading_state_widget.dart'; +import 'package:ht_shared/ht_shared.dart'; /// {@template sources_page} -/// A placeholder page for Sources. +/// A page for displaying and managing Sources in a tabular format. /// {@endtemplate} -class SourcesPage extends StatelessWidget { +class SourcesPage extends StatefulWidget { /// {@macro sources_page} const SourcesPage({super.key}); + @override + State createState() => _SourcesPageState(); +} + +class _SourcesPageState extends State { + static const int _rowsPerPage = 10; + + @override + void initState() { + super.initState(); + context.read().add( + const LoadSourcesRequested(limit: _rowsPerPage), + ); + } + @override Widget build(BuildContext context) { - return const Center( - child: Text('Sources Page'), + final l10n = context.l10n; + return Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: BlocBuilder( + builder: (context, state) { + if (state.sourcesStatus == ContentManagementStatus.loading && + state.sources.isEmpty) { + return LoadingStateWidget( + icon: Icons.source, + headline: l10n.loadingSources, + subheadline: l10n.pleaseWait, + ); + } + + if (state.sourcesStatus == ContentManagementStatus.failure) { + return FailureStateWidget( + message: state.errorMessage ?? l10n.unknownError, + onRetry: () => context.read().add( + const LoadSourcesRequested(limit: _rowsPerPage), + ), + ); + } + + if (state.sources.isEmpty) { + 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.language), + size: ColumnSize.S, + ), + DataColumn2( + label: Text(l10n.actions), + size: ColumnSize.S, + ), + ], + source: _SourcesDataSource( + context: context, + sources: state.sources, + l10n: l10n, + ), + rowsPerPage: _rowsPerPage, + availableRowsPerPage: const [_rowsPerPage], + onPageChanged: (pageIndex) { + final newOffset = pageIndex * _rowsPerPage; + if (newOffset >= state.sources.length && state.sourcesHasMore) { + context.read().add( + LoadSourcesRequested( + startAfterId: state.sourcesCursor, + limit: _rowsPerPage, + ), + ); + } + }, + 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.l10n, + }); + + final BuildContext context; + final List sources; + 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)), + DataCell(Text(source.sourceType?.name ?? l10n.unknown)), + DataCell(Text(source.language ?? l10n.unknown)), + DataCell( + Row( + children: [ + IconButton( + icon: const Icon(Icons.edit), + onPressed: () { + // Navigate to edit page + context.goNamed( + Routes.editSourceName, // Assuming an edit route exists + pathParameters: {'id': source.id}, + ); + }, + ), + IconButton( + icon: const Icon(Icons.delete), + onPressed: () { + // Dispatch delete event + context.read().add( + DeleteSourceRequested(source.id), + ); + }, + ), + ], + ), + ), + ], + ); + } + + @override + bool get isRowCountApproximate => false; + + @override + int get rowCount => sources.length; + + @override + int get selectedRowCount => 0; +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 6bbc9d0..ac43af8 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -974,11 +974,293 @@ abstract class AppLocalizations { /// **'Configure your personal preferences for the Dashboard interface, encompassing visual presentation and language selection.'** String get settingsPageDescription; - /// No description provided for @appearanceSettingsDescription. + /// Description for the Appearance settings tab /// /// In en, this message translates to: /// **'Adjust the visual characteristics of the Dashboard, including theme, accent colors, and typographic styles.'** String get appearanceSettingsDescription; + + /// Headline for loading state of headlines + /// + /// In en, this message translates to: + /// **'Loading Headlines'** + String get loadingHeadlines; + + /// Subheadline for loading state + /// + /// In en, this message translates to: + /// **'Please wait...'** + String get pleaseWait; + + /// Message when no headlines are found + /// + /// In en, this message translates to: + /// **'No headlines found.'** + String get noHeadlinesFound; + + /// Column header for headline title + /// + /// In en, this message translates to: + /// **'Title'** + String get headlineTitle; + + /// Column header for published date + /// + /// In en, this message translates to: + /// **'Published At'** + String get publishedAt; + + /// Column header for actions + /// + /// In en, this message translates to: + /// **'Actions'** + String get actions; + + /// Fallback text for unknown values + /// + /// In en, this message translates to: + /// **'Unknown'** + String get unknown; + + /// Headline for loading state of categories + /// + /// In en, this message translates to: + /// **'Loading Categories'** + String get loadingCategories; + + /// Message when no categories are found + /// + /// In en, this message translates to: + /// **'No categories found.'** + String get noCategoriesFound; + + /// Column header for category name + /// + /// In en, this message translates to: + /// **'Name'** + String get categoryName; + + /// Column header for description + /// + /// In en, this message translates to: + /// **'Description'** + String get description; + + /// Short text for 'not available' + /// + /// In en, this message translates to: + /// **'N/A'** + String get notAvailable; + + /// Headline for loading state of sources + /// + /// In en, this message translates to: + /// **'Loading Sources'** + String get loadingSources; + + /// Message when no sources are found + /// + /// In en, this message translates to: + /// **'No sources found.'** + String get noSourcesFound; + + /// Column header for source name + /// + /// In en, this message translates to: + /// **'Name'** + String get sourceName; + + /// Column header for source type + /// + /// In en, this message translates to: + /// **'Type'** + String get sourceType; + + /// Column header for language + /// + /// In en, this message translates to: + /// **'Language'** + String get language; + + /// Title for the Edit Category page + /// + /// In en, this message translates to: + /// **'Edit Category'** + String get editCategory; + + /// Tooltip for the save changes button + /// + /// In en, this message translates to: + /// **'Save Changes'** + String get saveChanges; + + /// Message displayed while loading category data + /// + /// In en, this message translates to: + /// **'Loading Category'** + String get loadingCategory; + + /// Label for the icon URL input field + /// + /// In en, this message translates to: + /// **'Icon URL'** + String get iconUrl; + + /// Message displayed when a category is updated successfully + /// + /// In en, this message translates to: + /// **'Category updated successfully.'** + String get categoryUpdatedSuccessfully; + + /// Error message when updating a category fails because the original data wasn't loaded + /// + /// In en, this message translates to: + /// **'Cannot update: Original category data not loaded.'** + String get cannotUpdateCategoryError; + + /// Title for the Edit Source page + /// + /// In en, this message translates to: + /// **'Edit Source'** + String get editSource; + + /// Message displayed when a source is updated successfully + /// + /// In en, this message translates to: + /// **'Source updated successfully.'** + String get sourceUpdatedSuccessfully; + + /// Message displayed while loading source data + /// + /// In en, this message translates to: + /// **'Loading Source...'** + String get loadingSource; + + /// Label for the source URL input field + /// + /// In en, this message translates to: + /// **'URL'** + String get sourceUrl; + + /// Label for the headquarters dropdown field + /// + /// In en, this message translates to: + /// **'Headquarters'** + String get headquarters; + + /// Default null option for dropdowns + /// + /// In en, this message translates to: + /// **'None'** + String get none; + + /// Error message when updating a source fails because the original data wasn't loaded + /// + /// In en, this message translates to: + /// **'Cannot update: Original source data not loaded.'** + String get cannotUpdateSourceError; + + /// A global news agency (e.g., Reuters, Associated Press). + /// + /// In en, this message translates to: + /// **'News Agency'** + String get sourceTypeNewsAgency; + + /// A news outlet focused on a specific local area. + /// + /// In en, this message translates to: + /// **'Local News Outlet'** + String get sourceTypeLocalNewsOutlet; + + /// A news outlet focused on a specific country. + /// + /// In en, this message translates to: + /// **'National News Outlet'** + String get sourceTypeNationalNewsOutlet; + + /// A news outlet with a broad international focus. + /// + /// In en, this message translates to: + /// **'International News Outlet'** + String get sourceTypeInternationalNewsOutlet; + + /// A publisher focused on a specific topic (e.g., technology, sports). + /// + /// In en, this message translates to: + /// **'Specialized Publisher'** + String get sourceTypeSpecializedPublisher; + + /// A blog or personal publication. + /// + /// In en, this message translates to: + /// **'Blog'** + String get sourceTypeBlog; + + /// An official government source. + /// + /// In en, this message translates to: + /// **'Government Source'** + String get sourceTypeGovernmentSource; + + /// A service that aggregates news from other sources. + /// + /// In en, this message translates to: + /// **'Aggregator'** + String get sourceTypeAggregator; + + /// Any other type of source not covered above. + /// + /// In en, this message translates to: + /// **'Other'** + String get sourceTypeOther; + + /// Title for the Edit Headline page + /// + /// In en, this message translates to: + /// **'Edit Headline'** + String get editHeadline; + + /// Message displayed when a headline is updated successfully + /// + /// In en, this message translates to: + /// **'Headline updated successfully.'** + String get headlineUpdatedSuccessfully; + + /// Message displayed while loading headline data + /// + /// In en, this message translates to: + /// **'Loading Headline...'** + String get loadingHeadline; + + /// Label for an image URL input field + /// + /// In en, this message translates to: + /// **'Image URL'** + String get imageUrl; + + /// Error message when updating a headline fails because the original data wasn't loaded + /// + /// In en, this message translates to: + /// **'Cannot update: Original headline data not loaded.'** + String get cannotUpdateHeadlineError; + + /// Title for the Create Headline page + /// + /// In en, this message translates to: + /// **'Create Headline'** + String get createHeadline; + + /// Message displayed when a headline is created successfully + /// + /// In en, this message translates to: + /// **'Headline created successfully.'** + String get headlineCreatedSuccessfully; + + /// Generic message displayed while loading data for a form + /// + /// In en, this message translates to: + /// **'Loading data...'** + String get loadingData; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index 8c587ef..81bb685 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -515,4 +515,148 @@ class AppLocalizationsAr extends AppLocalizations { @override String get appearanceSettingsDescription => 'اضبط الخصائص المرئية للوحة القيادة، بما في ذلك السمة وألوان التمييز والأنماط الطباعية.'; + + @override + String get loadingHeadlines => 'جاري تحميل العناوين الرئيسية'; + + @override + String get pleaseWait => 'الرجاء الانتظار...'; + + @override + String get noHeadlinesFound => 'لم يتم العثور على عناوين رئيسية.'; + + @override + String get headlineTitle => 'العنوان'; + + @override + String get publishedAt => 'تاريخ النشر'; + + @override + String get actions => 'الإجراءات'; + + @override + String get unknown => 'غير معروف'; + + @override + String get loadingCategories => 'جاري تحميل الفئات'; + + @override + String get noCategoriesFound => 'لم يتم العثور على فئات.'; + + @override + String get categoryName => 'الاسم'; + + @override + String get description => 'الوصف'; + + @override + String get notAvailable => 'غير متاح'; + + @override + String get loadingSources => 'جاري تحميل المصادر'; + + @override + String get noSourcesFound => 'لم يتم العثور على مصادر.'; + + @override + String get sourceName => 'الاسم'; + + @override + String get sourceType => 'النوع'; + + @override + String get language => 'اللغة'; + + @override + String get editCategory => 'تعديل الفئة'; + + @override + String get saveChanges => 'حفظ التغييرات'; + + @override + String get loadingCategory => 'جاري تحميل الفئة'; + + @override + String get iconUrl => 'رابط الأيقونة'; + + @override + String get categoryUpdatedSuccessfully => 'تم تحديث الفئة بنجاح.'; + + @override + String get cannotUpdateCategoryError => + 'لا يمكن التحديث: لم يتم تحميل بيانات الفئة الأصلية.'; + + @override + String get editSource => 'تعديل المصدر'; + + @override + String get sourceUpdatedSuccessfully => 'تم تحديث المصدر بنجاح.'; + + @override + String get loadingSource => 'جاري تحميل المصدر...'; + + @override + String get sourceUrl => 'الرابط'; + + @override + String get headquarters => 'المقر الرئيسي'; + + @override + String get none => 'لا شيء'; + + @override + String get cannotUpdateSourceError => + 'لا يمكن التحديث: لم يتم تحميل بيانات المصدر الأصلية.'; + + @override + String get sourceTypeNewsAgency => 'وكالة أنباء'; + + @override + String get sourceTypeLocalNewsOutlet => 'منفذ إخباري محلي'; + + @override + String get sourceTypeNationalNewsOutlet => 'منفذ إخباري وطني'; + + @override + String get sourceTypeInternationalNewsOutlet => 'منفذ إخباري دولي'; + + @override + String get sourceTypeSpecializedPublisher => 'ناشر متخصص'; + + @override + String get sourceTypeBlog => 'مدونة'; + + @override + String get sourceTypeGovernmentSource => 'مصدر حكومي'; + + @override + String get sourceTypeAggregator => 'مجمع'; + + @override + String get sourceTypeOther => 'أخرى'; + + @override + String get editHeadline => 'تعديل العنوان'; + + @override + String get headlineUpdatedSuccessfully => 'تم تحديث العنوان بنجاح.'; + + @override + String get loadingHeadline => 'جاري تحميل العنوان...'; + + @override + String get imageUrl => 'رابط الصورة'; + + @override + String get cannotUpdateHeadlineError => + 'لا يمكن التحديث: لم يتم تحميل بيانات العنوان الأصلية.'; + + @override + String get createHeadline => 'إنشاء عنوان'; + + @override + String get headlineCreatedSuccessfully => 'تم إنشاء العنوان بنجاح.'; + + @override + String get loadingData => 'جاري تحميل البيانات...'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 3b474c6..f2d1405 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -513,4 +513,148 @@ class AppLocalizationsEn extends AppLocalizations { @override String get appearanceSettingsDescription => 'Adjust the visual characteristics of the Dashboard, including theme, accent colors, and typographic styles.'; + + @override + String get loadingHeadlines => 'Loading Headlines'; + + @override + String get pleaseWait => 'Please wait...'; + + @override + String get noHeadlinesFound => 'No headlines found.'; + + @override + String get headlineTitle => 'Title'; + + @override + String get publishedAt => 'Published At'; + + @override + String get actions => 'Actions'; + + @override + String get unknown => 'Unknown'; + + @override + String get loadingCategories => 'Loading Categories'; + + @override + String get noCategoriesFound => 'No categories found.'; + + @override + String get categoryName => 'Name'; + + @override + String get description => 'Description'; + + @override + String get notAvailable => 'N/A'; + + @override + String get loadingSources => 'Loading Sources'; + + @override + String get noSourcesFound => 'No sources found.'; + + @override + String get sourceName => 'Name'; + + @override + String get sourceType => 'Type'; + + @override + String get language => 'Language'; + + @override + String get editCategory => 'Edit Category'; + + @override + String get saveChanges => 'Save Changes'; + + @override + String get loadingCategory => 'Loading Category'; + + @override + String get iconUrl => 'Icon URL'; + + @override + String get categoryUpdatedSuccessfully => 'Category updated successfully.'; + + @override + String get cannotUpdateCategoryError => + 'Cannot update: Original category data not loaded.'; + + @override + String get editSource => 'Edit Source'; + + @override + String get sourceUpdatedSuccessfully => 'Source updated successfully.'; + + @override + String get loadingSource => 'Loading Source...'; + + @override + String get sourceUrl => 'URL'; + + @override + String get headquarters => 'Headquarters'; + + @override + String get none => 'None'; + + @override + String get cannotUpdateSourceError => + 'Cannot update: Original source data not loaded.'; + + @override + String get sourceTypeNewsAgency => 'News Agency'; + + @override + String get sourceTypeLocalNewsOutlet => 'Local News Outlet'; + + @override + String get sourceTypeNationalNewsOutlet => 'National News Outlet'; + + @override + String get sourceTypeInternationalNewsOutlet => 'International News Outlet'; + + @override + String get sourceTypeSpecializedPublisher => 'Specialized Publisher'; + + @override + String get sourceTypeBlog => 'Blog'; + + @override + String get sourceTypeGovernmentSource => 'Government Source'; + + @override + String get sourceTypeAggregator => 'Aggregator'; + + @override + String get sourceTypeOther => 'Other'; + + @override + String get editHeadline => 'Edit Headline'; + + @override + String get headlineUpdatedSuccessfully => 'Headline updated successfully.'; + + @override + String get loadingHeadline => 'Loading Headline...'; + + @override + String get imageUrl => 'Image URL'; + + @override + String get cannotUpdateHeadlineError => + 'Cannot update: Original headline data not loaded.'; + + @override + String get createHeadline => 'Create Headline'; + + @override + String get headlineCreatedSuccessfully => 'Headline created successfully.'; + + @override + String get loadingData => 'Loading data...'; } diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index a704a88..e626e8d 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -608,5 +608,197 @@ "@settingsPageDescription": { "description": "وصف صفحة الإعدادات الرئيسية" }, - "appearanceSettingsDescription": "اضبط الخصائص المرئية للوحة القيادة، بما في ذلك السمة وألوان التمييز والأنماط الطباعية." -} + "appearanceSettingsDescription": "اضبط الخصائص المرئية للوحة القيادة، بما في ذلك السمة وألوان التمييز والأنماط الطباعية.", + "@appearanceSettingsDescription": { + "description": "وصف تبويب إعدادات المظهر" + }, + "loadingHeadlines": "جاري تحميل العناوين الرئيسية", + "@loadingHeadlines": { + "description": "عنوان حالة تحميل العناوين الرئيسية" + }, + "pleaseWait": "الرجاء الانتظار...", + "@pleaseWait": { + "description": "عنوان فرعي لحالة التحميل" + }, + "noHeadlinesFound": "لم يتم العثور على عناوين رئيسية.", + "@noHeadlinesFound": { + "description": "رسالة عند عدم العثور على عناوين رئيسية" + }, + "headlineTitle": "العنوان", + "@headlineTitle": { + "description": "رأس العمود لعنوان الخبر" + }, + "publishedAt": "تاريخ النشر", + "@publishedAt": { + "description": "رأس العمود لتاريخ النشر" + }, + "actions": "الإجراءات", + "@actions": { + "description": "رأس العمود للإجراءات" + }, + "unknown": "غير معروف", + "@unknown": { + "description": "نص احتياطي للقيم غير المعروفة" + }, + "loadingCategories": "جاري تحميل الفئات", + "@loadingCategories": { + "description": "عنوان حالة تحميل الفئات" + }, + "noCategoriesFound": "لم يتم العثور على فئات.", + "@noCategoriesFound": { + "description": "رسالة عند عدم العثور على فئات" + }, + "categoryName": "الاسم", + "@categoryName": { + "description": "رأس العمود لاسم الفئة" + }, + "description": "الوصف", + "@description": { + "description": "رأس العمود للوصف" + }, + "notAvailable": "غير متاح", + "@notAvailable": { + "description": "نص قصير لـ 'غير متاح'" + }, + "loadingSources": "جاري تحميل المصادر", + "@loadingSources": { + "description": "عنوان حالة تحميل المصادر" + }, + "noSourcesFound": "لم يتم العثور على مصادر.", + "@noSourcesFound": { + "description": "رسالة عند عدم العثور على مصادر" + }, + "sourceName": "الاسم", + "@sourceName": { + "description": "رأس العمود لاسم المصدر" + }, + "sourceType": "النوع", + "@sourceType": { + "description": "رأس العمود لنوع المصدر" + }, + "language": "اللغة", + "@language": { + "description": "رأس العمود للغة" + }, + "editCategory": "تعديل الفئة", + "@editCategory": { + "description": "عنوان صفحة تعديل الفئة" + }, + "saveChanges": "حفظ التغييرات", + "@saveChanges": { + "description": "تلميح لزر حفظ التغييرات" + }, + "loadingCategory": "جاري تحميل الفئة", + "@loadingCategory": { + "description": "رسالة تُعرض أثناء تحميل بيانات الفئة" + }, + "iconUrl": "رابط الأيقونة", + "@iconUrl": { + "description": "تسمية حقل إدخال رابط الأيقونة" + }, + "categoryUpdatedSuccessfully": "تم تحديث الفئة بنجاح.", + "@categoryUpdatedSuccessfully": { + "description": "رسالة تُعرض عند تحديث الفئة بنجاح" + }, + "cannotUpdateCategoryError": "لا يمكن التحديث: لم يتم تحميل بيانات الفئة الأصلية.", + "@cannotUpdateCategoryError": { + "description": "رسالة خطأ عند فشل تحديث الفئة بسبب عدم تحميل البيانات الأصلية" + }, + "editSource": "تعديل المصدر", + "@editSource": { + "description": "عنوان صفحة تعديل المصدر" + }, + "sourceUpdatedSuccessfully": "تم تحديث المصدر بنجاح.", + "@sourceUpdatedSuccessfully": { + "description": "رسالة تُعرض عند تحديث المصدر بنجاح" + }, + "loadingSource": "جاري تحميل المصدر...", + "@loadingSource": { + "description": "رسالة تُعرض أثناء تحميل بيانات المصدر" + }, + "sourceUrl": "الرابط", + "@sourceUrl": { + "description": "تسمية حقل إدخال رابط المصدر" + }, + "headquarters": "المقر الرئيسي", + "@headquarters": { + "description": "تسمية حقل القائمة المنسدلة للمقر الرئيسي" + }, + "none": "لا شيء", + "@none": { + "description": "خيار افتراضي فارغ للقوائم المنسدلة" + }, + "cannotUpdateSourceError": "لا يمكن التحديث: لم يتم تحميل بيانات المصدر الأصلية.", + "@cannotUpdateSourceError": { + "description": "رسالة خطأ عند فشل تحديث المصدر بسبب عدم تحميل البيانات الأصلية" + }, + "sourceTypeNewsAgency": "وكالة أنباء", + "@sourceTypeNewsAgency": { + "description": "وكالة أنباء عالمية (مثل رويترز، أسوشيتد برس)." + }, + "sourceTypeLocalNewsOutlet": "منفذ إخباري محلي", + "@sourceTypeLocalNewsOutlet": { + "description": "منفذ إخباري يركز على منطقة محلية معينة." + }, + "sourceTypeNationalNewsOutlet": "منفذ إخباري وطني", + "@sourceTypeNationalNewsOutlet": { + "description": "منفذ إخباري يركز على بلد معين." + }, + "sourceTypeInternationalNewsOutlet": "منفذ إخباري دولي", + "@sourceTypeInternationalNewsOutlet": { + "description": "منفذ إخباري ذو تركيز دولي واسع." + }, + "sourceTypeSpecializedPublisher": "ناشر متخصص", + "@sourceTypeSpecializedPublisher": { + "description": "ناشر يركز على موضوع معين (مثل التكنولوجيا، الرياضة)." + }, + "sourceTypeBlog": "مدونة", + "@sourceTypeBlog": { + "description": "مدونة أو منشور شخصي." + }, + "sourceTypeGovernmentSource": "مصدر حكومي", + "@sourceTypeGovernmentSource": { + "description": "مصدر حكومي رسمي." + }, + "sourceTypeAggregator": "مجمع", + "@sourceTypeAggregator": { + "description": "خدمة تجمع الأخبار من مصادر أخرى." + }, + "sourceTypeOther": "أخرى", + "@sourceTypeOther": { + "description": "أي نوع آخر من المصادر غير المذكورة أعلاه." + }, + "editHeadline": "تعديل العنوان", + "@editHeadline": { + "description": "عنوان صفحة تعديل العنوان" + }, + "headlineUpdatedSuccessfully": "تم تحديث العنوان بنجاح.", + "@headlineUpdatedSuccessfully": { + "description": "رسالة تُعرض عند تحديث العنوان بنجاح" + }, + "loadingHeadline": "جاري تحميل العنوان...", + "@loadingHeadline": { + "description": "رسالة تُعرض أثناء تحميل بيانات العنوان" + }, + "imageUrl": "رابط الصورة", + "@imageUrl": { + "description": "تسمية حقل إدخال رابط الصورة" + }, + "cannotUpdateHeadlineError": "لا يمكن التحديث: لم يتم تحميل بيانات العنوان الأصلية.", + "@cannotUpdateHeadlineError": { + "description": "رسالة خطأ عند فشل تحديث العنوان بسبب عدم تحميل البيانات الأصلية" + } +, + "createHeadline": "إنشاء عنوان", + "@createHeadline": { + "description": "عنوان صفحة إنشاء عنوان" + }, + "headlineCreatedSuccessfully": "تم إنشاء العنوان بنجاح.", + "@headlineCreatedSuccessfully": { + "description": "رسالة تُعرض عند إنشاء العنوان بنجاح" + }, + "loadingData": "جاري تحميل البيانات...", + "@loadingData": { + "description": "رسالة عامة تُعرض أثناء تحميل البيانات لنموذج" + } +} \ No newline at end of file diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 4d758d7..3491ce0 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -608,5 +608,199 @@ "@settingsPageDescription": { "description": "Description for the main settings page" }, - "appearanceSettingsDescription": "Adjust the visual characteristics of the Dashboard, including theme, accent colors, and typographic styles." + "appearanceSettingsDescription": "Adjust the visual characteristics of the Dashboard, including theme, accent colors, and typographic styles.", + "@appearanceSettingsDescription": { + "description": "Description for the Appearance settings tab" + }, + "loadingHeadlines": "Loading Headlines", + "@loadingHeadlines": { + "description": "Headline for loading state of headlines" + }, + "pleaseWait": "Please wait...", + "@pleaseWait": { + "description": "Subheadline for loading state" + }, + "noHeadlinesFound": "No headlines found.", + "@noHeadlinesFound": { + "description": "Message when no headlines are found" + }, + "headlineTitle": "Title", + "@headlineTitle": { + "description": "Column header for headline title" + }, + "publishedAt": "Published At", + "@publishedAt": { + "description": "Column header for published date" + }, + "actions": "Actions", + "@actions": { + "description": "Column header for actions" + }, + "unknown": "Unknown", + "@unknown": { + "description": "Fallback text for unknown values" + }, + "loadingCategories": "Loading Categories", + "@loadingCategories": { + "description": "Headline for loading state of categories" + }, + "noCategoriesFound": "No categories found.", + "@noCategoriesFound": { + "description": "Message when no categories are found" + }, + "categoryName": "Name", + "@categoryName": { + "description": "Column header for category name" + }, + "description": "Description", + "@description": { + "description": "Column header for description" + }, + "notAvailable": "N/A", + "@notAvailable": { + "description": "Short text for 'not available'" + }, + "loadingSources": "Loading Sources", + "@loadingSources": { + "description": "Headline for loading state of sources" + }, + "noSourcesFound": "No sources found.", + "@noSourcesFound": { + "description": "Message when no sources are found" + }, + "sourceName": "Name", + "@sourceName": { + "description": "Column header for source name" + }, + "sourceType": "Type", + "@sourceType": { + "description": "Column header for source type" + }, + "language": "Language", + "@language": { + "description": "Column header for language" + }, + "editCategory": "Edit Category", + "@editCategory": { + "description": "Title for the Edit Category page" + }, + "saveChanges": "Save Changes", + "@saveChanges": { + "description": "Tooltip for the save changes button" + }, + "loadingCategory": "Loading Category", + "@loadingCategory": { + "description": "Message displayed while loading category data" + }, + "iconUrl": "Icon URL", + "@iconUrl": { + "description": "Label for the icon URL input field" + }, + "categoryUpdatedSuccessfully": "Category updated successfully.", + "@categoryUpdatedSuccessfully": { + "description": "Message displayed when a category is updated successfully" + }, + "cannotUpdateCategoryError": "Cannot update: Original category data not loaded.", + "@cannotUpdateCategoryError": { + "description": "Error message when updating a category fails because the original data wasn't loaded" + } +, + "editSource": "Edit Source", + "@editSource": { + "description": "Title for the Edit Source page" + }, + "sourceUpdatedSuccessfully": "Source updated successfully.", + "@sourceUpdatedSuccessfully": { + "description": "Message displayed when a source is updated successfully" + }, + "loadingSource": "Loading Source...", + "@loadingSource": { + "description": "Message displayed while loading source data" + }, + "sourceUrl": "URL", + "@sourceUrl": { + "description": "Label for the source URL input field" + }, + "headquarters": "Headquarters", + "@headquarters": { + "description": "Label for the headquarters dropdown field" + }, + "none": "None", + "@none": { + "description": "Default null option for dropdowns" + }, + "cannotUpdateSourceError": "Cannot update: Original source data not loaded.", + "@cannotUpdateSourceError": { + "description": "Error message when updating a source fails because the original data wasn't loaded" + }, + "sourceTypeNewsAgency": "News Agency", + "@sourceTypeNewsAgency": { + "description": "A global news agency (e.g., Reuters, Associated Press)." + }, + "sourceTypeLocalNewsOutlet": "Local News Outlet", + "@sourceTypeLocalNewsOutlet": { + "description": "A news outlet focused on a specific local area." + }, + "sourceTypeNationalNewsOutlet": "National News Outlet", + "@sourceTypeNationalNewsOutlet": { + "description": "A news outlet focused on a specific country." + }, + "sourceTypeInternationalNewsOutlet": "International News Outlet", + "@sourceTypeInternationalNewsOutlet": { + "description": "A news outlet with a broad international focus." + }, + "sourceTypeSpecializedPublisher": "Specialized Publisher", + "@sourceTypeSpecializedPublisher": { + "description": "A publisher focused on a specific topic (e.g., technology, sports)." + }, + "sourceTypeBlog": "Blog", + "@sourceTypeBlog": { + "description": "A blog or personal publication." + }, + "sourceTypeGovernmentSource": "Government Source", + "@sourceTypeGovernmentSource": { + "description": "An official government source." + }, + "sourceTypeAggregator": "Aggregator", + "@sourceTypeAggregator": { + "description": "A service that aggregates news from other sources." + }, + "sourceTypeOther": "Other", + "@sourceTypeOther": { + "description": "Any other type of source not covered above." + } +, + "editHeadline": "Edit Headline", + "@editHeadline": { + "description": "Title for the Edit Headline page" + }, + "headlineUpdatedSuccessfully": "Headline updated successfully.", + "@headlineUpdatedSuccessfully": { + "description": "Message displayed when a headline is updated successfully" + }, + "loadingHeadline": "Loading Headline...", + "@loadingHeadline": { + "description": "Message displayed while loading headline data" + }, + "imageUrl": "Image URL", + "@imageUrl": { + "description": "Label for an image URL input field" + }, + "cannotUpdateHeadlineError": "Cannot update: Original headline data not loaded.", + "@cannotUpdateHeadlineError": { + "description": "Error message when updating a headline fails because the original data wasn't loaded" + } +, + "createHeadline": "Create Headline", + "@createHeadline": { + "description": "Title for the Create Headline page" + }, + "headlineCreatedSuccessfully": "Headline created successfully.", + "@headlineCreatedSuccessfully": { + "description": "Message displayed when a headline is created successfully" + }, + "loadingData": "Loading data...", + "@loadingData": { + "description": "Generic message displayed while loading data for a form" + } } diff --git a/lib/router/router.dart b/lib/router/router.dart index d0942b1..eca543c 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -10,14 +10,18 @@ import 'package:ht_dashboard/authentication/bloc/authentication_bloc.dart'; import 'package:ht_dashboard/authentication/view/authentication_page.dart'; import 'package:ht_dashboard/authentication/view/email_code_verification_page.dart'; import 'package:ht_dashboard/authentication/view/request_code_page.dart'; +import 'package:ht_dashboard/content_management/view/create_headline_page.dart'; import 'package:ht_dashboard/content_management/view/categories_page.dart'; +import 'package:ht_dashboard/content_management/view/edit_category_page.dart'; +import 'package:ht_dashboard/content_management/view/edit_source_page.dart'; import 'package:ht_dashboard/content_management/view/content_management_page.dart'; import 'package:ht_dashboard/content_management/view/headlines_page.dart'; import 'package:ht_dashboard/content_management/view/sources_page.dart'; import 'package:ht_dashboard/dashboard/view/dashboard_page.dart'; -import 'package:ht_dashboard/l10n/l10n.dart'; import 'package:ht_dashboard/router/routes.dart'; import 'package:ht_dashboard/settings/view/settings_page.dart'; +import 'package:ht_dashboard/shared/widgets/placeholder_create_page.dart'; +import 'package:ht_dashboard/content_management/view/edit_headline_page.dart'; /// Creates and configures the GoRouter instance for the application. /// @@ -47,7 +51,6 @@ GoRouter createRouter({ const authenticationPath = Routes.authentication; const dashboardPath = Routes.dashboard; final isGoingToAuth = currentLocation.startsWith(authenticationPath); - final isGoingToDashboard = currentLocation.startsWith(dashboardPath); // --- Case 1: Unauthenticated User --- if (appStatus == AppStatus.unauthenticated || @@ -96,7 +99,6 @@ GoRouter createRouter({ path: Routes.authentication, name: Routes.authenticationName, builder: (BuildContext context, GoRouterState state) { - final l10n = context.l10n; const headline = 'Sign In to Dashboard'; const subHeadline = 'Enter your email to get a verification code.'; const showAnonymousButton = false; @@ -161,20 +163,51 @@ GoRouter createRouter({ name: Routes.contentManagementName, builder: (context, state) => const ContentManagementPage(), routes: [ + // The create/edit routes are now direct children of + // content-management, so navigating back will always land on + // the ContentManagementPage with the correct AppBar/TabBar. GoRoute( - path: Routes.headlines, - name: Routes.headlinesName, - builder: (context, state) => const HeadlinesPage(), + path: Routes.createHeadline, + name: Routes.createHeadlineName, + builder: (context, state) => const CreateHeadlinePage(), ), GoRoute( - path: Routes.categories, - name: Routes.categoriesName, - builder: (context, state) => const CategoriesPage(), + path: Routes.editHeadline, + name: Routes.editHeadlineName, + builder: (context, state) { + final id = state.pathParameters['id']!; + return EditHeadlinePage(headlineId: id); + }, ), GoRoute( - path: Routes.sources, - name: Routes.sourcesName, - builder: (context, state) => const SourcesPage(), + path: Routes.createCategory, + name: Routes.createCategoryName, + builder: (context, state) => const PlaceholderCreatePage( + title: 'Create New Category', + ), // Placeholder + ), + GoRoute( + path: Routes.editCategory, + name: Routes.editCategoryName, + builder: (context, state) { + final id = state.pathParameters['id']!; + return EditCategoryPage(categoryId: id); + }, + ), + GoRoute( + path: Routes.createSource, + name: Routes.createSourceName, + builder: (context, state) => const PlaceholderCreatePage( + title: 'Create New Source', + ), // Placeholder + ), + GoRoute( + path: Routes.editSource, + name: Routes.editSourceName, + builder: (context, state) { + final id = state.pathParameters['id']!; + return EditSourcePage(sourceId: id); + }, ), ], ), diff --git a/lib/router/routes.dart b/lib/router/routes.dart index ccfde19..0769631 100644 --- a/lib/router/routes.dart +++ b/lib/router/routes.dart @@ -44,23 +44,41 @@ abstract final class Routes { /// The name for the content management section route. static const String contentManagementName = 'contentManagement'; - /// The path for the headlines page within content management. - static const String headlines = 'headlines'; + /// The path for creating a new headline. + static const String createHeadline = 'create-headline'; - /// The name for the headlines page route. - static const String headlinesName = 'headlines'; + /// The name for the create headline page route. + static const String createHeadlineName = 'createHeadline'; - /// The path for the categories page within content management. - static const String categories = 'categories'; + /// The path for editing an existing headline. + static const String editHeadline = 'edit-headline/:id'; - /// The name for the categories page route. - static const String categoriesName = 'categories'; + /// The name for the edit headline page route. + static const String editHeadlineName = 'editHeadline'; - /// The path for the sources page within content management. - static const String sources = 'sources'; + /// The path for creating a new category. + static const String createCategory = 'create-category'; - /// The name for the sources page route. - static const String sourcesName = 'sources'; + /// The name for the create category page route. + static const String createCategoryName = 'createCategory'; + + /// The path for editing an existing category. + static const String editCategory = 'edit-category/:id'; + + /// The name for the edit category page route. + static const String editCategoryName = 'editCategory'; + + /// The path for creating a new source. + static const String createSource = 'create-source'; + + /// The name for the create source page route. + static const String createSourceName = 'createSource'; + + /// The path for editing an existing source. + static const String editSource = 'edit-source/:id'; + + /// The name for the edit source page route. + static const String editSourceName = 'editSource'; /// The path for the app configuration page. static const String appConfiguration = '/app-configuration'; diff --git a/lib/shared/utils/date_formatter.dart b/lib/shared/utils/date_formatter.dart index 3ac0129..36d85d2 100644 --- a/lib/shared/utils/date_formatter.dart +++ b/lib/shared/utils/date_formatter.dart @@ -1,15 +1,29 @@ import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import 'package:timeago/timeago.dart' as timeago; -/// Formats the given [dateTime] into a relative time string -/// (e.g., "5m ago", "Yesterday", "now"). -/// -/// Uses the current locale from [context] to format appropriately. -/// Returns an empty string if [dateTime] is null. -String formatRelativeTime(BuildContext context, DateTime? dateTime) { - if (dateTime == null) { - return ''; +/// A utility class for formatting dates and times. +abstract final class DateFormatter { + /// Formats the given [dateTime] into a relative time string + /// (e.g., "5m ago", "Yesterday", "now"). + /// + /// Uses the current locale from [context] to format appropriately. + /// Returns an empty string if [dateTime] is null. + static String formatRelativeTime(BuildContext context, DateTime? dateTime) { + if (dateTime == null) { + return ''; + } + final locale = Localizations.localeOf(context).languageCode; + return timeago.format(dateTime, locale: locale); + } + + /// Formats the given [dateTime] into a short date string (e.g., "Jul 1, 2025"). + /// + /// Returns an empty string if [dateTime] is null. + static String formatDate(DateTime? dateTime) { + if (dateTime == null) { + return ''; + } + return DateFormat.yMMMd().format(dateTime); } - final locale = Localizations.localeOf(context).languageCode; - return timeago.format(dateTime, locale: locale); } diff --git a/lib/shared/widgets/placeholder_create_page.dart b/lib/shared/widgets/placeholder_create_page.dart new file mode 100644 index 0000000..333e2cb --- /dev/null +++ b/lib/shared/widgets/placeholder_create_page.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; + +/// {@template placeholder_create_page} +/// A simple placeholder page for content creation forms. +/// {@endtemplate} +class PlaceholderCreatePage extends StatelessWidget { + /// {@macro placeholder_create_page} + const PlaceholderCreatePage({required this.title, super.key}); + + /// The title to display on the page. + final String title; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(title), + ), + body: Center( + child: Text('Form for "$title" will go here.'), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 9e50380..9d67c2f 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.6" + data_table_2: + dependency: "direct main" + description: + name: data_table_2 + sha256: b8dd157e4efe5f2beef092c9952a254b2192cf76a26ad1c6aa8b06c8b9d665da + url: "https://pub.dev" + source: hosted + version: "2.6.0" device_frame: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 884c36c..d37f220 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,6 +9,7 @@ environment: dependencies: bloc: ^9.0.0 bloc_concurrency: ^0.3.0 + data_table_2: ^2.6.0 device_preview: ^1.2.0 equatable: ^2.0.7 flex_color_scheme: ^8.2.0