feat: manage categories

This commit is contained in:
2026-04-11 01:01:43 +03:00
parent 639fb86a20
commit 46dd3f21d6
7 changed files with 396 additions and 46 deletions

View File

@@ -66,6 +66,7 @@ class Messages {
LoginMessages get login => LoginMessages(this);
HomeMessages get home => HomeMessages(this);
NavMessages get nav => NavMessages(this);
CategoriesMessages get categories => CategoriesMessages(this);
ChecklistsMessages get checklists => ChecklistsMessages(this);
NotesWallMessages get notesWall => NotesWallMessages(this);
PhotoBoardMessages get photoBoard => PhotoBoardMessages(this);
@@ -244,10 +245,76 @@ class NavMessages {
String get notesWall => """Notes Wall""";
}
class CategoriesMessages {
final Messages _parent;
const CategoriesMessages(this._parent);
/// ```dart
/// "Manage categories"
/// ```
String get manageTitle => """Manage categories""";
/// ```dart
/// "No categories yet."
/// ```
String get noCategories => """No categories yet.""";
/// ```dart
/// "Edit category"
/// ```
String get editTitle => """Edit category""";
/// ```dart
/// "New category"
/// ```
String get addTitle => """New category""";
/// ```dart
/// "Name"
/// ```
String get name => """Name""";
/// ```dart
/// "Icon"
/// ```
String get icon => """Icon""";
/// ```dart
/// "Color"
/// ```
String get color => """Color""";
/// ```dart
/// "Failed to save category."
/// ```
String get saveFailed => """Failed to save category.""";
/// ```dart
/// "Failed to delete category."
/// ```
String get deleteFailed => """Failed to delete category.""";
/// ```dart
/// "Delete this category?"
/// ```
String get deleteConfirm => """Delete this category?""";
/// ```dart
/// "Items currently in this category will be uncategorized. This cannot be undone."
/// ```
String get deleteConfirmBody =>
"""Items currently in this category will be uncategorized. This cannot be undone.""";
}
class ChecklistsMessages {
final Messages _parent;
const ChecklistsMessages(this._parent);
/// ```dart
/// "Categories"
/// ```
String get categories => """Categories""";
/// ```dart
/// "No checklists yet."
/// ```
@@ -921,6 +988,19 @@ Please complete login in your browser.""",
"""nav.checklists""": """Checklists""",
"""nav.photoBoard""": """Photo Board""",
"""nav.notesWall""": """Notes Wall""",
"""categories.manageTitle""": """Manage categories""",
"""categories.noCategories""": """No categories yet.""",
"""categories.editTitle""": """Edit category""",
"""categories.addTitle""": """New category""",
"""categories.name""": """Name""",
"""categories.icon""": """Icon""",
"""categories.color""": """Color""",
"""categories.saveFailed""": """Failed to save category.""",
"""categories.deleteFailed""": """Failed to delete category.""",
"""categories.deleteConfirm""": """Delete this category?""",
"""categories.deleteConfirmBody""":
"""Items currently in this category will be uncategorized. This cannot be undone.""",
"""checklists.categories""": """Categories""",
"""checklists.noChecklists""": """No checklists yet.""",
"""checklists.noItems""": """No items in this list.""",
"""checklists.failedToLoad""": """Failed to load checklists.""",

View File

@@ -35,7 +35,21 @@ nav:
photoBoard: Photo Board
notesWall: Notes Wall
categories:
manageTitle: Manage categories
noCategories: No categories yet.
editTitle: Edit category
addTitle: New category
name: Name
icon: Icon
color: Color
saveFailed: Failed to save category.
deleteFailed: Failed to delete category.
deleteConfirm: Delete this category?
deleteConfirmBody: "Items currently in this category will be uncategorized. This cannot be undone."
checklists:
categories: Categories
noChecklists: No checklists yet.
noItems: No items in this list.
failedToLoad: Failed to load checklists.

View File

@@ -36,4 +36,26 @@ class CategoryService {
fromJson: (data) => Category.fromJson(data),
);
}
Future<Category> updateCategory(
int houseId,
int categoryId, {
String? name,
String? icon,
String? color,
}) async {
return ApiClient.instance.patch<Map<String, dynamic>, Category>(
'/houses/$houseId/categories/$categoryId',
body: {
if (name != null) 'name': name,
if (icon != null) 'icon': icon,
if (color != null) 'color': color,
},
fromJson: (data) => Category.fromJson(data),
);
}
Future<void> deleteCategory(int houseId, int categoryId) async {
await ApiClient.instance.delete('/houses/$houseId/categories/$categoryId');
}
}

View File

@@ -0,0 +1,185 @@
import 'package:flutter/material.dart';
import 'package:pantry/i18n.dart';
import 'package:pantry/models/category.dart';
import 'package:pantry/services/category_service.dart';
import 'package:pantry/utils/category_icons.dart';
import 'package:pantry/widgets/create_category_dialog.dart';
class CategoriesView extends StatefulWidget {
final int houseId;
const CategoriesView({super.key, required this.houseId});
@override
State<CategoriesView> createState() => _CategoriesViewState();
}
class _CategoriesViewState extends State<CategoriesView> {
List<Category> _categories = [];
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final list = await CategoryService.instance.getCategories(widget.houseId);
if (!mounted) return;
setState(() {
_categories = list..sort((a, b) => a.sortOrder.compareTo(b.sortOrder));
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
Future<void> _create() async {
final created = await showDialog<Category>(
context: context,
builder: (_) => CreateCategoryDialog(houseId: widget.houseId),
);
if (created != null) {
setState(() => _categories = [..._categories, created]);
}
}
Future<void> _edit(Category category) async {
final updated = await showDialog<Category>(
context: context,
builder: (_) =>
CreateCategoryDialog(houseId: widget.houseId, existing: category),
);
if (updated != null) {
setState(() {
final index = _categories.indexWhere((c) => c.id == updated.id);
if (index != -1) {
_categories[index] = updated;
_categories = [..._categories];
}
});
}
}
Future<void> _delete(Category category) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(m.categories.deleteConfirm),
content: Text(m.categories.deleteConfirmBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(m.common.cancel),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: Theme.of(ctx).colorScheme.error,
),
onPressed: () => Navigator.pop(ctx, true),
child: Text(m.common.delete),
),
],
),
);
if (confirmed != true) return;
try {
await CategoryService.instance.deleteCategory(
widget.houseId,
category.id,
);
setState(() {
_categories = _categories.where((c) => c.id != category.id).toList();
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(m.categories.deleteFailed)));
}
}
Color? _parseColor(String hex) {
if (hex.isEmpty) return null;
hex = hex.replaceFirst('#', '');
if (hex.length == 6) hex = 'FF$hex';
final value = int.tryParse(hex, radix: 16);
return value != null ? Color(value) : null;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(title: Text(m.categories.manageTitle)),
floatingActionButton: FloatingActionButton(
onPressed: _create,
child: const Icon(Icons.add),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(_error!, textAlign: TextAlign.center),
const SizedBox(height: 16),
FilledButton(onPressed: _load, child: Text(m.common.retry)),
],
),
),
)
: _categories.isEmpty
? Center(child: Text(m.categories.noCategories))
: RefreshIndicator(
onRefresh: _load,
child: ListView.builder(
itemCount: _categories.length,
itemBuilder: (context, index) {
final cat = _categories[index];
final color =
_parseColor(cat.color) ?? theme.colorScheme.primary;
return ListTile(
leading: CircleAvatar(
backgroundColor: color.withAlpha(40),
child: Icon(categoryIcon(cat.icon), color: color),
),
title: Text(cat.name),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit, size: 20),
onPressed: () => _edit(cat),
),
IconButton(
icon: const Icon(Icons.delete, size: 20),
onPressed: () => _delete(cat),
),
],
),
onTap: () => _edit(cat),
);
},
),
),
);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:pantry/i18n.dart';
import 'package:pantry/views/categories/categories_view.dart';
import 'package:pantry/views/checklists/checklists_view.dart';
import 'package:pantry/views/notes/notes_wall_view.dart';
import 'package:pantry/views/photos/photo_board_view.dart';
@@ -67,10 +68,24 @@ class _HomeViewBodyState extends State<_HomeViewBody> {
Widget build(BuildContext context) {
final controller = context.watch<HomeController>();
final houseId = controller.currentHouse?.id;
return Scaffold(
appBar: AppBar(
title: Text(_tabTitle),
actions: [
if (_tabIndex == 0 && houseId != null)
IconButton(
icon: const Icon(Icons.sell_outlined),
tooltip: m.checklists.categories,
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => CategoriesView(houseId: houseId),
),
);
},
),
UserMenuButton(
houses: controller.houses,
currentHouse: controller.currentHouse,

View File

@@ -4,6 +4,9 @@ import 'package:pantry/models/category.dart';
import 'package:pantry/utils/category_icons.dart';
import 'package:pantry/widgets/create_category_dialog.dart';
/// Sentinel value used for the "Create category" dropdown item.
const int _createCategoryValue = -1;
class CategoryPicker extends StatelessWidget {
final List<Category> categories;
final int? selectedId;
@@ -33,41 +36,49 @@ class CategoryPicker extends StatelessWidget {
final theme = Theme.of(context);
final f = m.checklists.itemForm;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<int?>(
initialValue: selectedId,
decoration: const InputDecoration(
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
isDense: true,
return DropdownButtonFormField<int?>(
initialValue: selectedId,
decoration: const InputDecoration(
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
isDense: true,
),
items: [
DropdownMenuItem<int?>(value: null, child: Text(f.noCategory)),
...categories.map((cat) {
final color = _parseColor(cat.color) ?? theme.colorScheme.primary;
return DropdownMenuItem<int?>(
value: cat.id,
child: Row(
children: [
Icon(categoryIcon(cat.icon), size: 20, color: color),
const SizedBox(width: 8),
Text(cat.name),
],
),
);
}),
DropdownMenuItem<int?>(
value: _createCategoryValue,
child: Row(
children: [
Icon(Icons.add, size: 20, color: theme.colorScheme.primary),
const SizedBox(width: 8),
Text(
f.createCategory,
style: TextStyle(color: theme.colorScheme.primary),
),
],
),
items: [
DropdownMenuItem<int?>(value: null, child: Text(f.noCategory)),
...categories.map((cat) {
final color = _parseColor(cat.color) ?? theme.colorScheme.primary;
return DropdownMenuItem<int?>(
value: cat.id,
child: Row(
children: [
Icon(categoryIcon(cat.icon), size: 20, color: color),
const SizedBox(width: 8),
Text(cat.name),
],
),
);
}),
],
onChanged: onChanged,
),
const SizedBox(height: 8),
TextButton.icon(
onPressed: () => _showCreateDialog(context),
icon: const Icon(Icons.add, size: 18),
label: Text(f.createCategory),
),
],
onChanged: (value) {
if (value == _createCategoryValue) {
_showCreateDialog(context);
} else {
onChanged(value);
}
},
);
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:pantry/i18n.dart';
import 'package:pantry/models/category.dart';
import 'package:pantry/services/category_service.dart';
import 'package:pantry/utils/category_icons.dart';
@@ -19,18 +20,32 @@ const categoryColors = [
class CreateCategoryDialog extends StatefulWidget {
final int houseId;
const CreateCategoryDialog({super.key, required this.houseId});
/// If non-null, we're editing this category instead of creating a new one.
final Category? existing;
const CreateCategoryDialog({super.key, required this.houseId, this.existing});
@override
State<CreateCategoryDialog> createState() => _CreateCategoryDialogState();
}
class _CreateCategoryDialogState extends State<CreateCategoryDialog> {
final _nameController = TextEditingController();
String _selectedIcon = 'tag';
String _selectedColor = categoryColors.first;
late final TextEditingController _nameController;
late String _selectedIcon;
late String _selectedColor;
bool _saving = false;
bool get _isEditing => widget.existing != null;
@override
void initState() {
super.initState();
final e = widget.existing;
_nameController = TextEditingController(text: e?.name ?? '');
_selectedIcon = e?.icon ?? 'tag';
_selectedColor = e?.color ?? categoryColors.first;
}
@override
void dispose() {
_nameController.dispose();
@@ -43,18 +58,26 @@ class _CreateCategoryDialogState extends State<CreateCategoryDialog> {
setState(() => _saving = true);
try {
final category = await CategoryService.instance.createCategory(
widget.houseId,
name: name,
icon: _selectedIcon,
color: _selectedColor,
);
final category = _isEditing
? await CategoryService.instance.updateCategory(
widget.houseId,
widget.existing!.id,
name: name,
icon: _selectedIcon,
color: _selectedColor,
)
: await CategoryService.instance.createCategory(
widget.houseId,
name: name,
icon: _selectedIcon,
color: _selectedColor,
);
if (mounted) Navigator.of(context).pop(category);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(m.checklists.itemForm.categoryCreateFailed)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(m.categories.saveFailed)));
}
} finally {
if (mounted) setState(() => _saving = false);
@@ -73,7 +96,7 @@ class _CreateCategoryDialogState extends State<CreateCategoryDialog> {
final f = m.checklists.itemForm;
return AlertDialog(
title: Text(f.createCategory),
title: Text(_isEditing ? m.categories.editTitle : m.categories.addTitle),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,