From 47387bb3959328e089427e581493734e5fb50521 Mon Sep 17 00:00:00 2001 From: Jakob Meier Date: Tue, 4 Apr 2023 10:29:29 +0200 Subject: [PATCH] Added basic Shopping List and Product list including the ability to add item into the shopping cart and remove them. The click events have been implemented, however the routes do not exist yet. NOTE: The shopping list item info sheet is still missing the unit type and value and some more advanced options, like deleting the item --- lib/backend/room.dart | 97 +++++- lib/components/category_picker.dart | 57 ++++ lib/components/labeled_divider.dart | 19 ++ lib/components/product_picker.dart | 51 ++++ lib/l10n/app_en.arb | 21 +- lib/screens/room/pages/list.dart | 422 +++++++++++++++++++++++++-- lib/screens/room/pages/products.dart | 84 +++++- 7 files changed, 724 insertions(+), 27 deletions(-) create mode 100644 lib/components/category_picker.dart create mode 100644 lib/components/labeled_divider.dart create mode 100644 lib/components/product_picker.dart diff --git a/lib/backend/room.dart b/lib/backend/room.dart index 4f7c5c0..1b4ae54 100644 --- a/lib/backend/room.dart +++ b/lib/backend/room.dart @@ -315,8 +315,6 @@ class Room { } } -class ShoppingListItem {} - class RoomMember { final String id; final String serverTag; @@ -372,6 +370,13 @@ class RoomCategory { name: json['title'], color: colorFromString(json['color'])); } + factory RoomCategory.other(BuildContext context) { + return RoomCategory( + id: -1, + name: AppLocalizations.of(context)!.categoryNameOther, + color: Colors.grey + ); + } static List> listColors() { return [ @@ -463,3 +468,91 @@ String colorIdFromColor(ColorSwatch color) { return 'purple'; } + +class RoomProduct { + int id; + String name; + String description; + // category ID + // or null for category: "other" + int? category; + // unitID + int defaultUnit; + // NOTE: has to be string, + // as it may hold plain text, + // integers or doubles + String defaultValue; + String? ean; + // parent product ID + int? parent; + + RoomProduct( + {required this.id, + required this.name, + this.description = '', + this.category = -1, + this.defaultUnit = 0, + this.defaultValue = '', + this.ean, + this.parent}); + + factory RoomProduct.fromJSON(dynamic json) { + return RoomProduct( + id: json['listProdID'], + name: json['title'], + description: json['description'], + category: json['category'], + defaultUnit: json['defUnit'], + defaultValue: json['defValue'], + ean: json['ean'], + parent: json['parent']); + } +} + +class RoomItem { + int id; + int state; + String name; + String description; + // may link to a category + // null for other + int? category; + + int unit; + String value; + // may link to a product + int? link; + + RoomItem( + {required this.id, + required this.name, + this.description = '', + this.state = 0, + this.category = -1, + this.unit = 0, + this.value = '', + this.link}); + + factory RoomItem.fromJSON(dynamic json) { + return RoomItem( + id: json['listItemID'], + name: json['title'], + description: json['description'], + category: json['listCatID'], + state: json['state'], + unit: json['unit'], + value: json['value'], + link: json['listProdID']); + } + + RoomItem clone() { + return RoomItem( + id: id, + name: name, + description: description, + category: category, + unit: unit, + value: value, + link: link); + } +} diff --git a/lib/components/category_picker.dart b/lib/components/category_picker.dart new file mode 100644 index 0000000..c4b4eee --- /dev/null +++ b/lib/components/category_picker.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:outbag_app/backend/room.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class CategoryPicker extends StatelessWidget { + List categories = []; + int? selected; + bool enabled = true; + Function(int?)? onSelect; + + // hint and label may differ depending on the screen + String? hint; + String? label; + + CategoryPicker( + {required this.categories, + this.selected, + this.onSelect, + this.hint, + this.label, + this.enabled = true}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8), + child: DropdownMenu( + initialSelection: selected, + enabled: enabled, + hintText: hint, + label: (label!=null)?Text(label!):null, + onSelected: ((id) { + if (onSelect != null) { + onSelect!(id); + } + }), + dropdownMenuEntries: [ + // entry for every categry + ...categories.map((category) => DropdownMenuEntry( + value: category.id, + label: category.name, + trailingIcon: Icon( + Icons.square_rounded, + color: category.color, + ))), + // entry for default ("other") category + DropdownMenuEntry( + value: null, + label: AppLocalizations.of(context)!.categoryNameOther, + trailingIcon: Icon( + Icons.square_rounded, + color: RoomCategory.other(context).color, + )) + ], + )); + } +} diff --git a/lib/components/labeled_divider.dart b/lib/components/labeled_divider.dart new file mode 100644 index 0000000..9855e03 --- /dev/null +++ b/lib/components/labeled_divider.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; + +class LabeledDivider extends StatelessWidget { + String label; + LabeledDivider(this.label); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + const Expanded(child: Divider()), + Padding( + padding: const EdgeInsets.all(8), + child: Text(label)), + const Expanded(child: Divider()), + ] + ); + } +} diff --git a/lib/components/product_picker.dart b/lib/components/product_picker.dart new file mode 100644 index 0000000..e14f3a4 --- /dev/null +++ b/lib/components/product_picker.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:outbag_app/backend/room.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +class ProductPicker extends StatelessWidget { + List products = []; + int? selected; + bool enabled = true; + Function(int?)? onSelect; + + // hint and label may differ depending on the screen + String? hint; + String? label; + + ProductPicker( + {required this.products, + this.selected, + this.onSelect, + this.hint, + this.label, + this.enabled = true}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8), + child: DropdownMenu( + initialSelection: selected, + label: (label!=null)?Text(label!):null, + hintText: hint, + enabled: enabled, + onSelected: ((id) { + if (onSelect != null) { + onSelect!(id); + } + }), + dropdownMenuEntries: [ + // entry for no product + DropdownMenuEntry( + value: null, + label: AppLocalizations.of(context)!.productNameNone, + ), + // entry for every product + ...products.map((product) => DropdownMenuEntry( + value: product.id, + label: product.name, + )), + ], + )); + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e9dc8d1..94919bd 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -336,5 +336,24 @@ "chooseCategoryColor": "Choose a color for your category", "inputCategoryNameLabel": "Category Name", "inputCategoryNameHint": "Name the category", - "inputCategoryNameHelp": "Categories can be used to sort your shopping list" + "inputCategoryNameHelp": "Categories can be used to sort your shopping list", + "categoryNameOther": "Other", + + "createProduct": "New Product", + "createProductShort": "New", + "editProduct": "Edit Product", + "editProductShort": "Edit", + "shoppingList": "Shopping List", + "shoppingCart": "Shopping Cart", + + "itemShowLinkedProductTitle": "View linked Product", + "itemShowLinkedProductSubtitle": "Displays additional information about the product", + + "productNameNone": "None", + "selectCategoryLabel": "Category", + "selectCategoryHint": "Select a category", + "selectLinkedProductLabel": "Linked Product", + "selectLinkedProductHint": "Link a product to your item", + "selectParentProductLabel": "Parent Product", + "selectParentProductHint": "Nest products by choosing a parent product" } diff --git a/lib/screens/room/pages/list.dart b/lib/screens/room/pages/list.dart index 4eab83c..3c9cc39 100644 --- a/lib/screens/room/pages/list.dart +++ b/lib/screens/room/pages/list.dart @@ -1,5 +1,15 @@ 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_picker.dart'; +import 'package:outbag_app/components/labeled_divider.dart'; +import 'package:outbag_app/components/product_picker.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; @@ -12,45 +22,411 @@ class ShoppingListPage extends StatefulWidget { } class _ShoppingListPageState extends State { - List list = []; + List list = []; + List cart = []; + Map weights = {}; + List categories = []; + List products = []; - void loadData() async { - //bool foundData = false; + void fetchItems() { + final user = context.read(); - // TODO: 1. load data from disk (if available) - // NOTE: errors do not matter, - // hopefully the network request will succeed - try { - //List list = await ShoppingListItem.getAllFromDisk(); - //setState(() { - // this.list = list; - //}) + // TODO: load cached items first - //foundData = true; - } catch (_) {} + 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(); - // TODO: 2. load data from web + final List l = []; + final List c = []; - // NOTE: might want to close room - // or show snackbar if no data is available + for (RoomItem item in resp) { + if (item.state == 0) { + l.add(item); + } else { + c.add(item); + } + } + + // TODO: cache items + + 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(raw)) + .toList(); + + Map map = {}; + for (int i = 0; i < resp.length; i++) { + map[resp[i].id] = i; + } + setState(() { + weights = map; + categories = resp; + 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(); + + setState(() { + products = resp; + }); + }); } @override void initState() { super.initState(); - loadData(); + 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 ListView.builder( - itemBuilder: (ctx, index) { - final item = list[index]; + return Scaffold( + body: ListView(children: [ + LabeledDivider(AppLocalizations.of(context)!.shoppingList), + ListView.builder( + shrinkWrap: true, + physics: const ClampingScrollPhysics(), + itemCount: list.length, + itemBuilder: (context, index) { + final item = list[index]; - return ListTile(); - }, - itemCount: list.length, + return ShoppingListItem( + name: item.name, + description: item.description, + category: (item.category != null) + ? categories[item.category!] + : RoomCategory.other(context), + 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, + categories: categories, + 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]; + + return ShoppingListItem( + name: item.name, + description: item.description, + category: (item.category != null) + ? categories[item.category!] + : RoomCategory.other(context), + 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: () { + // 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, + categories: categories, + item: item, + room: widget.room!.id, + server: widget.room!.serverTag)); + }); + }, + ) + ]), + floatingActionButton: ((widget.info?.isAdmin ?? false) || + (widget.info?.isOwner ?? false) || + ((widget.info?.permissions)! & RoomPermission.editRoomContent != + 0)) + ? FloatingActionButton.extended( + icon: const Icon(Icons.add), + label: Text(AppLocalizations.of(context)!.newCategoryShort), + tooltip: AppLocalizations.of(context)!.newCategoryLong, + onPressed: () { + // show new category popup + context.pushNamed('new-item', params: { + 'server': widget.room!.serverTag, + 'id': widget.room!.id, + }); + }, + ) + : null, + ); + } +} + +class ShoppingListItem extends StatelessWidget { + String name; + RoomCategory category; + String description; + bool inCart; + final Key _key; + Function()? onDismiss; + Function()? onTap; + + ShoppingListItem( + {required this.name, + required this.category, + required this.inCart, + required this.description, + 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: ListTile( + enabled: !inCart, + title: Text(name), + subtitle: Text(description), + trailing: ActionChip( + avatar: Icon(Icons.square_rounded, color: category.color), + label: Text(category.name), + ), + onTap: () { + if (onTap != null) { + onTap!(); + } + }, + ), + ); + } +} + +class ShoppingListItemInfo extends StatelessWidget { + RoomItem item; + String server; + String room; + List categories = []; + List products = []; + + ShoppingListItemInfo( + {required this.item, + required this.server, + required this.room, + required this.categories, + 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), + CategoryPicker( + label: AppLocalizations.of(context)!.selectCategoryLabel, + categories: categories, + selected: item.category, + enabled: false), + ProductPicker( + label: + AppLocalizations.of(context)!.selectLinkedProductLabel, + products: products, + selected: item.link, + enabled: false) + + // TODO: show more info + ]))), + ...(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.id.toString() + }); + }, + ) + ] + : [] + ], + ), ); } } diff --git a/lib/screens/room/pages/products.dart b/lib/screens/room/pages/products.dart index c4b24c8..38bd272 100644 --- a/lib/screens/room/pages/products.dart +++ b/lib/screens/room/pages/products.dart @@ -1,5 +1,12 @@ 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/tools/fetch_wrapper.dart'; +import 'package:provider/provider.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; class RoomProductsPage extends StatefulWidget { final RoomInfo? info; @@ -12,8 +19,83 @@ class RoomProductsPage extends StatefulWidget { } class _RoomProductsPageState extends State { + List products = []; + + void fetchProducts() { + final user = context.read(); + + 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(); + + // TODO: cache products + + setState(() { + products = resp; + }); + }); + } + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) => fetchProducts()); + } + @override Widget build(BuildContext context) { - return const Text('Products'); + return Scaffold( + body: ListView.builder( + itemCount: products.length, + itemBuilder: (context, index) { + final item = products[index]; + + return ListTile( + title: Text(item.name), + subtitle: Text(item.description), + onTap: () { + // NOTE: we could also show a bottom sheet here, + // but because we need a seperate page/route either way + // (for viewing item-links and exploring the product-tree) + // we might as well use the view-product page here + // NOTE: This might seem inconsistent, + // but you probably wont ever need to read a product description, + // where as reading the shopping item description, + // might be a good idea + context.pushNamed('view-product', params: { + 'server': widget.room!.serverTag, + 'id': widget.room!.id, + 'product': item.id.toString() + }); + }, + ); + }, + ), + floatingActionButton: ((widget.info?.isAdmin ?? false) || + (widget.info?.isOwner ?? false) || + ((widget.info?.permissions)! & RoomPermission.editRoomContent != + 0)) + ? FloatingActionButton.extended( + icon: const Icon(Icons.add), + label: Text(AppLocalizations.of(context)!.newCategoryShort), + tooltip: AppLocalizations.of(context)!.newCategoryLong, + onPressed: () { + // show new category popup + context.pushNamed('new-product', params: { + 'server': widget.room!.serverTag, + 'id': widget.room!.id, + }); + }, + ) + : null, + ); } }