diff --git a/lib/content_management/bloc/create_source/create_source_bloc.dart b/lib/content_management/bloc/create_source/create_source_bloc.dart new file mode 100644 index 0000000..4448de9 --- /dev/null +++ b/lib/content_management/bloc/create_source/create_source_bloc.dart @@ -0,0 +1,141 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; +import 'package:ht_data_repository/ht_data_repository.dart'; +import 'package:ht_shared/ht_shared.dart'; + +part 'create_source_event.dart'; +part 'create_source_state.dart'; + +/// A BLoC to manage the state of creating a new source. +class CreateSourceBloc extends Bloc { + /// {@macro create_source_bloc} + CreateSourceBloc({ + required HtDataRepository sourcesRepository, + required HtDataRepository countriesRepository, + }) : _sourcesRepository = sourcesRepository, + _countriesRepository = countriesRepository, + super(const CreateSourceState()) { + on(_onDataLoaded); + on(_onNameChanged); + on(_onDescriptionChanged); + on(_onUrlChanged); + on(_onSourceTypeChanged); + on(_onLanguageChanged); + on(_onHeadquartersChanged); + on(_onSubmitted); + } + + final HtDataRepository _sourcesRepository; + final HtDataRepository _countriesRepository; + + Future _onDataLoaded( + CreateSourceDataLoaded event, + Emitter emit, + ) async { + emit(state.copyWith(status: CreateSourceStatus.loading)); + try { + final countriesResponse = await _countriesRepository.readAll(); + final countries = (countriesResponse as PaginatedResponse).items; + + emit( + state.copyWith( + status: CreateSourceStatus.initial, + countries: countries, + ), + ); + } on HtHttpException catch (e) { + emit( + state.copyWith( + status: CreateSourceStatus.failure, + errorMessage: e.message, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: CreateSourceStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } + + void _onNameChanged( + CreateSourceNameChanged event, + Emitter emit, + ) { + emit(state.copyWith(name: event.name)); + } + + void _onDescriptionChanged( + CreateSourceDescriptionChanged event, + Emitter emit, + ) { + emit(state.copyWith(description: event.description)); + } + + void _onUrlChanged( + CreateSourceUrlChanged event, + Emitter emit, + ) { + emit(state.copyWith(url: event.url)); + } + + void _onSourceTypeChanged( + CreateSourceTypeChanged event, + Emitter emit, + ) { + emit(state.copyWith(sourceType: () => event.sourceType)); + } + + void _onLanguageChanged( + CreateSourceLanguageChanged event, + Emitter emit, + ) { + emit(state.copyWith(language: event.language)); + } + + void _onHeadquartersChanged( + CreateSourceHeadquartersChanged event, + Emitter emit, + ) { + emit(state.copyWith(headquarters: () => event.headquarters)); + } + + Future _onSubmitted( + CreateSourceSubmitted event, + Emitter emit, + ) async { + if (!state.isFormValid) return; + + emit(state.copyWith(status: CreateSourceStatus.submitting)); + try { + final newSource = Source( + 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.create(item: newSource); + emit(state.copyWith(status: CreateSourceStatus.success)); + } on HtHttpException catch (e) { + emit( + state.copyWith( + status: CreateSourceStatus.failure, + errorMessage: e.message, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: CreateSourceStatus.failure, + errorMessage: e.toString(), + ), + ); + } + } +} diff --git a/lib/content_management/bloc/create_source/create_source_event.dart b/lib/content_management/bloc/create_source/create_source_event.dart new file mode 100644 index 0000000..055ebf7 --- /dev/null +++ b/lib/content_management/bloc/create_source/create_source_event.dart @@ -0,0 +1,67 @@ +part of 'create_source_bloc.dart'; + +/// Base class for all events related to the [CreateSourceBloc]. +sealed class CreateSourceEvent extends Equatable { + const CreateSourceEvent(); + + @override + List get props => []; +} + +/// Event to signal that the data for dropdowns should be loaded. +final class CreateSourceDataLoaded extends CreateSourceEvent { + const CreateSourceDataLoaded(); +} + +/// Event for when the source's name is changed. +final class CreateSourceNameChanged extends CreateSourceEvent { + const CreateSourceNameChanged(this.name); + final String name; + @override + List get props => [name]; +} + +/// Event for when the source's description is changed. +final class CreateSourceDescriptionChanged extends CreateSourceEvent { + const CreateSourceDescriptionChanged(this.description); + final String description; + @override + List get props => [description]; +} + +/// Event for when the source's URL is changed. +final class CreateSourceUrlChanged extends CreateSourceEvent { + const CreateSourceUrlChanged(this.url); + final String url; + @override + List get props => [url]; +} + +/// Event for when the source's type is changed. +final class CreateSourceTypeChanged extends CreateSourceEvent { + const CreateSourceTypeChanged(this.sourceType); + final SourceType? sourceType; + @override + List get props => [sourceType]; +} + +/// Event for when the source's language is changed. +final class CreateSourceLanguageChanged extends CreateSourceEvent { + const CreateSourceLanguageChanged(this.language); + final String language; + @override + List get props => [language]; +} + +/// Event for when the source's headquarters is changed. +final class CreateSourceHeadquartersChanged extends CreateSourceEvent { + const CreateSourceHeadquartersChanged(this.headquarters); + final Country? headquarters; + @override + List get props => [headquarters]; +} + +/// Event to signal that the form should be submitted. +final class CreateSourceSubmitted extends CreateSourceEvent { + const CreateSourceSubmitted(); +} diff --git a/lib/content_management/bloc/create_source/create_source_state.dart b/lib/content_management/bloc/create_source/create_source_state.dart new file mode 100644 index 0000000..469d3ea --- /dev/null +++ b/lib/content_management/bloc/create_source/create_source_state.dart @@ -0,0 +1,85 @@ +part of 'create_source_bloc.dart'; + +/// Represents the status of the create source operation. +enum CreateSourceStatus { + /// 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 [CreateSourceBloc]. +final class CreateSourceState extends Equatable { + /// {@macro create_source_state} + const CreateSourceState({ + this.status = CreateSourceStatus.initial, + this.name = '', + this.description = '', + this.url = '', + this.sourceType, + this.language = '', + this.headquarters, + this.countries = const [], + this.errorMessage, + }); + + final CreateSourceStatus status; + 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; + + CreateSourceState copyWith({ + CreateSourceStatus? status, + String? name, + String? description, + String? url, + ValueGetter? sourceType, + String? language, + ValueGetter? headquarters, + List? countries, + String? errorMessage, + }) { + return CreateSourceState( + status: status ?? this.status, + 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, + ); + } + + @override + List get props => [ + status, + name, + description, + url, + sourceType, + language, + headquarters, + countries, + errorMessage, + ]; +} diff --git a/lib/content_management/view/create_source_page.dart b/lib/content_management/view/create_source_page.dart new file mode 100644 index 0000000..c5c70d6 --- /dev/null +++ b/lib/content_management/view/create_source_page.dart @@ -0,0 +1,220 @@ +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_source/create_source_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 create_source_page} +/// A page for creating a new source. +/// It uses a [BlocProvider] to create and provide a [CreateSourceBloc]. +/// {@endtemplate} +class CreateSourcePage extends StatelessWidget { + /// {@macro create_source_page} + const CreateSourcePage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => CreateSourceBloc( + sourcesRepository: context.read>(), + countriesRepository: context.read>(), + )..add(const CreateSourceDataLoaded()), + child: const _CreateSourceView(), + ); + } +} + +class _CreateSourceView extends StatefulWidget { + const _CreateSourceView(); + + @override + State<_CreateSourceView> createState() => _CreateSourceViewState(); +} + +class _CreateSourceViewState extends State<_CreateSourceView> { + final _formKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Scaffold( + appBar: AppBar( + title: Text(l10n.createSource), + actions: [ + BlocBuilder( + builder: (context, state) { + if (state.status == CreateSourceStatus.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 CreateSourceSubmitted(), + ) + : null, + ); + }, + ), + ], + ), + body: BlocConsumer( + listenWhen: (previous, current) => previous.status != current.status, + listener: (context, state) { + if (state.status == CreateSourceStatus.success && + ModalRoute.of(context)!.isCurrent) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar(content: Text(l10n.sourceCreatedSuccessfully)), + ); + context.read().add( + const LoadSourcesRequested(), + ); + context.pop(); + } + if (state.status == CreateSourceStatus.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 == CreateSourceStatus.loading) { + return LoadingStateWidget( + icon: Icons.source, + headline: l10n.loadingData, + subheadline: l10n.pleaseWait, + ); + } + + if (state.status == CreateSourceStatus.failure && + state.countries.isEmpty) { + return FailureStateWidget( + message: state.errorMessage ?? l10n.unknownError, + onRetry: () => context.read().add( + const CreateSourceDataLoaded(), + ), + ); + } + + 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.sourceName, + border: const OutlineInputBorder(), + ), + onChanged: (value) => context + .read() + .add(CreateSourceNameChanged(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(CreateSourceDescriptionChanged(value)), + ), + const SizedBox(height: AppSpacing.lg), + TextFormField( + initialValue: state.url, + decoration: InputDecoration( + labelText: l10n.sourceUrl, + border: const OutlineInputBorder(), + ), + onChanged: (value) => context + .read() + .add(CreateSourceUrlChanged(value)), + ), + const SizedBox(height: AppSpacing.lg), + TextFormField( + initialValue: state.language, + decoration: InputDecoration( + labelText: l10n.language, + border: const OutlineInputBorder(), + ), + onChanged: (value) => context + .read() + .add(CreateSourceLanguageChanged(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(CreateSourceTypeChanged(value)), + ), + const SizedBox(height: AppSpacing.lg), + DropdownButtonFormField( + value: state.headquarters, + 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(CreateSourceHeadquartersChanged(value)), + ), + ], + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 5dc89b5..ed2ae68 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1273,6 +1273,18 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Loading data...'** String get loadingData; + + /// Title for the Create Source page + /// + /// In en, this message translates to: + /// **'Create Source'** + String get createSource; + + /// Message displayed when a source is created successfully + /// + /// In en, this message translates to: + /// **'Source created successfully.'** + String get sourceCreatedSuccessfully; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index db1ebc3..6509b2e 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -665,4 +665,10 @@ class AppLocalizationsAr extends AppLocalizations { @override String get loadingData => 'جاري تحميل البيانات...'; + + @override + String get createSource => 'إنشاء مصدر'; + + @override + String get sourceCreatedSuccessfully => 'تم إنشاء المصدر بنجاح.'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 0946ec6..f4767b1 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -663,4 +663,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get loadingData => 'Loading data...'; + + @override + String get createSource => 'Create Source'; + + @override + String get sourceCreatedSuccessfully => 'Source created successfully.'; } diff --git a/lib/l10n/arb/app_ar.arb b/lib/l10n/arb/app_ar.arb index ca9504e..d00ab8d 100644 --- a/lib/l10n/arb/app_ar.arb +++ b/lib/l10n/arb/app_ar.arb @@ -807,5 +807,13 @@ "loadingData": "جاري تحميل البيانات...", "@loadingData": { "description": "رسالة عامة تُعرض أثناء تحميل البيانات لنموذج" + }, + "createSource": "إنشاء مصدر", + "@createSource": { + "description": "عنوان صفحة إنشاء مصدر" + }, + "sourceCreatedSuccessfully": "تم إنشاء المصدر بنجاح.", + "@sourceCreatedSuccessfully": { + "description": "رسالة تُعرض عند إنشاء المصدر بنجاح" } } \ No newline at end of file diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 7c1cd76..14b6501 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -807,5 +807,13 @@ "loadingData": "Loading data...", "@loadingData": { "description": "Generic message displayed while loading data for a form" + }, + "createSource": "Create Source", + "@createSource": { + "description": "Title for the Create Source page" + }, + "sourceCreatedSuccessfully": "Source created successfully.", + "@sourceCreatedSuccessfully": { + "description": "Message displayed when a source is created successfully" } } \ No newline at end of file diff --git a/lib/router/router.dart b/lib/router/router.dart index 366f2c2..e00fe2b 100644 --- a/lib/router/router.dart +++ b/lib/router/router.dart @@ -13,13 +13,13 @@ 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/create_source_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/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'; /// Creates and configures the GoRouter instance for the application. /// @@ -193,9 +193,7 @@ GoRouter createRouter({ GoRoute( path: Routes.createSource, name: Routes.createSourceName, - builder: (context, state) => const PlaceholderCreatePage( - title: 'Create New Source', - ), // Placeholder + builder: (context, state) => const CreateSourcePage(), ), GoRoute( path: Routes.editSource,