mirror of
https://github.com/chenasraf/pantry-flutter.git
synced 2026-05-17 17:28:03 +00:00
feat: move items between lists
This commit is contained in:
@@ -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:""",
|
||||
|
||||
@@ -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:"
|
||||
|
||||
@@ -95,6 +95,37 @@ class ChecklistService {
|
||||
});
|
||||
}
|
||||
|
||||
Future<ChecklistList> createList(
|
||||
int houseId, {
|
||||
required String name,
|
||||
String? description,
|
||||
String? icon,
|
||||
}) async {
|
||||
return ApiClient.instance.post<Map<String, dynamic>, 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<ListItem> moveItem(
|
||||
int houseId,
|
||||
int listId,
|
||||
int itemId, {
|
||||
required int targetListId,
|
||||
}) async {
|
||||
return ApiClient.instance.patch<Map<String, dynamic>, ListItem>(
|
||||
'/houses/$houseId/lists/$listId/items/$itemId',
|
||||
body: {'targetListId': targetListId},
|
||||
fromJson: (data) => ListItem.fromJson(data),
|
||||
);
|
||||
}
|
||||
|
||||
Future<ListItem> createItem(
|
||||
int houseId,
|
||||
int listId, {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
const _iconMap = <String, IconData>{
|
||||
const checklistIconMap = <String, IconData>{
|
||||
'clipboard-check': Icons.assignment_turned_in,
|
||||
'clipboard-list': Icons.assignment,
|
||||
'format-list-checks': Icons.checklist,
|
||||
@@ -44,5 +44,5 @@ const _iconMap = <String, IconData>{
|
||||
const defaultChecklistIcon = Icons.assignment_turned_in;
|
||||
|
||||
IconData checklistIcon(String? key) {
|
||||
return _iconMap[key ?? ''] ?? defaultChecklistIcon;
|
||||
return checklistIconMap[key ?? ''] ?? defaultChecklistIcon;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ class ChecklistItemTile extends StatelessWidget {
|
||||
final ValueChanged<ListItem> onToggle;
|
||||
final ValueChanged<ListItem> onView;
|
||||
final ValueChanged<ListItem> onEdit;
|
||||
final ValueChanged<ListItem> onMove;
|
||||
final ValueChanged<ListItem> 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<ListItem> onEdit;
|
||||
final ValueChanged<ListItem> onMove;
|
||||
final ValueChanged<ListItem> 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);
|
||||
}
|
||||
|
||||
@@ -229,6 +229,35 @@ class ChecklistsController extends ChangeNotifier {
|
||||
_checklistService.invalidateItems(keepListId: _currentList?.id);
|
||||
}
|
||||
|
||||
Future<ChecklistList> 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<void> 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<ListItem> addItem({
|
||||
required String name,
|
||||
String? description,
|
||||
|
||||
@@ -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<int>(
|
||||
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),
|
||||
),
|
||||
);
|
||||
|
||||
152
lib/widgets/create_list_dialog.dart
Normal file
152
lib/widgets/create_list_dialog.dart
Normal file
@@ -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<ChecklistList?> showCreateListDialog(
|
||||
BuildContext context,
|
||||
ChecklistsController controller,
|
||||
) {
|
||||
return showDialog<ChecklistList>(
|
||||
context: context,
|
||||
builder: (_) => CreateListDialog(controller: controller),
|
||||
);
|
||||
}
|
||||
|
||||
class CreateListDialog extends StatefulWidget {
|
||||
final ChecklistsController controller;
|
||||
|
||||
const CreateListDialog({super.key, required this.controller});
|
||||
|
||||
@override
|
||||
State<CreateListDialog> createState() => _CreateListDialogState();
|
||||
}
|
||||
|
||||
class _CreateListDialogState extends State<CreateListDialog> {
|
||||
final _nameController = TextEditingController();
|
||||
final _descriptionController = TextEditingController();
|
||||
String _selectedIcon = 'clipboard-check';
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_descriptionController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user