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

@ -127,32 +127,32 @@ class RoomIcon {
String get text { String get text {
switch (type.toLowerCase()) { switch (type.toLowerCase()) {
case 'love': case 'love':
return 'Friends'; return 'Friends';
case 'sports': case 'sports':
return 'Sports'; return 'Sports';
case 'pets': case 'pets':
return 'Pets'; return 'Pets';
case 'vacation': case 'vacation':
return 'Vacation'; return 'Vacation';
case 'gifts': case 'gifts':
return 'Gifts'; return 'Gifts';
case 'groceries': case 'groceries':
return 'Groceries'; return 'Groceries';
case 'fashion': case 'fashion':
return 'Clothing'; return 'Clothing';
case 'art': case 'art':
return 'Arts & Crafts'; return 'Arts & Crafts';
case 'tech': case 'tech':
return 'Electronics'; return 'Electronics';
case 'home': case 'home':
return 'Home supplies'; return 'Home supplies';
case 'family': case 'family':
return 'Family'; return 'Family';
case 'social': case 'social':
return 'Social'; return 'Social';
case 'other': case 'other':
default: default:
return 'Other'; return 'Other';
} }
} }
@ -161,44 +161,44 @@ class RoomIcon {
String path = ""; String path = "";
switch (type.toLowerCase()) { switch (type.toLowerCase()) {
case 'love': case 'love':
path = 'undraw/undraw_couple.svg'; path = 'undraw/undraw_couple.svg';
break; break;
case 'sports': case 'sports':
path = 'undraw/undraw_greek_freak.svg'; path = 'undraw/undraw_greek_freak.svg';
break; break;
case 'pets': case 'pets':
path = 'undraw/undraw_dog.svg'; path = 'undraw/undraw_dog.svg';
break; break;
case 'vacation': case 'vacation':
path = 'undraw/undraw_trip.svg'; path = 'undraw/undraw_trip.svg';
break; break;
case 'gifts': case 'gifts':
path = 'undraw/undraw_gifts.svg'; path = 'undraw/undraw_gifts.svg';
break; break;
case 'groceries': case 'groceries':
path = 'undraw/undraw_gone_shopping.svg'; path = 'undraw/undraw_gone_shopping.svg';
break; break;
case 'fashion': case 'fashion':
path = 'undraw/undraw_jewelry.svg'; path = 'undraw/undraw_jewelry.svg';
break; break;
case 'art': case 'art':
path = 'undraw/undraw_sculpting.svg'; path = 'undraw/undraw_sculpting.svg';
break; break;
case 'tech': case 'tech':
path = 'undraw/undraw_progressive_app.svg'; path = 'undraw/undraw_progressive_app.svg';
break; break;
case 'home': case 'home':
path = 'undraw/undraw_under_construction.svg'; path = 'undraw/undraw_under_construction.svg';
break; break;
case 'family': case 'family':
path = 'undraw/undraw_family.svg'; path = 'undraw/undraw_family.svg';
break; break;
case 'social': case 'social':
path = 'undraw/undraw_pizza_sharing.svg'; path = 'undraw/undraw_pizza_sharing.svg';
break; break;
case 'other': case 'other':
default: default:
path = 'undraw/undraw_file_manager.svg'; path = 'undraw/undraw_file_manager.svg';
} }
return asset(path); return asset(path);
@ -214,7 +214,7 @@ class Room {
RoomVisibility? visibility = RoomVisibility.private; RoomVisibility? visibility = RoomVisibility.private;
Room( Room(
{required this.id, {required this.id,
required this.serverTag, required this.serverTag,
this.name = "", this.name = "",
this.description = "", this.description = "",
@ -263,12 +263,12 @@ class Room {
factory Room.fromMap(Map<String, dynamic> map) { factory Room.fromMap(Map<String, dynamic> map) {
return Room( return Room(
id: map['id'], id: map['id'],
serverTag: map['server'], serverTag: map['server'],
name: map['name'], name: map['name'],
description: map['description'] ?? '', description: map['description'] ?? '',
icon: RoomIcon(type: map['icon'] ?? 'Other'), icon: RoomIcon(type: map['icon'] ?? 'Other'),
visibility: RoomVisibility(map['visibility'] ?? 0)); visibility: RoomVisibility(map['visibility'] ?? 0));
} }
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
@ -284,12 +284,12 @@ class Room {
factory Room.fromJSON(dynamic json) { factory Room.fromJSON(dynamic json) {
return Room( return Room(
id: json['name'], id: json['name'],
serverTag: json['server'], serverTag: json['server'],
name: json['title'], name: json['title'],
description: json['description'], description: json['description'],
icon: RoomIcon(type: json['icon']), icon: RoomIcon(type: json['icon']),
visibility: RoomVisibility(json['visibility'])); visibility: RoomVisibility(json['visibility']));
} }
Future<void> toDisk() async { Future<void> toDisk() async {
@ -308,7 +308,7 @@ class Room {
} }
static Future<Room> fromDisk( static Future<Room> fromDisk(
{required String id, required String serverTag}) async { {required String id, required String serverTag}) async {
final db = Localstore.instance; final db = Localstore.instance;
final raw = await db.collection('rooms').doc('$id@$serverTag').get(); final raw = await db.collection('rooms').doc('$id@$serverTag').get();
return Room.fromMap(raw!); return Room.fromMap(raw!);
@ -321,13 +321,13 @@ class RoomMember {
final bool isAdmin; final bool isAdmin;
const RoomMember( const RoomMember(
{required this.id, required this.serverTag, required this.isAdmin}); {required this.id, required this.serverTag, required this.isAdmin});
factory RoomMember.fromJSON(dynamic json) { factory RoomMember.fromJSON(dynamic json) {
return RoomMember( return RoomMember(
id: json['name'], id: json['name'],
serverTag: json['server'], serverTag: json['server'],
isAdmin: json['admin'] == 1); isAdmin: json['admin'] == 1);
} }
String get humanReadableName { String get humanReadableName {
@ -342,40 +342,39 @@ class RoomInfo {
final int permissions; final int permissions;
const RoomInfo( const RoomInfo(
{required this.permissions, {required this.permissions,
required this.owner, required this.owner,
required this.isAdmin, required this.isAdmin,
required this.isOwner}); required this.isOwner});
factory RoomInfo.fromJSON(dynamic json) { factory RoomInfo.fromJSON(dynamic json) {
return RoomInfo( return RoomInfo(
permissions: json['rights'], permissions: json['rights'],
owner: json['owner'], owner: json['owner'],
isAdmin: json['isAdmin'], isAdmin: json['isAdmin'],
isOwner: json['isOwner']); isOwner: json['isOwner']);
} }
} }
class RoomCategory { class RoomCategory {
final int id; final int? id;
final String name; final String name;
final ColorSwatch<int> color; final ColorSwatch<int> color;
const RoomCategory( const RoomCategory(
{required this.id, required this.name, required this.color}); {required this.id, required this.name, required this.color});
factory RoomCategory.fromJSON(dynamic json) { factory RoomCategory.fromJSON(dynamic json) {
return RoomCategory( return RoomCategory(
id: json['id'], id: json['id'],
name: json['title'], name: json['title'],
color: colorFromString(json['color'])); color: colorFromString(json['color']));
} }
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() {
@ -399,31 +398,31 @@ class RoomCategory {
ColorSwatch<int> colorFromString(String text) { ColorSwatch<int> colorFromString(String text) {
switch (text.toLowerCase()) { switch (text.toLowerCase()) {
case 'red-acc': case 'red-acc':
return Colors.redAccent; return Colors.redAccent;
case 'green-acc': case 'green-acc':
return Colors.greenAccent; return Colors.greenAccent;
case 'yellow-acc': case 'yellow-acc':
return Colors.yellowAccent; return Colors.yellowAccent;
case 'blue-acc': case 'blue-acc':
return Colors.blueAccent; return Colors.blueAccent;
case 'aqua-acc': case 'aqua-acc':
return Colors.tealAccent; return Colors.tealAccent;
case 'purple-acc': case 'purple-acc':
return Colors.purpleAccent; return Colors.purpleAccent;
case 'red': case 'red':
return Colors.red; return Colors.red;
case 'green': case 'green':
return Colors.green; return Colors.green;
case 'yellow': case 'yellow':
return Colors.yellow; return Colors.yellow;
case 'blue': case 'blue':
return Colors.blue; return Colors.blue;
case 'aqua': case 'aqua':
return Colors.teal; return Colors.teal;
case 'purple': case 'purple':
default: default:
return Colors.purple; return Colors.purple;
} }
} }
@ -487,7 +486,7 @@ class RoomProduct {
int? parent; int? parent;
RoomProduct( RoomProduct(
{required this.id, {required this.id,
required this.name, required this.name,
this.description = '', this.description = '',
this.category = -1, this.category = -1,
@ -498,14 +497,14 @@ class RoomProduct {
factory RoomProduct.fromJSON(dynamic json) { factory RoomProduct.fromJSON(dynamic json) {
return RoomProduct( return RoomProduct(
id: json['listProdID'], id: json['listProdID'],
name: json['title'], name: json['title'],
description: json['description'], description: json['description'],
category: json['category'], category: json['category'],
defaultUnit: json['defUnit'], defaultUnit: json['defUnit'],
defaultValue: json['defValue'], defaultValue: json['defValue'],
ean: json['ean'], ean: json['ean'],
parent: json['parent']); parent: json['parent']);
} }
} }
@ -524,7 +523,7 @@ class RoomItem {
int? link; int? link;
RoomItem( RoomItem(
{required this.id, {required this.id,
required this.name, required this.name,
this.description = '', this.description = '',
this.state = 0, this.state = 0,
@ -535,24 +534,24 @@ class RoomItem {
factory RoomItem.fromJSON(dynamic json) { factory RoomItem.fromJSON(dynamic json) {
return RoomItem( return RoomItem(
id: json['listItemID'], id: json['listItemID'],
name: json['title'], name: json['title'],
description: json['description'], description: json['description'],
category: json['listCatID'], category: json['listCatID'],
state: json['state'], state: json['state'],
unit: json['unit'], unit: json['unit'],
value: json['value'], value: json['value'],
link: json['listProdID']); link: json['listProdID']);
} }
RoomItem clone() { RoomItem clone() {
return RoomItem( return RoomItem(
id: id, id: id,
name: name, name: name,
description: description, description: description,
category: category, category: category,
unit: unit, unit: unit,
value: value, value: value,
link: link); link: link);
} }
} }

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, child: Row(
label: category.name, mainAxisAlignment: MainAxisAlignment.spaceBetween,
trailingIcon: Icon( crossAxisAlignment: CrossAxisAlignment.center,
Icons.square_rounded, children: [
color: category.color, Text(category.name),
))), Icon(Icons.square_rounded,
// entry for default ("other") category color:category.color,
DropdownMenuEntry( size: 32)
value: null, ]
label: AppLocalizations.of(context)!.categoryNameOther, ),
trailingIcon: Icon( )).toList(),
Icons.square_rounded, onChanged: enabled?(cid) {
color: RoomCategory.other(context).color, if (onSelect != null) {
)) onSelect!(cid);
], }
}: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),
),
])),
);
}
}