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, + ); } }