From 5cd21c8adf93e2a5dcd285a04f2ba4d638084f7f Mon Sep 17 00:00:00 2001 From: Jakob Meier Date: Tue, 4 Apr 2023 20:28:26 +0200 Subject: [PATCH] Added Combined new/edit and view product screen Similarily to categories and rooms, the edit product screen is reused as a new-room screen, which is especially easy, because the user is unable to select the product id themselves. NOTE: the dynamic value-unit input is still missing some "subunits" The view product screen has links to the edit product page, the view parent page (if available) and a not yet functional view children screen. NOTE: The parent product display should be restricted in width, and the screen is missing value/unit information. --- lib/backend/room.dart | 213 +++++++++-------- lib/components/category_chip.dart | 16 ++ lib/components/category_picker.dart | 75 +++--- lib/components/product_picker.dart | 60 ++--- lib/components/value_unit_input.dart | 331 +++++++++++++++++++++++++++ lib/l10n/app_en.arb | 42 +++- lib/main.dart | 29 ++- lib/screens/room/pages/list.dart | 14 +- lib/screens/room/pages/products.dart | 4 +- lib/screens/room/products/edit.dart | 287 +++++++++++++++++++++++ lib/screens/room/products/view.dart | 215 +++++++++++++++++ 11 files changed, 1101 insertions(+), 185 deletions(-) create mode 100644 lib/components/category_chip.dart create mode 100644 lib/components/value_unit_input.dart create mode 100644 lib/screens/room/products/edit.dart create mode 100644 lib/screens/room/products/view.dart diff --git a/lib/backend/room.dart b/lib/backend/room.dart index 1b4ae54..a6eaad8 100644 --- a/lib/backend/room.dart +++ b/lib/backend/room.dart @@ -127,32 +127,32 @@ class RoomIcon { String get text { switch (type.toLowerCase()) { case 'love': - return 'Friends'; + return 'Friends'; case 'sports': - return 'Sports'; + return 'Sports'; case 'pets': - return 'Pets'; + return 'Pets'; case 'vacation': - return 'Vacation'; + return 'Vacation'; case 'gifts': - return 'Gifts'; + return 'Gifts'; case 'groceries': - return 'Groceries'; + return 'Groceries'; case 'fashion': - return 'Clothing'; + return 'Clothing'; case 'art': - return 'Arts & Crafts'; + return 'Arts & Crafts'; case 'tech': - return 'Electronics'; + return 'Electronics'; case 'home': - return 'Home supplies'; + return 'Home supplies'; case 'family': - return 'Family'; + return 'Family'; case 'social': - return 'Social'; + return 'Social'; case 'other': default: - return 'Other'; + return 'Other'; } } @@ -161,44 +161,44 @@ class RoomIcon { String path = ""; switch (type.toLowerCase()) { case 'love': - path = 'undraw/undraw_couple.svg'; - break; + path = 'undraw/undraw_couple.svg'; + break; case 'sports': - path = 'undraw/undraw_greek_freak.svg'; - break; + path = 'undraw/undraw_greek_freak.svg'; + break; case 'pets': - path = 'undraw/undraw_dog.svg'; - break; + path = 'undraw/undraw_dog.svg'; + break; case 'vacation': - path = 'undraw/undraw_trip.svg'; - break; + path = 'undraw/undraw_trip.svg'; + break; case 'gifts': - path = 'undraw/undraw_gifts.svg'; - break; + path = 'undraw/undraw_gifts.svg'; + break; case 'groceries': - path = 'undraw/undraw_gone_shopping.svg'; - break; + path = 'undraw/undraw_gone_shopping.svg'; + break; case 'fashion': - path = 'undraw/undraw_jewelry.svg'; - break; + path = 'undraw/undraw_jewelry.svg'; + break; case 'art': - path = 'undraw/undraw_sculpting.svg'; - break; + path = 'undraw/undraw_sculpting.svg'; + break; case 'tech': - path = 'undraw/undraw_progressive_app.svg'; - break; + path = 'undraw/undraw_progressive_app.svg'; + break; case 'home': - path = 'undraw/undraw_under_construction.svg'; - break; + path = 'undraw/undraw_under_construction.svg'; + break; case 'family': - path = 'undraw/undraw_family.svg'; - break; + path = 'undraw/undraw_family.svg'; + break; case 'social': - path = 'undraw/undraw_pizza_sharing.svg'; - break; + path = 'undraw/undraw_pizza_sharing.svg'; + break; case 'other': default: - path = 'undraw/undraw_file_manager.svg'; + path = 'undraw/undraw_file_manager.svg'; } return asset(path); @@ -214,7 +214,7 @@ class Room { RoomVisibility? visibility = RoomVisibility.private; Room( - {required this.id, + {required this.id, required this.serverTag, this.name = "", this.description = "", @@ -263,12 +263,12 @@ class Room { factory Room.fromMap(Map map) { return Room( - id: map['id'], - serverTag: map['server'], - name: map['name'], - description: map['description'] ?? '', - icon: RoomIcon(type: map['icon'] ?? 'Other'), - visibility: RoomVisibility(map['visibility'] ?? 0)); + id: map['id'], + serverTag: map['server'], + name: map['name'], + description: map['description'] ?? '', + icon: RoomIcon(type: map['icon'] ?? 'Other'), + visibility: RoomVisibility(map['visibility'] ?? 0)); } Map toMap() { @@ -284,12 +284,12 @@ class Room { factory Room.fromJSON(dynamic json) { return Room( - id: json['name'], - serverTag: json['server'], - name: json['title'], - description: json['description'], - icon: RoomIcon(type: json['icon']), - visibility: RoomVisibility(json['visibility'])); + id: json['name'], + serverTag: json['server'], + name: json['title'], + description: json['description'], + icon: RoomIcon(type: json['icon']), + visibility: RoomVisibility(json['visibility'])); } Future toDisk() async { @@ -308,7 +308,7 @@ class Room { } static Future fromDisk( - {required String id, required String serverTag}) async { + {required String id, required String serverTag}) async { final db = Localstore.instance; final raw = await db.collection('rooms').doc('$id@$serverTag').get(); return Room.fromMap(raw!); @@ -321,13 +321,13 @@ class RoomMember { final bool isAdmin; const RoomMember( - {required this.id, required this.serverTag, required this.isAdmin}); + {required this.id, required this.serverTag, required this.isAdmin}); factory RoomMember.fromJSON(dynamic json) { return RoomMember( - id: json['name'], - serverTag: json['server'], - isAdmin: json['admin'] == 1); + id: json['name'], + serverTag: json['server'], + isAdmin: json['admin'] == 1); } String get humanReadableName { @@ -342,40 +342,39 @@ class RoomInfo { final int permissions; const RoomInfo( - {required this.permissions, + {required this.permissions, required this.owner, required this.isAdmin, required this.isOwner}); factory RoomInfo.fromJSON(dynamic json) { return RoomInfo( - permissions: json['rights'], - owner: json['owner'], - isAdmin: json['isAdmin'], - isOwner: json['isOwner']); + permissions: json['rights'], + owner: json['owner'], + isAdmin: json['isAdmin'], + isOwner: json['isOwner']); } } class RoomCategory { - final int id; + final int? id; final String name; final ColorSwatch color; const RoomCategory( - {required this.id, required this.name, required this.color}); + {required this.id, required this.name, required this.color}); factory RoomCategory.fromJSON(dynamic json) { return RoomCategory( - id: json['id'], - name: json['title'], - color: colorFromString(json['color'])); + id: json['id'], + name: json['title'], + color: colorFromString(json['color'])); } factory RoomCategory.other(BuildContext context) { return RoomCategory( - id: -1, - name: AppLocalizations.of(context)!.categoryNameOther, - color: Colors.grey - ); + id: null, + name: AppLocalizations.of(context)!.categoryNameOther, + color: Colors.grey); } static List> listColors() { @@ -399,31 +398,31 @@ class RoomCategory { ColorSwatch colorFromString(String text) { switch (text.toLowerCase()) { case 'red-acc': - return Colors.redAccent; + return Colors.redAccent; case 'green-acc': - return Colors.greenAccent; + return Colors.greenAccent; case 'yellow-acc': - return Colors.yellowAccent; + return Colors.yellowAccent; case 'blue-acc': - return Colors.blueAccent; + return Colors.blueAccent; case 'aqua-acc': - return Colors.tealAccent; + return Colors.tealAccent; case 'purple-acc': - return Colors.purpleAccent; + return Colors.purpleAccent; case 'red': - return Colors.red; + return Colors.red; case 'green': - return Colors.green; + return Colors.green; case 'yellow': - return Colors.yellow; + return Colors.yellow; case 'blue': - return Colors.blue; + return Colors.blue; case 'aqua': - return Colors.teal; + return Colors.teal; case 'purple': default: - return Colors.purple; + return Colors.purple; } } @@ -487,7 +486,7 @@ class RoomProduct { int? parent; RoomProduct( - {required this.id, + {required this.id, required this.name, this.description = '', this.category = -1, @@ -498,14 +497,14 @@ class RoomProduct { 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']); + id: json['listProdID'], + name: json['title'], + description: json['description'], + category: json['category'], + defaultUnit: json['defUnit'], + defaultValue: json['defValue'], + ean: json['ean'], + parent: json['parent']); } } @@ -524,7 +523,7 @@ class RoomItem { int? link; RoomItem( - {required this.id, + {required this.id, required this.name, this.description = '', this.state = 0, @@ -535,24 +534,24 @@ class RoomItem { 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']); + 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); + id: id, + name: name, + description: description, + category: category, + unit: unit, + value: value, + link: link); } } diff --git a/lib/components/category_chip.dart b/lib/components/category_chip.dart new file mode 100644 index 0000000..e878923 --- /dev/null +++ b/lib/components/category_chip.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; +import 'package:outbag_app/backend/room.dart'; + +class CategoryChip extends StatelessWidget { + final RoomCategory? category; + + const CategoryChip({super.key, this.category}); + + @override + Widget build(BuildContext context) { + return ActionChip( + avatar: Icon(Icons.square_rounded, color: category?.color ?? RoomCategory.other(context).color), + label: Text(category?.name ?? RoomCategory.other(context).name), + ); + } +} diff --git a/lib/components/category_picker.dart b/lib/components/category_picker.dart index c4b4eee..b0a97bb 100644 --- a/lib/components/category_picker.dart +++ b/lib/components/category_picker.dart @@ -1,19 +1,19 @@ 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; + final List categories; + final int? selected; + final bool enabled; + final Function(int?)? onSelect; // hint and label may differ depending on the screen - String? hint; - String? label; + final String? hint; + final String? label; - CategoryPicker( - {required this.categories, + const CategoryPicker( + {super.key, + required this.categories, this.selected, this.onSelect, this.hint, @@ -24,34 +24,35 @@ class CategoryPicker extends StatelessWidget { 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, - )) - ], + child: DropdownButtonFormField( + hint: hint==null?null:Text(hint!), + decoration: InputDecoration( + label: label==null?null:Text(label!), + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.category) + ), + value: selected, + items: [ + ...categories, + RoomCategory.other(context) + ].map((category)=>DropdownMenuItem( + value: category.id, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text(category.name), + Icon(Icons.square_rounded, + color:category.color, + size: 32) + ] + ), + )).toList(), + onChanged: enabled?(cid) { + if (onSelect != null) { + onSelect!(cid); + } + }:null, )); } } diff --git a/lib/components/product_picker.dart b/lib/components/product_picker.dart index e14f3a4..3cd723a 100644 --- a/lib/components/product_picker.dart +++ b/lib/components/product_picker.dart @@ -3,20 +3,23 @@ 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; + final List products; + final int? selected; + final bool enabled; + final Function(int?)? onSelect; // hint and label may differ depending on the screen - String? hint; - String? label; + final String? hint; + final String? label; + final String? help; - ProductPicker( - {required this.products, + const ProductPicker( + {super.key, + required this.products, this.selected, this.onSelect, this.hint, + this.help, this.label, this.enabled = true}); @@ -24,28 +27,31 @@ class ProductPicker extends StatelessWidget { 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( + child: DropdownButtonFormField( + hint: hint == null ? null : Text(hint!), + decoration: InputDecoration( + label: label == null ? null : Text(label!), + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.inventory_2), + helperText: help), + value: selected, + items: [ + // "no product" entry + DropdownMenuItem( value: null, - label: AppLocalizations.of(context)!.productNameNone, + child: Text(AppLocalizations.of(context)!.productNameNone), ), - // entry for every product - ...products.map((product) => DropdownMenuEntry( - value: product.id, - label: product.name, - )), + // other products + ...products.map((product) => DropdownMenuItem( + value: product.id, child: Text(product.name))) ], + onChanged: enabled + ? (pid) { + if (onSelect != null) { + onSelect!(pid); + } + } + : null, )); } } diff --git a/lib/components/value_unit_input.dart b/lib/components/value_unit_input.dart new file mode 100644 index 0000000..2201fc3 --- /dev/null +++ b/lib/components/value_unit_input.dart @@ -0,0 +1,331 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; + +enum UnitType { text, amount, mass, volume, length, area } + +class Unit { + UnitType type = UnitType.text; + + Unit(this.type); + + factory Unit.fromId(int id) { + switch (id) { + case 1: + return Unit(UnitType.amount); + case 2: + return Unit(UnitType.mass); + case 3: + return Unit(UnitType.volume); + case 4: + return Unit(UnitType.length); + case 5: + return Unit(UnitType.area); + case 0: + default: + return Unit(UnitType.text); + } + } + + String name(BuildContext context) { + final trans = AppLocalizations.of(context); + + if (type == UnitType.text) { + return trans!.unitText; + } else if (type == UnitType.amount) { + return trans!.unitAmount; + } else if (type == UnitType.mass) { + return trans!.unitMass; + } else if (type == UnitType.volume) { + return trans!.unitVolume; + } else if (type == UnitType.length) { + return trans!.unitLength; + } else if (type == UnitType.area) { + return trans!.unitArea; + } + + return trans!.unitText; + } + + int get id { + if (type == UnitType.text) { + return 0; + } else if (type == UnitType.amount) { + return 1; + } else if (type == UnitType.mass) { + return 2; + } else if (type == UnitType.volume) { + return 3; + } else if (type == UnitType.length) { + return 4; + } else if (type == UnitType.area) { + return 5; + } + + return 0; + } + + static List list() { + return [ + Unit.fromId(0), + Unit.fromId(1), + Unit.fromId(2), + Unit.fromId(3), + Unit.fromId(4), + Unit.fromId(5), + ]; + } + + List get subUnits { + if (type == UnitType.text) { + // NOTE: plain text does not need subunits + return []; + } else if (type == UnitType.amount) { + // NOTE: amount does not need subunits + return []; + } else if (type == UnitType.mass) { + // TODO: add subunits (with conversion) + return [SubUnit.def('kg'), SubUnit.nth('g', 1 / 1000)]; + } else if (type == UnitType.volume) { + // TODO: add subunits (with conversion) + return [SubUnit.def('l')]; + } else if (type == UnitType.length) { + // TODO: add subunits (with conversion) + return [SubUnit.def('m')]; + } else if (type == UnitType.area) { + // TODO: add subunits (with conversion) + return [SubUnit.def('m²')]; + } + + return []; + } + + int? get defaultSubUnit { + if (type == UnitType.text) { + // NOTE: plain text does not need subunits + return null; + } else if (type == UnitType.amount) { + // NOTE: amount does not need subunits + return null; + } else if (type == UnitType.mass) { + return 0; + } else if (type == UnitType.volume) { + return 0; + } else if (type == UnitType.length) { + return 0; + } else if (type == UnitType.area) { + return 0; + } + + return null; + } + + TextInputType get recommendedType { + if (type == UnitType.text) { + return TextInputType.text; + } else { + return TextInputType.number; + } + } +} + +class SubUnit { + final String name; + // function to convert from default subunit to this subunit + final String Function(String value) convertTo; + // function to convert from this subunit to default subunit + final String Function(String value) convertFrom; + + const SubUnit({ + required this.name, + required this.convertTo, + required this.convertFrom, + }); + + @override + bool operator ==(Object other) { + if (runtimeType != other.runtimeType) { + return false; + } + + return name.hashCode == other.hashCode; + } + + @override + int get hashCode { + return name.hashCode; + } + + factory SubUnit.def(String name) { + return SubUnit(name: name, convertTo: (v) => v, convertFrom: (v) => v); + } + factory SubUnit.nth(String name, double fact) { + return SubUnit( + name: name, + convertTo: (v) { + try { + final double number = double.parse(v); + return (number * fact).toString(); + } catch (_) { + return v; + } + }, + convertFrom: (v) { + try { + final double number = double.parse(v); + return (number / fact).toString(); + } catch (_) { + return v; + } + }); + } +} + +class DynamicValueUnitInput extends StatefulWidget { + final int initialUnit; + final String initialValue; + final Function(String)? onValueChange; + final Function(int)? onUnitChange; + + const DynamicValueUnitInput( + {super.key, + required this.initialUnit, + required this.initialValue, + this.onValueChange, + this.onUnitChange}); + + @override + State createState() => _DynamicValueUnitInputState(); +} + +class _DynamicValueUnitInputState extends State { + Unit unit = Unit.fromId(0); + bool enabled = true; + TextEditingController controller = TextEditingController(); + SubUnit? sub; + + @override + void initState() { + super.initState(); + + controller = TextEditingController(text: widget.initialValue); + unit = Unit.fromId(widget.initialUnit); + } + + @override + Widget build(BuildContext context) { + return Wrap(children: [ + // unit type picker + Padding( + padding: const EdgeInsets.all(8), + child: DropdownMenu( + label: Text(AppLocalizations.of(context)!.selectUnitTypeLabel), + enabled: enabled, + initialSelection: unit.id, + onSelected: (unit) { + if (unit == null) { + return; + } + + final u = Unit.fromId(unit); + SubUnit? s; + if (u.defaultSubUnit != null) { + s = u.subUnits[u.defaultSubUnit!]; + } + // NOTE: we could run this here as well, + // but at least to me it seems more natural without it + // convertSubunit(s); + + if (widget.onUnitChange != null) { + widget.onUnitChange!(unit); + } + + setState(() { + this.unit = u; + sub = s; + }); + }, + dropdownMenuEntries: Unit.list() + .map((unit) => DropdownMenuEntry( + value: unit.id, label: unit.name(context))) + .toList(), + )), + + // (optional) subunit selector + ...(unit.defaultSubUnit != null) + ? [ + Padding( + padding: const EdgeInsets.all(8), + child: DropdownButtonFormField( + hint: Text(AppLocalizations.of(context)!.selectUnitLabel), + decoration: InputDecoration( + label: + Text(AppLocalizations.of(context)!.selectUnitLabel), + border: const OutlineInputBorder()), + value: sub, + items: unit.subUnits + .map((sub) => + DropdownMenuItem(value: sub, child: Text(sub.name))) + .toList(), + onChanged: (sub) { + // there is no way to select nothing + if (sub == null) { + return; + } + + if (sub == this.sub) { + // no new subunit selected + return; + } + + convertSubunit(sub); + setState(() { + this.sub = sub; + }); + }, + )) + ] + : [], + + // value input field + Padding( + padding: const EdgeInsets.all(8), + child: TextField( + controller: controller, + keyboardType: unit.recommendedType, + decoration: InputDecoration( + labelText: AppLocalizations.of(context)!.inputUnitValueLabel, + border: const OutlineInputBorder(), + ), + onChanged: (value) { + final String intermediate = + (sub != null) ? sub!.convertFrom(value) : value; + + if (widget.onValueChange != null) { + widget.onValueChange!(intermediate); + } + }, + ), + ), + ]); + } + + void convertSubunit(SubUnit? newest) { + final String old2intermediate = + (sub != null) ? sub!.convertFrom(value) : value; + final String intermediate2new = (newest != null) + ? newest.convertTo(old2intermediate) + : old2intermediate; + + setState(() { + value = intermediate2new; + }); + } + + String get value { + return controller.text; + } + + set value(String txt) { + controller.text = txt; + } +} diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 94919bd..9d92cc0 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -352,8 +352,46 @@ "productNameNone": "None", "selectCategoryLabel": "Category", "selectCategoryHint": "Select a category", + "selectCategoryHelp": "Categories determine your shopping list order", "selectLinkedProductLabel": "Linked Product", - "selectLinkedProductHint": "Link a product to your item", + "selectLinkedProductHint": "Select a linked Product", + "selectLinkedProductHelp": "Link a product to your item, to provide more information", "selectParentProductLabel": "Parent Product", - "selectParentProductHint": "Nest products by choosing a parent product" + "selectParentProductHint": "Select a parent Product", + "selectParentProductHelp": "Nest products by choosing a parent product", + + "unitText": "Plain text", + "unitAmount": "Amount", + "unitMass": "Mass", + "unitVolume": "Volume", + "unitLength": "Length", + "unitArea": "Area", + + "inputUnitValueLabel": "Value", + "selectUnitTypeLabel": "Unit Type", + "selectUnitLabel": "Unit", + + "inputProductNameLabel":"Name", + "inputProductNameHint":"Product name", + "inputProductNameHelp":"Give the product a name", + "inputProductDescriptionLabel":"Description", + "inputProductDescriptionHint":"Product Description", + "inputProductDescriptionHelp":"Give a brief description of this product", + "inputProductEANLabel":"EAN", + "inputProductEANHint":"Product EAN", + "inputProductEANHelp":"Easily identify products in the shelf by looking at their ean", + + "newItemShort": "Add", + "newItemLong": "Add new Shopping list entry", + "newProductShort": "New", + "newProductLong": "Create a new product", + + "errorProductNameShouldNotBeEmpty": "Product name shouldn't be empty", + + "viewParentProductTitle": "View parent Product", + "viewParentProductSubtitle": "This product is the child of a different product, have a look at it", + "viewProductChildrenTitle": "View children", + "viewProductChildrenSubtitle": "If other products specify this product as their parent, they are listed here", + "editProductTitle": "Edit Product", + "editProductSubtitle": "Change product metadata" } diff --git a/lib/main.dart b/lib/main.dart index a5727a3..6f64ad6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -5,6 +5,8 @@ import 'package:outbag_app/backend/themes.dart'; import 'package:outbag_app/backend/user.dart'; import 'package:outbag_app/backend/request.dart'; import 'package:outbag_app/screens/room/categories/edit.dart'; +import 'package:outbag_app/screens/room/products/edit.dart'; +import 'package:outbag_app/screens/room/products/view.dart'; import 'package:outbag_app/tools/fetch_wrapper.dart'; @@ -254,11 +256,34 @@ class _OutbagAppState extends State { state.params['id'] ?? '')), GoRoute( name: 'edit-category', - path: 'edit-category/:cid', + path: 'edit-category/:category', builder: (context, state)=>EditCategoryPage( state.params['server'] ?? '', state.params['id'] ?? '', - id: int.tryParse(state.params['cid'] ?? ''))), + id: int.tryParse(state.params['category'] ?? ''))), + GoRoute( + name: 'new-product', + path: 'new-product', + builder: (context, state)=>EditProductPage( + server: state.params['server'] ?? '', + room: state.params['id'] ?? '',)), + GoRoute( + name: 'view-product', + path: 'p/:product', + builder: (context, state)=>ViewProductPage( + server: state.params['server'] ?? '', + room: state.params['id'] ?? '', + product: int.tryParse(state.params['product'] ?? '') ?? 0), + routes: [ + GoRoute( + name: 'edit-product', + path: 'edit', + builder: (context, state)=>EditProductPage( + server: state.params['server'] ?? '', + room: state.params['id'] ?? '', + product: int.tryParse(state.params['product'] ?? ''))), + ] + ), ]) ]), ]), diff --git a/lib/screens/room/pages/list.dart b/lib/screens/room/pages/list.dart index 3c9cc39..e11c63e 100644 --- a/lib/screens/room/pages/list.dart +++ b/lib/screens/room/pages/list.dart @@ -4,6 +4,7 @@ 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/category_picker.dart'; import 'package:outbag_app/components/labeled_divider.dart'; import 'package:outbag_app/components/product_picker.dart'; @@ -292,8 +293,8 @@ class _ShoppingListPageState extends State { 0)) ? FloatingActionButton.extended( icon: const Icon(Icons.add), - label: Text(AppLocalizations.of(context)!.newCategoryShort), - tooltip: AppLocalizations.of(context)!.newCategoryLong, + label: Text(AppLocalizations.of(context)!.newItemShort), + tooltip: AppLocalizations.of(context)!.newItemLong, onPressed: () { // show new category popup context.pushNamed('new-item', params: { @@ -351,10 +352,7 @@ class ShoppingListItem extends StatelessWidget { enabled: !inCart, title: Text(name), subtitle: Text(description), - trailing: ActionChip( - avatar: Icon(Icons.square_rounded, color: category.color), - label: Text(category.name), - ), + trailing: CategoryChip(category: category,), onTap: () { if (onTap != null) { onTap!(); @@ -398,8 +396,8 @@ class ShoppingListItemInfo extends StatelessWidget { selected: item.category, enabled: false), ProductPicker( - label: - AppLocalizations.of(context)!.selectLinkedProductLabel, + label:AppLocalizations.of(context)!.selectLinkedProductLabel, + help:AppLocalizations.of(context)!.selectLinkedProductHelp, products: products, selected: item.link, enabled: false) diff --git a/lib/screens/room/pages/products.dart b/lib/screens/room/pages/products.dart index 38bd272..1c5d89b 100644 --- a/lib/screens/room/pages/products.dart +++ b/lib/screens/room/pages/products.dart @@ -85,8 +85,8 @@ class _RoomProductsPageState extends State { 0)) ? FloatingActionButton.extended( icon: const Icon(Icons.add), - label: Text(AppLocalizations.of(context)!.newCategoryShort), - tooltip: AppLocalizations.of(context)!.newCategoryLong, + label: Text(AppLocalizations.of(context)!.newProductShort), + tooltip: AppLocalizations.of(context)!.newProductLong, onPressed: () { // show new category popup context.pushNamed('new-product', params: { diff --git a/lib/screens/room/products/edit.dart b/lib/screens/room/products/edit.dart new file mode 100644 index 0000000..38d44a9 --- /dev/null +++ b/lib/screens/room/products/edit.dart @@ -0,0 +1,287 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:go_router/go_router.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/product_picker.dart'; +import 'package:outbag_app/components/value_unit_input.dart'; +import 'package:outbag_app/tools/fetch_wrapper.dart'; +import 'package:outbag_app/tools/snackbar.dart'; +import 'package:provider/provider.dart'; + +class EditProductPage extends StatefulWidget { + final int? product; + final String server; + final String room; + const EditProductPage( + {required this.server, required this.room, this.product, super.key}); + + @override + State createState() => _EditProductPageState(); +} + +class _EditProductPageState extends State { + // input controllers + final _ctrName = TextEditingController(); + final _ctrDescription = TextEditingController(); + final _ctrEAN = TextEditingController(); + int? _ctrCategory; + int _ctrUnit = 0; + String _ctrValue = ''; + int? _ctrParent; + + // data cache + List categories = []; + List products = []; + + 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, 'server': widget.server}), + onOK: (body) async { + final resp = body['data'] + .map((raw) => RoomCategory.fromJSON(raw)) + .toList(); + + setState(() { + categories = resp; + }); + }); + } + + 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, 'server': widget.server}), + onOK: (body) async { + final resp = body['data'] + .map((raw) => RoomProduct.fromJSON(raw)) + .toList(); + + if (widget.product != null) { + for (RoomProduct prod in resp) { + // load product info + // for current product + if (prod.id == widget.product) { + setState(() { + _ctrName.text = prod.name; + _ctrDescription.text = prod.description; + _ctrEAN.text = prod.ean ?? ''; + _ctrUnit = prod.defaultUnit; + _ctrValue = prod.defaultValue; + _ctrParent = prod.parent; + _ctrCategory = prod.category; + }); + } + } + } + + setState(() { + products = resp; + }); + }); + } + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + fetchCategories(); + fetchProducts(); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text((widget.product == null) + ? AppLocalizations.of(context)!.createProduct + : AppLocalizations.of(context)!.editProduct), + ), + body: SingleChildScrollView( + child: Center( + child: Padding( + padding: const EdgeInsets.all(14), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: TextField( + controller: _ctrName, + keyboardType: TextInputType.name, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.badge), + labelText: AppLocalizations.of(context)! + .inputProductNameLabel, + hintText: AppLocalizations.of(context)! + .inputProductNameHint, + helperText: AppLocalizations.of(context)! + .inputProductNameHelp, + border: const OutlineInputBorder(), + ), + ), + ), + Padding( + padding: const EdgeInsets.all(8), + child: TextField( + controller: _ctrDescription, + keyboardType: TextInputType.text, + decoration: InputDecoration( + labelText: AppLocalizations.of(context)! + .inputProductDescriptionLabel, + hintText: AppLocalizations.of(context)! + .inputProductDescriptionHint, + helperText: AppLocalizations.of(context)! + .inputProductDescriptionHelp, + prefixIcon: const Icon(Icons.dns), + border: const OutlineInputBorder(), + ), + ), + ), + Padding( + padding: const EdgeInsets.all(8), + child: TextField( + controller: _ctrEAN, + keyboardType: TextInputType.phone, + decoration: InputDecoration( + labelText: AppLocalizations.of(context)! + .inputProductEANLabel, + hintText: AppLocalizations.of(context)! + .inputProductEANHint, + helperText: AppLocalizations.of(context)! + .inputProductEANHelp, + prefixIcon: const Icon(Icons.qr_code_rounded), + border: const OutlineInputBorder(), + ), + ), + ), + DynamicValueUnitInput( + initialUnit: _ctrUnit, + initialValue: _ctrValue, + onUnitChange: (unit) { + setState(() { + _ctrUnit = unit; + }); + }, + onValueChange: (value) { + setState(() { + _ctrValue = value; + }); + }, + ), + CategoryPicker( + label: AppLocalizations.of(context)! + .selectCategoryLabel, + hint: AppLocalizations.of(context)! + .selectCategoryHint, + categories: categories, + selected: _ctrCategory, + onSelect: (cid) { + setState(() { + _ctrCategory = cid; + }); + }, + ), + ProductPicker( + label: AppLocalizations.of(context)! + .selectParentProductLabel, + hint: AppLocalizations.of(context)! + .selectParentProductHint, + products: products, + selected: _ctrParent, + onSelect: (pid) { + setState(() { + _ctrParent = pid; + }); + }, + ) + ], + ))))), + floatingActionButton: FloatingActionButton.extended( + onPressed: () async { + final scaffMgr = ScaffoldMessenger.of(context); + final router = GoRouter.of(context); + final trans = AppLocalizations.of(context); + final nav = Navigator.of(context); + + if (_ctrName.text.isEmpty) { + showSimpleSnackbar(scaffMgr, + text: trans!.errorProductNameShouldNotBeEmpty, + action: trans.ok); + return; + } + + final user = context.read(); + + if (widget.product == null) { + doNetworkRequest(scaffMgr, + req: () => postWithCreadentials( + credentials: user, + target: user.server, + path: 'addProduct', + body: { + 'room': widget.room, + 'server': widget.server, + 'title': _ctrName.text, + 'description': _ctrDescription.text, + 'listCatID': _ctrCategory, + 'defUnit': _ctrUnit, + 'defValue': _ctrValue, + 'ean': _ctrEAN.text, + 'parent': _ctrParent + }), + onOK: (_) async { + nav.pop(); + }); + } else { + doNetworkRequest(scaffMgr, + req: () => postWithCreadentials( + credentials: user, + target: user.server, + path: 'changeProduct', + body: { + 'listProdID': widget.product, + 'room': widget.room, + 'server': widget.server, + 'title': _ctrName.text, + 'description': _ctrDescription.text, + 'listCatID': _ctrCategory, + 'defUnit': _ctrUnit, + 'defValue': _ctrValue, + 'ean': _ctrEAN.text, + 'parent': _ctrParent + }), + onOK: (_) async { + nav.pop(); + }); + } + }, + label: Text(widget.product != null + ? AppLocalizations.of(context)!.editProductShort + : AppLocalizations.of(context)!.createProductShort), + icon: Icon(widget.product != null ? Icons.edit : Icons.add)), + ); + } +} diff --git a/lib/screens/room/products/view.dart b/lib/screens/room/products/view.dart new file mode 100644 index 0000000..f2df009 --- /dev/null +++ b/lib/screens/room/products/view.dart @@ -0,0 +1,215 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.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/product_picker.dart'; +import 'package:outbag_app/tools/fetch_wrapper.dart'; +import 'package:provider/provider.dart'; + +class ViewProductPage extends StatefulWidget { + final int product; + final String server; + final String room; + const ViewProductPage( + {required this.server, + required this.room, + required this.product, + super.key}); + + @override + State createState() => _ViewProductPageState(); +} + +class _ViewProductPageState extends State { + RoomProduct? product; + // data cache + List products = []; + Map categories = {}; + + RoomInfo? info; + + void fetchInfo() async { + final sm = ScaffoldMessenger.of(context); + final user = context.read(); + + doNetworkRequest( + sm, + req: () => postWithCreadentials( + path: 'getRoomInfo', + credentials: user, + target: user.server, + body: {'room': widget.room, 'server': widget.server}), + onOK: (body) async { + final info = RoomInfo.fromJSON(body['data']); + setState(() { + this.info = info; + }); + return true; + }, + ); + } + + 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, 'server': widget.server}), + onOK: (body) async { + final resp = body['data'] + .map((raw) => RoomCategory.fromJSON(raw)) + .toList(); + + Map map = {}; + + for (RoomCategory cat in resp) { + map[cat.id] = cat; + } + setState(() { + categories = map; + }); + }); + } + + 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, 'server': widget.server}), + onOK: (body) async { + final resp = body['data'] + .map((raw) => RoomProduct.fromJSON(raw)) + .toList(); + + for (RoomProduct prod in resp) { + // load product info + // for current product + if (prod.id == widget.product) { + setState(() { + product = prod; + }); + } + } + setState(() { + products = resp; + }); + }); + } + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + fetchCategories(); + fetchProducts(); + fetchInfo(); + }); + } + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + + return Scaffold( + appBar: AppBar( + title: Text(product?.name ?? ''), + ), + body: SingleChildScrollView( + child: Column(children: [ + // display product into + Center( + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text(product?.name ?? '', style: textTheme.headlineLarge), + Text(product?.description ?? '', + style: textTheme.titleMedium), + Text(product?.ean ?? ''), + CategoryChip(category: categories[product?.category]), + ProductPicker( + label: AppLocalizations.of(context)! + .selectParentProductLabel, + products: products, + selected: product?.parent, + enabled: false) + ], + ))), + + // show actions (if allowed / available + // edit product button + ...(info != null && + (info!.isAdmin || + info!.isOwner || + (info!.permissions & RoomPermission.editRoomContent != 0))) + ? [ + ListTile( + title: Text(AppLocalizations.of(context)!.editProductTitle), + subtitle: + Text(AppLocalizations.of(context)!.editProductSubtitle), + onTap: () { + context.pushNamed('edit-product', params: { + 'server': widget.server, + 'id': widget.room, + 'product': widget.product.toString() + }); + }, + trailing: const Icon(Icons.chevron_right), + ), + ] + : [], + // show parent? + ...(product?.parent != null) + ? [ + ListTile( + title: Text( + AppLocalizations.of(context)!.viewParentProductTitle), + subtitle: Text( + AppLocalizations.of(context)!.viewParentProductSubtitle), + onTap: () { + context.pushNamed('view-product', params: { + 'server': widget.server, + 'id': widget.room, + 'product': product!.parent.toString() + }); + }, + trailing: const Icon(Icons.chevron_right), + ), + ] + : [], + // show/manage children + ListTile( + title: Text(AppLocalizations.of(context)!.viewProductChildrenTitle), + subtitle: + Text(AppLocalizations.of(context)!.viewProductChildrenSubtitle), + onTap: () { + context.pushNamed('view-product-children', params: { + 'server': widget.server, + 'id': widget.room, + 'product': widget.product.toString() + }); + }, + trailing: const Icon(Icons.chevron_right), + ), + ])), + ); + } +}