From bf53ae4b99487f0bb4dd392f77f1ba054d13d5c5 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 07:05:57 +0100 Subject: [PATCH 01/48] feat: add data_table_2 dependency - Added data_table_2 package - For enhanced table rendering --- pubspec.lock | 8 ++++++++ pubspec.yaml | 1 + 2 files changed, 9 insertions(+) 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 From 50e694ea2d997b28c70f6e4a074d615ab2abb0c7 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 07:06:07 +0100 Subject: [PATCH 02/48] feat: add content management events - Added events for headlines - Added events for categories - Added events for sources --- .../bloc/content_management_event.dart | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 lib/content_management/bloc/content_management_event.dart 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]; +} From 5d12be54e1a445e824a17fbc10b52c7c1a3aff19 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 07:06:15 +0100 Subject: [PATCH 03/48] feat: add ContentManagementState - Define content management state - Add status, data, and cursors - Implement copyWith method --- .../bloc/content_management_state.dart | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 lib/content_management/bloc/content_management_state.dart 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, + ]; +} From 84a311d8eb6563db6294183096bc83768088ff5f Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 07:06:24 +0100 Subject: [PATCH 04/48] feat: Implement Content Management BLoC - Manages Headlines, Categories, Sources - Implements CRUD operations - Implements pagination - Handles loading states --- .../bloc/content_management_bloc.dart | 395 ++++++++++++++++++ 1 file changed, 395 insertions(+) create mode 100644 lib/content_management/bloc/content_management_bloc.dart 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(), + ), + ); + } + } +} From f15462310d6b28f9e753feb1058c74a8c27930a9 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 07:06:34 +0100 Subject: [PATCH 05/48] feat(content): content management page - Added tab controller - Added bloc listener - Added floating action button --- .../view/content_management_page.dart | 110 ++++++++++++------ 1 file changed, 72 insertions(+), 38 deletions(-) diff --git a/lib/content_management/view/content_management_page.dart b/lib/content_management/view/content_management_page.dart index c52f67d..7b31763 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,89 @@ class _ContentManagementPageState extends State void initState() { super.initState(); _tabController = TabController(length: 3, vsync: this); + _tabController.addListener(_onTabChanged); } @override void dispose() { + _tabController.removeListener(_onTabChanged); _tabController.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), + ], + ), + ], + ), ), ), - ), - body: TabBarView( - controller: _tabController, - children: const [ - HeadlinesPage(), - CategoriesPage(), - SourcesPage(), - ], + body: TabBarView( + controller: _tabController, + children: const [ + HeadlinesPage(), + CategoriesPage(), + SourcesPage(), + ], + ), + floatingActionButton: FloatingActionButton( + 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); + } + }, + child: const Icon(Icons.add), + ), ), ); } From fe09c634b322d771d12412ae4e335e1c2d3d2c65 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 07:06:42 +0100 Subject: [PATCH 06/48] feat: display and manage categories in table - Implemented data table for categories - Added loading and failure states - Implemented delete functionality - Implemented edit functionality --- .../view/categories_page.dart | 166 +++++++++++++++++- 1 file changed, 162 insertions(+), 4 deletions(-) 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; +} From b5687c61ce1f79d9b34786f0abb23182b152565b Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 07:06:50 +0100 Subject: [PATCH 07/48] feat(content): add headlines page with data table - Implemented headline listing - Added edit and delete actions - Integrated pagination --- .../view/headlines_page.dart | 178 +++++++++++++++++- 1 file changed, 174 insertions(+), 4 deletions(-) diff --git a/lib/content_management/view/headlines_page.dart b/lib/content_management/view/headlines_page.dart index 851a966..f55b3e2 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.source), + 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; +} From 173e2e63ce457b3b1b754b6141bcb73b6972c3bc Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 07:06:58 +0100 Subject: [PATCH 08/48] feat: display and manage sources in a table - Implemented data table for sources - Added loading and failure states - Implemented pagination - Added edit and delete actions --- lib/content_management/view/sources_page.dart | 170 +++++++++++++++++- 1 file changed, 166 insertions(+), 4 deletions(-) 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; +} From aec0347fa37f1f93ca4f688dc99ba34be952bee8 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 07:07:12 +0100 Subject: [PATCH 09/48] feat: Add new localization keys for Arabic - Added keys for headlines - Added keys for categories - Added keys for sources --- lib/l10n/arb/app_ar.arb | 70 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index a704a88..55508e3 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -608,5 +608,73 @@ "@settingsPageDescription": { "description": "وصف صفحة الإعدادات الرئيسية" }, - "appearanceSettingsDescription": "اضبط الخصائص المرئية للوحة القيادة، بما في ذلك السمة وألوان التمييز والأنماط الطباعية." + "appearanceSettingsDescription": "اضبط الخصائص المرئية للوحة القيادة، بما في ذلك السمة وألوان التمييز والأنماط الطباعية.", + "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": "رأس العمود للغة" + } } From 7b778492af7ec78f52631446c27a337c84f87a64 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 07:07:25 +0100 Subject: [PATCH 10/48] feat: add internationalization strings - Added loading states strings - Added table headers strings - Added no data found strings --- lib/l10n/app_localizations.dart | 102 +++++++++++++++++++++++++++++ lib/l10n/app_localizations_ar.dart | 51 +++++++++++++++ lib/l10n/app_localizations_en.dart | 51 +++++++++++++++ lib/l10n/arb/app_en.arb | 70 +++++++++++++++++++- 4 files changed, 273 insertions(+), 1 deletion(-) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 6bbc9d0..081185d 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -979,6 +979,108 @@ abstract class AppLocalizations { /// 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; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index 8c587ef..83daa0f 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -515,4 +515,55 @@ 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 => 'اللغة'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 3b474c6..108853a 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -513,4 +513,55 @@ 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'; } diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 4d758d7..e951c33 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -608,5 +608,73 @@ "@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.", + "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" + } } From ae36a8ba261d46b4d7537c1fc4ebed48eee4cddf Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 07:07:37 +0100 Subject: [PATCH 11/48] feat(router): add nested routes for create/edit - Added create headline route - Added create/edit category routes - Added create/edit source routes --- lib/router/router.dart | 51 ++++++++++++++++++++++++++++++++++++++++++ lib/router/routes.dart | 30 +++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/lib/router/router.dart b/lib/router/router.dart index d0942b1..04bdb99 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -18,6 +18,7 @@ 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'; /// Creates and configures the GoRouter instance for the application. /// @@ -165,16 +166,66 @@ GoRouter createRouter({ path: Routes.headlines, name: Routes.headlinesName, builder: (context, state) => const HeadlinesPage(), + routes: [ + GoRoute( + path: Routes.createHeadline, + name: Routes.createHeadlineName, + builder: (context, state) => + const PlaceholderCreatePage( + title: 'Create New Headline', + ), // Placeholder + ), + ], ), GoRoute( path: Routes.categories, name: Routes.categoriesName, builder: (context, state) => const CategoriesPage(), + routes: [ + GoRoute( + 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 PlaceholderCreatePage( + title: 'Edit Category $id', + ); // Placeholder + }, + ), + ], ), GoRoute( path: Routes.sources, name: Routes.sourcesName, builder: (context, state) => const SourcesPage(), + routes: [ + 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 PlaceholderCreatePage( + title: 'Edit Source $id', + ); // Placeholder + }, + ), + ], ), ], ), diff --git a/lib/router/routes.dart b/lib/router/routes.dart index ccfde19..22509d8 100644 --- a/lib/router/routes.dart +++ b/lib/router/routes.dart @@ -50,18 +50,48 @@ abstract final class Routes { /// The name for the headlines page route. static const String headlinesName = 'headlines'; + /// The path for creating a new headline. + static const String createHeadline = 'create-headline'; + + /// 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 name for the categories page route. static const String categoriesName = 'categories'; + /// The path for creating a new category. + static const String createCategory = 'create-category'; + + /// 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 the sources page within content management. static const String sources = 'sources'; /// The name for the sources page route. static const String sourcesName = 'sources'; + /// 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'; From 826cda43fc8eedcf79c28b9e86bde95492be85e5 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 07:07:44 +0100 Subject: [PATCH 12/48] refactor: Date formatting utility class - Added formatDate method - Encapsulated logic in class --- lib/shared/utils/date_formatter.dart | 34 ++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 10 deletions(-) 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); } From a1326345eda222d39fda7c3e6be535c71dbd0901 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 07:07:49 +0100 Subject: [PATCH 13/48] feat: add PlaceholderCreatePage widget --- .../widgets/placeholder_create_page.dart | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 lib/shared/widgets/placeholder_create_page.dart 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.'), + ), + ); + } +} From 8207aa62b72f0a04fce37bf196a907237ac462ec Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 07:10:03 +0100 Subject: [PATCH 14/48] refactor(headlines): use sourceName localization key --- lib/content_management/view/headlines_page.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/content_management/view/headlines_page.dart b/lib/content_management/view/headlines_page.dart index f55b3e2..51d0339 100644 --- a/lib/content_management/view/headlines_page.dart +++ b/lib/content_management/view/headlines_page.dart @@ -72,7 +72,7 @@ class _HeadlinesPageState extends State { size: ColumnSize.L, ), DataColumn2( - label: Text(l10n.source), + label: Text(l10n.sourceName), size: ColumnSize.M, ), DataColumn2( From c31042574cb2d129ac8fd4e1baf4a8ab82b8af18 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 07:20:46 +0100 Subject: [PATCH 15/48] lint: misc --- analysis_options.yaml | 2 ++ .../view/app_configuration_page.dart | 2 +- .../view/content_management_page.dart | 24 ++++++++++++------- 3 files changed, 18 insertions(+), 10 deletions(-) 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_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/view/content_management_page.dart b/lib/content_management/view/content_management_page.dart index 7b31763..e76c1fd 100644 --- a/lib/content_management/view/content_management_page.dart +++ b/lib/content_management/view/content_management_page.dart @@ -33,15 +33,18 @@ class _ContentManagementPageState extends State @override void dispose() { - _tabController.removeListener(_onTabChanged); - _tabController.dispose(); + _tabController + ..removeListener(_onTabChanged) + ..dispose(); super.dispose(); } void _onTabChanged() { if (!_tabController.indexIsChanging) { final tab = ContentManagementTab.values[_tabController.index]; - context.read().add(ContentManagementTabChanged(tab)); + context.read().add( + ContentManagementTabChanged(tab), + ); } } @@ -56,8 +59,9 @@ class _ContentManagementPageState extends State appBar: AppBar( title: Text(l10n.contentManagement), bottom: PreferredSize( - preferredSize: - const Size.fromHeight(kTextTabBarHeight + AppSpacing.lg), + preferredSize: const Size.fromHeight( + kTextTabBarHeight + AppSpacing.lg, + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -70,8 +74,8 @@ class _ContentManagementPageState extends State child: Text( l10n.contentManagementPageDescription, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), ), ), TabBar( @@ -98,8 +102,10 @@ class _ContentManagementPageState extends State ), floatingActionButton: FloatingActionButton( onPressed: () { - final currentTab = - context.read().state.activeTab; + final currentTab = context + .read() + .state + .activeTab; switch (currentTab) { case ContentManagementTab.headlines: context.goNamed(Routes.createHeadlineName); From faaee8074764e3f9e0f5d02bd7051c3e6f326714 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 07:26:23 +0100 Subject: [PATCH 16/48] feat(router): add edit headline route - Added edit headline route - Added edit headline name --- lib/router/router.dart | 12 ++++++++++-- lib/router/routes.dart | 6 ++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/router/router.dart b/lib/router/router.dart index 04bdb99..8936fe6 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -48,7 +48,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 || @@ -97,7 +96,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; @@ -175,6 +173,16 @@ GoRouter createRouter({ title: 'Create New Headline', ), // Placeholder ), + GoRoute( + path: Routes.editHeadline, + name: Routes.editHeadlineName, + builder: (context, state) { + final id = state.pathParameters['id']!; + return PlaceholderCreatePage( + title: 'Edit Headline $id', + ); // Placeholder + }, + ), ], ), GoRoute( diff --git a/lib/router/routes.dart b/lib/router/routes.dart index 22509d8..4a158c9 100644 --- a/lib/router/routes.dart +++ b/lib/router/routes.dart @@ -56,6 +56,12 @@ abstract final class Routes { /// The name for the create headline page route. static const String createHeadlineName = 'createHeadline'; + /// The path for editing an existing headline. + static const String editHeadline = 'edit-headline/:id'; + + /// The name for the edit headline page route. + static const String editHeadlineName = 'editHeadline'; + /// The path for the categories page within content management. static const String categories = 'categories'; From 3b12ddede21e45261eaedaa64de51bdf7a59b9a7 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 07:26:29 +0100 Subject: [PATCH 17/48] refactor: Remove unused dependencies from AppBloc --- lib/app/bloc/app_bloc.dart | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index 9ffc5d0..d4c0fb3 100644 --- a/lib/app/bloc/app_bloc.dart +++ b/lib/app/bloc/app_bloc.dart @@ -18,8 +18,6 @@ class AppBloc extends Bloc { required local_config.AppEnvironment environment, }) : _authenticationRepository = authenticationRepository, _userAppSettingsRepository = userAppSettingsRepository, - _appConfigRepository = appConfigRepository, - _environment = environment, super( const AppState(), ) { @@ -34,8 +32,6 @@ class AppBloc extends Bloc { final HtAuthRepository _authenticationRepository; final HtDataRepository _userAppSettingsRepository; - final HtDataRepository _appConfigRepository; - final local_config.AppEnvironment _environment; late final StreamSubscription _userSubscription; /// Handles user changes and loads initial settings once user is available. From f668e28d90aa7f55822ada51d2de433075599151 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 07:34:47 +0100 Subject: [PATCH 18/48] liut: misc --- lib/app/bloc/app_bloc.dart | 6 ++++++ lib/router/router.dart | 1 - 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/app/bloc/app_bloc.dart b/lib/app/bloc/app_bloc.dart index d4c0fb3..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'; @@ -18,6 +20,8 @@ class AppBloc extends Bloc { required local_config.AppEnvironment environment, }) : _authenticationRepository = authenticationRepository, _userAppSettingsRepository = userAppSettingsRepository, + _appConfigRepository = appConfigRepository, + _environment = environment, super( const AppState(), ) { @@ -32,6 +36,8 @@ class AppBloc extends Bloc { final HtAuthRepository _authenticationRepository; final HtDataRepository _userAppSettingsRepository; + final HtDataRepository _appConfigRepository; + final local_config.AppEnvironment _environment; late final StreamSubscription _userSubscription; /// Handles user changes and loads initial settings once user is available. diff --git a/lib/router/router.dart b/lib/router/router.dart index 8936fe6..005b71a 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -15,7 +15,6 @@ import 'package:ht_dashboard/content_management/view/content_management_page.dar 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'; From 6720b0839cf0221183a3b0fcab723dafa6cae04b Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 07:53:30 +0100 Subject: [PATCH 19/48] docs: add description for appearance settings - Added description for settings tab --- lib/l10n/app_localizations.dart | 2 +- lib/l10n/arb/app_ar.arb | 3 +++ lib/l10n/arb/app_en.arb | 3 +++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 081185d..99b8f7c 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -974,7 +974,7 @@ 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.'** diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 55508e3..e9d8133 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -609,6 +609,9 @@ "description": "وصف صفحة الإعدادات الرئيسية" }, "appearanceSettingsDescription": "اضبط الخصائص المرئية للوحة القيادة، بما في ذلك السمة وألوان التمييز والأنماط الطباعية.", + "@appearanceSettingsDescription": { + "description": "وصف تبويب إعدادات المظهر" + }, "loadingHeadlines": "جاري تحميل العناوين الرئيسية", "@loadingHeadlines": { "description": "عنوان حالة تحميل العناوين الرئيسية" diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index e951c33..cc1fee1 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -609,6 +609,9 @@ "description": "Description for the main settings page" }, "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" From 75064d44b9a06a92618372c4a178da9154f1aff6 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 08:06:27 +0100 Subject: [PATCH 20/48] fix: fixed the ProviderNotFoundException by adding ContentManagementBloc to the MultiBlocProvider in lib/app/view/app.dart. The application should now correctly provide the ContentManagementBloc to the ContentManagementPage and its sub-pages --- lib/app/view/app.dart | 8 ++++++++ 1 file changed, 8 insertions(+) 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, From f0a8588386a159de061a4745acf3e7d28f3b5dc4 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 18:25:06 +0100 Subject: [PATCH 21/48] refactor(router): flatten content management routes - Simplified routes structure - Improved navigation flow --- lib/router/router.dart | 117 +++++++++++++++++------------------------ lib/router/routes.dart | 18 ------- 2 files changed, 48 insertions(+), 87 deletions(-) diff --git a/lib/router/router.dart b/lib/router/router.dart index 005b71a..d257325 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -159,80 +159,59 @@ 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(), - routes: [ - GoRoute( - path: Routes.createHeadline, - name: Routes.createHeadlineName, - builder: (context, state) => - const PlaceholderCreatePage( - title: 'Create New Headline', - ), // Placeholder - ), - GoRoute( - path: Routes.editHeadline, - name: Routes.editHeadlineName, - builder: (context, state) { - final id = state.pathParameters['id']!; - return PlaceholderCreatePage( - title: 'Edit Headline $id', - ); // Placeholder - }, - ), - ], + path: Routes.createHeadline, + name: Routes.createHeadlineName, + builder: (context, state) => const PlaceholderCreatePage( + title: 'Create New Headline', + ), // Placeholder ), GoRoute( - path: Routes.categories, - name: Routes.categoriesName, - builder: (context, state) => const CategoriesPage(), - routes: [ - GoRoute( - 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 PlaceholderCreatePage( - title: 'Edit Category $id', - ); // Placeholder - }, - ), - ], + path: Routes.editHeadline, + name: Routes.editHeadlineName, + builder: (context, state) { + final id = state.pathParameters['id']!; + return PlaceholderCreatePage( + title: 'Edit Headline $id', + ); // Placeholder + }, ), GoRoute( - path: Routes.sources, - name: Routes.sourcesName, - builder: (context, state) => const SourcesPage(), - routes: [ - 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 PlaceholderCreatePage( - title: 'Edit Source $id', - ); // Placeholder - }, - ), - ], + 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 PlaceholderCreatePage( + title: 'Edit Category $id', + ); // Placeholder + }, + ), + 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 PlaceholderCreatePage( + title: 'Edit Source $id', + ); // Placeholder + }, ), ], ), diff --git a/lib/router/routes.dart b/lib/router/routes.dart index 4a158c9..0769631 100644 --- a/lib/router/routes.dart +++ b/lib/router/routes.dart @@ -44,12 +44,6 @@ 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 name for the headlines page route. - static const String headlinesName = 'headlines'; - /// The path for creating a new headline. static const String createHeadline = 'create-headline'; @@ -62,12 +56,6 @@ abstract final class Routes { /// The name for the edit headline page route. static const String editHeadlineName = 'editHeadline'; - /// The path for the categories page within content management. - static const String categories = 'categories'; - - /// The name for the categories page route. - static const String categoriesName = 'categories'; - /// The path for creating a new category. static const String createCategory = 'create-category'; @@ -80,12 +68,6 @@ abstract final class Routes { /// The name for the edit category page route. static const String editCategoryName = 'editCategory'; - /// The path for the sources page within content management. - static const String sources = 'sources'; - - /// The name for the sources page route. - static const String sourcesName = 'sources'; - /// The path for creating a new source. static const String createSource = 'create-source'; From 99a69be1937099a01a9cf0d1b9ed3b11a6b1707e Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 18:29:25 +0100 Subject: [PATCH 22/48] feat(content): move add button to app bar - Removed FAB - Added add button to appBar - Adjusted spacing --- .../view/content_management_page.dart | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/lib/content_management/view/content_management_page.dart b/lib/content_management/view/content_management_page.dart index e76c1fd..3f9749f 100644 --- a/lib/content_management/view/content_management_page.dart +++ b/lib/content_management/view/content_management_page.dart @@ -91,6 +91,25 @@ class _ContentManagementPageState extends State ], ), ), + 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, @@ -100,23 +119,6 @@ class _ContentManagementPageState extends State SourcesPage(), ], ), - floatingActionButton: FloatingActionButton( - 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); - } - }, - child: const Icon(Icons.add), - ), ), ); } From 0af84e1c09251aa48381b148027e380abe4695dd Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 19:27:18 +0100 Subject: [PATCH 23/48] feat(edit_category): add EditCategory events - Define events for the edit category bloc - Loaded, NameChanged, DescriptionChanged, etc --- .../edit_category/edit_category_event.dart | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 lib/content_management/bloc/edit_category/edit_category_event.dart 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(); +} From 7b4a99a3d51a9b28f1131675122fe915ec932792 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 19:27:25 +0100 Subject: [PATCH 24/48] feat(content_management): Add EditCategoryState - Defines EditCategoryStatus enum - Implements EditCategoryState class - Includes form validation logic --- .../edit_category/edit_category_state.dart | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 lib/content_management/bloc/edit_category/edit_category_state.dart 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]; +} + From 3436a24cf1392a72389759690c053be02f59b6bd Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 19:27:31 +0100 Subject: [PATCH 25/48] feat(content): add edit category bloc - Implemented edit category logic - Added state management with bloc - Handles loading, updates, submission --- .../edit_category/edit_category_bloc.dart | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 lib/content_management/bloc/edit_category/edit_category_bloc.dart 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..7593e03 --- /dev/null +++ b/lib/content_management/bloc/edit_category/edit_category_bloc.dart @@ -0,0 +1,147 @@ +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, + // TODO(l10n): Localize this message. + 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(), + ), + ); + } + } +} From fcdb27367ef6cfe6b7977fd3075479269c23f3c0 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 19:27:45 +0100 Subject: [PATCH 26/48] feat: add edit category page - Implemented edit category UI - Added bloc logic - Integrated with repository --- .../view/edit_category_page.dart | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 lib/content_management/view/edit_category_page.dart 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)), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } +} From 7798dfc201689d56f50f5e2c65791151d6b5b4ad Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 19:28:02 +0100 Subject: [PATCH 27/48] feat(content): Implement edit category page - Added EditCategoryPage - Passed categoryId to the page --- lib/router/router.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/router/router.dart b/lib/router/router.dart index d257325..160a546 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -11,6 +11,7 @@ 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/categories_page.dart'; +import 'package:ht_dashboard/content_management/view/edit_category_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'; @@ -191,9 +192,7 @@ GoRouter createRouter({ name: Routes.editCategoryName, builder: (context, state) { final id = state.pathParameters['id']!; - return PlaceholderCreatePage( - title: 'Edit Category $id', - ); // Placeholder + return EditCategoryPage(categoryId: id); }, ), GoRoute( From 106eee412146d48ee134a872714daa54d24ee0e9 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 19:28:15 +0100 Subject: [PATCH 28/48] feat: Add localization for category editing - Added translations for edit category screen - Included success and error messages - Added labels for icon URL field --- lib/l10n/app_localizations.dart | 36 ++++++++++++++++++++++++++++++ lib/l10n/app_localizations_ar.dart | 19 ++++++++++++++++ lib/l10n/app_localizations_en.dart | 19 ++++++++++++++++ lib/l10n/arb/app_ar.arb | 25 +++++++++++++++++++++ lib/l10n/arb/app_en.arb | 24 ++++++++++++++++++++ 5 files changed, 123 insertions(+) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 99b8f7c..dc7f006 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1081,6 +1081,42 @@ abstract class AppLocalizations { /// 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; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index 83daa0f..97d178f 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -566,4 +566,23 @@ class AppLocalizationsAr extends AppLocalizations { @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 => + 'لا يمكن التحديث: لم يتم تحميل بيانات الفئة الأصلية.'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 108853a..ac941fb 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -564,4 +564,23 @@ class AppLocalizationsEn extends AppLocalizations { @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.'; } diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index e9d8133..dd6c38a 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -680,4 +680,29 @@ "@language": { "description": "رأس العمود للغة" } +, + "editCategory": "تعديل الفئة", + "@editCategory": { + "description": "عنوان صفحة تعديل الفئة" + }, + "saveChanges": "حفظ التغييرات", + "@saveChanges": { + "description": "تلميح لزر حفظ التغييرات" + }, + "loadingCategory": "جاري تحميل الفئة", + "@loadingCategory": { + "description": "رسالة تُعرض أثناء تحميل بيانات الفئة" + }, + "iconUrl": "رابط الأيقونة", + "@iconUrl": { + "description": "تسمية حقل إدخال رابط الأيقونة" + }, + "categoryUpdatedSuccessfully": "تم تحديث الفئة بنجاح.", + "@categoryUpdatedSuccessfully": { + "description": "رسالة تُعرض عند تحديث الفئة بنجاح" + }, + "cannotUpdateCategoryError": "لا يمكن التحديث: لم يتم تحميل بيانات الفئة الأصلية.", + "@cannotUpdateCategoryError": { + "description": "رسالة خطأ عند فشل تحديث الفئة بسبب عدم تحميل البيانات الأصلية" + } } diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index cc1fee1..bf2cc1d 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -679,5 +679,29 @@ "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" } } From 760be5705a3654c1eb33c4127dc55548c1f49069 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 21:35:08 +0100 Subject: [PATCH 29/48] fix(edit_cat): handle missing original data - Prevents update with no original data - Displays error message --- .../bloc/edit_category/edit_category_bloc.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/content_management/bloc/edit_category/edit_category_bloc.dart b/lib/content_management/bloc/edit_category/edit_category_bloc.dart index 7593e03..6266e16 100644 --- a/lib/content_management/bloc/edit_category/edit_category_bloc.dart +++ b/lib/content_management/bloc/edit_category/edit_category_bloc.dart @@ -107,7 +107,6 @@ class EditCategoryBloc extends Bloc { emit( state.copyWith( status: EditCategoryStatus.failure, - // TODO(l10n): Localize this message. errorMessage: 'Cannot update: Original category data not loaded.', ), ); From 5a7aefeece40a747ce5a37d9f76c36d2e8647c30 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 21:35:14 +0100 Subject: [PATCH 30/48] feat(edit_source): add EditSourceEvent - Added events for data changes - Added event for form submission --- .../bloc/edit_source/edit_source_event.dart | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 lib/content_management/bloc/edit_source/edit_source_event.dart 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(); +} From 99f07fcb159a8fda5dbd94f7f832d66976289706 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 21:35:21 +0100 Subject: [PATCH 31/48] feat(content): add EditSourceState - Implemented state management - Added EditSourceStatus enum - Defined state variables --- .../bloc/edit_source/edit_source_state.dart | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 lib/content_management/bloc/edit_source/edit_source_state.dart 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, + ]; +} From 5fac0732ff5f396b4c188643b8fc55c8f9a289ad Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 21:35:28 +0100 Subject: [PATCH 32/48] feat: implement edit source bloc - Manages state of single source editing - Fetches source and countries data - Handles form input changes - Submits updated source data --- .../bloc/edit_source/edit_source_bloc.dart | 218 ++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 lib/content_management/bloc/edit_source/edit_source_bloc.dart 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; + } + } +} From d519424b36782cc73f2da52b7c8a0714d5c1661e Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 21:35:41 +0100 Subject: [PATCH 33/48] feat(content_mgmt): add edit source page - Implemented edit source form - Added bloc for state management - Integrated with data repository --- .../view/edit_source_page.dart | 267 ++++++++++++++++++ 1 file changed, 267 insertions(+) create mode 100644 lib/content_management/view/edit_source_page.dart 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), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } +} From f698167aaa75a3d2eb119b884d2c8a2cb5c7791b Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 21:35:50 +0100 Subject: [PATCH 34/48] feat(router): Add EditSourcePage route - Added route for editing sources - Uses EditSourcePage component --- lib/router/router.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/router/router.dart b/lib/router/router.dart index 160a546..7d1bc3a 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -12,6 +12,7 @@ import 'package:ht_dashboard/authentication/view/email_code_verification_page.da import 'package:ht_dashboard/authentication/view/request_code_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'; @@ -207,9 +208,7 @@ GoRouter createRouter({ name: Routes.editSourceName, builder: (context, state) { final id = state.pathParameters['id']!; - return PlaceholderCreatePage( - title: 'Edit Source $id', - ); // Placeholder + return EditSourcePage(sourceId: id); }, ), ], From 268dcffd492f8d5750a7f222fcec95101dfdf4df Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 21:36:02 +0100 Subject: [PATCH 35/48] feat: add source edit localization - Added source edit strings - Added source type strings - Added headquarters string --- lib/l10n/app_localizations.dart | 96 ++++++++++++++++++++++++++++++ lib/l10n/app_localizations_ar.dart | 49 +++++++++++++++ lib/l10n/app_localizations_en.dart | 49 +++++++++++++++ lib/l10n/arb/app_ar.arb | 65 ++++++++++++++++++++ lib/l10n/arb/app_en.arb | 65 ++++++++++++++++++++ 5 files changed, 324 insertions(+) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index dc7f006..e1aff7f 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1117,6 +1117,102 @@ abstract class AppLocalizations { /// 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; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index 97d178f..53d305b 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -585,4 +585,53 @@ class AppLocalizationsAr extends AppLocalizations { @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 => 'أخرى'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index ac941fb..313d833 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -583,4 +583,53 @@ class AppLocalizationsEn extends AppLocalizations { @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'; } diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index dd6c38a..688585f 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -705,4 +705,69 @@ "@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": "أي نوع آخر من المصادر غير المذكورة أعلاه." + } } diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index bf2cc1d..b49e727 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -704,4 +704,69 @@ "@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." + } } From 6caa1d2085f04d7184e7311c330bec23ece07d14 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 22:06:30 +0100 Subject: [PATCH 36/48] feat(content): add EditHeadlineEvent - Created EditHeadlineEvent class - Added events for data changes - Added submit event --- .../edit_headline/edit_headline_event.dart | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 lib/content_management/bloc/edit_headline/edit_headline_event.dart 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(); +} + From b231dd86d516419e79fa3b766f0fd8c4ae8d88dd Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 22:06:47 +0100 Subject: [PATCH 37/48] feat(content): add EditHeadlineState - Implemented state management - Added status enum - Defined form validation --- .../edit_headline/edit_headline_state.dart | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 lib/content_management/bloc/edit_headline/edit_headline_state.dart 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, + ]; +} From e53d688cda6773700b193ed4609252494c942015 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 22:06:54 +0100 Subject: [PATCH 38/48] feat: Implement EditHeadlineBloc - Manages headline editing state - Handles loading data - Handles form submissions - Implements state updates --- .../edit_headline/edit_headline_bloc.dart | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 lib/content_management/bloc/edit_headline/edit_headline_bloc.dart 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(), + ), + ); + } + } +} From f9ad1051e233851510deee8dd2f51e107e326c60 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 22:07:01 +0100 Subject: [PATCH 39/48] feat: Create edit headline page - Implemented EditHeadlinePage - Added EditHeadlineBloc - Added form to edit headline --- .../view/edit_headline_page.dart | 282 ++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 lib/content_management/view/edit_headline_page.dart 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)), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } +} From 155b234b8303e991580fbe4035caca451622e59b Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 22:07:13 +0100 Subject: [PATCH 40/48] feat(router): connect edit headline page - Connect route to EditHeadlinePage --- lib/router/router.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/router/router.dart b/lib/router/router.dart index 7d1bc3a..32a440f 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -20,6 +20,7 @@ import 'package:ht_dashboard/dashboard/view/dashboard_page.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. /// @@ -176,9 +177,7 @@ GoRouter createRouter({ name: Routes.editHeadlineName, builder: (context, state) { final id = state.pathParameters['id']!; - return PlaceholderCreatePage( - title: 'Edit Headline $id', - ); // Placeholder + return EditHeadlinePage(headlineId: id); }, ), GoRoute( From 254c8258005a18e8a0771127551a4d9d97deeed5 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 22:07:22 +0100 Subject: [PATCH 41/48] feat: add headline localization keys - Added edit headline title - Added success message - Added loading message - Added image URL label - Added update error message --- lib/l10n/arb/app_ar.arb | 28 +++++++++++++++++++++++----- lib/l10n/arb/app_en.arb | 21 +++++++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index 688585f..114bcdd 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -679,8 +679,7 @@ "language": "اللغة", "@language": { "description": "رأس العمود للغة" - } -, + }, "editCategory": "تعديل الفئة", "@editCategory": { "description": "عنوان صفحة تعديل الفئة" @@ -704,8 +703,7 @@ "cannotUpdateCategoryError": "لا يمكن التحديث: لم يتم تحميل بيانات الفئة الأصلية.", "@cannotUpdateCategoryError": { "description": "رسالة خطأ عند فشل تحديث الفئة بسبب عدم تحميل البيانات الأصلية" - } -, + }, "editSource": "تعديل المصدر", "@editSource": { "description": "عنوان صفحة تعديل المصدر" @@ -769,5 +767,25 @@ "sourceTypeOther": "أخرى", "@sourceTypeOther": { "description": "أي نوع آخر من المصادر غير المذكورة أعلاه." + }, + "editHeadline": "تعديل العنوان", + "@editHeadline": { + "description": "عنوان صفحة تعديل العنوان" + }, + "headlineUpdatedSuccessfully": "تم تحديث العنوان بنجاح.", + "@headlineUpdatedSuccessfully": { + "description": "رسالة تُعرض عند تحديث العنوان بنجاح" + }, + "loadingHeadline": "جاري تحميل العنوان...", + "@loadingHeadline": { + "description": "رسالة تُعرض أثناء تحميل بيانات العنوان" + }, + "imageUrl": "رابط الصورة", + "@imageUrl": { + "description": "تسمية حقل إدخال رابط الصورة" + }, + "cannotUpdateHeadlineError": "لا يمكن التحديث: لم يتم تحميل بيانات العنوان الأصلية.", + "@cannotUpdateHeadlineError": { + "description": "رسالة خطأ عند فشل تحديث العنوان بسبب عدم تحميل البيانات الأصلية" } -} +} \ No newline at end of file diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index b49e727..957d068 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -769,4 +769,25 @@ "@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" + } } From 1f86a0dcbfd6c9872e3134b6ee24019b4db6f2fc Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 22:07:30 +0100 Subject: [PATCH 42/48] feat: Add localization for edit headline feature - Added new strings to localizations - Supports 'Edit Headline' screen - Includes success/error messages --- lib/l10n/app_localizations.dart | 30 ++++++++++++++++++++++++++++++ lib/l10n/app_localizations_ar.dart | 16 ++++++++++++++++ lib/l10n/app_localizations_en.dart | 16 ++++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index e1aff7f..e4441f3 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1213,6 +1213,36 @@ abstract class AppLocalizations { /// 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; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index 53d305b..78e7d27 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -634,4 +634,20 @@ class AppLocalizationsAr extends AppLocalizations { @override String get sourceTypeOther => 'أخرى'; + + @override + String get editHeadline => 'تعديل العنوان'; + + @override + String get headlineUpdatedSuccessfully => 'تم تحديث العنوان بنجاح.'; + + @override + String get loadingHeadline => 'جاري تحميل العنوان...'; + + @override + String get imageUrl => 'رابط الصورة'; + + @override + String get cannotUpdateHeadlineError => + 'لا يمكن التحديث: لم يتم تحميل بيانات العنوان الأصلية.'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 313d833..67efd5a 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -632,4 +632,20 @@ class AppLocalizationsEn extends AppLocalizations { @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.'; } From af9351151aedc0a57db7ed90e3672725fe388df6 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 22:27:42 +0100 Subject: [PATCH 43/48] feat(content): create headline events - Added data load event - Added title change event - Added description change event - Added submit event --- .../create_headline_event.dart | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 lib/content_management/bloc/create_headline/create_headline_event.dart 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(); +} + From 2d33a9f78886ecc781110c5e148d4096b2784175 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 22:35:57 +0100 Subject: [PATCH 44/48] feat: Implement CreateHeadlineBloc - Manage headline creation state - Load sources and categories - Handle form input changes - Submit new headline to repository --- .../create_headline/create_headline_bloc.dart | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 lib/content_management/bloc/create_headline/create_headline_bloc.dart 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(), + ), + ); + } + } +} From acc3909dd7b33d2334b08b46b9330987c708a42d Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 22:36:03 +0100 Subject: [PATCH 45/48] feat(headline): create headline state - Added CreateHeadlineStatus enum - Implemented CreateHeadlineState class - Added form validation logic --- .../create_headline_state.dart | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 lib/content_management/bloc/create_headline/create_headline_state.dart 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, + ]; +} + From f59f4bbe1b5ee7f36c009d2721fcc111ac1b62fc Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 22:36:09 +0100 Subject: [PATCH 46/48] feat: add create headline page - Implemented form for new headlines - Added bloc for state management - Handles loading, success, error states --- .../view/create_headline_page.dart | 223 ++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 lib/content_management/view/create_headline_page.dart 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)), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } +} From 7afb88faaa202f7d768119744db0bd689a3fcf07 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 22:36:19 +0100 Subject: [PATCH 47/48] feat(content): implement create headline page --- lib/router/router.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/router/router.dart b/lib/router/router.dart index 32a440f..eca543c 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -10,6 +10,7 @@ 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'; @@ -168,9 +169,7 @@ GoRouter createRouter({ GoRoute( path: Routes.createHeadline, name: Routes.createHeadlineName, - builder: (context, state) => const PlaceholderCreatePage( - title: 'Create New Headline', - ), // Placeholder + builder: (context, state) => const CreateHeadlinePage(), ), GoRoute( path: Routes.editHeadline, From 526c6222f055bca543abf297a2f94429177db221 Mon Sep 17 00:00:00 2001 From: fulleni Date: Tue, 1 Jul 2025 22:36:41 +0100 Subject: [PATCH 48/48] feat: add create headline localization keys - Added createHeadline key - Added success message key - Added loading data key --- lib/l10n/app_localizations.dart | 18 ++++++++++++++++++ lib/l10n/app_localizations_ar.dart | 9 +++++++++ lib/l10n/app_localizations_en.dart | 9 +++++++++ lib/l10n/arb/app_ar.arb | 13 +++++++++++++ lib/l10n/arb/app_en.arb | 13 +++++++++++++ 5 files changed, 62 insertions(+) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index e4441f3..ac43af8 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1243,6 +1243,24 @@ abstract class AppLocalizations { /// 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 78e7d27..81bb685 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -650,4 +650,13 @@ class AppLocalizationsAr extends AppLocalizations { @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 67efd5a..f2d1405 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -648,4 +648,13 @@ class AppLocalizationsEn extends AppLocalizations { @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 114bcdd..e626e8d 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -788,4 +788,17 @@ "@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 957d068..3491ce0 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -790,4 +790,17 @@ "@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" + } }