cache products for item and product list and product view screen

BUG: when deleting a product the product list won't update
This commit is contained in:
Jakob Meier 2024-02-23 20:06:49 +01:00
parent 6cdfcdf85c
commit 9ff6d97c90
No known key found for this signature in database
GPG key ID: 66BDC7E6A01A6152
8 changed files with 466 additions and 177 deletions

View file

@ -547,7 +547,7 @@ String colorIdFromColor(ColorSwatch<int> color) {
} }
class RoomProduct { class RoomProduct {
int id; final int id;
String name; String name;
String description; String description;
// category ID // category ID
@ -563,9 +563,14 @@ class RoomProduct {
// parent product ID // parent product ID
int? parent; int? parent;
final String server;
final String room;
RoomProduct( RoomProduct(
{required this.id, {required this.id,
required this.name, required this.name,
required this.server,
required this.room,
this.description = '', this.description = '',
this.category = -1, this.category = -1,
this.defaultUnit = 0, this.defaultUnit = 0,
@ -573,8 +578,10 @@ class RoomProduct {
this.ean, this.ean,
this.parent}); this.parent});
factory RoomProduct.fromJSON(dynamic json) { factory RoomProduct.fromJSON(String server, String room, dynamic json) {
return RoomProduct( return RoomProduct(
server: server,
room: room,
id: json['listProdID'], id: json['listProdID'],
name: json['title'], name: json['title'],
description: json['description'], description: json['description'],
@ -584,6 +591,78 @@ class RoomProduct {
ean: json['ean'], ean: json['ean'],
parent: json['parent']); parent: json['parent']);
} }
// get list of all categories in a given room
static Future<List<RoomProduct>> list(String server, String room) async {
final db = Localstore.instance;
final rooms = (await db.collection('products:$room@$server').get()) ?? {};
List<RoomProduct> 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<String, dynamic>) cb) async {
final db = Localstore.instance;
final stream = db.collection('products:$room@$server').stream;
stream.listen(cb);
}
factory RoomProduct.fromMap(Map<String, dynamic> 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<String, dynamic> 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<void> toDisk() async {
final db = Localstore.instance;
await db.collection('products:$room@$server').doc('$id').set(toMap());
}
Future<void> removeDisk() async {
final db = Localstore.instance;
await db.collection('products:$room@$server').doc('$id').delete();
}
static Future<RoomProduct> 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 { class RoomItem {

View file

@ -77,7 +77,8 @@ class _EditItemPageState extends State<EditItemPage> {
body: {'room': widget.room, 'server': widget.server}), body: {'room': widget.room, 'server': widget.server}),
onOK: (body) async { onOK: (body) async {
final resp = body['data'] final resp = body['data']
.map<RoomProduct>((raw) => RoomProduct.fromJSON(raw)) .map<RoomProduct>((raw) =>
RoomProduct.fromJSON(widget.server, widget.room, raw))
.toList(); .toList();
setState(() { setState(() {

View file

@ -66,7 +66,8 @@ class _NewItemPageState extends State<NewItemPage> {
body: {'room': widget.room, 'server': widget.server}), body: {'room': widget.room, 'server': widget.server}),
onOK: (body) async { onOK: (body) async {
final resp = body['data'] final resp = body['data']
.map<RoomProduct>((raw) => RoomProduct.fromJSON(raw)) .map<RoomProduct>((raw) =>
RoomProduct.fromJSON(widget.server, widget.room, raw))
.toList(); .toList();
setState(() { setState(() {

View file

@ -106,12 +106,25 @@ class _ShoppingListPageState extends State<ShoppingListPage> {
} }
} }
void fetchCategories() { void fetchCategories() async {
final user = context.read<User>(); final user = context.read<User>();
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<int?, RoomCategory> map = {};
doNetworkRequest(ScaffoldMessenger.of(context), for (RoomCategory cat in cache) {
map[cat.id] = cat;
}
setState(() {
categories = map;
});
}
doNetworkRequest(scaffmgr,
req: () => postWithCreadentials( req: () => postWithCreadentials(
credentials: user, credentials: user,
target: user.server, target: user.server,
@ -140,12 +153,20 @@ class _ShoppingListPageState extends State<ShoppingListPage> {
}); });
} }
void fetchProducts() { void fetchProducts() async {
final user = context.read<User>(); final user = context.read<User>();
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( req: () => postWithCreadentials(
credentials: user, credentials: user,
target: user.server, target: user.server,
@ -153,7 +174,8 @@ class _ShoppingListPageState extends State<ShoppingListPage> {
body: {'room': widget.room?.id, 'server': widget.room?.serverTag}), body: {'room': widget.room?.id, 'server': widget.room?.serverTag}),
onOK: (body) async { onOK: (body) async {
final resp = body['data'] final resp = body['data']
.map<RoomProduct>((raw) => RoomProduct.fromJSON(raw)) .map<RoomProduct>((raw) => RoomProduct.fromJSON(
widget.room!.serverTag, widget.room!.id, raw))
.toList(); .toList();
if (mounted) { if (mounted) {
@ -168,6 +190,34 @@ class _ShoppingListPageState extends State<ShoppingListPage> {
void initState() { void initState() {
super.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<int?, RoomCategory> map = {};
for (RoomCategory cat in updated) {
map[cat.id] = cat;
}
setState(() {
categories = map;
});
} catch (_) {}
});
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
fetchItems(); fetchItems();
fetchCategories(); fetchCategories();

View file

@ -21,10 +21,20 @@ class RoomProductsPage extends StatefulWidget {
class _RoomProductsPageState extends State<RoomProductsPage> { class _RoomProductsPageState extends State<RoomProductsPage> {
List<RoomProduct> products = []; List<RoomProduct> products = [];
void fetchProducts() { void fetchProducts() async {
final user = context.read<User>(); final user = context.read<User>();
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( req: () => postWithCreadentials(
credentials: user, credentials: user,
target: user.server, target: user.server,
@ -32,10 +42,13 @@ class _RoomProductsPageState extends State<RoomProductsPage> {
body: {'room': widget.room?.id, 'server': widget.room?.serverTag}), body: {'room': widget.room?.id, 'server': widget.room?.serverTag}),
onOK: (body) async { onOK: (body) async {
final resp = body['data'] final resp = body['data']
.map<RoomProduct>((raw) => RoomProduct.fromJSON(raw)) .map<RoomProduct>((raw) => RoomProduct.fromJSON(
widget.room!.serverTag, widget.room!.id, raw))
.toList(); .toList();
// TODO: cache products for (RoomProduct prod in resp) {
prod.toDisk();
}
if (mounted) { if (mounted) {
setState(() { setState(() {
@ -49,6 +62,18 @@ class _RoomProductsPageState extends State<RoomProductsPage> {
void initState() { void initState() {
super.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()); WidgetsBinding.instance.addPostFrameCallback((_) => fetchProducts());
} }

View file

@ -36,12 +36,19 @@ class _EditProductPageState extends State<EditProductPage> {
List<RoomCategory> categories = []; List<RoomCategory> categories = [];
List<RoomProduct> products = []; List<RoomProduct> products = [];
void fetchCategories() { void fetchCategories() async {
final user = context.read<User>(); final user = context.read<User>();
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( req: () => postWithCreadentials(
credentials: user, credentials: user,
target: user.server, target: user.server,
@ -59,12 +66,19 @@ class _EditProductPageState extends State<EditProductPage> {
}); });
} }
void fetchProducts() { void fetchProducts() async {
final user = context.read<User>(); final user = context.read<User>();
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( req: () => postWithCreadentials(
credentials: user, credentials: user,
target: user.server, target: user.server,
@ -72,7 +86,8 @@ class _EditProductPageState extends State<EditProductPage> {
body: {'room': widget.room, 'server': widget.server}), body: {'room': widget.room, 'server': widget.server}),
onOK: (body) async { onOK: (body) async {
final resp = body['data'] final resp = body['data']
.map<RoomProduct>((raw) => RoomProduct.fromJSON(raw)) .map<RoomProduct>((raw) =>
RoomProduct.fromJSON(widget.server, widget.room, raw))
.toList(); .toList();
if (widget.product != null) { if (widget.product != null) {
@ -255,7 +270,22 @@ class _EditProductPageState extends State<EditProductPage> {
'ean': _ctrEAN.text, 'ean': _ctrEAN.text,
'parent': _ctrParent '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(); nav.pop();
}); });
} else { } else {
@ -277,6 +307,20 @@ class _EditProductPageState extends State<EditProductPage> {
'parent': _ctrParent 'parent': _ctrParent
}), }),
onOK: (_) async { 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(); nav.pop();
}); });
} }

View file

@ -53,12 +53,24 @@ class _ViewProductPageState extends State<ViewProductPage> {
); );
} }
void fetchCategories() { void fetchCategories() async {
final user = context.read<User>(); final user = context.read<User>();
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<int?, RoomCategory> map = {};
doNetworkRequest(ScaffoldMessenger.of(context), for (RoomCategory cat in cache) {
map[cat.id] = cat;
}
setState(() {
categories = map;
});
}
doNetworkRequest(scaffmgr,
req: () => postWithCreadentials( req: () => postWithCreadentials(
credentials: user, credentials: user,
target: user.server, target: user.server,
@ -81,12 +93,19 @@ class _ViewProductPageState extends State<ViewProductPage> {
}); });
} }
void fetchProducts() { void fetchProducts() async {
final user = context.read<User>(); final user = context.read<User>();
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( req: () => postWithCreadentials(
credentials: user, credentials: user,
target: user.server, target: user.server,
@ -94,7 +113,8 @@ class _ViewProductPageState extends State<ViewProductPage> {
body: {'room': widget.room, 'server': widget.server}), body: {'room': widget.room, 'server': widget.server}),
onOK: (body) async { onOK: (body) async {
final resp = body['data'] final resp = body['data']
.map<RoomProduct>((raw) => RoomProduct.fromJSON(raw)) .map<RoomProduct>((raw) =>
RoomProduct.fromJSON(widget.server, widget.room, raw))
.toList(); .toList();
for (RoomProduct prod in resp) { for (RoomProduct prod in resp) {
@ -116,6 +136,39 @@ class _ViewProductPageState extends State<ViewProductPage> {
void initState() { void initState() {
super.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<int?, RoomCategory> map = {};
for (RoomCategory cat in updated) {
map[cat.id] = cat;
}
setState(() {
categories = map;
});
} catch (_) {}
});
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
fetchCategories(); fetchCategories();
fetchProducts(); fetchProducts();
@ -132,156 +185,192 @@ class _ViewProductPageState extends State<ViewProductPage> {
title: Text(product?.name ?? ''), title: Text(product?.name ?? ''),
), ),
body: SingleChildScrollView( body: SingleChildScrollView(
child: Column(children: [ child: Center(
// display product into child: Padding(
Center( padding: const EdgeInsets.all(14),
child: Padding( child: ConstrainedBox(
padding: const EdgeInsets.all(14), constraints: const BoxConstraints(maxWidth: 600),
child: Column( child: Column(children: [
mainAxisAlignment: MainAxisAlignment.center, // display product into
crossAxisAlignment: CrossAxisAlignment.center, Center(
children: [ child: Padding(
Text(product?.name ?? '', style: textTheme.headlineLarge), padding: const EdgeInsets.all(14),
Text(product?.description ?? '', child: Column(
style: textTheme.titleMedium), mainAxisAlignment: MainAxisAlignment.center,
Text(product?.ean ?? ''), crossAxisAlignment: CrossAxisAlignment.center,
CategoryChip( children: [
server: widget.server, Text(product?.name ?? '',
room: widget.room, style: textTheme.headlineLarge),
category: categories[product?.category]), Text(product?.description ?? '',
Text(product != null style: textTheme.titleMedium),
? Unit.fromId(product!.defaultUnit) Text(product?.ean ?? ''),
.display(context, product!.defaultValue) 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 // show actions (if allowed / available
// edit product button // edit product button
...(info != null && ...(info != null &&
(info!.isAdmin || (info!.isAdmin ||
info!.isOwner || info!.isOwner ||
(info!.permissions & RoomPermission.editRoomContent != 0))) (info!.permissions &
? [ RoomPermission.editRoomContent !=
ListTile( 0)))
title: Text(AppLocalizations.of(context)!.editProductTitle), ? [
subtitle: ListTile(
Text(AppLocalizations.of(context)!.editProductSubtitle), title: Text(AppLocalizations.of(context)!
onTap: () { .editProductTitle),
context.pushNamed('edit-product', params: { subtitle: Text(AppLocalizations.of(context)!
'server': widget.server, .editProductSubtitle),
'id': widget.room, onTap: () {
'product': widget.product.toString() context.pushNamed('edit-product', params: {
}); 'server': widget.server,
}, 'id': widget.room,
trailing: const Icon(Icons.chevron_right), 'product': widget.product.toString()
), });
]
: [],
// 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();
}, },
child: Text( trailing: const Icon(Icons.chevron_right),
AppLocalizations.of(context)!.cancel),
), ),
FilledButton( ]
onPressed: () async { : [],
// send request // show parent?
final scaffMgr = ScaffoldMessenger.of(ctx); ...(product?.parent != null)
// popup context ? [
final navInner = Navigator.of(ctx); ListTile(
// bottomsheet context title: Text(AppLocalizations.of(context)!
final nav = Navigator.of(context); .viewParentProductTitle),
final user = context.read<User>(); subtitle: Text(AppLocalizations.of(context)!
.viewParentProductSubtitle),
doNetworkRequest(scaffMgr, onTap: () {
req: () => postWithCreadentials( context.pushNamed('view-product', params: {
path: 'deleteProduct', 'server': widget.server,
target: user.server, 'id': widget.room,
body: { 'product': product!.parent.toString()
'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();
});
}, },
child: Text(AppLocalizations.of(context)! trailing: const Icon(Icons.chevron_right),
.deleteProduct), ),
) ]
], : [],
)); // show/manage children
}, ListTile(
trailing: const Icon(Icons.chevron_right), 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<User>();
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),
),
]
: []
]))))),
); );
} }
} }

View file

@ -71,6 +71,6 @@ Future<void> doNetworkRequest(ScaffoldMessengerState? sm,
} }
if (after != null) { if (after != null) {
after(); await after();
} }
} }