Skip to content

Commit 27e4d58

Browse files
authored
Merge pull request #15 from headlines-toolkit/feature_content_management_create_category
Feature content management create category
2 parents d083f59 + 24db001 commit 27e4d58

File tree

10 files changed

+396
-25
lines changed

10 files changed

+396
-25
lines changed
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import 'package:bloc/bloc.dart';
2+
import 'package:equatable/equatable.dart';
3+
import 'package:ht_data_repository/ht_data_repository.dart';
4+
import 'package:ht_shared/ht_shared.dart';
5+
6+
part 'create_category_event.dart';
7+
part 'create_category_state.dart';
8+
9+
/// A BLoC to manage the state of creating a new category.
10+
class CreateCategoryBloc
11+
extends Bloc<CreateCategoryEvent, CreateCategoryState> {
12+
/// {@macro create_category_bloc}
13+
CreateCategoryBloc({
14+
required HtDataRepository<Category> categoriesRepository,
15+
}) : _categoriesRepository = categoriesRepository,
16+
super(const CreateCategoryState()) {
17+
on<CreateCategoryNameChanged>(_onNameChanged);
18+
on<CreateCategoryDescriptionChanged>(_onDescriptionChanged);
19+
on<CreateCategoryIconUrlChanged>(_onIconUrlChanged);
20+
on<CreateCategorySubmitted>(_onSubmitted);
21+
}
22+
23+
final HtDataRepository<Category> _categoriesRepository;
24+
25+
void _onNameChanged(
26+
CreateCategoryNameChanged event,
27+
Emitter<CreateCategoryState> emit,
28+
) {
29+
emit(
30+
state.copyWith(
31+
name: event.name,
32+
status: CreateCategoryStatus.initial,
33+
),
34+
);
35+
}
36+
37+
void _onDescriptionChanged(
38+
CreateCategoryDescriptionChanged event,
39+
Emitter<CreateCategoryState> emit,
40+
) {
41+
emit(
42+
state.copyWith(
43+
description: event.description,
44+
status: CreateCategoryStatus.initial,
45+
),
46+
);
47+
}
48+
49+
void _onIconUrlChanged(
50+
CreateCategoryIconUrlChanged event,
51+
Emitter<CreateCategoryState> emit,
52+
) {
53+
emit(
54+
state.copyWith(
55+
iconUrl: event.iconUrl,
56+
status: CreateCategoryStatus.initial,
57+
),
58+
);
59+
}
60+
61+
Future<void> _onSubmitted(
62+
CreateCategorySubmitted event,
63+
Emitter<CreateCategoryState> emit,
64+
) async {
65+
if (!state.isFormValid) return;
66+
67+
emit(state.copyWith(status: CreateCategoryStatus.submitting));
68+
try {
69+
final newCategory = Category(
70+
name: state.name,
71+
description: state.description.isNotEmpty ? state.description : null,
72+
iconUrl: state.iconUrl.isNotEmpty ? state.iconUrl : null,
73+
);
74+
75+
await _categoriesRepository.create(item: newCategory);
76+
emit(state.copyWith(status: CreateCategoryStatus.success));
77+
} on HtHttpException catch (e) {
78+
emit(
79+
state.copyWith(
80+
status: CreateCategoryStatus.failure,
81+
errorMessage: e.message,
82+
),
83+
);
84+
} catch (e) {
85+
emit(
86+
state.copyWith(
87+
status: CreateCategoryStatus.failure,
88+
errorMessage: e.toString(),
89+
),
90+
);
91+
}
92+
}
93+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
part of 'create_category_bloc.dart';
2+
3+
/// Base class for all events related to the [CreateCategoryBloc].
4+
sealed class CreateCategoryEvent extends Equatable {
5+
const CreateCategoryEvent();
6+
7+
@override
8+
List<Object> get props => [];
9+
}
10+
11+
/// Event for when the category's name is changed.
12+
final class CreateCategoryNameChanged extends CreateCategoryEvent {
13+
const CreateCategoryNameChanged(this.name);
14+
final String name;
15+
@override
16+
List<Object> get props => [name];
17+
}
18+
19+
/// Event for when the category's description is changed.
20+
final class CreateCategoryDescriptionChanged extends CreateCategoryEvent {
21+
const CreateCategoryDescriptionChanged(this.description);
22+
final String description;
23+
@override
24+
List<Object> get props => [description];
25+
}
26+
27+
/// Event for when the category's icon URL is changed.
28+
final class CreateCategoryIconUrlChanged extends CreateCategoryEvent {
29+
const CreateCategoryIconUrlChanged(this.iconUrl);
30+
final String iconUrl;
31+
@override
32+
List<Object> get props => [iconUrl];
33+
}
34+
35+
/// Event to signal that the form should be submitted.
36+
final class CreateCategorySubmitted extends CreateCategoryEvent {
37+
const CreateCategorySubmitted();
38+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
part of 'create_category_bloc.dart';
2+
3+
/// Represents the status of the create category operation.
4+
enum CreateCategoryStatus {
5+
/// Initial state.
6+
initial,
7+
8+
/// The form is being submitted.
9+
submitting,
10+
11+
/// The operation completed successfully.
12+
success,
13+
14+
/// An error occurred.
15+
failure,
16+
}
17+
18+
/// The state for the [CreateCategoryBloc].
19+
final class CreateCategoryState extends Equatable {
20+
/// {@macro create_category_state}
21+
const CreateCategoryState({
22+
this.status = CreateCategoryStatus.initial,
23+
this.name = '',
24+
this.description = '',
25+
this.iconUrl = '',
26+
this.errorMessage,
27+
});
28+
29+
final CreateCategoryStatus status;
30+
final String name;
31+
final String description;
32+
final String iconUrl;
33+
final String? errorMessage;
34+
35+
/// Returns true if the form is valid and can be submitted.
36+
/// Based on the Category model, only the name is required.
37+
bool get isFormValid => name.isNotEmpty;
38+
39+
CreateCategoryState copyWith({
40+
CreateCategoryStatus? status,
41+
String? name,
42+
String? description,
43+
String? iconUrl,
44+
String? errorMessage,
45+
}) {
46+
return CreateCategoryState(
47+
status: status ?? this.status,
48+
name: name ?? this.name,
49+
description: description ?? this.description,
50+
iconUrl: iconUrl ?? this.iconUrl,
51+
errorMessage: errorMessage,
52+
);
53+
}
54+
55+
@override
56+
List<Object?> get props => [status, name, description, iconUrl, errorMessage];
57+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter_bloc/flutter_bloc.dart';
3+
import 'package:go_router/go_router.dart';
4+
import 'package:ht_dashboard/content_management/bloc/content_management_bloc.dart';
5+
import 'package:ht_dashboard/content_management/bloc/create_category/create_category_bloc.dart';
6+
import 'package:ht_dashboard/l10n/l10n.dart';
7+
import 'package:ht_dashboard/shared/shared.dart';
8+
import 'package:ht_data_repository/ht_data_repository.dart';
9+
import 'package:ht_shared/ht_shared.dart';
10+
11+
/// {@template create_category_page}
12+
/// A page for creating a new category.
13+
/// It uses a [BlocProvider] to create and provide a [CreateCategoryBloc].
14+
/// {@endtemplate}
15+
class CreateCategoryPage extends StatelessWidget {
16+
/// {@macro create_category_page}
17+
const CreateCategoryPage({super.key});
18+
19+
@override
20+
Widget build(BuildContext context) {
21+
return BlocProvider(
22+
create: (context) => CreateCategoryBloc(
23+
categoriesRepository: context.read<HtDataRepository<Category>>(),
24+
),
25+
child: const _CreateCategoryView(),
26+
);
27+
}
28+
}
29+
30+
class _CreateCategoryView extends StatefulWidget {
31+
const _CreateCategoryView();
32+
33+
@override
34+
State<_CreateCategoryView> createState() => _CreateCategoryViewState();
35+
}
36+
37+
class _CreateCategoryViewState extends State<_CreateCategoryView> {
38+
final _formKey = GlobalKey<FormState>();
39+
40+
@override
41+
Widget build(BuildContext context) {
42+
final l10n = context.l10n;
43+
return Scaffold(
44+
appBar: AppBar(
45+
title: Text(l10n.createCategory),
46+
actions: [
47+
BlocBuilder<CreateCategoryBloc, CreateCategoryState>(
48+
builder: (context, state) {
49+
if (state.status == CreateCategoryStatus.submitting) {
50+
return const Padding(
51+
padding: EdgeInsets.only(right: AppSpacing.lg),
52+
child: SizedBox(
53+
width: 24,
54+
height: 24,
55+
child: CircularProgressIndicator(strokeWidth: 3),
56+
),
57+
);
58+
}
59+
return IconButton(
60+
icon: const Icon(Icons.save),
61+
tooltip: l10n.saveChanges,
62+
onPressed: state.isFormValid
63+
? () => context.read<CreateCategoryBloc>().add(
64+
const CreateCategorySubmitted(),
65+
)
66+
: null,
67+
);
68+
},
69+
),
70+
],
71+
),
72+
body: BlocConsumer<CreateCategoryBloc, CreateCategoryState>(
73+
listenWhen: (previous, current) => previous.status != current.status,
74+
listener: (context, state) {
75+
if (state.status == CreateCategoryStatus.success &&
76+
ModalRoute.of(context)!.isCurrent) {
77+
ScaffoldMessenger.of(context)
78+
..hideCurrentSnackBar()
79+
..showSnackBar(
80+
SnackBar(
81+
content: Text(l10n.categoryCreatedSuccessfully),
82+
),
83+
);
84+
context.read<ContentManagementBloc>().add(
85+
const LoadCategoriesRequested(),
86+
);
87+
context.pop();
88+
}
89+
if (state.status == CreateCategoryStatus.failure) {
90+
ScaffoldMessenger.of(context)
91+
..hideCurrentSnackBar()
92+
..showSnackBar(
93+
SnackBar(
94+
content: Text(state.errorMessage ?? l10n.unknownError),
95+
backgroundColor: Theme.of(context).colorScheme.error,
96+
),
97+
);
98+
}
99+
},
100+
builder: (context, state) {
101+
return SingleChildScrollView(
102+
child: Padding(
103+
padding: const EdgeInsets.all(AppSpacing.lg),
104+
child: Form(
105+
key: _formKey,
106+
child: Column(
107+
crossAxisAlignment: CrossAxisAlignment.start,
108+
children: [
109+
TextFormField(
110+
initialValue: state.name,
111+
decoration: InputDecoration(
112+
labelText: l10n.categoryName,
113+
border: const OutlineInputBorder(),
114+
),
115+
onChanged: (value) => context
116+
.read<CreateCategoryBloc>()
117+
.add(CreateCategoryNameChanged(value)),
118+
),
119+
const SizedBox(height: AppSpacing.lg),
120+
TextFormField(
121+
initialValue: state.description,
122+
decoration: InputDecoration(
123+
labelText: l10n.description,
124+
border: const OutlineInputBorder(),
125+
),
126+
maxLines: 3,
127+
onChanged: (value) => context
128+
.read<CreateCategoryBloc>()
129+
.add(CreateCategoryDescriptionChanged(value)),
130+
),
131+
const SizedBox(height: AppSpacing.lg),
132+
TextFormField(
133+
initialValue: state.iconUrl,
134+
decoration: InputDecoration(
135+
labelText: l10n.iconUrl,
136+
border: const OutlineInputBorder(),
137+
),
138+
onChanged: (value) => context
139+
.read<CreateCategoryBloc>()
140+
.add(CreateCategoryIconUrlChanged(value)),
141+
),
142+
],
143+
),
144+
),
145+
),
146+
);
147+
},
148+
),
149+
);
150+
}
151+
}

lib/l10n/app_localizations.dart

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1034,10 +1034,10 @@ abstract class AppLocalizations {
10341034
/// **'No categories found.'**
10351035
String get noCategoriesFound;
10361036

1037-
/// Column header for category name
1037+
/// Label for the category name field in forms and tables.
10381038
///
10391039
/// In en, this message translates to:
1040-
/// **'Name'**
1040+
/// **'Category Name'**
10411041
String get categoryName;
10421042

10431043
/// Column header for description
@@ -1118,6 +1118,18 @@ abstract class AppLocalizations {
11181118
/// **'Cannot update: Original category data not loaded.'**
11191119
String get cannotUpdateCategoryError;
11201120

1121+
/// Title for the Create Category page
1122+
///
1123+
/// In en, this message translates to:
1124+
/// **'Create Category'**
1125+
String get createCategory;
1126+
1127+
/// Message displayed when a category is created successfully
1128+
///
1129+
/// In en, this message translates to:
1130+
/// **'Category created successfully.'**
1131+
String get categoryCreatedSuccessfully;
1132+
11211133
/// Title for the Edit Source page
11221134
///
11231135
/// In en, this message translates to:

lib/l10n/app_localizations_ar.dart

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -544,7 +544,7 @@ class AppLocalizationsAr extends AppLocalizations {
544544
String get noCategoriesFound => 'لم يتم العثور على فئات.';
545545

546546
@override
547-
String get categoryName => 'الاسم';
547+
String get categoryName => 'اسم الفئة';
548548

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

589+
@override
590+
String get createCategory => 'إنشاء فئة';
591+
592+
@override
593+
String get categoryCreatedSuccessfully => 'تم إنشاء الفئة بنجاح.';
594+
589595
@override
590596
String get editSource => 'تعديل المصدر';
591597

0 commit comments

Comments
 (0)