mirror of
https://github.com/chenasraf/pantry-flutter.git
synced 2026-05-17 17:28:03 +00:00
feat: manage categories
This commit is contained in:
@@ -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.""",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
185
lib/views/categories/categories_view.dart
Normal file
185
lib/views/categories/categories_view.dart
Normal 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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user