actions-test/lib/screens/room/pages/list.dart
Jakob Meier 384fbb0573
separate new item screen
The edit item screen might be overwhelming at first,
and if you only want to add simple items (by name) to the list,
it is way easier to simply type the name and click create to create a
simple item.
After creating the item the user will be redirected (history
replacement) to the edit screen, but clicking back will bring them back
to the list.
This screen also makes linking products easier and allows the user to
create new products if they notice they are using the same item multiple
times or can't be bothered to switch to the products tab
2024-02-22 20:36:59 +01:00

493 lines
16 KiB
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/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';
import 'package:outbag_app/components/value_unit_input.dart';
import 'package:outbag_app/tools/fetch_wrapper.dart';
import 'package:provider/provider.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class ShoppingListPage extends StatefulWidget {
final RoomInfo? info;
final Room? room;
const ShoppingListPage(this.room, this.info, {super.key});
@override
State<StatefulWidget> createState() => _ShoppingListPageState();
}
class _ShoppingListPageState extends State<ShoppingListPage> {
List<RoomItem> list = [];
List<RoomItem> cart = [];
Map<int, int> weights = {};
Map<int?, RoomCategory> categories = {};
List<RoomProduct> products = [];
void fetchItems() {
final user = context.read<User>();
// TODO: load cached items first
doNetworkRequest(ScaffoldMessenger.of(context),
req: () => postWithCreadentials(
credentials: user,
target: user.server,
path: 'getItems',
body: {'room': widget.room?.id, 'server': widget.room?.serverTag}),
onOK: (body) async {
final resp = body['data']
.map<RoomItem>((raw) => RoomItem.fromJSON(raw))
.toList();
final List<RoomItem> l = [];
final List<RoomItem> c = [];
for (RoomItem item in resp) {
if (item.state == 0) {
l.add(item);
} else {
c.add(item);
}
}
// TODO: cache items
if (mounted) {
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 = {};
Map<int?, RoomCategory> cat = {};
for (int i = 0; i < resp.length; i++) {
map[resp[i].id] = i;
cat[resp[i].id] = resp[i];
}
if (mounted) {
setState(() {
weights = map;
categories = cat;
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();
if (mounted) {
setState(() {
products = resp;
});
}
});
}
@override
void initState() {
super.initState();
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
Widget build(BuildContext context) {
return Scaffold(
body: ListView(children: [
LabeledDivider(AppLocalizations.of(context)!.shoppingList),
ListView.builder(
shrinkWrap: true,
physics: const ClampingScrollPhysics(),
itemCount: list.length,
itemBuilder: (context, index) {
final item = list[index];
final cat =
categories[item.category] ?? RoomCategory.other(context);
return ShoppingListItem(
name: item.name,
description: item.description,
category: cat,
key: Key(item.id.toString()),
inCart: item.state != 0,
onDismiss: () {
// move to cart
item.state = 1;
setState(() {
list.removeAt(index);
cart.add(item);
sortAll();
});
// network request
// NOTE: for now we do not care if it is successfull,
// because the shopping cart is supposed to work even
// without network
changeItemState(item);
},
onTap: () {
// TODO: show modal bottom sheet
// containing
// - item info
// - option to view linked product (if available)
// - edit item (if allowed)
// - delete item (if allowed)
// - move to/from shopping cart?
showModalBottomSheet(
context: context,
builder: (context) => ShoppingListItemInfo(
products: products,
category: cat,
info: widget.info,
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];
final cat =
categories[item.category] ?? RoomCategory.other(context);
return ShoppingListItem(
name: item.name,
description: item.description,
category: cat,
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: () {
// 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,
category: cat,
item: item,
info: widget.info,
room: widget.room!.id,
server: widget.room!.serverTag));
});
},
)
]),
floatingActionButton: (widget.info != null &&
((widget.info?.isAdmin ?? false) ||
(widget.info?.isOwner ?? false) ||
((widget.info?.permissions)! &
RoomPermission.addShoppingListItems !=
0)))
? FloatingActionButton.extended(
icon: const Icon(Icons.add),
label: Text(AppLocalizations.of(context)!.newItemShort),
tooltip: AppLocalizations.of(context)!.newItemLong,
onPressed: () {
// show new category popup
context.pushNamed('new-item', params: {
'server': widget.room!.serverTag,
'id': widget.room!.id,
});
},
)
: null,
);
}
}
class ShoppingListItem extends StatelessWidget {
final String name;
final RoomCategory category;
final String description;
final bool inCart;
final Key _key;
final Function()? onDismiss;
final Function()? onTap;
const 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: Opacity(
opacity: inCart ? 0.5 : 1.0,
child: ListTile(
title: Text(name),
subtitle: Text(description),
trailing: CategoryChip(
category: category,
),
onTap: () {
if (onTap != null) {
onTap!();
}
},
)),
);
}
}
class ShoppingListItemInfo extends StatelessWidget {
final RoomItem item;
final String server;
final String room;
final RoomInfo? info;
final RoomCategory category;
final List<RoomProduct> products;
const ShoppingListItemInfo(
{super.key,
this.info,
required this.item,
required this.server,
required this.room,
required this.category,
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),
CategoryChip(
category: category,
),
Text(Unit.fromId(item.unit).display(context, item.value))
]))),
...(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.link.toString()
});
},
)
]
: [],
...(info != null &&
((info?.isAdmin ?? false) ||
(info?.isOwner ?? false) ||
((info?.permissions)! &
RoomPermission.addShoppingListItems !=
0)))
? [
ListTile(
title: Text(AppLocalizations.of(context)!.editItem),
subtitle: Text(AppLocalizations.of(context)!.editItemLong),
trailing: const Icon(Icons.chevron_right),
onTap: () {
context.pushNamed('edit-item', params: {
'server': server,
'id': room,
'item': item.id.toString()
});
},
),
ListTile(
title:
Text(AppLocalizations.of(context)!.deleteItemTitle),
subtitle: Text(
AppLocalizations.of(context)!.deleteItemSubtitle),
trailing: const Icon(Icons.chevron_right),
onTap: () {
// TODO: show confirm dialog
}),
]
: [],
ListTile(
title: Text(item.state == 0
? AppLocalizations.of(context)!.moveItemToCartTitle
: AppLocalizations.of(context)!.moveItemToCartSubtitle),
subtitle: Text(item.state == 0
? AppLocalizations.of(context)!.removeItemFromCartTitle
: AppLocalizations.of(context)!.removeItemFromCartSubtitle),
onTap: () {
// flip state
item.state = (item.state - 1).abs();
final user = context.read<User>();
doNetworkRequest(ScaffoldMessenger.of(context),
req: () => postWithCreadentials(
credentials: user,
target: user.server,
path: 'changeItemState',
body: {
'room': room,
'server': server,
'listItemID': item.id,
'state': item.state
}));
})
],
),
);
}
}