diff --git a/lib/backend/room.dart b/lib/backend/room.dart index 42c85df..323beb8 100644 --- a/lib/backend/room.dart +++ b/lib/backend/room.dart @@ -547,7 +547,7 @@ String colorIdFromColor(ColorSwatch color) { } class RoomProduct { - int id; + final int id; String name; String description; // category ID @@ -563,9 +563,14 @@ class RoomProduct { // parent product ID int? parent; + final String server; + final String room; + RoomProduct( {required this.id, required this.name, + required this.server, + required this.room, this.description = '', this.category = -1, this.defaultUnit = 0, @@ -573,8 +578,10 @@ class RoomProduct { this.ean, this.parent}); - factory RoomProduct.fromJSON(dynamic json) { + factory RoomProduct.fromJSON(String server, String room, dynamic json) { return RoomProduct( + server: server, + room: room, id: json['listProdID'], name: json['title'], description: json['description'], @@ -584,6 +591,78 @@ class RoomProduct { ean: json['ean'], parent: json['parent']); } + + // get list of all categories in a given room + static Future> list(String server, String room) async { + final db = Localstore.instance; + final rooms = (await db.collection('products:$room@$server').get()) ?? {}; + List builder = []; + for (MapEntry entry in rooms.entries) { + try { + builder.add(RoomProduct.fromMap(entry.value)); + } catch (e) { + // skip invalid entries + // NOTE: might want to autodelete them in the future + // although keeping them might be ok, + // in case we ever get a new dataset to fix the current state + } + } + return builder; + } + + // listen to room change + static listen( + String server, String room, Function(Map) cb) async { + final db = Localstore.instance; + final stream = db.collection('products:$room@$server').stream; + stream.listen(cb); + } + + factory RoomProduct.fromMap(Map map) { + return RoomProduct( + server: map['server'], + room: map['room'], + id: map['id'], + name: map['name'], + description: map['description'], + category: map['category'], + defaultUnit: map['default_unit'], + defaultValue: map['default_value'], + ean: map['ean'], + parent: map['parent']); + } + + Map toMap() { + return { + 'server': server, + 'room': room, + 'id': id, + 'name': name, + 'description': description, + 'category': category, + 'default_unit': defaultUnit, + 'default_value': defaultValue, + 'ean': ean, + 'parent': parent + }; + } + + Future toDisk() async { + final db = Localstore.instance; + await db.collection('products:$room@$server').doc('$id').set(toMap()); + } + + Future removeDisk() async { + final db = Localstore.instance; + await db.collection('products:$room@$server').doc('$id').delete(); + } + + static Future fromDisk( + {required int id, required String server, required String room}) async { + final db = Localstore.instance; + final raw = await db.collection('products:$room@$server').doc('$id').get(); + return RoomProduct.fromMap(raw!); + } } class RoomItem { diff --git a/lib/screens/room/items/edit.dart b/lib/screens/room/items/edit.dart index fa63a13..15490f5 100644 --- a/lib/screens/room/items/edit.dart +++ b/lib/screens/room/items/edit.dart @@ -77,7 +77,8 @@ class _EditItemPageState extends State { body: {'room': widget.room, 'server': widget.server}), onOK: (body) async { final resp = body['data'] - .map((raw) => RoomProduct.fromJSON(raw)) + .map((raw) => + RoomProduct.fromJSON(widget.server, widget.room, raw)) .toList(); setState(() { diff --git a/lib/screens/room/items/new.dart b/lib/screens/room/items/new.dart index 2f7ad40..a54b292 100644 --- a/lib/screens/room/items/new.dart +++ b/lib/screens/room/items/new.dart @@ -66,7 +66,8 @@ class _NewItemPageState extends State { body: {'room': widget.room, 'server': widget.server}), onOK: (body) async { final resp = body['data'] - .map((raw) => RoomProduct.fromJSON(raw)) + .map((raw) => + RoomProduct.fromJSON(widget.server, widget.room, raw)) .toList(); setState(() { diff --git a/lib/screens/room/pages/list.dart b/lib/screens/room/pages/list.dart index 79289e8..c7dfeb5 100644 --- a/lib/screens/room/pages/list.dart +++ b/lib/screens/room/pages/list.dart @@ -106,12 +106,25 @@ class _ShoppingListPageState extends State { } } - void fetchCategories() { + void fetchCategories() async { final user = context.read(); + final scaffmgr = ScaffoldMessenger.of(context); - // TODO: load cached categories first + // load cached categories + final cache = await RoomCategory.list( + widget.room?.serverTag ?? "", widget.room?.id ?? ""); + if (mounted) { + Map map = {}; - doNetworkRequest(ScaffoldMessenger.of(context), + for (RoomCategory cat in cache) { + map[cat.id] = cat; + } + setState(() { + categories = map; + }); + } + + doNetworkRequest(scaffmgr, req: () => postWithCreadentials( credentials: user, target: user.server, @@ -140,12 +153,20 @@ class _ShoppingListPageState extends State { }); } - void fetchProducts() { + void fetchProducts() async { final user = context.read(); + final scaffmgr = ScaffoldMessenger.of(context); - // TODO: load cached products first + // load cached products first + final cache = await RoomProduct.list( + widget.room?.serverTag ?? "", widget.room?.id ?? ""); + if (mounted) { + setState(() { + products = cache; + }); + } - doNetworkRequest(ScaffoldMessenger.of(context), + doNetworkRequest(scaffmgr, req: () => postWithCreadentials( credentials: user, target: user.server, @@ -153,7 +174,8 @@ class _ShoppingListPageState extends State { body: {'room': widget.room?.id, 'server': widget.room?.serverTag}), onOK: (body) async { final resp = body['data'] - .map((raw) => RoomProduct.fromJSON(raw)) + .map((raw) => RoomProduct.fromJSON( + widget.room!.serverTag, widget.room!.id, raw)) .toList(); if (mounted) { @@ -168,6 +190,34 @@ class _ShoppingListPageState extends State { void initState() { super.initState(); + // wait for background room product changes + RoomProduct.listen(widget.room?.serverTag ?? "", widget.room?.id ?? "", + (_) async { + try { + final updated = await RoomProduct.list( + widget.room?.serverTag ?? "", widget.room?.id ?? ""); + setState(() { + products = updated; + }); + } catch (_) {} + }); + // wait for background room category changes + RoomCategory.listen(widget.room?.serverTag ?? "", widget.room?.id ?? "", + (_) async { + try { + final updated = await RoomCategory.list( + widget.room?.serverTag ?? "", widget.room?.id ?? ""); + Map map = {}; + + for (RoomCategory cat in updated) { + map[cat.id] = cat; + } + setState(() { + categories = map; + }); + } catch (_) {} + }); + WidgetsBinding.instance.addPostFrameCallback((_) { fetchItems(); fetchCategories(); diff --git a/lib/screens/room/pages/products.dart b/lib/screens/room/pages/products.dart index 7b29f60..716d84d 100644 --- a/lib/screens/room/pages/products.dart +++ b/lib/screens/room/pages/products.dart @@ -21,10 +21,20 @@ class RoomProductsPage extends StatefulWidget { class _RoomProductsPageState extends State { List products = []; - void fetchProducts() { + void fetchProducts() async { final user = context.read(); + final scaffmgr = ScaffoldMessenger.of(context); - doNetworkRequest(ScaffoldMessenger.of(context), + // load cached products first + final cache = await RoomProduct.list( + widget.room?.serverTag ?? "", widget.room?.id ?? ""); + if (mounted) { + setState(() { + products = cache; + }); + } + + doNetworkRequest(scaffmgr, req: () => postWithCreadentials( credentials: user, target: user.server, @@ -32,10 +42,13 @@ class _RoomProductsPageState extends State { body: {'room': widget.room?.id, 'server': widget.room?.serverTag}), onOK: (body) async { final resp = body['data'] - .map((raw) => RoomProduct.fromJSON(raw)) + .map((raw) => RoomProduct.fromJSON( + widget.room!.serverTag, widget.room!.id, raw)) .toList(); - // TODO: cache products + for (RoomProduct prod in resp) { + prod.toDisk(); + } if (mounted) { setState(() { @@ -49,6 +62,18 @@ class _RoomProductsPageState extends State { void initState() { super.initState(); + // wait for background room product changes + RoomProduct.listen(widget.room?.serverTag ?? "", widget.room?.id ?? "", + (_) async { + try { + final updated = await RoomProduct.list( + widget.room?.serverTag ?? "", widget.room?.id ?? ""); + setState(() { + products = updated; + }); + } catch (_) {} + }); + WidgetsBinding.instance.addPostFrameCallback((_) => fetchProducts()); } diff --git a/lib/screens/room/products/edit.dart b/lib/screens/room/products/edit.dart index 0e32af2..0f88259 100644 --- a/lib/screens/room/products/edit.dart +++ b/lib/screens/room/products/edit.dart @@ -36,12 +36,19 @@ class _EditProductPageState extends State { List categories = []; List products = []; - void fetchCategories() { + void fetchCategories() async { final user = context.read(); + final scaffmgr = ScaffoldMessenger.of(context); - // TODO: load cached categories first + // load cached categories + final cache = await RoomCategory.list(widget.server, widget.room); + if (mounted) { + setState(() { + categories = cache; + }); + } - doNetworkRequest(ScaffoldMessenger.of(context), + doNetworkRequest(scaffmgr, req: () => postWithCreadentials( credentials: user, target: user.server, @@ -59,12 +66,19 @@ class _EditProductPageState extends State { }); } - void fetchProducts() { + void fetchProducts() async { final user = context.read(); + final scaffmgr = ScaffoldMessenger.of(context); - // TODO: load cached products first + // load cached products first + final cache = await RoomProduct.list(widget.server, widget.room); + if (mounted) { + setState(() { + products = cache; + }); + } - doNetworkRequest(ScaffoldMessenger.of(context), + doNetworkRequest(scaffmgr, req: () => postWithCreadentials( credentials: user, target: user.server, @@ -72,7 +86,8 @@ class _EditProductPageState extends State { body: {'room': widget.room, 'server': widget.server}), onOK: (body) async { final resp = body['data'] - .map((raw) => RoomProduct.fromJSON(raw)) + .map((raw) => + RoomProduct.fromJSON(widget.server, widget.room, raw)) .toList(); if (widget.product != null) { @@ -255,7 +270,22 @@ class _EditProductPageState extends State { 'ean': _ctrEAN.text, 'parent': _ctrParent }), - onOK: (_) async { + onOK: (body) async { + // cache product + final id = body["data"]["listProdID"]; + final prod = RoomProduct( + id: id, + name: _ctrName.text, + server: widget.server, + room: widget.room, + description: _ctrDescription.text, + category: _ctrCategory, + defaultUnit: _ctrUnit, + defaultValue: _ctrValue, + ean: _ctrEAN.text, + parent: _ctrParent); + await prod.toDisk(); + nav.pop(); }); } else { @@ -277,6 +307,20 @@ class _EditProductPageState extends State { 'parent': _ctrParent }), onOK: (_) async { + // cache product + final prod = RoomProduct( + id: widget.product!, + name: _ctrName.text, + server: widget.server, + room: widget.room, + description: _ctrDescription.text, + category: _ctrCategory, + defaultUnit: _ctrUnit, + defaultValue: _ctrValue, + ean: _ctrEAN.text, + parent: _ctrParent); + await prod.toDisk(); + nav.pop(); }); } diff --git a/lib/screens/room/products/view.dart b/lib/screens/room/products/view.dart index f8901b6..149c18c 100644 --- a/lib/screens/room/products/view.dart +++ b/lib/screens/room/products/view.dart @@ -53,12 +53,24 @@ class _ViewProductPageState extends State { ); } - void fetchCategories() { + void fetchCategories() async { final user = context.read(); + final scaffmgr = ScaffoldMessenger.of(context); - // TODO: load cached categories first + // load cached categories + final cache = await RoomCategory.list(widget.server, widget.room); + if (mounted) { + Map map = {}; - doNetworkRequest(ScaffoldMessenger.of(context), + for (RoomCategory cat in cache) { + map[cat.id] = cat; + } + setState(() { + categories = map; + }); + } + + doNetworkRequest(scaffmgr, req: () => postWithCreadentials( credentials: user, target: user.server, @@ -81,12 +93,19 @@ class _ViewProductPageState extends State { }); } - void fetchProducts() { + void fetchProducts() async { final user = context.read(); + final scaffmgr = ScaffoldMessenger.of(context); - // TODO: load cached products first + // load cached products first + final cache = await RoomProduct.list(widget.server, widget.room); + if (mounted) { + setState(() { + products = cache; + }); + } - doNetworkRequest(ScaffoldMessenger.of(context), + doNetworkRequest(scaffmgr, req: () => postWithCreadentials( credentials: user, target: user.server, @@ -94,7 +113,8 @@ class _ViewProductPageState extends State { body: {'room': widget.room, 'server': widget.server}), onOK: (body) async { final resp = body['data'] - .map((raw) => RoomProduct.fromJSON(raw)) + .map((raw) => + RoomProduct.fromJSON(widget.server, widget.room, raw)) .toList(); for (RoomProduct prod in resp) { @@ -116,6 +136,39 @@ class _ViewProductPageState extends State { void initState() { super.initState(); + // wait for background room product changes + RoomProduct.listen(widget.server, widget.room, (_) async { + try { + final updated = await RoomProduct.list(widget.server, widget.room); + for (RoomProduct prod in updated) { + // load product info + // for current product + if (prod.id == widget.product) { + setState(() { + product = prod; + }); + } + } + setState(() { + products = updated; + }); + } catch (_) {} + }); + // wait for background room category changes + RoomCategory.listen(widget.server, widget.room, (_) async { + try { + final updated = await RoomCategory.list(widget.server, widget.room); + Map map = {}; + + for (RoomCategory cat in updated) { + map[cat.id] = cat; + } + setState(() { + categories = map; + }); + } catch (_) {} + }); + WidgetsBinding.instance.addPostFrameCallback((_) { fetchCategories(); fetchProducts(); @@ -132,156 +185,192 @@ class _ViewProductPageState extends State { 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( - server: widget.server, - room: widget.room, - category: categories[product?.category]), - Text(product != null - ? Unit.fromId(product!.defaultUnit) - .display(context, product!.defaultValue) - : '') - ], - ))), + child: Center( + child: Padding( + padding: const EdgeInsets.all(14), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + 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( + server: widget.server, + room: widget.room, + category: + categories[product?.category]), + Text(product != null + ? Unit.fromId(product!.defaultUnit) + .display( + context, product!.defaultValue) + : '') + ], + ))), - // 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), - ), - ...(info != null && - ((info?.isAdmin ?? false) || - (info?.isOwner ?? false) || - ((info?.permissions)! & RoomPermission.editRoomContent != - 0))) - ? [ - // delete product - ListTile( - title: Text(AppLocalizations.of(context)!.deleteProductTitle), - subtitle: - Text(AppLocalizations.of(context)!.deleteProductSubtitle), - onTap: () { - // show popup - showDialog( - context: context, - builder: (ctx) => AlertDialog( - icon: const Icon(Icons.delete), - title: Text( - AppLocalizations.of(context)!.deleteProduct), - content: Text(AppLocalizations.of(context)! - .deleteProductConfirm(product?.name ?? "")), - actions: [ - TextButton( - onPressed: () { - // close popup - Navigator.of(ctx).pop(); + // 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() + }); }, - child: Text( - AppLocalizations.of(context)!.cancel), + trailing: const Icon(Icons.chevron_right), ), - 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: 'deleteProduct', - target: user.server, - body: { - 'room': widget.room, - 'server': widget.server, - 'listProdID': product?.id ?? "" - }, - credentials: user), - onOK: (_) async { - // TODO: remove cached product - }, - after: () { - // close popup - navInner.pop(); - // close modal bottom sheet - nav.pop(); - }); + ] + : [], + // 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() + }); }, - child: Text(AppLocalizations.of(context)! - .deleteProduct), - ) - ], - )); - }, - trailing: const Icon(Icons.chevron_right), - ), - ] - : [] - ])), + 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), + ), + ...(info != null && + ((info?.isAdmin ?? false) || + (info?.isOwner ?? false) || + ((info?.permissions)! & + RoomPermission.editRoomContent != + 0))) + ? [ + // delete product + ListTile( + title: Text(AppLocalizations.of(context)! + .deleteProductTitle), + subtitle: Text(AppLocalizations.of(context)! + .deleteProductSubtitle), + onTap: () { + // show popup + showDialog( + context: context, + builder: (ctx) => AlertDialog( + icon: const Icon(Icons.delete), + title: Text( + AppLocalizations.of(context)! + .deleteProduct), + content: Text( + AppLocalizations.of(context)! + .deleteProductConfirm( + product?.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: + 'deleteProduct', + target: + user.server, + body: { + 'room': widget + .room, + 'server': widget + .server, + 'listProdID': + product?.id ?? + "" + }, + credentials: + user), + onOK: (_) async { + // remove cached product + await product! + .removeDisk(); + }, + after: () { + // close popup + navInner.pop(); + // close modal bottom sheet + nav.pop(); + }); + }, + child: Text( + AppLocalizations.of( + context)! + .deleteProduct), + ) + ], + )); + }, + trailing: const Icon(Icons.chevron_right), + ), + ] + : [] + ]))))), ); } } diff --git a/lib/tools/fetch_wrapper.dart b/lib/tools/fetch_wrapper.dart index 5ac8939..cc1b811 100644 --- a/lib/tools/fetch_wrapper.dart +++ b/lib/tools/fetch_wrapper.dart @@ -71,6 +71,6 @@ Future doNetworkRequest(ScaffoldMessengerState? sm, } if (after != null) { - after(); + await after(); } }