Added basic Shopping List and Product list

including the ability to
add item into the shopping cart and remove them.

The click events have been implemented,
however the routes do not exist yet.

NOTE: The shopping list item info sheet is still missing
the unit type and value
and some more advanced options, like deleting the item
This commit is contained in:
Jakob Meier 2023-04-04 10:29:29 +02:00
parent 8706122590
commit 47387bb395
No known key found for this signature in database
GPG key ID: 66BDC7E6A01A6152
7 changed files with 724 additions and 27 deletions

View file

@ -315,8 +315,6 @@ class Room {
} }
} }
class ShoppingListItem {}
class RoomMember { class RoomMember {
final String id; final String id;
final String serverTag; final String serverTag;
@ -372,6 +370,13 @@ class RoomCategory {
name: json['title'], name: json['title'],
color: colorFromString(json['color'])); color: colorFromString(json['color']));
} }
factory RoomCategory.other(BuildContext context) {
return RoomCategory(
id: -1,
name: AppLocalizations.of(context)!.categoryNameOther,
color: Colors.grey
);
}
static List<ColorSwatch<int>> listColors() { static List<ColorSwatch<int>> listColors() {
return [ return [
@ -463,3 +468,91 @@ String colorIdFromColor(ColorSwatch<int> color) {
return 'purple'; 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);
}
}

View file

@ -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<RoomCategory> 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<int?>(
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,
))
],
));
}
}

View file

@ -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()),
]
);
}
}

View file

@ -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<RoomProduct> 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<int?>(
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,
)),
],
));
}
}

View file

@ -336,5 +336,24 @@
"chooseCategoryColor": "Choose a color for your category", "chooseCategoryColor": "Choose a color for your category",
"inputCategoryNameLabel": "Category Name", "inputCategoryNameLabel": "Category Name",
"inputCategoryNameHint": "Name the category", "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"
} }

View file

@ -1,5 +1,15 @@
import 'package:flutter/material.dart'; 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/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 { class ShoppingListPage extends StatefulWidget {
final RoomInfo? info; final RoomInfo? info;
@ -12,45 +22,411 @@ class ShoppingListPage extends StatefulWidget {
} }
class _ShoppingListPageState extends State<ShoppingListPage> { class _ShoppingListPageState extends State<ShoppingListPage> {
List<ShoppingListItem> list = []; List<RoomItem> list = [];
List<RoomItem> cart = [];
Map<int, int> weights = {};
List<RoomCategory> categories = [];
List<RoomProduct> products = [];
void loadData() async { void fetchItems() {
//bool foundData = false; final user = context.read<User>();
// TODO: 1. load data from disk (if available) // TODO: load cached items first
// NOTE: errors do not matter,
// hopefully the network request will succeed
try {
//List<ShoppingListItem> list = await ShoppingListItem.getAllFromDisk();
//setState(() {
// this.list = list;
//})
//foundData = true; doNetworkRequest(ScaffoldMessenger.of(context),
} catch (_) {} 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<RoomItem>((raw) => RoomItem.fromJSON(raw))
.toList();
// TODO: 2. load data from web final List<RoomItem> l = [];
final List<RoomItem> c = [];
// NOTE: might want to close room for (RoomItem item in resp) {
// or show snackbar if no data is available if (item.state == 0) {
l.add(item);
} else {
c.add(item);
}
}
// TODO: cache items
setState(() {
list = l;
cart = c;
sortAll();
});
});
}
void sortAll() {
for (List<RoomItem> 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<User>();
// 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<RoomCategory>((raw) => RoomCategory.fromJSON(raw))
.toList();
Map<int, int> 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<User>();
// 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<RoomProduct>((raw) => RoomProduct.fromJSON(raw))
.toList();
setState(() {
products = resp;
});
});
} }
@override @override
void initState() { void initState() {
super.initState(); super.initState();
loadData(); WidgetsBinding.instance.addPostFrameCallback((_) {
fetchItems();
fetchCategories();
fetchProducts();
});
}
void changeItemState(RoomItem item) {
final user = context.read<User>();
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListView.builder( return Scaffold(
itemBuilder: (ctx, index) { 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]; final item = list[index];
return ListTile(); 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);
}, },
itemCount: list.length, 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<RoomCategory> categories = [];
List<RoomProduct> 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()
});
},
)
]
: []
],
),
); );
} }
} }

View file

@ -1,5 +1,12 @@
import 'package:flutter/material.dart'; 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/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 { class RoomProductsPage extends StatefulWidget {
final RoomInfo? info; final RoomInfo? info;
@ -12,8 +19,83 @@ class RoomProductsPage extends StatefulWidget {
} }
class _RoomProductsPageState extends State<RoomProductsPage> { class _RoomProductsPageState extends State<RoomProductsPage> {
List<RoomProduct> products = [];
void fetchProducts() {
final user = context.read<User>();
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<RoomProduct>((raw) => RoomProduct.fromJSON(raw))
.toList();
// TODO: cache products
setState(() {
products = resp;
});
});
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => fetchProducts());
}
@override @override
Widget build(BuildContext context) { 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,
);
} }
} }