/// Items on one shopping list, with check-off and edit/remove. library; import 'package:flutter/material.dart'; import 'models/product.dart'; import 'models/shopping_list.dart'; import 'product_detail_screen.dart'; import 'services/product_repository.dart'; import 'widgets/confirm_dialog.dart'; import 'widgets/name_dialog.dart'; class ShoppingListDetailScreen extends StatefulWidget { const ShoppingListDetailScreen({ super.key, required this.repository, required this.listId, }); final ProductRepository repository; final int listId; @override State createState() => _ShoppingListDetailScreenState(); } class _ShoppingListDetailScreenState extends State { late Future<({ShoppingList list, List items})> _future; bool _editing = false; @override void initState() { super.initState(); _future = _load(); } Future<({ShoppingList list, List items})> _load() async { final list = await widget.repository.getShoppingList(widget.listId); if (list == null) { throw StateError('List not found'); } final items = await widget.repository.getShoppingListItems(widget.listId); return (list: list, items: items); } void _reload() { setState(() => _future = _load()); } Future _rename(ShoppingList list) async { final name = await showNameDialog( context, title: 'Rename list', initial: list.name, label: 'List name', ); if (name == null || !mounted) return; await widget.repository.renameShoppingList(widget.listId, name); if (mounted) _reload(); } Future _openAddSheet() async { final products = await widget.repository.getAll(); if (!mounted) return; final result = await showModalBottomSheet<({String name, int? productId})>( context: context, isScrollControlled: true, showDragHandle: true, builder: (context) => _AddListItemSheet(products: products), ); if (result == null || !mounted) return; await widget.repository.addShoppingListItem( listId: widget.listId, name: result.name, productId: result.productId, ); if (mounted) _reload(); } Future _editItem(ShoppingListItem item) async { final name = await showNameDialog( context, title: 'Edit item', initial: item.name, label: 'Item', ); if (name == null || !mounted) return; await widget.repository.updateShoppingListItem(id: item.id, name: name); if (mounted) _reload(); } Future _removeItem(ShoppingListItem item) async { final confirmed = await confirmAction( context, title: 'Remove item?', message: '"${item.name}" will be removed from this list.', confirmLabel: 'Remove', ); if (!confirmed || !mounted) return; await widget.repository.deleteShoppingListItem(item.id); if (mounted) _reload(); } Future _toggle(ShoppingListItem item) async { await widget.repository.updateShoppingListItem( id: item.id, checked: !item.checked, ); if (mounted) _reload(); } Future _openProduct(int productId) async { await Navigator.of(context).push( MaterialPageRoute( builder: (_) => ProductDetailScreen( repository: widget.repository, productId: productId, ), ), ); } @override Widget build(BuildContext context) { final theme = Theme.of(context); return Scaffold( body: FutureBuilder( future: _future, builder: (context, snapshot) { if (snapshot.hasError) { return Scaffold( appBar: AppBar(), body: Center( child: Text( 'Could not load this list.', style: theme.textTheme.bodyLarge, ), ), ); } if (!snapshot.hasData) { return Scaffold( appBar: AppBar(), body: const Center(child: CircularProgressIndicator()), ); } final list = snapshot.data!.list; final items = snapshot.data!.items; return Scaffold( appBar: AppBar( title: Text(list.name), actions: [ IconButton( tooltip: 'Rename', onPressed: () => _rename(list), icon: const Icon(Icons.drive_file_rename_outline), ), IconButton( tooltip: _editing ? 'Done' : 'Edit', onPressed: () => setState(() => _editing = !_editing), icon: Icon(_editing ? Icons.check : Icons.edit_outlined), ), ], ), floatingActionButton: FloatingActionButton( onPressed: _openAddSheet, tooltip: 'Add item', child: const Icon(Icons.add), ), body: items.isEmpty ? Center( child: Text( 'Add things to buy.', style: theme.textTheme.bodyLarge, ), ) : ListView.builder( padding: const EdgeInsets.fromLTRB(8, 8, 8, 88), itemCount: items.length, itemBuilder: (context, index) { final item = items[index]; return ListTile( leading: Checkbox( value: item.checked, onChanged: _editing ? null : (_) => _toggle(item), ), title: Text( item.name, style: item.checked ? theme.textTheme.bodyLarge?.copyWith( decoration: TextDecoration.lineThrough, color: theme.colorScheme.onSurfaceVariant, ) : null, ), trailing: _editing ? Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( tooltip: 'Edit', onPressed: () => _editItem(item), icon: const Icon(Icons.edit_outlined), ), IconButton( tooltip: 'Remove', onPressed: () => _removeItem(item), icon: const Icon(Icons.delete_outline), ), ], ) : item.productId == null ? null : IconButton( tooltip: 'Product', onPressed: () => _openProduct(item.productId!), icon: const Icon(Icons.chevron_right), ), onTap: _editing ? null : () => _toggle(item), ); }, ), ); }, ), ); } } class _AddListItemSheet extends StatefulWidget { const _AddListItemSheet({required this.products}); final List products; @override State<_AddListItemSheet> createState() => _AddListItemSheetState(); } class _AddListItemSheetState extends State<_AddListItemSheet> { final TextEditingController _controller = TextEditingController(); String? _error; @override void dispose() { _controller.dispose(); super.dispose(); } List get _matches { final query = _controller.text.trim().toLowerCase(); if (query.isEmpty) { return widget.products.take(8).toList(); } return widget.products .where((product) => product.name.toLowerCase().contains(query)) .take(8) .toList(); } void _add({String? name, int? productId}) { final value = (name ?? _controller.text).trim(); if (value.isEmpty) { setState(() => _error = 'Enter an item name.'); return; } Navigator.of(context).pop((name: value, productId: productId)); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final bottom = MediaQuery.viewInsetsOf(context).bottom; final matches = _matches; return Padding( padding: EdgeInsets.fromLTRB(16, 0, 16, 16 + bottom), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text('Add item', style: theme.textTheme.titleLarge), const SizedBox(height: 16), TextField( controller: _controller, autofocus: true, textCapitalization: TextCapitalization.sentences, decoration: const InputDecoration(labelText: 'Item name'), onChanged: (_) => setState(() => _error = null), onSubmitted: (_) => _add(), ), if (_error != null) ...[ const SizedBox(height: 8), Text(_error!, style: TextStyle(color: theme.colorScheme.error)), ], if (matches.isNotEmpty) ...[ const SizedBox(height: 12), Text('From products', style: theme.textTheme.labelMedium), const SizedBox(height: 4), for (final product in matches) ListTile( contentPadding: EdgeInsets.zero, title: Text(product.name), onTap: () => _add(name: product.name, productId: product.id), ), ], const SizedBox(height: 8), FilledButton(onPressed: () => _add(), child: const Text('Add')), ], ), ); } }