diff --git a/lib/content_management/bloc/create_category/create_category_bloc.dart b/lib/content_management/bloc/create_category/create_category_bloc.dart new file mode 100644 index 0000000..164e459 --- /dev/null +++ b/lib/content_management/bloc/create_category/create_category_bloc.dart @@ -0,0 +1,93 @@ +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 'create_category_event.dart'; +part 'create_category_state.dart'; + +/// A BLoC to manage the state of creating a new category. +class CreateCategoryBloc + extends Bloc { + /// {@macro create_category_bloc} + CreateCategoryBloc({ + required HtDataRepository categoriesRepository, + }) : _categoriesRepository = categoriesRepository, + super(const CreateCategoryState()) { + on(_onNameChanged); + on(_onDescriptionChanged); + on(_onIconUrlChanged); + on(_onSubmitted); + } + + final HtDataRepository _categoriesRepository; + + void _onNameChanged( + CreateCategoryNameChanged event, + Emitter emit, + ) { + emit( + state.copyWith( + name: event.name, + status: CreateCategoryStatus.initial, + ), + ); + } + + void _onDescriptionChanged( + CreateCategoryDescriptionChanged event, + Emitter emit, + ) { + emit( + state.copyWith( + description: event.description, + status: CreateCategoryStatus.initial, + ), + ); + } + + void _onIconUrlChanged( + CreateCategoryIconUrlChanged event, + Emitter emit, + ) { + emit( + state.copyWith( + iconUrl: event.iconUrl, + status: CreateCategoryStatus.initial, + ), + ); + } + + Future _onSubmitted( + CreateCategorySubmitted event, + Emitter emit, + ) async { + if (!state.isFormValid) return; + + emit(state.copyWith(status: CreateCategoryStatus.submitting)); + try { + final newCategory = Category( + name: state.name, + description: state.description.isNotEmpty ? state.description : null, + iconUrl: state.iconUrl.isNotEmpty ? state.iconUrl : null, + ); + + await _categoriesRepository.create(item: newCategory); + emit(state.copyWith(status: CreateCategoryStatus.success)); + } on HtHttpException catch (e) { + emit( + state.copyWith( + status: CreateCategoryStatus.failure, + errorMessage: e.message, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: CreateCategoryStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } +} diff --git a/lib/content_management/bloc/create_category/create_category_event.dart b/lib/content_management/bloc/create_category/create_category_event.dart new file mode 100644 index 0000000..e159d37 --- /dev/null +++ b/lib/content_management/bloc/create_category/create_category_event.dart @@ -0,0 +1,38 @@ +part of 'create_category_bloc.dart'; + +/// Base class for all events related to the [CreateCategoryBloc]. +sealed class CreateCategoryEvent extends Equatable { + const CreateCategoryEvent(); + + @override + List get props => []; +} + +/// Event for when the category's name is changed. +final class CreateCategoryNameChanged extends CreateCategoryEvent { + const CreateCategoryNameChanged(this.name); + final String name; + @override + List get props => [name]; +} + +/// Event for when the category's description is changed. +final class CreateCategoryDescriptionChanged extends CreateCategoryEvent { + const CreateCategoryDescriptionChanged(this.description); + final String description; + @override + List get props => [description]; +} + +/// Event for when the category's icon URL is changed. +final class CreateCategoryIconUrlChanged extends CreateCategoryEvent { + const CreateCategoryIconUrlChanged(this.iconUrl); + final String iconUrl; + @override + List get props => [iconUrl]; +} + +/// Event to signal that the form should be submitted. +final class CreateCategorySubmitted extends CreateCategoryEvent { + const CreateCategorySubmitted(); +} diff --git a/lib/content_management/bloc/create_category/create_category_state.dart b/lib/content_management/bloc/create_category/create_category_state.dart new file mode 100644 index 0000000..7c4fdee --- /dev/null +++ b/lib/content_management/bloc/create_category/create_category_state.dart @@ -0,0 +1,57 @@ +part of 'create_category_bloc.dart'; + +/// Represents the status of the create category operation. +enum CreateCategoryStatus { + /// Initial state. + initial, + + /// The form is being submitted. + submitting, + + /// The operation completed successfully. + success, + + /// An error occurred. + failure, +} + +/// The state for the [CreateCategoryBloc]. +final class CreateCategoryState extends Equatable { + /// {@macro create_category_state} + const CreateCategoryState({ + this.status = CreateCategoryStatus.initial, + this.name = '', + this.description = '', + this.iconUrl = '', + this.errorMessage, + }); + + final CreateCategoryStatus status; + final String name; + final String description; + final String iconUrl; + final String? errorMessage; + + /// Returns true if the form is valid and can be submitted. + /// Based on the Category model, only the name is required. + bool get isFormValid => name.isNotEmpty; + + CreateCategoryState copyWith({ + CreateCategoryStatus? status, + String? name, + String? description, + String? iconUrl, + String? errorMessage, + }) { + return CreateCategoryState( + status: status ?? this.status, + name: name ?? this.name, + description: description ?? this.description, + iconUrl: iconUrl ?? this.iconUrl, + errorMessage: errorMessage, + ); + } + + @override + List get props => [status, name, description, iconUrl, errorMessage]; +} diff --git a/lib/content_management/view/create_category_page.dart b/lib/content_management/view/create_category_page.dart new file mode 100644 index 0000000..2602094 --- /dev/null +++ b/lib/content_management/view/create_category_page.dart @@ -0,0 +1,151 @@ +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_category/create_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 create_category_page} +/// A page for creating a new category. +/// It uses a [BlocProvider] to create and provide a [CreateCategoryBloc]. +/// {@endtemplate} +class CreateCategoryPage extends StatelessWidget { + /// {@macro create_category_page} + const CreateCategoryPage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => CreateCategoryBloc( + categoriesRepository: context.read>(), + ), + child: const _CreateCategoryView(), + ); + } +} + +class _CreateCategoryView extends StatefulWidget { + const _CreateCategoryView(); + + @override + State<_CreateCategoryView> createState() => _CreateCategoryViewState(); +} + +class _CreateCategoryViewState extends State<_CreateCategoryView> { + final _formKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Scaffold( + appBar: AppBar( + title: Text(l10n.createCategory), + actions: [ + BlocBuilder( + builder: (context, state) { + if (state.status == CreateCategoryStatus.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 CreateCategorySubmitted(), + ) + : null, + ); + }, + ), + ], + ), + body: BlocConsumer( + listenWhen: (previous, current) => previous.status != current.status, + listener: (context, state) { + if (state.status == CreateCategoryStatus.success && + ModalRoute.of(context)!.isCurrent) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(l10n.categoryCreatedSuccessfully), + ), + ); + context.read().add( + const LoadCategoriesRequested(), + ); + context.pop(); + } + if (state.status == CreateCategoryStatus.failure) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(state.errorMessage ?? l10n.unknownError), + backgroundColor: Theme.of(context).colorScheme.error, + ), + ); + } + }, + builder: (context, state) { + return SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.lg), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + initialValue: state.name, + decoration: InputDecoration( + labelText: l10n.categoryName, + border: const OutlineInputBorder(), + ), + onChanged: (value) => context + .read() + .add(CreateCategoryNameChanged(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(CreateCategoryDescriptionChanged(value)), + ), + const SizedBox(height: AppSpacing.lg), + TextFormField( + initialValue: state.iconUrl, + decoration: InputDecoration( + labelText: l10n.iconUrl, + border: const OutlineInputBorder(), + ), + onChanged: (value) => context + .read() + .add(CreateCategoryIconUrlChanged(value)), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index ac43af8..5dc89b5 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1034,10 +1034,10 @@ abstract class AppLocalizations { /// **'No categories found.'** String get noCategoriesFound; - /// Column header for category name + /// Label for the category name field in forms and tables. /// /// In en, this message translates to: - /// **'Name'** + /// **'Category Name'** String get categoryName; /// Column header for description @@ -1118,6 +1118,18 @@ abstract class AppLocalizations { /// **'Cannot update: Original category data not loaded.'** String get cannotUpdateCategoryError; + /// Title for the Create Category page + /// + /// In en, this message translates to: + /// **'Create Category'** + String get createCategory; + + /// Message displayed when a category is created successfully + /// + /// In en, this message translates to: + /// **'Category created successfully.'** + String get categoryCreatedSuccessfully; + /// Title for the Edit Source page /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index 81bb685..db1ebc3 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -544,7 +544,7 @@ class AppLocalizationsAr extends AppLocalizations { String get noCategoriesFound => 'لم يتم العثور على فئات.'; @override - String get categoryName => 'الاسم'; + String get categoryName => 'اسم الفئة'; @override String get description => 'الوصف'; @@ -586,6 +586,12 @@ class AppLocalizationsAr extends AppLocalizations { String get cannotUpdateCategoryError => 'لا يمكن التحديث: لم يتم تحميل بيانات الفئة الأصلية.'; + @override + String get createCategory => 'إنشاء فئة'; + + @override + String get categoryCreatedSuccessfully => 'تم إنشاء الفئة بنجاح.'; + @override String get editSource => 'تعديل المصدر'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index f2d1405..0946ec6 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -542,7 +542,7 @@ class AppLocalizationsEn extends AppLocalizations { String get noCategoriesFound => 'No categories found.'; @override - String get categoryName => 'Name'; + String get categoryName => 'Category Name'; @override String get description => 'Description'; @@ -584,6 +584,12 @@ class AppLocalizationsEn extends AppLocalizations { String get cannotUpdateCategoryError => 'Cannot update: Original category data not loaded.'; + @override + String get createCategory => 'Create Category'; + + @override + String get categoryCreatedSuccessfully => 'Category created successfully.'; + @override String get editSource => 'Edit Source'; diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index e626e8d..ca9504e 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -648,9 +648,9 @@ "@noCategoriesFound": { "description": "رسالة عند عدم العثور على فئات" }, - "categoryName": "الاسم", + "categoryName": "اسم الفئة", "@categoryName": { - "description": "رأس العمود لاسم الفئة" + "description": "تسمية حقل اسم الفئة في النماذج والجداول." }, "description": "الوصف", "@description": { @@ -704,6 +704,14 @@ "@cannotUpdateCategoryError": { "description": "رسالة خطأ عند فشل تحديث الفئة بسبب عدم تحميل البيانات الأصلية" }, + "createCategory": "إنشاء فئة", + "@createCategory": { + "description": "عنوان صفحة إنشاء فئة" + }, + "categoryCreatedSuccessfully": "تم إنشاء الفئة بنجاح.", + "@categoryCreatedSuccessfully": { + "description": "رسالة تُعرض عند إنشاء الفئة بنجاح" + }, "editSource": "تعديل المصدر", "@editSource": { "description": "عنوان صفحة تعديل المصدر" @@ -787,8 +795,7 @@ "cannotUpdateHeadlineError": "لا يمكن التحديث: لم يتم تحميل بيانات العنوان الأصلية.", "@cannotUpdateHeadlineError": { "description": "رسالة خطأ عند فشل تحديث العنوان بسبب عدم تحميل البيانات الأصلية" - } -, + }, "createHeadline": "إنشاء عنوان", "@createHeadline": { "description": "عنوان صفحة إنشاء عنوان" diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 3491ce0..7c1cd76 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -648,9 +648,9 @@ "@noCategoriesFound": { "description": "Message when no categories are found" }, - "categoryName": "Name", + "categoryName": "Category Name", "@categoryName": { - "description": "Column header for category name" + "description": "Label for the category name field in forms and tables." }, "description": "Description", "@description": { @@ -703,8 +703,15 @@ "cannotUpdateCategoryError": "Cannot update: Original category data not loaded.", "@cannotUpdateCategoryError": { "description": "Error message when updating a category fails because the original data wasn't loaded" - } -, + }, + "createCategory": "Create Category", + "@createCategory": { + "description": "Title for the Create Category page" + }, + "categoryCreatedSuccessfully": "Category created successfully.", + "@categoryCreatedSuccessfully": { + "description": "Message displayed when a category is created successfully" + }, "editSource": "Edit Source", "@editSource": { "description": "Title for the Edit Source page" @@ -768,8 +775,7 @@ "sourceTypeOther": "Other", "@sourceTypeOther": { "description": "Any other type of source not covered above." - } -, + }, "editHeadline": "Edit Headline", "@editHeadline": { "description": "Title for the Edit Headline page" @@ -789,8 +795,7 @@ "cannotUpdateHeadlineError": "Cannot update: Original headline data not loaded.", "@cannotUpdateHeadlineError": { "description": "Error message when updating a headline fails because the original data wasn't loaded" - } -, + }, "createHeadline": "Create Headline", "@createHeadline": { "description": "Title for the Create Headline page" @@ -803,4 +808,4 @@ "@loadingData": { "description": "Generic message displayed while loading data for a form" } -} +} \ No newline at end of file diff --git a/lib/router/router.dart b/lib/router/router.dart index eca543c..366f2c2 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -10,18 +10,16 @@ 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/content_management_page.dart'; +import 'package:ht_dashboard/content_management/view/create_category_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_headline_page.dart'; import 'package:ht_dashboard/content_management/view/edit_source_page.dart'; -import 'package:ht_dashboard/content_management/view/content_management_page.dart'; -import 'package:ht_dashboard/content_management/view/headlines_page.dart'; -import 'package:ht_dashboard/content_management/view/sources_page.dart'; import 'package:ht_dashboard/dashboard/view/dashboard_page.dart'; import 'package:ht_dashboard/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. /// @@ -182,9 +180,7 @@ GoRouter createRouter({ GoRoute( path: Routes.createCategory, name: Routes.createCategoryName, - builder: (context, state) => const PlaceholderCreatePage( - title: 'Create New Category', - ), // Placeholder + builder: (context, state) => const CreateCategoryPage(), ), GoRoute( path: Routes.editCategory,