diff --git a/lib/messages.i18n.dart b/lib/messages.i18n.dart index 16ca29a..95cf791 100644 --- a/lib/messages.i18n.dart +++ b/lib/messages.i18n.dart @@ -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.""", diff --git a/lib/messages.i18n.yaml b/lib/messages.i18n.yaml index 6d1017d..fa81cd2 100644 --- a/lib/messages.i18n.yaml +++ b/lib/messages.i18n.yaml @@ -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. diff --git a/lib/services/category_service.dart b/lib/services/category_service.dart index d6c07ba..d271da9 100644 --- a/lib/services/category_service.dart +++ b/lib/services/category_service.dart @@ -36,4 +36,26 @@ class CategoryService { fromJson: (data) => Category.fromJson(data), ); } + + Future updateCategory( + int houseId, + int categoryId, { + String? name, + String? icon, + String? color, + }) async { + return ApiClient.instance.patch, 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 deleteCategory(int houseId, int categoryId) async { + await ApiClient.instance.delete('/houses/$houseId/categories/$categoryId'); + } } diff --git a/lib/views/categories/categories_view.dart b/lib/views/categories/categories_view.dart new file mode 100644 index 0000000..7bab437 --- /dev/null +++ b/lib/views/categories/categories_view.dart @@ -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 createState() => _CategoriesViewState(); +} + +class _CategoriesViewState extends State { + List _categories = []; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _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 _create() async { + final created = await showDialog( + context: context, + builder: (_) => CreateCategoryDialog(houseId: widget.houseId), + ); + if (created != null) { + setState(() => _categories = [..._categories, created]); + } + } + + Future _edit(Category category) async { + final updated = await showDialog( + 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 _delete(Category category) async { + final confirmed = await showDialog( + 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), + ); + }, + ), + ), + ); + } +} diff --git a/lib/views/home/home_view.dart b/lib/views/home/home_view.dart index c11fe7a..213fe73 100644 --- a/lib/views/home/home_view.dart +++ b/lib/views/home/home_view.dart @@ -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(); + 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, diff --git a/lib/widgets/category_picker.dart b/lib/widgets/category_picker.dart index 2f09820..779058e 100644 --- a/lib/widgets/category_picker.dart +++ b/lib/widgets/category_picker.dart @@ -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 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( - initialValue: selectedId, - decoration: const InputDecoration( - border: OutlineInputBorder(), - contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), - isDense: true, + return DropdownButtonFormField( + initialValue: selectedId, + decoration: const InputDecoration( + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), + isDense: true, + ), + items: [ + DropdownMenuItem(value: null, child: Text(f.noCategory)), + ...categories.map((cat) { + final color = _parseColor(cat.color) ?? theme.colorScheme.primary; + return DropdownMenuItem( + value: cat.id, + child: Row( + children: [ + Icon(categoryIcon(cat.icon), size: 20, color: color), + const SizedBox(width: 8), + Text(cat.name), + ], + ), + ); + }), + DropdownMenuItem( + 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(value: null, child: Text(f.noCategory)), - ...categories.map((cat) { - final color = _parseColor(cat.color) ?? theme.colorScheme.primary; - return DropdownMenuItem( - 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); + } + }, ); } diff --git a/lib/widgets/create_category_dialog.dart b/lib/widgets/create_category_dialog.dart index 284d099..9ae68bd 100644 --- a/lib/widgets/create_category_dialog.dart +++ b/lib/widgets/create_category_dialog.dart @@ -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 createState() => _CreateCategoryDialogState(); } class _CreateCategoryDialogState extends State { - 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 { 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 { 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,