Skip to content

Feature content management create category #15

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Jul 2, 2025
Merged
Original file line number Diff line number Diff line change
@@ -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<CreateCategoryEvent, CreateCategoryState> {
/// {@macro create_category_bloc}
CreateCategoryBloc({
required HtDataRepository<Category> categoriesRepository,
}) : _categoriesRepository = categoriesRepository,
super(const CreateCategoryState()) {
on<CreateCategoryNameChanged>(_onNameChanged);
on<CreateCategoryDescriptionChanged>(_onDescriptionChanged);
on<CreateCategoryIconUrlChanged>(_onIconUrlChanged);
on<CreateCategorySubmitted>(_onSubmitted);
}

final HtDataRepository<Category> _categoriesRepository;

void _onNameChanged(
CreateCategoryNameChanged event,
Emitter<CreateCategoryState> emit,
) {
emit(
state.copyWith(
name: event.name,
status: CreateCategoryStatus.initial,
),
);
}

void _onDescriptionChanged(
CreateCategoryDescriptionChanged event,
Emitter<CreateCategoryState> emit,
) {
emit(
state.copyWith(
description: event.description,
status: CreateCategoryStatus.initial,
),
);
}

void _onIconUrlChanged(
CreateCategoryIconUrlChanged event,
Emitter<CreateCategoryState> emit,
) {
emit(
state.copyWith(
iconUrl: event.iconUrl,
status: CreateCategoryStatus.initial,
),
);
}

Future<void> _onSubmitted(
CreateCategorySubmitted event,
Emitter<CreateCategoryState> 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(),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using e.toString() for user-facing error messages can expose sensitive information. Return a generic, user-friendly message instead, and log the original error and stack trace for debugging.

          errorMessage: 'An unexpected error occurred.',

),
);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Object> 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<Object> 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<Object> 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<Object> get props => [iconUrl];
}

/// Event to signal that the form should be submitted.
final class CreateCategorySubmitted extends CreateCategoryEvent {
const CreateCategorySubmitted();
}
Original file line number Diff line number Diff line change
@@ -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,
);
}
Comment on lines +39 to +53

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation of copyWith for the errorMessage field (errorMessage: errorMessage) implicitly clears the error message if it's not provided in a call. A more explicit approach is to use a function wrapper for nullable fields. This makes the intent to set, clear, or preserve the value unambiguous.

  CreateCategoryState copyWith({
    CreateCategoryStatus? status,
    String? name,
    String? description,
    String? iconUrl,
    String? Function()? errorMessage,
  }) {
    return CreateCategoryState(
      status: status ?? this.status,
      name: name ?? this.name,
      description: description ?? this.description,
      iconUrl: iconUrl ?? this.iconUrl,
      errorMessage: errorMessage != null ? errorMessage() : this.errorMessage,
    );
  }


@override
List<Object?> get props => [status, name, description, iconUrl, errorMessage];
}
151 changes: 151 additions & 0 deletions lib/content_management/view/create_category_page.dart
Original file line number Diff line number Diff line change
@@ -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<HtDataRepository<Category>>(),
),
child: const _CreateCategoryView(),
);
}
}

class _CreateCategoryView extends StatefulWidget {
const _CreateCategoryView();

@override
State<_CreateCategoryView> createState() => _CreateCategoryViewState();
}

class _CreateCategoryViewState extends State<_CreateCategoryView> {
final _formKey = GlobalKey<FormState>();

@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Scaffold(
appBar: AppBar(
title: Text(l10n.createCategory),
actions: [
BlocBuilder<CreateCategoryBloc, CreateCategoryState>(
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<CreateCategoryBloc>().add(
const CreateCategorySubmitted(),
)
: null,
);
},
),
],
),
body: BlocConsumer<CreateCategoryBloc, CreateCategoryState>(
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<ContentManagementBloc>().add(
const LoadCategoriesRequested(),
);
context.pop();
}
if (state.status == CreateCategoryStatus.failure) {
Comment on lines +88 to +89

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using an else if for the second check would be slightly more efficient and clearly express the mutually exclusive nature of these conditions.

          } else 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<CreateCategoryBloc>()
.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<CreateCategoryBloc>()
.add(CreateCategoryDescriptionChanged(value)),
),
const SizedBox(height: AppSpacing.lg),
TextFormField(
initialValue: state.iconUrl,
decoration: InputDecoration(
labelText: l10n.iconUrl,
border: const OutlineInputBorder(),
),
onChanged: (value) => context
.read<CreateCategoryBloc>()
.add(CreateCategoryIconUrlChanged(value)),
),
],
),
),
),
);
},
),
);
}
}
16 changes: 14 additions & 2 deletions lib/l10n/app_localizations.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 7 additions & 1 deletion lib/l10n/app_localizations_ar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -544,7 +544,7 @@ class AppLocalizationsAr extends AppLocalizations {
String get noCategoriesFound => 'لم يتم العثور على فئات.';

@override
String get categoryName => 'الاسم';
String get categoryName => 'اسم الفئة';

@override
String get description => 'الوصف';
Expand Down Expand Up @@ -586,6 +586,12 @@ class AppLocalizationsAr extends AppLocalizations {
String get cannotUpdateCategoryError =>
'لا يمكن التحديث: لم يتم تحميل بيانات الفئة الأصلية.';

@override
String get createCategory => 'إنشاء فئة';

@override
String get categoryCreatedSuccessfully => 'تم إنشاء الفئة بنجاح.';

@override
String get editSource => 'تعديل المصدر';

Expand Down
Loading
Loading