import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:outbag_app/backend/permissions.dart'; import 'package:outbag_app/backend/request.dart'; import 'package:outbag_app/backend/room.dart'; import 'package:outbag_app/backend/user.dart'; import 'package:outbag_app/components/category_chip.dart'; import 'package:outbag_app/components/labeled_divider.dart'; import 'package:outbag_app/components/value_unit_input.dart'; import 'package:outbag_app/tools/fetch_wrapper.dart'; import 'package:provider/provider.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; class ShoppingListPage extends StatefulWidget { final RoomInfo? info; final Room? room; const ShoppingListPage(this.room, this.info, {super.key}); @override State createState() => _ShoppingListPageState(); } class _ShoppingListPageState extends State { List list = []; List cart = []; Map weights = {}; Map categories = {}; List products = []; void fetchItems() { final user = context.read(); // TODO: load cached items first doNetworkRequest(ScaffoldMessenger.of(context), req: () => postWithCreadentials( credentials: user, target: user.server, path: 'getItems', body: {'room': widget.room?.id, 'server': widget.room?.serverTag}), onOK: (body) async { final resp = body['data'] .map((raw) => RoomItem.fromJSON(raw)) .toList(); final List l = []; final List c = []; for (RoomItem item in resp) { if (item.state == 0) { l.add(item); } else { c.add(item); } } // TODO: cache items if (mounted) { setState(() { list = l; cart = c; sortAll(); }); } }); } void sortAll() { for (List input in [list, cart]) { setState(() { input.sort((a, b) { if (a.category == b.category) { return 0; } if (a.category == null) { // b should be below return -1; } if (b.category == null) { // a should be below return 1; } final weightA = weights[a.category]; final weightB = weights[b.category]; // both could be null now, // so we have to check agein if (weightA == weightB) { return 0; } if (weightA == null) { // b should be below return -1; } if (weightB == null) { // a should be below return 1; } return weightA.compareTo(weightB); }); }); } } void fetchCategories() { final user = context.read(); // TODO: load cached categories first doNetworkRequest(ScaffoldMessenger.of(context), req: () => postWithCreadentials( credentials: user, target: user.server, path: 'getCategories', body: {'room': widget.room?.id, 'server': widget.room?.serverTag}), onOK: (body) async { final resp = body['data'] .map((raw) => RoomCategory.fromJSON( widget.room?.serverTag ?? "", widget.room?.id ?? "", raw)) .toList(); Map map = {}; Map cat = {}; for (int i = 0; i < resp.length; i++) { map[resp[i].id] = i; cat[resp[i].id] = resp[i]; } if (mounted) { setState(() { weights = map; categories = cat; sortAll(); }); } }); } void fetchProducts() { final user = context.read(); // TODO: load cached products first doNetworkRequest(ScaffoldMessenger.of(context), req: () => postWithCreadentials( credentials: user, target: user.server, path: 'getProducts', body: {'room': widget.room?.id, 'server': widget.room?.serverTag}), onOK: (body) async { final resp = body['data'] .map((raw) => RoomProduct.fromJSON(raw)) .toList(); if (mounted) { setState(() { products = resp; }); } }); } @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { fetchItems(); fetchCategories(); fetchProducts(); }); } void changeItemState(RoomItem item) { final user = context.read(); doNetworkRequest(ScaffoldMessenger.of(context), req: () => postWithCreadentials( credentials: user, target: user.server, path: 'changeItemState', body: { 'room': widget.room?.id, 'server': widget.room?.serverTag, 'listItemID': item.id, 'state': item.state })); } @override Widget build(BuildContext context) { return Scaffold( body: Center( child: Padding( padding: const EdgeInsets.all(14), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 600), child: ListView(children: [ LabeledDivider(AppLocalizations.of(context)!.shoppingList), ListView.builder( shrinkWrap: true, physics: const ClampingScrollPhysics(), itemCount: list.length, itemBuilder: (context, index) { final item = list[index]; final cat = categories[item.category] ?? RoomCategory.other(widget.room?.serverTag ?? "", widget.room?.id ?? "", context); return ShoppingListItem( name: item.name, server: widget.room!.serverTag, room: widget.room!.id, description: item.description, category: cat, key: Key(item.id.toString()), inCart: item.state != 0, onDismiss: () { // move to cart item.state = 1; setState(() { list.removeAt(index); cart.add(item); sortAll(); }); // network request // NOTE: for now we do not care if it is successfull, // because the shopping cart is supposed to work even // without network changeItemState(item); }, onTap: () { // TODO: show modal bottom sheet // containing // - item info // - option to view linked product (if available) // - edit item (if allowed) // - delete item (if allowed) // - move to/from shopping cart? showModalBottomSheet( context: context, builder: (context) => ShoppingListItemInfo( products: products, category: cat, info: widget.info, item: item, room: widget.room!.id, server: widget.room!.serverTag)); }); }, ), LabeledDivider(AppLocalizations.of(context)!.shoppingCart), ListView.builder( itemCount: cart.length, shrinkWrap: true, physics: const ClampingScrollPhysics(), itemBuilder: (context, index) { final item = cart[index]; final cat = categories[item.category] ?? RoomCategory.other(widget.room!.serverTag, widget.room!.id, context); return ShoppingListItem( server: widget.room!.serverTag, room: widget.room!.id, name: item.name, description: item.description, category: cat, key: Key(item.id.toString()), inCart: item.state != 0, onDismiss: () { // move back to list item.state = 0; setState(() { cart.removeAt(index); list.add(item); sortAll(); }); // network request // NOTE: for now we do not care if it is successfull, // because the shopping cart is supposed to work even // without network changeItemState(item); }, onTap: () { // show modal bottom sheet // containing // - item info // - option to view linked product (if available) // - edit item (if allowed) // - delete item (if allowed) // - move to/from shopping cart? showModalBottomSheet( context: context, builder: (context) => ShoppingListItemInfo( products: products, category: cat, item: item, info: widget.info, room: widget.room!.id, server: widget.room!.serverTag)); }); }, ) ])))), floatingActionButton: (widget.info != null && ((widget.info?.isAdmin ?? false) || (widget.info?.isOwner ?? false) || ((widget.info?.permissions)! & RoomPermission.addShoppingListItems != 0))) ? FloatingActionButton.extended( icon: const Icon(Icons.add), label: Text(AppLocalizations.of(context)!.newItemShort), tooltip: AppLocalizations.of(context)!.newItemLong, onPressed: () { // show new category popup context.pushNamed('new-item', params: { 'server': widget.room!.serverTag, 'id': widget.room!.id, }); }, ) : null, ); } } class ShoppingListItem extends StatelessWidget { final String name; final RoomCategory category; final String description; final bool inCart; final Key _key; final Function()? onDismiss; final Function()? onTap; final String server; final String room; const ShoppingListItem( {required this.name, required this.category, required this.inCart, required this.description, required this.server, required this.room, required key, this.onDismiss, this.onTap}) : _key = key; @override Widget build(BuildContext context) { return Dismissible( key: Key('item-$_key'), dismissThresholds: const { // NOTE: value might need updating // maybe we could calculate this using the screen width DismissDirection.horizontal: 500.0, }, confirmDismiss: (_) async { if (onDismiss != null) { onDismiss!(); } // keep item in list // NOTE: might want to set this to true/variable // if in-shopping-cart items have a second screen return true; }, background: Icon(!inCart ? Icons.shopping_cart : Icons.remove_shopping_cart), child: Opacity( opacity: inCart ? 0.5 : 1.0, child: ListTile( title: Text(name), subtitle: Text(description), trailing: CategoryChip( server: server, room: room, category: category, ), onTap: () { if (onTap != null) { onTap!(); } }, )), ); } } class ShoppingListItemInfo extends StatelessWidget { final RoomItem item; final String server; final String room; final RoomInfo? info; final RoomCategory category; final List products; const ShoppingListItemInfo( {super.key, this.info, required this.item, required this.server, required this.room, required this.category, required this.products}); @override Widget build(BuildContext context) { final textTheme = Theme.of(context).textTheme; return BottomSheet( onClosing: () {}, builder: (context) => Column( children: [ Padding( padding: const EdgeInsets.all(14), child: Center( child: Column(children: [ Text(item.name, style: textTheme.headlineLarge), Text(item.description, style: textTheme.titleMedium), CategoryChip( server: server, room: room, category: category, ), Text(Unit.fromId(item.unit).display(context, item.value)) ]))), ...(item.link != null) ? [ ListTile( title: Text(AppLocalizations.of(context)! .itemShowLinkedProductTitle), subtitle: Text(AppLocalizations.of(context)! .itemShowLinkedProductSubtitle), trailing: const Icon(Icons.chevron_right), onTap: () { // launch "view-product" page for specific product context.pushNamed('view-product', params: { 'server': server, 'id': room, 'product': item.link.toString() }); }, ) ] : [], ...(info != null && ((info?.isAdmin ?? false) || (info?.isOwner ?? false) || ((info?.permissions)! & RoomPermission.addShoppingListItems != 0))) ? [ ListTile( title: Text(AppLocalizations.of(context)!.editItem), subtitle: Text(AppLocalizations.of(context)!.editItemLong), trailing: const Icon(Icons.chevron_right), onTap: () { context.pushNamed('edit-item', params: { 'server': server, 'id': room, 'item': item.id.toString() }); }, ), ListTile( title: Text(AppLocalizations.of(context)!.deleteItemTitle), subtitle: Text( AppLocalizations.of(context)!.deleteItemSubtitle), trailing: const Icon(Icons.chevron_right), onTap: () { // show popup showDialog( context: context, builder: (ctx) => AlertDialog( icon: const Icon(Icons.delete), title: Text( AppLocalizations.of(context)!.deleteItem), content: Text(AppLocalizations.of(context)! .deleteItemConfirm(item.name)), actions: [ TextButton( onPressed: () { // close popup Navigator.of(ctx).pop(); }, child: Text( AppLocalizations.of(context)!.cancel), ), FilledButton( onPressed: () async { // send request final scaffMgr = ScaffoldMessenger.of(ctx); // popup context final navInner = Navigator.of(ctx); // bottomsheet context final nav = Navigator.of(context); final user = context.read(); doNetworkRequest(scaffMgr, req: () => postWithCreadentials( path: 'deleteItem', target: user.server, body: { 'room': room, 'server': server, 'listItemID': item.id }, credentials: user), onOK: (_) async { // TODO: remove cached item }, after: () { // close popup navInner.pop(); // close modal bottom sheet nav.pop(); }); }, child: Text(AppLocalizations.of(context)! .deleteItem), ) ], )); }), ] : [], ListTile( title: Text(item.state == 0 ? AppLocalizations.of(context)!.moveItemToCartTitle : AppLocalizations.of(context)!.moveItemToCartSubtitle), subtitle: Text(item.state == 0 ? AppLocalizations.of(context)!.removeItemFromCartTitle : AppLocalizations.of(context)!.removeItemFromCartSubtitle), onTap: () { // flip state item.state = (item.state - 1).abs(); final user = context.read(); doNetworkRequest(ScaffoldMessenger.of(context), req: () => postWithCreadentials( credentials: user, target: user.server, path: 'changeItemState', body: { 'room': room, 'server': server, 'listItemID': item.id, 'state': item.state })); }) ], ), ); } }