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.
This commit is contained in:
Jakob Meier 2023-04-04 20:28:26 +02:00
parent 47387bb395
commit 5cd21c8adf
No known key found for this signature in database
GPG key ID: 66BDC7E6A01A6152
11 changed files with 1101 additions and 185 deletions

View file

@ -357,7 +357,7 @@ class RoomInfo {
} }
class RoomCategory { class RoomCategory {
final int id; final int? id;
final String name; final String name;
final ColorSwatch<int> color; final ColorSwatch<int> color;
@ -372,10 +372,9 @@ class RoomCategory {
} }
factory RoomCategory.other(BuildContext context) { factory RoomCategory.other(BuildContext context) {
return RoomCategory( return RoomCategory(
id: -1, id: null,
name: AppLocalizations.of(context)!.categoryNameOther, name: AppLocalizations.of(context)!.categoryNameOther,
color: Colors.grey color: Colors.grey);
);
} }
static List<ColorSwatch<int>> listColors() { static List<ColorSwatch<int>> listColors() {

View file

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

View file

@ -1,19 +1,19 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:outbag_app/backend/room.dart'; import 'package:outbag_app/backend/room.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class CategoryPicker extends StatelessWidget { class CategoryPicker extends StatelessWidget {
List<RoomCategory> categories = []; final List<RoomCategory> categories;
int? selected; final int? selected;
bool enabled = true; final bool enabled;
Function(int?)? onSelect; final Function(int?)? onSelect;
// hint and label may differ depending on the screen // hint and label may differ depending on the screen
String? hint; final String? hint;
String? label; final String? label;
CategoryPicker( const CategoryPicker(
{required this.categories, {super.key,
required this.categories,
this.selected, this.selected,
this.onSelect, this.onSelect,
this.hint, this.hint,
@ -24,34 +24,35 @@ class CategoryPicker extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: DropdownMenu<int?>( child: DropdownButtonFormField<int?>(
initialSelection: selected, hint: hint==null?null:Text(hint!),
enabled: enabled, decoration: InputDecoration(
hintText: hint, label: label==null?null:Text(label!),
label: (label!=null)?Text(label!):null, border: const OutlineInputBorder(),
onSelected: ((id) { prefixIcon: const Icon(Icons.category)
if (onSelect != null) { ),
onSelect!(id); value: selected,
} items: [
}), ...categories,
dropdownMenuEntries: [ RoomCategory.other(context)
// entry for every categry ].map((category)=>DropdownMenuItem<int?>(
...categories.map((category) => DropdownMenuEntry(
value: category.id, value: category.id,
label: category.name, child: Row(
trailingIcon: Icon( mainAxisAlignment: MainAxisAlignment.spaceBetween,
Icons.square_rounded, crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(category.name),
Icon(Icons.square_rounded,
color:category.color, color:category.color,
))), size: 32)
// entry for default ("other") category ]
DropdownMenuEntry( ),
value: null, )).toList(),
label: AppLocalizations.of(context)!.categoryNameOther, onChanged: enabled?(cid) {
trailingIcon: Icon( if (onSelect != null) {
Icons.square_rounded, onSelect!(cid);
color: RoomCategory.other(context).color, }
)) }:null,
],
)); ));
} }
} }

View file

@ -3,20 +3,23 @@ import 'package:outbag_app/backend/room.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart';
class ProductPicker extends StatelessWidget { class ProductPicker extends StatelessWidget {
List<RoomProduct> products = []; final List<RoomProduct> products;
int? selected; final int? selected;
bool enabled = true; final bool enabled;
Function(int?)? onSelect; final Function(int?)? onSelect;
// hint and label may differ depending on the screen // hint and label may differ depending on the screen
String? hint; final String? hint;
String? label; final String? label;
final String? help;
ProductPicker( const ProductPicker(
{required this.products, {super.key,
required this.products,
this.selected, this.selected,
this.onSelect, this.onSelect,
this.hint, this.hint,
this.help,
this.label, this.label,
this.enabled = true}); this.enabled = true});
@ -24,28 +27,31 @@ class ProductPicker extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
child: DropdownMenu<int?>( child: DropdownButtonFormField<int?>(
initialSelection: selected, hint: hint == null ? null : Text(hint!),
label: (label!=null)?Text(label!):null, decoration: InputDecoration(
hintText: hint, label: label == null ? null : Text(label!),
enabled: enabled, border: const OutlineInputBorder(),
onSelected: ((id) { prefixIcon: const Icon(Icons.inventory_2),
if (onSelect != null) { helperText: help),
onSelect!(id); value: selected,
} items: [
}), // "no product" entry
dropdownMenuEntries: [ DropdownMenuItem<int?>(
// entry for no product
DropdownMenuEntry(
value: null, value: null,
label: AppLocalizations.of(context)!.productNameNone, child: Text(AppLocalizations.of(context)!.productNameNone),
), ),
// entry for every product // other products
...products.map((product) => DropdownMenuEntry( ...products.map((product) => DropdownMenuItem<int?>(
value: product.id, value: product.id, child: Text(product.name)))
label: product.name,
)),
], ],
onChanged: enabled
? (pid) {
if (onSelect != null) {
onSelect!(pid);
}
}
: null,
)); ));
} }
} }

View file

@ -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<Unit> list() {
return [
Unit.fromId(0),
Unit.fromId(1),
Unit.fromId(2),
Unit.fromId(3),
Unit.fromId(4),
Unit.fromId(5),
];
}
List<SubUnit> 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('')];
}
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<StatefulWidget> createState() => _DynamicValueUnitInputState();
}
class _DynamicValueUnitInputState extends State<DynamicValueUnitInput> {
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<int>(
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<SubUnit>(
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;
}
}

View file

@ -352,8 +352,46 @@
"productNameNone": "None", "productNameNone": "None",
"selectCategoryLabel": "Category", "selectCategoryLabel": "Category",
"selectCategoryHint": "Select a category", "selectCategoryHint": "Select a category",
"selectCategoryHelp": "Categories determine your shopping list order",
"selectLinkedProductLabel": "Linked Product", "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", "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"
} }

View file

@ -5,6 +5,8 @@ import 'package:outbag_app/backend/themes.dart';
import 'package:outbag_app/backend/user.dart'; import 'package:outbag_app/backend/user.dart';
import 'package:outbag_app/backend/request.dart'; import 'package:outbag_app/backend/request.dart';
import 'package:outbag_app/screens/room/categories/edit.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'; import 'package:outbag_app/tools/fetch_wrapper.dart';
@ -254,11 +256,34 @@ class _OutbagAppState extends State {
state.params['id'] ?? '')), state.params['id'] ?? '')),
GoRoute( GoRoute(
name: 'edit-category', name: 'edit-category',
path: 'edit-category/:cid', path: 'edit-category/:category',
builder: (context, state)=>EditCategoryPage( builder: (context, state)=>EditCategoryPage(
state.params['server'] ?? '', state.params['server'] ?? '',
state.params['id'] ?? '', 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'] ?? ''))),
]
),
]) ])
]), ]),
]), ]),

View file

@ -4,6 +4,7 @@ import 'package:outbag_app/backend/permissions.dart';
import 'package:outbag_app/backend/request.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/backend/user.dart';
import 'package:outbag_app/components/category_chip.dart';
import 'package:outbag_app/components/category_picker.dart'; import 'package:outbag_app/components/category_picker.dart';
import 'package:outbag_app/components/labeled_divider.dart'; import 'package:outbag_app/components/labeled_divider.dart';
import 'package:outbag_app/components/product_picker.dart'; import 'package:outbag_app/components/product_picker.dart';
@ -292,8 +293,8 @@ class _ShoppingListPageState extends State<ShoppingListPage> {
0)) 0))
? FloatingActionButton.extended( ? FloatingActionButton.extended(
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: Text(AppLocalizations.of(context)!.newCategoryShort), label: Text(AppLocalizations.of(context)!.newItemShort),
tooltip: AppLocalizations.of(context)!.newCategoryLong, tooltip: AppLocalizations.of(context)!.newItemLong,
onPressed: () { onPressed: () {
// show new category popup // show new category popup
context.pushNamed('new-item', params: { context.pushNamed('new-item', params: {
@ -351,10 +352,7 @@ class ShoppingListItem extends StatelessWidget {
enabled: !inCart, enabled: !inCart,
title: Text(name), title: Text(name),
subtitle: Text(description), subtitle: Text(description),
trailing: ActionChip( trailing: CategoryChip(category: category,),
avatar: Icon(Icons.square_rounded, color: category.color),
label: Text(category.name),
),
onTap: () { onTap: () {
if (onTap != null) { if (onTap != null) {
onTap!(); onTap!();
@ -398,8 +396,8 @@ class ShoppingListItemInfo extends StatelessWidget {
selected: item.category, selected: item.category,
enabled: false), enabled: false),
ProductPicker( ProductPicker(
label: label:AppLocalizations.of(context)!.selectLinkedProductLabel,
AppLocalizations.of(context)!.selectLinkedProductLabel, help:AppLocalizations.of(context)!.selectLinkedProductHelp,
products: products, products: products,
selected: item.link, selected: item.link,
enabled: false) enabled: false)

View file

@ -85,8 +85,8 @@ class _RoomProductsPageState extends State<RoomProductsPage> {
0)) 0))
? FloatingActionButton.extended( ? FloatingActionButton.extended(
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: Text(AppLocalizations.of(context)!.newCategoryShort), label: Text(AppLocalizations.of(context)!.newProductShort),
tooltip: AppLocalizations.of(context)!.newCategoryLong, tooltip: AppLocalizations.of(context)!.newProductLong,
onPressed: () { onPressed: () {
// show new category popup // show new category popup
context.pushNamed('new-product', params: { context.pushNamed('new-product', params: {

View file

@ -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<StatefulWidget> createState() => _EditProductPageState();
}
class _EditProductPageState extends State<EditProductPage> {
// input controllers
final _ctrName = TextEditingController();
final _ctrDescription = TextEditingController();
final _ctrEAN = TextEditingController();
int? _ctrCategory;
int _ctrUnit = 0;
String _ctrValue = '';
int? _ctrParent;
// data cache
List<RoomCategory> categories = [];
List<RoomProduct> products = [];
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, 'server': widget.server}),
onOK: (body) async {
final resp = body['data']
.map<RoomCategory>((raw) => RoomCategory.fromJSON(raw))
.toList();
setState(() {
categories = resp;
});
});
}
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, 'server': widget.server}),
onOK: (body) async {
final resp = body['data']
.map<RoomProduct>((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<User>();
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)),
);
}
}

View file

@ -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<StatefulWidget> createState() => _ViewProductPageState();
}
class _ViewProductPageState extends State<ViewProductPage> {
RoomProduct? product;
// data cache
List<RoomProduct> products = [];
Map<int?, RoomCategory> categories = {};
RoomInfo? info;
void fetchInfo() async {
final sm = ScaffoldMessenger.of(context);
final user = context.read<User>();
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<User>();
// 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<RoomCategory>((raw) => RoomCategory.fromJSON(raw))
.toList();
Map<int?, RoomCategory> map = {};
for (RoomCategory cat in resp) {
map[cat.id] = cat;
}
setState(() {
categories = map;
});
});
}
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, 'server': widget.server}),
onOK: (body) async {
final resp = body['data']
.map<RoomProduct>((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),
),
])),
);
}
}