Files
pantry-flutter/lib/views/checklists/checklists_view.dart

997 lines
29 KiB
Dart

import 'package:flutter/material.dart';
import 'package:pantry/i18n.dart';
import 'package:pantry/models/category.dart' as models;
import 'package:provider/provider.dart';
import 'package:pantry/models/checklist.dart';
import 'package:pantry/services/prefs_service.dart';
import 'package:pantry/utils/category_icons.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';
import 'item_form_view.dart';
class ChecklistsView extends StatefulWidget {
final int houseId;
const ChecklistsView({super.key, required this.houseId});
@override
State<ChecklistsView> createState() => _ChecklistsViewState();
}
class _ChecklistsViewState extends State<ChecklistsView> {
late final _controller = ChecklistsController(houseId: widget.houseId);
@override
void initState() {
super.initState();
_controller.load();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider.value(
value: _controller,
child: const _ChecklistsBody(),
);
}
}
class _ChecklistsBody extends StatefulWidget {
const _ChecklistsBody();
@override
State<_ChecklistsBody> createState() => _ChecklistsBodyState();
}
class _ChecklistsBodyState extends State<_ChecklistsBody> {
bool _searchOpen = false;
final _searchController = TextEditingController();
final Set<int> _selectedCategoryIds = {};
String get _query => _searchController.text.trim().toLowerCase();
bool get _isFiltering =>
_searchOpen && (_query.isNotEmpty || _selectedCategoryIds.isNotEmpty);
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _toggleSearch() {
setState(() {
_searchOpen = !_searchOpen;
if (!_searchOpen) {
_searchController.clear();
_selectedCategoryIds.clear();
}
});
}
List<ListItem> _filterItems(List<ListItem> items) {
if (!_isFiltering) return items;
return items.where((item) {
// Category filter
if (_selectedCategoryIds.isNotEmpty) {
if (!_selectedCategoryIds.contains(item.categoryId)) return false;
}
// Text filter
if (_query.isNotEmpty) {
final nameMatch = item.name.toLowerCase().contains(_query);
final descMatch =
item.description?.toLowerCase().contains(_query) ?? false;
if (!nameMatch && !descMatch) return false;
}
return true;
}).toList();
}
@override
Widget build(BuildContext context) {
final controller = context.watch<ChecklistsController>();
if (controller.isLoading && controller.lists.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (controller.error != null && controller.lists.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(controller.error!, textAlign: TextAlign.center),
const SizedBox(height: 16),
FilledButton(
onPressed: controller.load,
child: Text(m.common.retry),
),
],
),
),
);
}
if (controller.lists.isEmpty) {
return Center(child: Text(m.checklists.noChecklists));
}
final filteredItems = _filterItems(controller.items);
Widget itemsArea;
if (controller.isLoading) {
itemsArea = const Center(child: CircularProgressIndicator());
} else if (controller.error != null) {
itemsArea = Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(controller.error!, textAlign: TextAlign.center),
const SizedBox(height: 16),
FilledButton(
onPressed: controller.load,
child: Text(m.common.retry),
),
],
),
),
);
} else {
itemsArea = RefreshIndicator(
onRefresh: controller.refresh,
child: _ItemList(
controller: controller,
items: filteredItems,
isFiltering: _isFiltering,
),
);
}
return Stack(
children: [
Column(
children: [
Row(
children: [
Expanded(
child: ChecklistSelector(
lists: controller.lists,
currentList: controller.currentList,
onSelected: controller.selectList,
onCreateNew: () => _createList(context, controller),
),
),
if (!controller.isTrashMode)
IconButton(
icon: Icon(_searchOpen ? Icons.search_off : Icons.search),
onPressed: _toggleSearch,
),
if (!controller.isTrashMode)
ChecklistSortButton(
currentSort: controller.sortBy,
onSelected: controller.setSortBy,
),
PopupMenuButton<String>(
icon: Icon(
controller.isTrashMode ? Icons.delete : Icons.more_vert,
),
tooltip: controller.isTrashMode
? m.checklists.trashTitle
: null,
onSelected: (value) =>
_onListMenuSelected(context, controller, value),
itemBuilder: (_) => _listMenuItems(controller),
),
],
),
if (controller.isTrashMode && controller.currentList != null)
_TrashBanner(onExit: () => controller.setTrashMode(false)),
AnimatedSize(
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
alignment: Alignment.topCenter,
child: (_searchOpen && !controller.isTrashMode)
? _SearchPanel(
searchController: _searchController,
selectedCategoryIds: _selectedCategoryIds,
items: controller.items,
categories: controller.categories,
onChanged: () => setState(() {}),
)
: const SizedBox.shrink(),
),
Expanded(child: itemsArea),
],
),
if (controller.currentList != null && !controller.isTrashMode)
PositionedDirectional(
end: 16,
bottom: 16,
child: FloatingActionButton(
heroTag: 'checklists-fab',
onPressed: () async {
final added = await Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (_) => ItemFormView(controller: controller),
),
);
if (added == true) {
controller.refresh();
}
},
child: const Icon(Icons.add),
),
),
],
);
}
Future<void> _createList(
BuildContext context,
ChecklistsController controller,
) async {
final created = await showCreateListDialog(context, controller);
if (created != null) {
await controller.selectList(created);
}
}
List<PopupMenuEntry<String>> _listMenuItems(ChecklistsController controller) {
if (controller.isTrashMode) {
return [
PopupMenuItem(
value: 'exit_trash',
child: Row(
children: [
const Icon(Icons.arrow_back, size: 18),
const SizedBox(width: 8),
Text(m.checklists.exitTrash),
],
),
),
PopupMenuItem(
value: 'empty_trash',
child: Row(
children: [
const Icon(Icons.delete_forever, size: 18),
const SizedBox(width: 8),
Text(m.checklists.emptyTrash),
],
),
),
];
}
return [
PopupMenuItem(
value: 'view_trash',
child: Row(
children: [
const Icon(Icons.delete_outline, size: 18),
const SizedBox(width: 8),
Text(m.checklists.viewTrash),
],
),
),
];
}
Future<void> _onListMenuSelected(
BuildContext context,
ChecklistsController controller,
String value,
) async {
switch (value) {
case 'view_trash':
if (_searchOpen) {
setState(() {
_searchOpen = false;
_searchController.clear();
_selectedCategoryIds.clear();
});
}
await controller.setTrashMode(true);
case 'exit_trash':
await controller.setTrashMode(false);
case 'empty_trash':
await _confirmEmptyTrash(context, controller);
}
}
Future<void> _confirmEmptyTrash(
BuildContext context,
ChecklistsController controller,
) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(m.checklists.emptyTrashConfirm),
content: Text(m.checklists.emptyTrashConfirmBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(m.common.cancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(m.checklists.emptyTrash),
),
],
),
);
if (confirmed != true) return;
try {
await controller.emptyTrash();
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(m.checklists.emptyTrashFailed)));
}
}
}
}
class _TrashBanner extends StatelessWidget {
final VoidCallback onExit;
const _TrashBanner({required this.onExit});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
color: theme.colorScheme.surfaceContainerHighest,
padding: const EdgeInsetsDirectional.fromSTEB(16, 8, 8, 8),
child: Row(
children: [
Icon(
Icons.delete_outline,
size: 18,
color: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 8),
Expanded(
child: Text(
m.checklists.trashTitle,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
TextButton.icon(
onPressed: onExit,
icon: const Icon(Icons.close, size: 16),
label: Text(m.checklists.exitTrash),
),
],
),
);
}
}
class _SearchPanel extends StatelessWidget {
final TextEditingController searchController;
final Set<int> selectedCategoryIds;
final List<ListItem> items;
final Map<int, models.Category> categories;
final VoidCallback onChanged;
const _SearchPanel({
required this.searchController,
required this.selectedCategoryIds,
required this.items,
required this.categories,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
// Collect categories actually used in the current list, with counts
final categoryCounts = <int, int>{};
for (final item in items) {
if (item.categoryId != null) {
categoryCounts[item.categoryId!] =
(categoryCounts[item.categoryId!] ?? 0) + 1;
}
}
// Sort by category sortOrder
final usedCategories =
categoryCounts.keys.where((id) => categories.containsKey(id)).toList()
..sort(
(a, b) =>
categories[a]!.sortOrder.compareTo(categories[b]!.sortOrder),
);
return Padding(
padding: const EdgeInsetsDirectional.fromSTEB(16, 0, 16, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: searchController,
decoration: InputDecoration(
hintText: m.checklists.searchHint,
prefixIcon: const Icon(Icons.search, size: 20),
border: const OutlineInputBorder(),
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
suffixIcon: ListenableBuilder(
listenable: searchController,
builder: (_, _) => searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 18),
onPressed: () {
searchController.clear();
onChanged();
},
)
: const SizedBox.shrink(),
),
),
onChanged: (_) => onChanged(),
),
if (usedCategories.isNotEmpty) ...[
const SizedBox(height: 8),
SizedBox(
height: 36,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
_CategoryChip(
label: m.checklists.allCategories,
count: items.length,
selected: selectedCategoryIds.isEmpty,
color: theme.colorScheme.primary,
onTap: () {
selectedCategoryIds.clear();
onChanged();
},
),
const SizedBox(width: 6),
...usedCategories.map((catId) {
final cat = categories[catId]!;
final count = categoryCounts[catId]!;
final color = _parseColor(cat.color, theme);
return Padding(
padding: const EdgeInsetsDirectional.only(end: 6),
child: _CategoryChip(
icon: categoryIcon(cat.icon),
label: cat.name,
count: count,
selected: selectedCategoryIds.contains(catId),
color: color,
onTap: () {
if (selectedCategoryIds.contains(catId)) {
selectedCategoryIds.remove(catId);
} else {
selectedCategoryIds.add(catId);
}
onChanged();
},
),
);
}),
],
),
),
],
],
),
);
}
static Color _parseColor(String hex, ThemeData theme) {
try {
final value = int.parse(hex.replaceFirst('#', ''), radix: 16);
return Color(value | 0xFF000000);
} catch (_) {
return theme.colorScheme.primary;
}
}
}
class _CategoryChip extends StatelessWidget {
final IconData? icon;
final String label;
final int count;
final bool selected;
final Color color;
final VoidCallback onTap;
const _CategoryChip({
this.icon,
required this.label,
required this.count,
required this.selected,
required this.color,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final fgColor = selected ? color : theme.colorScheme.onSurfaceVariant;
return FilterChip(
avatar: icon != null ? Icon(icon, size: 16, color: fgColor) : null,
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(child: Text(label)),
const SizedBox(width: 6),
_CountBadge(count: count, color: fgColor),
],
),
selected: selected,
onSelected: (_) => onTap(),
selectedColor: color.withValues(alpha: 0.2),
showCheckmark: false,
labelStyle: TextStyle(fontSize: 12, color: selected ? color : null),
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: const EdgeInsets.symmetric(horizontal: 4),
);
}
}
class _CountBadge extends StatelessWidget {
final int count;
final Color color;
const _CountBadge({required this.count, required this.color});
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(minWidth: 20),
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
shape: count < 100 ? BoxShape.circle : BoxShape.rectangle,
borderRadius: count >= 100 ? BorderRadius.circular(10) : null,
),
child: Text(
'$count',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: color,
),
),
);
}
}
class _ItemList extends StatelessWidget {
final ChecklistsController controller;
final List<ListItem> items;
final bool isFiltering;
const _ItemList({
required this.controller,
required this.items,
required this.isFiltering,
});
@override
Widget build(BuildContext context) {
if (items.isEmpty) {
return ListView(
children: [
const SizedBox(height: 100),
Center(
child: Text(
controller.isTrashMode
? m.checklists.noTrashedItems
: (isFiltering
? m.checklists.noSearchResults
: m.checklists.noItems),
),
),
],
);
}
if (controller.isTrashMode) {
return CustomScrollView(
slivers: [
_ReorderablePartition(
items: items,
controller: controller,
categorySpacing: 'disabled',
allowReorder: false,
),
const SliverToBoxAdapter(child: SizedBox(height: 88)),
],
);
}
final unchecked = items.where((i) => !i.done).toList();
final checked = items.where((i) => i.done).toList();
final spacingPref = context.watch<PrefsService>().checklistCategorySpacing;
final categorySpacing = controller.sortBy == 'category'
? spacingPref
: 'disabled';
return CustomScrollView(
slivers: [
_ReorderablePartition(
items: unchecked,
controller: controller,
categorySpacing: categorySpacing,
),
if (checked.isNotEmpty) ...[
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Divider(),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
child: Text(
m.checklists.completedCount(checked.length),
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
),
],
),
),
_ReorderablePartition(
items: checked,
controller: controller,
categorySpacing: categorySpacing,
),
],
const SliverToBoxAdapter(child: SizedBox(height: 88)),
],
);
}
}
class _ReorderablePartition extends StatelessWidget {
final List<ListItem> items;
final ChecklistsController controller;
final String categorySpacing;
final bool allowReorder;
const _ReorderablePartition({
required this.items,
required this.controller,
this.categorySpacing = 'disabled',
this.allowReorder = true,
});
void _toggleItem(
BuildContext context,
ChecklistsController controller,
ListItem item,
) {
final wasDone = item.done;
final wasDeleteOnDone = item.deleteOnDone;
controller.toggleItem(item);
if (wasDone) return;
final messenger = ScaffoldMessenger.of(context);
messenger.clearSnackBars();
messenger.showSnackBar(
SnackBar(
content: Text(m.checklists.itemMarkedDone),
duration: const Duration(seconds: 6),
action: SnackBarAction(
label: m.checklists.undo,
onPressed: () async {
if (wasDeleteOnDone) {
try {
await controller.restoreItem(item);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(m.checklists.restoreFailed)),
);
}
}
return;
}
final current = controller.items.firstWhere(
(i) => i.id == item.id,
orElse: () => item.copyWith(done: true),
);
if (current.done) controller.toggleItem(current);
},
),
),
);
}
Future<void> _restoreItem(
BuildContext context,
ChecklistsController controller,
ListItem item,
) async {
try {
await controller.restoreItem(item);
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(m.checklists.itemRestored)));
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(m.checklists.restoreFailed)));
}
}
}
void _permanentlyDelete(
BuildContext context,
ChecklistsController controller,
ListItem item,
) {
showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(m.checklists.permanentlyDeleteConfirm),
content: Text(m.checklists.permanentlyDeleteConfirmBody),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(m.common.cancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(m.common.delete),
),
],
),
).then((confirmed) async {
if (confirmed != true) return;
try {
await controller.permanentlyDeleteItem(item);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(m.checklists.permanentlyDeleteFailed)),
);
}
}
});
}
void _viewItem(
BuildContext context,
ChecklistsController controller,
ListItem item,
) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ItemDetailView(
item: item,
category: item.categoryId != null
? controller.categories[item.categoryId]
: null,
houseId: controller.houseId,
controller: controller,
),
),
);
}
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,
ListItem item,
) {
Navigator.of(context).push<bool>(
MaterialPageRoute(
builder: (_) => ItemFormView(controller: controller, item: item),
),
);
}
void _deleteItem(
BuildContext context,
ChecklistsController controller,
ListItem item,
) {
showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(m.checklists.itemForm.deleteConfirm),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(m.common.cancel),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(m.common.delete),
),
],
),
).then((confirmed) async {
if (confirmed != true) return;
try {
await controller.deleteItem(item);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(m.checklists.itemForm.deleteFailed)),
);
}
}
});
}
Widget _tileFor(BuildContext context, int index) {
final item = items[index];
final showSeparator =
categorySpacing != 'disabled' &&
index > 0 &&
items[index - 1].categoryId != item.categoryId;
final tile = ChecklistItemTile(
key: allowReorder ? null : ValueKey(item.id),
item: item,
category: item.categoryId != null
? controller.categories[item.categoryId]
: null,
houseId: controller.houseId,
trashMode: controller.isTrashMode,
onToggle: (item) => _toggleItem(context, controller, item),
onView: (item) => _viewItem(context, controller, item),
onEdit: (item) => _editItem(context, controller, item),
onMove: (item) => _moveItem(context, controller, item),
onDelete: (item) => _deleteItem(context, controller, item),
onRestore: (item) => _restoreItem(context, controller, item),
onPermanentDelete: (item) =>
_permanentlyDelete(context, controller, item),
);
return showSeparator
? Column(
children: [
if (categorySpacing == 'divider')
const Divider(height: 25)
else
const SizedBox(height: 20),
tile,
],
)
: tile;
}
@override
Widget build(BuildContext context) {
if (!allowReorder) {
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) => _tileFor(context, index),
childCount: items.length,
),
);
}
return SliverReorderableList(
itemCount: items.length,
onReorder: (oldIndex, newIndex) {
if (newIndex > oldIndex) newIndex--;
controller.reorderItems(items, oldIndex, newIndex);
},
itemBuilder: (context, index) {
final item = items[index];
return ReorderableDelayedDragStartListener(
key: ValueKey(item.id),
index: index,
child: _tileFor(context, index),
);
},
);
}
}