diff --git a/lib/messages.i18n.dart b/lib/messages.i18n.dart index c43e7f4..a48e476 100644 --- a/lib/messages.i18n.dart +++ b/lib/messages.i18n.dart @@ -517,6 +517,41 @@ class ChecklistsMessages { /// "Remove item" /// ``` String get removeItem => """Remove item"""; + + /// ```dart + /// "Move to list" + /// ``` + String get moveItem => """Move to list"""; + + /// ```dart + /// "Failed to move item." + /// ``` + String get moveFailed => """Failed to move item."""; + + /// ```dart + /// "New list" + /// ``` + String get createList => """New list"""; + + /// ```dart + /// "List name" + /// ``` + String get listName => """List name"""; + + /// ```dart + /// "Description (optional)" + /// ``` + String get listDescription => """Description (optional)"""; + + /// ```dart + /// "Icon" + /// ``` + String get listIcon => """Icon"""; + + /// ```dart + /// "Failed to create list." + /// ``` + String get createListFailed => """Failed to create list."""; ViewItemChecklistsMessages get viewItem => ViewItemChecklistsMessages(this); ItemFormChecklistsMessages get itemForm => ItemFormChecklistsMessages(this); SortChecklistsMessages get sort => SortChecklistsMessages(this); @@ -1210,6 +1245,13 @@ Please complete login in your browser.""", """checklists.failedToLoadItems""": """Failed to load items.""", """checklists.editItem""": """Edit item""", """checklists.removeItem""": """Remove item""", + """checklists.moveItem""": """Move to list""", + """checklists.moveFailed""": """Failed to move item.""", + """checklists.createList""": """New list""", + """checklists.listName""": """List name""", + """checklists.listDescription""": """Description (optional)""", + """checklists.listIcon""": """Icon""", + """checklists.createListFailed""": """Failed to create list.""", """checklists.viewItem.quantity""": """Quantity:""", """checklists.viewItem.category""": """Category:""", """checklists.viewItem.recurrence""": """Recurrence:""", diff --git a/lib/messages.i18n.yaml b/lib/messages.i18n.yaml index a44ea00..357d6e1 100644 --- a/lib/messages.i18n.yaml +++ b/lib/messages.i18n.yaml @@ -92,6 +92,13 @@ checklists: completedCount(int count): "Completed ($count)" editItem: Edit item removeItem: Remove item + moveItem: Move to list + moveFailed: Failed to move item. + createList: New list + listName: List name + listDescription: Description (optional) + listIcon: Icon + createListFailed: Failed to create list. viewItem: quantity: "Quantity:" category: "Category:" diff --git a/lib/services/checklist_service.dart b/lib/services/checklist_service.dart index a78ea29..11df7ac 100644 --- a/lib/services/checklist_service.dart +++ b/lib/services/checklist_service.dart @@ -95,6 +95,37 @@ class ChecklistService { }); } + Future createList( + int houseId, { + required String name, + String? description, + String? icon, + }) async { + return ApiClient.instance.post, ChecklistList>( + '/houses/$houseId/lists', + body: { + 'name': name, + if (description != null && description.isNotEmpty) + 'description': description, + if (icon != null && icon.isNotEmpty) 'icon': icon, + }, + fromJson: (data) => ChecklistList.fromJson(data), + ); + } + + Future moveItem( + int houseId, + int listId, + int itemId, { + required int targetListId, + }) async { + return ApiClient.instance.patch, ListItem>( + '/houses/$houseId/lists/$listId/items/$itemId', + body: {'targetListId': targetListId}, + fromJson: (data) => ListItem.fromJson(data), + ); + } + Future createItem( int houseId, int listId, { diff --git a/lib/utils/checklist_icons.dart b/lib/utils/checklist_icons.dart index 214d3fd..98abdff 100644 --- a/lib/utils/checklist_icons.dart +++ b/lib/utils/checklist_icons.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -const _iconMap = { +const checklistIconMap = { 'clipboard-check': Icons.assignment_turned_in, 'clipboard-list': Icons.assignment, 'format-list-checks': Icons.checklist, @@ -44,5 +44,5 @@ const _iconMap = { const defaultChecklistIcon = Icons.assignment_turned_in; IconData checklistIcon(String? key) { - return _iconMap[key ?? ''] ?? defaultChecklistIcon; + return checklistIconMap[key ?? ''] ?? defaultChecklistIcon; } diff --git a/lib/views/checklists/checklist_item_tile.dart b/lib/views/checklists/checklist_item_tile.dart index ba70ca4..ca74596 100644 --- a/lib/views/checklists/checklist_item_tile.dart +++ b/lib/views/checklists/checklist_item_tile.dart @@ -16,6 +16,7 @@ class ChecklistItemTile extends StatelessWidget { final ValueChanged onToggle; final ValueChanged onView; final ValueChanged onEdit; + final ValueChanged onMove; final ValueChanged onDelete; const ChecklistItemTile({ @@ -26,6 +27,7 @@ class ChecklistItemTile extends StatelessWidget { required this.onToggle, required this.onView, required this.onEdit, + required this.onMove, required this.onDelete, }); @@ -93,7 +95,12 @@ class ChecklistItemTile extends StatelessWidget { constraints: const BoxConstraints(), onPressed: () => onView(item), ), - _MoreMenuButton(item: item, onEdit: onEdit, onDelete: onDelete), + _MoreMenuButton( + item: item, + onEdit: onEdit, + onMove: onMove, + onDelete: onDelete, + ), ], ), ), @@ -252,11 +259,13 @@ class _Badge extends StatelessWidget { class _MoreMenuButton extends StatelessWidget { final ListItem item; final ValueChanged onEdit; + final ValueChanged onMove; final ValueChanged onDelete; const _MoreMenuButton({ required this.item, required this.onEdit, + required this.onMove, required this.onDelete, }); @@ -281,6 +290,16 @@ class _MoreMenuButton extends StatelessWidget { ], ), ), + PopupMenuItem( + value: 'move', + child: Row( + children: [ + const Icon(Icons.drive_file_move_outlined, size: 18), + const SizedBox(width: 8), + Text(m.checklists.moveItem), + ], + ), + ), PopupMenuItem( value: 'remove', child: Row( @@ -296,6 +315,8 @@ class _MoreMenuButton extends StatelessWidget { switch (value) { case 'edit': onEdit(item); + case 'move': + onMove(item); case 'remove': onDelete(item); } diff --git a/lib/views/checklists/checklists_controller.dart b/lib/views/checklists/checklists_controller.dart index e92cc0c..a53dce9 100644 --- a/lib/views/checklists/checklists_controller.dart +++ b/lib/views/checklists/checklists_controller.dart @@ -229,6 +229,35 @@ class ChecklistsController extends ChangeNotifier { _checklistService.invalidateItems(keepListId: _currentList?.id); } + Future createList({ + required String name, + String? description, + String? icon, + }) async { + final list = await _checklistService.createList( + houseId, + name: name, + description: description, + icon: icon, + ); + _lists = [..._lists, list]; + _checklistService.cacheLists(houseId, _lists); + notifyListeners(); + return list; + } + + Future moveItem(ListItem item, int targetListId) async { + await _checklistService.moveItem( + houseId, + item.listId, + item.id, + targetListId: targetListId, + ); + _items.removeWhere((i) => i.id == item.id); + _checklistService.cacheItems(_currentList!.id, List.of(_items)); + notifyListeners(); + } + Future addItem({ required String name, String? description, diff --git a/lib/views/checklists/checklists_view.dart b/lib/views/checklists/checklists_view.dart index 5eb7a2b..a946acd 100644 --- a/lib/views/checklists/checklists_view.dart +++ b/lib/views/checklists/checklists_view.dart @@ -3,8 +3,10 @@ import 'package:pantry/i18n.dart'; import 'package:provider/provider.dart'; import 'package:pantry/models/checklist.dart'; +import 'package:pantry/utils/checklist_icons.dart'; import 'package:pantry/widgets/checklist_selector.dart'; import 'package:pantry/widgets/checklist_sort_button.dart'; +import 'package:pantry/widgets/create_list_dialog.dart'; import 'checklist_item_tile.dart'; import 'checklists_controller.dart'; import 'item_detail_view.dart'; @@ -225,6 +227,80 @@ class _ReorderablePartition extends StatelessWidget { ); } + void _moveItem( + BuildContext context, + ChecklistsController controller, + ListItem item, + ) { + final otherLists = controller.lists + .where((l) => l.id != controller.currentList?.id) + .toList(); + + showDialog( + context: context, + builder: (ctx) => SimpleDialog( + title: Text(m.checklists.moveItem), + children: [ + ...otherLists.map( + (list) => SimpleDialogOption( + onPressed: () => Navigator.pop(ctx, list.id), + child: Row( + children: [ + Icon(checklistIcon(list.icon), size: 20), + const SizedBox(width: 12), + Text(list.name), + ], + ), + ), + ), + const Divider(), + SimpleDialogOption( + onPressed: () async { + Navigator.pop(ctx); + final created = await showCreateListDialog(context, controller); + if (created != null && context.mounted) { + try { + await controller.moveItem(item, created.id); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(m.checklists.moveFailed)), + ); + } + } + } + }, + child: Row( + children: [ + Icon( + Icons.add, + size: 20, + color: Theme.of(ctx).colorScheme.primary, + ), + const SizedBox(width: 12), + Text( + m.checklists.createList, + style: TextStyle(color: Theme.of(ctx).colorScheme.primary), + ), + ], + ), + ), + ], + ), + ).then((targetListId) async { + if (targetListId == null) return; + try { + await controller.moveItem(item, targetListId); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(m.checklists.moveFailed))); + } + } + }); + } + void _editItem( BuildContext context, ChecklistsController controller, @@ -293,6 +369,7 @@ class _ReorderablePartition extends StatelessWidget { onToggle: controller.toggleItem, onView: (item) => _viewItem(context, controller, item), onEdit: (item) => _editItem(context, controller, item), + onMove: (item) => _moveItem(context, controller, item), onDelete: (item) => _deleteItem(context, controller, item), ), ); diff --git a/lib/widgets/create_list_dialog.dart b/lib/widgets/create_list_dialog.dart new file mode 100644 index 0000000..47c7bc9 --- /dev/null +++ b/lib/widgets/create_list_dialog.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; +import 'package:pantry/i18n.dart'; +import 'package:pantry/models/checklist.dart'; +import 'package:pantry/utils/checklist_icons.dart'; +import 'package:pantry/views/checklists/checklists_controller.dart'; + +/// Shows a dialog to create a new checklist. Returns the created +/// [ChecklistList] on success, or null if cancelled. +Future showCreateListDialog( + BuildContext context, + ChecklistsController controller, +) { + return showDialog( + context: context, + builder: (_) => CreateListDialog(controller: controller), + ); +} + +class CreateListDialog extends StatefulWidget { + final ChecklistsController controller; + + const CreateListDialog({super.key, required this.controller}); + + @override + State createState() => _CreateListDialogState(); +} + +class _CreateListDialogState extends State { + final _nameController = TextEditingController(); + final _descriptionController = TextEditingController(); + String _selectedIcon = 'clipboard-check'; + bool _saving = false; + + @override + void dispose() { + _nameController.dispose(); + _descriptionController.dispose(); + super.dispose(); + } + + Future _save() async { + final name = _nameController.text.trim(); + if (name.isEmpty) return; + + setState(() => _saving = true); + try { + final list = await widget.controller.createList( + name: name, + description: _descriptionController.text.trim(), + icon: _selectedIcon, + ); + if (mounted) Navigator.of(context).pop(list); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(m.checklists.createListFailed))); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return AlertDialog( + title: Text(m.checklists.createList), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: _nameController, + autofocus: true, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + labelText: m.checklists.listName, + border: const OutlineInputBorder(), + ), + textInputAction: TextInputAction.next, + ), + const SizedBox(height: 16), + TextField( + controller: _descriptionController, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + labelText: m.checklists.listDescription, + border: const OutlineInputBorder(), + ), + maxLines: 2, + ), + const SizedBox(height: 16), + Text(m.checklists.listIcon, style: theme.textTheme.bodyMedium), + const SizedBox(height: 8), + Wrap( + spacing: 4, + runSpacing: 4, + children: checklistIconMap.entries.map((entry) { + final isSelected = _selectedIcon == entry.key; + return GestureDetector( + onTap: () => setState(() => _selectedIcon = entry.key), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: isSelected + ? theme.colorScheme.primaryContainer + : null, + borderRadius: BorderRadius.circular(8), + border: isSelected + ? Border.all( + color: theme.colorScheme.primary, + width: 2, + ) + : null, + ), + child: Icon( + entry.value, + size: 20, + color: isSelected + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant, + ), + ), + ); + }).toList(), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: _saving ? null : () => Navigator.pop(context), + child: Text(m.common.cancel), + ), + FilledButton( + onPressed: _saving ? null : _save, + child: _saving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(m.common.save), + ), + ], + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index fc7191c..4938f45 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,5 +1,5 @@ name: pantry -description: "A new Flutter project." +description: "Manage your household with your Nextcloud — lists, photos & notes." # The following line prevents the package from being accidentally published to # pub.dev using `flutter pub publish`. This is preferred for private packages. publish_to: "none" # Remove this line if you wish to publish to pub.dev