rewrite of cdb_ui in dioxus rust. goal is to integrate into a single rust codebase
This commit is contained in:
JMARyA 2025-05-25 20:03:42 +02:00
commit b3a96ed3e3
Signed by: jmarya
GPG key ID: 901B2ADDF27C2263
37 changed files with 9927 additions and 0 deletions

115
src/pages/consume.dart Normal file
View file

@ -0,0 +1,115 @@
import 'package:cdb_ui/api.dart';
import 'package:cdb_ui/pages/supply.dart';
import 'package:flutter/material.dart';
class ConsumePage extends StatefulWidget {
final Transaction transaction;
final Function refresh;
const ConsumePage(this.transaction, this.refresh, {super.key});
@override
State<ConsumePage> createState() => _ConsumePageState();
}
class _ConsumePageState extends State<ConsumePage> {
final _formKey = GlobalKey<FormState>();
String _selectedDestination = "";
String _price = "";
@override
void initState() {
super.initState();
}
void _consume() {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
API()
.consumeItem(widget.transaction.uuid, _selectedDestination,
double.parse(_price))
.then((_) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Item consumed successfully!')),
);
Navigator.of(context).pop();
widget.refresh();
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Consume Item'),
),
body: FutureBuilder(
future: API().getUniqueField(
widget.transaction.item, widget.transaction.variant, "destination"),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
var destinations = snapshot.data!;
return Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Destination Field with Dropdown and Text Input
AutocompletedTextField(
options: destinations,
getValue: () => _selectedDestination,
onChanged: (value) {
_selectedDestination = value;
},
onSelection: (selection) {
setState(() {
_selectedDestination = selection;
});
},
label: "Destination"),
const SizedBox(height: 16),
// Price Field
TextFormField(
decoration: const InputDecoration(labelText: 'Price'),
keyboardType: TextInputType.number,
initialValue: _price,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a price';
}
if (double.tryParse(value) == null) {
return 'Please enter a valid number';
}
return null;
},
onSaved: (value) {
_price = value!;
},
),
const SizedBox(height: 20),
// Submit Button
ElevatedButton(
onPressed: _consume,
child: const Text('Consume Item'),
),
],
),
),
);
},
),
);
}
}

View file

@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
class ExpandableListItem {
ExpandableListItem({
required this.body,
required this.header,
this.isExpanded = false,
});
Widget body;
Widget header;
bool isExpanded;
}
class ExpandableList extends StatefulWidget {
final List<ExpandableListItem> entries;
const ExpandableList(this.entries, {super.key});
@override
State<ExpandableList> createState() => _ExpandableListState();
}
class _ExpandableListState extends State<ExpandableList> {
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
child: Container(
child: _buildPanel(),
),
);
}
Widget _buildPanel() {
return ExpansionPanelList(
expansionCallback: (int index, bool isExpanded) {
setState(() {
widget.entries[index].isExpanded = isExpanded;
});
},
children: widget.entries.map<ExpansionPanel>((ExpandableListItem item) {
return ExpansionPanel(
headerBuilder: (BuildContext context, bool isExpanded) {
return ListTile(
title: item.header,
onTap: () {
setState(() {
widget.entries.firstWhere((x) => x == item).isExpanded =
!isExpanded;
});
},
);
},
body: item.body,
isExpanded: item.isExpanded,
);
}).toList(),
);
}
}

View file

@ -0,0 +1,144 @@
import 'package:cdb_ui/api.dart' as API;
import 'package:cdb_ui/pages/flow/create_flow_page.dart';
import 'package:cdb_ui/pages/flow/end_flow_page.dart';
import 'package:cdb_ui/pages/flow/flow_note.dart';
import 'package:cdb_ui/pages/transaction.dart';
import 'package:flutter/material.dart';
import 'package:qr_bar_code/qr/src/qr_code.dart';
import 'package:qr_bar_code/qr/src/types.dart';
class ActiveFlowPage extends StatefulWidget {
final API.Flow flow;
final API.FlowInfo info;
const ActiveFlowPage(this.flow, this.info, {super.key});
@override
State<ActiveFlowPage> createState() => _ActiveFlowPageState();
}
class _ActiveFlowPageState extends State<ActiveFlowPage> {
_refresh() {
setState(() {});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.info.name),
actions: [
IconButton(
onPressed: () async {
if (widget.info.produces?.isNotEmpty ?? false) {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) =>
EndFlowWithProduce(widget.flow, widget.info)));
return;
}
// simple dialog
var confirm = await showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Are you sure?'),
content: const Text('This will end the flow.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('End'),
),
],
),
);
if (confirm ?? false) {
await API
.API()
.endFlow(widget.flow.id)
.then((x) => Navigator.of(context).pop());
}
},
icon: const Icon(Icons.stop)),
if (widget.info.next != null)
IconButton(
onPressed: () {
var newInfo = API.API().getFlowInfo(widget.info.next!);
Navigator.of(context).push(MaterialPageRoute(
builder: (context) {
return CreateFlowPage(
newInfo,
() {},
previousFlow: widget.flow,
);
},
));
},
icon: const Icon(Icons.arrow_forward)),
],
),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
children: [
Row(
children: [
QRCode(
data: widget.flow.id,
size: 128,
eyeStyle: const QREyeStyle(color: Colors.white),
dataModuleStyle: const QRDataModuleStyle(color: Colors.white),
semanticsLabel: "Transaction UUID",
),
const SizedBox(
width: 16.0,
),
Text("Started since: ${tsFormat(widget.flow.started)}"),
],
),
if (widget.flow.input != null)
...widget.flow.input!.map((x) => Text("Input: $x")).toList(),
if (widget.flow.done != null) ...[
Text("Ended: ${tsFormat(widget.flow.done!.ended)}"),
if (widget.flow.done!.next != null)
Text("Next: ${widget.flow.done!.next!}"),
...widget.flow.done!.produced!
.map((x) => Text("Produced: $x"))
.toList(),
],
const Divider(),
FutureBuilder(
future: API.API().getNotesOfFlow(widget.flow.id),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const CircularProgressIndicator();
}
var data = snapshot.data!;
return Expanded(
child: ListView(
children: data
.map(
(x) => FlowNoteCard(x),
)
.toList()));
},
)
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => AddNotePage(widget.flow, _refresh)));
},
child: const Icon(Icons.note_add),
),
);
}
}

View file

@ -0,0 +1,135 @@
import 'package:cdb_ui/api.dart' as API;
import 'package:cdb_ui/pages/flow/active_flow_page.dart';
import 'package:cdb_ui/pages/transaction.dart';
import 'package:flutter/material.dart';
class CreateFlowPage extends StatefulWidget {
final API.FlowInfo info;
final Function refresh;
final API.Flow? previousFlow;
const CreateFlowPage(this.info, this.refresh, {this.previousFlow, super.key});
@override
State<CreateFlowPage> createState() => _CreateFlowPageState();
}
class _CreateFlowPageState extends State<CreateFlowPage> {
List<API.Transaction> depends = [];
void _create(BuildContext context) {
if (widget.previousFlow != null) {
API
.API()
.continueFlow(widget.previousFlow!.id,
input: depends.map((x) => x.uuid).toList())
.then((x) {
API.API().getFlow(x).then((flow) {
var info = API.API().getFlowInfo(flow.kind);
Navigator.of(context).pushReplacement(MaterialPageRoute(
builder: (context) => ActiveFlowPage(flow, info),
));
});
});
return;
}
API
.API()
.startFlow(widget.info.id, input: depends.map((x) => x.uuid).toList())
.then((flowID) {
widget.refresh();
API.API().getFlow(flowID).then((flow) {
Navigator.of(context).pushReplacement(MaterialPageRoute(
builder: (context) => ActiveFlowPage(flow, widget.info)));
});
});
}
void selectDependItems(BuildContext context, String itemVariant) {
var (item, variant) = API.itemVariant(itemVariant);
API.API().getInventoryOfVariant(item, variant).then((transactions) {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) {
return TransactionSelectPage(transactions, onSelect: (t) {
if (!depends.contains(t)) {
setState(() {
depends.add(t);
});
}
}, exclude: depends);
},
));
});
}
List<Widget> buildInputSelection(BuildContext context) {
if (widget.info.depends.isEmpty) {
return [];
}
return [
Column(
children: widget.info.depends.map((x) {
var (item, variant) = API.itemVariant(x);
return ElevatedButton(
onPressed: () {
selectDependItems(context, x);
},
child:
Text("Add ${API.API().getItem(item).variants[variant]!.name}"));
}).toList()),
const Divider(),
];
}
Widget flowContinuation() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Card(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(API.API().getFlowInfo(widget.previousFlow!.kind).name),
),
),
const Card(child: Icon(Icons.arrow_right)),
Card(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Text(widget.info.name),
),
)
],
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.previousFlow != null
? "Continue to ${widget.previousFlow!.kind}"
: "Create new ${widget.info.name} Flow")),
body: Column(
children: [
if (widget.previousFlow != null) ...[
flowContinuation(),
const Divider()
],
...buildInputSelection(context),
Card(
child: Column(
children: depends
.map((x) => TransactionCard(x, () {},
onTap: (x) {}, onLongPress: (x) {}))
.toList())),
ElevatedButton(
onPressed: () => _create(context),
child: const Text("Create Flow"))
],
),
);
}
}

View file

@ -0,0 +1,96 @@
import 'package:cdb_ui/api.dart' as API;
import 'package:cdb_ui/api.dart';
import 'package:cdb_ui/pages/supply.dart';
import 'package:cdb_ui/pages/transaction.dart';
import 'package:flutter/material.dart';
class EndFlowWithProduce extends StatefulWidget {
final API.Flow flow;
final API.FlowInfo info;
const EndFlowWithProduce(this.flow, this.info, {super.key});
@override
State<EndFlowWithProduce> createState() => _EndFlowWithProduceState();
}
class _EndFlowWithProduceState extends State<EndFlowWithProduce> {
List<SupplyForm> produces = [];
late Map<String, Location> locations;
@override
void initState() {
super.initState();
locations = API.API().getLocations();
}
refresh() {
setState(() {});
}
void addProduced(SupplyForm t) {
setState(() {
produces.add(t);
});
}
List<Widget> addProduceButtons() {
List<Widget> ret = [];
for (var i in widget.info.produces!) {
var (itemID, variant) = API.itemVariant(i);
var item = API.API().getItem(itemID);
ret.add(ElevatedButton(
onPressed: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) {
return SupplyPage(
item,
refresh,
onlyVariants: [variant],
forcePrice: "0.00",
forceOrigin: "flow::${widget.flow.kind}::${widget.flow.id}",
onCreate: addProduced,
);
},
));
},
child: Text("Produced ${item.variants[variant]!.name}")));
}
return ret;
}
_endFlow() {
API.API().endFlow(widget.flow.id, produced: produces).then((x) {
Navigator.of(context).pop();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("End ${widget.info.name} Flow"),
),
body: Column(
children: [
...addProduceButtons(),
const Divider(),
...produces.map((x) {
return TransactionCard(
x.transaction(locations),
() {},
onLongPress: (x) {},
onTap: (x) {},
);
}).toList(),
const SizedBox(
height: 10,
),
ElevatedButton(onPressed: _endFlow, child: const Text("End Flow"))
],
),
);
}
}

View file

@ -0,0 +1,85 @@
import 'package:cdb_ui/api.dart' as API;
import 'package:cdb_ui/pages/flow/active_flow_page.dart';
import 'package:cdb_ui/pages/flow/create_flow_page.dart';
import 'package:flutter/material.dart';
class FlowInfoPage extends StatefulWidget {
final API.FlowInfo info;
const FlowInfoPage(this.info, {super.key});
@override
State<FlowInfoPage> createState() => _FlowInfoPageState();
}
class _FlowInfoPageState extends State<FlowInfoPage> {
void refresh() {
setState(() {});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.info.name)),
body: Column(
children: [
// todo : ui improve
if (widget.info.next != null)
Text("Next: ${API.API().getFlowInfo(widget.info.next!).name}"),
if (widget.info.depends.isNotEmpty)
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
const Text("Flow can use: "),
...widget.info.depends.map((x) {
var (item, variant) = API.itemVariant(x);
return Text(API.API().getItem(item).variants[variant]!.name);
}).toList(),
],
),
if (widget.info.produces?.isNotEmpty ?? false)
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
const Text("Flow can produce: "),
...widget.info.produces!.map((x) {
var (item, variant) = API.itemVariant(x);
return Text(API.API().getItem(item).variants[variant]!.name);
}).toList(),
],
),
const Divider(),
FutureBuilder(
future: API.API().getActiveFlowsOf(widget.info.id),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const CircularProgressIndicator();
}
var data = snapshot.data!;
return Expanded(
child: ListView(
children: data
.map((x) => ListTile(
title: Text(x.id),
onTap: () =>
Navigator.of(context).push(MaterialPageRoute(
builder: (context) =>
ActiveFlowPage(x, widget.info),
))))
.toList()),
);
},
)
],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => CreateFlowPage(widget.info, refresh)));
},
child: const Icon(Icons.add)),
);
}
}

View file

@ -0,0 +1,58 @@
import 'package:cdb_ui/pages/transaction.dart';
import 'package:flutter/material.dart';
import 'package:cdb_ui/api.dart' as API;
class AddNotePage extends StatelessWidget {
late final TextEditingController _noteController = TextEditingController();
final API.Flow flow;
final Function refresh;
AddNotePage(this.flow, this.refresh, {super.key});
void _submit(BuildContext context) {
API.API().addNoteToFlow(flow.id, _noteController.text).then((x) {
refresh();
Navigator.of(context).pop();
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Add Note"),
),
body: Column(
children: [
TextFormField(
decoration: const InputDecoration(labelText: 'Note'),
controller: _noteController,
maxLines: 10),
const SizedBox(
height: 14,
),
ElevatedButton(
onPressed: () => _submit(context), child: const Text("Add Note"))
],
),
);
}
}
class FlowNoteCard extends StatelessWidget {
final API.FlowNote note;
const FlowNoteCard(this.note, {super.key});
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
dense: true,
title: Text(tsFormat(note.timestamp),
style: const TextStyle(fontSize: 12)),
subtitle: Text(note.content, overflow: TextOverflow.ellipsis),
),
);
}
}

View file

@ -0,0 +1,175 @@
import 'package:cdb_ui/api.dart' as API;
import 'package:cdb_ui/pages/expandable_list.dart';
import 'package:cdb_ui/pages/flow/active_flow_page.dart';
import 'package:cdb_ui/pages/flow/flow_info_page.dart';
import 'package:cdb_ui/pages/supply.dart';
import 'package:flutter/material.dart';
class FlowsPage extends StatefulWidget {
const FlowsPage({super.key});
@override
State<FlowsPage> createState() => _FlowsPageState();
}
class _FlowsPageState extends State<FlowsPage> {
int tabSelection = 0;
Map<String, API.FlowInfo>? flowInfos;
@override
void initState() {
super.initState();
flowInfos = API.API().getFlows();
}
Widget flowTile(BuildContext context, API.FlowInfo x) {
return ListTile(
title: Text(x.name),
onTap: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => FlowInfoPage(x),
));
},
);
}
Widget listFlowInfoByActive(
BuildContext context, Map<String, API.FlowInfo> infos) {
return FutureBuilder(future: () async {
var included = [];
for (var key in infos.keys) {
var active = await API.API().getActiveFlowsOf(key);
if (active.isNotEmpty) {
included.add(infos[key]);
}
}
return included;
}(), builder: (context, snapshot) {
if (!snapshot.hasData) {
return const CircularProgressIndicator();
}
var included = snapshot.data!;
return ListView(
children: included.map((x) => flowTile(context, x)).toList());
});
}
Widget listAllFlowInfos(
BuildContext context, Map<String, API.FlowInfo> infos) {
return ListView(
children: infos.values.map((x) {
return flowTile(context, x);
}).toList());
}
Widget listFlowInfoByProduced(
BuildContext context, Map<String, API.FlowInfo> infos) {
Map<String, List<API.FlowInfo>> producedMapping = {};
for (var f in infos.values) {
for (var produces in f.produces ?? []) {
var item = API.itemVariant(produces).$1;
producedMapping.putIfAbsent(item, () {
return [];
});
producedMapping[item]!.add(f);
}
}
List<ExpandableListItem> items = [];
for (var key in producedMapping.keys) {
var flows = Column(
children: producedMapping[key]!.map((x) {
return flowTile(context, x);
}).toList());
items.add(ExpandableListItem(
body: flows, header: Text(API.API().getItem(key).name)));
}
return ExpandableList(items);
}
Widget listFlowInfoByDependant(
BuildContext context, Map<String, API.FlowInfo> infos) {
Map<String, List<API.FlowInfo>> dependsMapping = {};
for (var f in infos.values) {
for (var produces in f.depends) {
var item = API.itemVariant(produces).$1;
// todo : add only if item is in inventory
dependsMapping.putIfAbsent(item, () {
return [];
});
dependsMapping[item]!.add(f);
}
}
List<ExpandableListItem> items = [];
for (var key in dependsMapping.keys) {
var flows = Column(
children: dependsMapping[key]!.map((x) {
return flowTile(context, x);
}).toList());
items.add(ExpandableListItem(
body: flows, header: Text(API.API().getItem(key).name)));
}
return ExpandableList(items);
}
@override
Widget build(BuildContext context) {
if (flowInfos == null) {
return const CircularProgressIndicator();
}
return DefaultTabController(
length: 4,
child: Scaffold(
appBar: AppBar(
title: const Text("Flows"),
bottom: TabBar(
tabs: const [
Tab(text: "All"),
Tab(text: "Produces"),
Tab(text: "Depends"),
Tab(text: "Active")
],
onTap: (value) {
setState(() {
tabSelection = value;
});
},
)),
body: switch (tabSelection) {
0 => listAllFlowInfos(context, flowInfos!),
1 => listFlowInfoByProduced(context, flowInfos!),
2 => listFlowInfoByDependant(context, flowInfos!),
3 => listFlowInfoByActive(context, flowInfos!),
_ => const Text("..."),
},
floatingActionButton: FloatingActionButton(
onPressed: () async {
// scan flow code
var code = await scanQRCode(context, title: "Scan Flow Code");
API.API().getFlow(code!).then((flow) {
var info = API.API().getFlowInfo(flow.kind);
Navigator.of(context).push(MaterialPageRoute(
builder: (context) {
return ActiveFlowPage(flow, info);
},
));
});
},
child: const Icon(Icons.qr_code),
),
));
}
}

118
src/pages/itemview.dart Normal file
View file

@ -0,0 +1,118 @@
import 'package:cdb_ui/api.dart';
import 'package:cdb_ui/pages/home.dart';
import 'package:cdb_ui/pages/transaction.dart';
import 'package:flutter/material.dart';
import 'supply.dart';
// todo : show est. time remaining until inventory gets empty (based on demand)
class ItemView extends StatefulWidget {
final Item item;
@override
State<ItemView> createState() => _ItemViewState();
const ItemView({super.key, required this.item});
}
class _ItemViewState extends State<ItemView> {
void refresh() {
setState(() {});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.item.name)),
body: Column(children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 28),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Align(
alignment: Alignment.centerLeft,
child: widget.item.image != null
? Image.network(
"${API().instance}/${widget.item.image}",
height: 100,
width: 100,
)
: null,
),
const SizedBox(
width: 16.0,
),
Column(
children: [
Text(
widget.item.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
if (widget.item.category != null)
Text(widget.item.category!),
],
)
],
),
const SizedBox(height: 18),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: widget.item.variants.entries.map((entry) {
return Column(children: [
Text(
entry.value.name,
style: const TextStyle(fontWeight: FontWeight.bold),
),
FutureBuilder(
future: API().getStat(widget.item.id, entry.key),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const CircularProgressIndicator();
}
var stat = snapshot.data!;
return Column(
children: [
Text("Amount: ${stat.amount}"),
Text(
"Total Cost: ${stat.totalPrice.toStringAsFixed(2)}")
],
);
},
)
]);
}).toList()),
const SizedBox(
height: 12,
)
],
),
),
FutureBuilder(
future: API().getInventory(widget.item.id),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const CircularProgressIndicator();
}
var data = snapshot.data!;
return Expanded(
child: ListView(
children:
data.map((x) => TransactionCard(x, refresh)).toList()));
},
)
]),
floatingActionButton: FloatingActionButton(
onPressed: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => SupplyPage(widget.item, refresh)));
},
child: const Icon(Icons.add)),
);
}
}

165
src/pages/locations.dart Normal file
View file

@ -0,0 +1,165 @@
import 'package:cdb_ui/api.dart';
import 'package:cdb_ui/pages/supply.dart';
import 'package:cdb_ui/pages/transaction.dart';
import 'package:flutter/material.dart';
import 'package:flutter_simple_treeview/flutter_simple_treeview.dart';
class LocationsPage extends StatefulWidget {
const LocationsPage({super.key});
@override
State<LocationsPage> createState() => _LocationsPageState();
}
class _LocationsPageState extends State<LocationsPage> {
Map<String, Location>? locations;
@override
void initState() {
super.initState();
locations = API().getLocations();
}
TreeNode buildTree(BuildContext context, String locID) {
return TreeNode(
key: ValueKey(locID),
content: Expanded(
child: ListTile(
title: Text(locations![locID]!.name),
onTap: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => LocationView(locations![locID]!),
));
},
),
),
children: locations!.keys.where((key2) {
if (locations![key2]!.parent != null) {
return locations![key2]!.parent! == locations![locID]!.id;
}
return false;
}).map((key2) {
return buildTree(context, key2);
}).toList());
}
@override
Widget build(BuildContext context) {
if (locations == null) {
return const Scaffold(
body: CircularProgressIndicator(),
);
}
return Scaffold(
appBar: AppBar(
title: const Text("Locations"),
),
body: TreeView(
indent: 15,
nodes: locations!.keys
.where((key) => locations![key]!.parent == null)
.map((key) {
return buildTree(context, key);
}).toList()),
floatingActionButton: FloatingActionButton(
onPressed: () async {
// scan location code
var code = await scanQRCode(context, title: "Scan Location Code");
if (!locations!.containsKey(code)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('The location $code does not exist.')),
);
return;
}
Navigator.of(context).push(MaterialPageRoute(
builder: (context) => LocationView(locations![code]!),
));
},
child: const Icon(Icons.qr_code),
),
);
}
}
class LocationView extends StatefulWidget {
final Location location;
const LocationView(this.location, {super.key});
@override
State<LocationView> createState() => _LocationViewState();
}
class _LocationViewState extends State<LocationView> {
bool recursive = true;
void refresh() {
setState(() {});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.location.name),
),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10.0),
child: Column(
children: [
Card(
child: Column(
children: [
if (widget.location.parent != null)
Text("Inside: ${widget.location.parent!}"),
if (widget.location.conditions?.temperature != null)
Text(
"Temperature: ${widget.location.conditions!.temperature}")
],
),
),
Row(
children: [
Checkbox(
value: !recursive,
onChanged: (bool? newValue) {
setState(() {
recursive = !(newValue ?? false);
});
},
),
const Expanded(
child: Text(
'Show only exact matches with location',
style: TextStyle(fontSize: 16),
),
),
],
),
Expanded(
child: FutureBuilder(
future: API().getTransactionsOfLocation(widget.location.id,
recursive: recursive),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const CircularProgressIndicator();
}
var data = snapshot.data!;
return ListView(
children: data
.map((x) => TransactionCard(x, refresh))
.toList());
},
),
)
],
),
),
);
}
}

354
src/pages/supply.dart Normal file
View file

@ -0,0 +1,354 @@
import 'package:cdb_ui/api.dart';
import 'package:flutter/material.dart';
import 'package:simple_barcode_scanner/enum.dart';
import 'package:simple_barcode_scanner/simple_barcode_scanner.dart';
Future<String?> scanQRCode(BuildContext context,
{String title = "Scan QR Code"}) async {
var res = await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => SimpleBarcodeScannerPage(
scanType: ScanType.qr,
appBarTitle: title,
),
));
return res;
}
class SupplyPage extends StatefulWidget {
final Item item;
final Function refresh;
final List<String>? onlyVariants;
final String? forcePrice;
final String? forceOrigin;
// callback function for receiving a transaction without creating on the API
final Function(SupplyForm)? onCreate;
const SupplyPage(this.item, this.refresh,
{this.onlyVariants,
this.onCreate,
this.forceOrigin,
this.forcePrice,
super.key});
@override
State<SupplyPage> createState() => _SupplyPageState();
}
class _SupplyPageState extends State<SupplyPage> {
late List<String> availableVariants;
late String variant;
final _formKey = GlobalKey<FormState>();
String _selectedOrigin = "";
String _selectedLocation = "";
late TextEditingController _priceController;
late TextEditingController _noteController;
@override
void initState() {
super.initState();
availableVariants =
widget.onlyVariants ?? widget.item.variants.keys.toList();
variant = availableVariants.first;
_selectedOrigin = widget.forceOrigin ?? "";
_priceController = TextEditingController(text: widget.forcePrice ?? "");
_noteController = TextEditingController(text: "");
}
void _supply() {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
if (widget.onCreate != null) {
var t = SupplyForm(
itemID: widget.item.id,
variant: variant,
price: double.parse(_priceController.text),
origin: _selectedOrigin,
location: _selectedLocation,
note: _noteController.text);
widget.onCreate!(t);
Navigator.of(context).pop();
widget.refresh();
return;
}
API()
.supplyItem(
widget.item.id,
variant,
double.parse(_priceController.text),
_selectedOrigin,
_selectedLocation,
_noteController.text)
.then((_) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Item added successfully!')),
);
Navigator.of(context).pop();
widget.refresh();
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Add New Item'),
),
body: FutureBuilder(
future: _fetchData(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
var data = snapshot.data as Map<String, dynamic>;
var locations = data['locations']! as Map<String, Location>;
var origins = data['origins']! as List<String>;
return Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Variant Selection
DropdownButtonFormField<String>(
hint: const Text('Select Variant'),
value: variant,
onChanged: (value) {
setState(() {
variant = value!;
});
},
items: availableVariants.map((entryKey) {
var entry = widget.item.variants[entryKey]!;
return DropdownMenuItem<String>(
value: entryKey,
child: Text(entry.name),
);
}).toList(),
onSaved: (value) {
variant = value!;
},
),
const SizedBox(height: 16),
// Origin Field with Dropdown and Text Input
if (widget.forceOrigin == null) ...[
AutocompletedTextField(
options: origins,
getValue: () => _selectedOrigin,
onChanged: (value) {
_selectedOrigin = value;
},
onSelection: (String selection) async {
var price = _priceController.text.isEmpty
? await API()
.getLatestPrice(widget.item.id, variant,
origin: selection)
.then((x) => x.toStringAsFixed(2))
: _priceController.text;
setState(() {
_priceController.text = price;
_selectedOrigin = selection;
});
},
label: "Origin"),
const SizedBox(height: 16),
],
// Price Field
if (widget.forcePrice == null) ...[
TextFormField(
decoration: const InputDecoration(labelText: 'Price'),
keyboardType: TextInputType.number,
controller: _priceController,
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a price';
}
if (double.tryParse(value) == null) {
return 'Please enter a valid number';
}
return null;
},
),
const SizedBox(height: 16),
],
// Location Dropdown
Row(
children: [
Expanded(
child: DropdownButtonFormField<String>(
hint: const Text('Select Location'),
value: _selectedLocation,
onChanged: (value) {
setState(() {
_selectedLocation = value!;
});
},
items: locations.keys
.map<DropdownMenuItem<String>>((id) {
return DropdownMenuItem<String>(
value: id,
child:
Text(locations[id]!.fullNamePath(locations)),
);
}).toList(),
onSaved: (value) {
_selectedLocation = value!;
},
),
),
const SizedBox(
width: 12,
),
IconButton(
onPressed: () async {
var code = await scanQRCode(context);
setState(() {
if (API().getLocations().keys.contains(code)) {
_selectedLocation = code!;
}
});
},
icon: const Icon(Icons.qr_code),
),
],
),
// Note
TextFormField(
decoration: const InputDecoration(labelText: 'Note'),
controller: _noteController,
maxLines: 5),
const SizedBox(height: 20),
// Submit Button
ElevatedButton(
onPressed: _supply,
child: const Text('Add Item'),
),
],
),
),
);
},
),
);
}
Future<Map<String, dynamic>> _fetchData() async {
var locations = API().getLocations();
var origins = await API().getUniqueField(widget.item.id, variant, "origin");
origins.insert(0, "");
locations[""] = Location.zero();
return {'locations': locations, 'origins': origins};
}
}
// ignore: must_be_immutable
class AutocompletedTextField extends StatelessWidget {
late List<String> options;
late Function(String) onChanged;
late String Function() getValue;
late Function(String) onSelection;
late String label;
AutocompletedTextField(
{super.key,
required this.options,
required this.getValue,
required this.onChanged,
required this.onSelection,
required this.label});
@override
Widget build(BuildContext context) {
return Autocomplete<String>(
optionsBuilder: (TextEditingValue textEditingValue) {
if (textEditingValue.text.isEmpty) {
return options;
}
return options.where((String option) {
return option
.toLowerCase()
.contains(textEditingValue.text.toLowerCase());
});
},
onSelected: onSelection,
fieldViewBuilder:
(context, textEditingController, focusNode, onFieldSubmitted) {
textEditingController.text = getValue();
return TextFormField(
onChanged: onChanged,
controller: textEditingController,
focusNode: focusNode,
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
);
},
);
}
}
class SupplyForm {
final String itemID;
final String variant;
final double price;
final String? origin;
final String? location;
final String note;
factory SupplyForm.fromJson(Map<String, dynamic> json) {
return SupplyForm(
itemID: json['item'],
variant: json['variant'],
price: json['price'],
origin: json['origin'],
location: json['location'],
note: json['note'],
);
}
Map<String, dynamic> json() {
return {
"item": itemID,
"variant": variant,
"price": price,
"origin": origin,
"location": location,
"note": note
};
}
SupplyForm({
required this.itemID,
required this.variant,
required this.price,
required this.origin,
required this.location,
required this.note,
});
Transaction transaction(Map<String, Location> locations) {
return Transaction.inMemory(itemID, variant, price, origin,
location != null ? locations[location!] : null, note);
}
}

378
src/pages/transaction.dart Normal file
View file

@ -0,0 +1,378 @@
import 'package:cdb_ui/api.dart';
import 'package:cdb_ui/pages/consume.dart';
import 'package:cdb_ui/pages/supply.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:qr_bar_code/qr/qr.dart';
class TransactionPage extends StatefulWidget {
final Transaction transaction;
final Function? refresh;
const TransactionPage(this.transaction, {this.refresh, super.key});
@override
State<TransactionPage> createState() => _TransactionPageState();
}
class _TransactionPageState extends State<TransactionPage> {
late Transaction transaction;
@override
void initState() {
super.initState();
transaction = widget.transaction;
}
Future<void> reload() async {
if (widget.refresh != null) {
widget.refresh!();
}
var updateTransaction = await API().getTransaction(transaction.uuid);
setState(() {
transaction = updateTransaction;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(API().getItem(transaction.item).name),
actions: [
IconButton(
onPressed: () {
var locations = API().getLocations();
List<String> locationList = locations.keys.toList();
String? selectedLocationID;
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Move Transaction'),
content: Row(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButton<String>(
value: selectedLocationID,
onChanged: (value) {
selectedLocationID = value!;
API()
.moveTransaction(widget.transaction.uuid,
selectedLocationID!)
.then((x) {
Navigator.of(context).pop();
});
setState(() {});
},
items: locationList
.map<DropdownMenuItem<String>>((locationID) {
return DropdownMenuItem<String>(
value: locationID,
child: Text(locations[locationID]!.name),
);
}).toList(),
),
IconButton(
onPressed: () async {
var locations = API().getLocations();
var code = await scanQRCode(context,
title: "Scan Location Code");
if (!locations.containsKey(code)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'The location $code does not exist.')),
);
return;
}
API()
.moveTransaction(widget.transaction.uuid,
selectedLocationID!)
.then(
(x) {
Navigator.of(context).pop();
},
);
setState(() {});
},
icon: const Icon(Icons.qr_code))
],
),
);
},
);
},
icon: const Icon(Icons.move_up))
],
),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: Column(
children: [
Row(
children: [
QRCode(
data: transaction.uuid,
size: 128,
eyeStyle: const QREyeStyle(color: Colors.white),
dataModuleStyle: const QRDataModuleStyle(color: Colors.white),
semanticsLabel: "Transaction UUID",
),
const SizedBox(
width: 16.0,
),
Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
// todo : human names
Text(
API().getItem(transaction.item).name,
style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 28),
),
Text(
API()
.getItem(transaction.item)
.variants[transaction.variant]!
.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 20,
color: Colors.grey),
),
const SizedBox(
height: 8.0,
),
Text("Added: ${tsFormat(transaction.timestamp)}"),
],
)
],
),
const Divider(),
const SizedBox(
height: 12.0,
),
if (transaction.expired) const Text("Transaction is Expired!"),
IconText(Icons.money, transaction.price.toStringAsFixed(2),
color: Colors.green),
if (transaction.origin != null)
IconText(Icons.store, transaction.origin!, color: Colors.blue),
if (transaction.location != null)
IconText(Icons.location_city, transaction.location!.name),
if (transaction.note != null) Text(transaction.note!),
if (transaction.consumed != null) ...[
const Divider(),
Text("Consumed on: ${tsFormat(transaction.consumed!.timestamp)}"),
IconText(Icons.store, transaction.consumed!.destination,
color: Colors.blue),
IconText(
Icons.money, transaction.consumed!.price.toStringAsFixed(2),
color: Colors.green),
]
// todo : chart with price history
],
),
),
floatingActionButton: transaction.consumed == null
? FloatingActionButton(
onPressed: () {
Navigator.of(context).push(MaterialPageRoute(
builder: (context) {
return ConsumePage(transaction, reload);
},
));
},
child: const Icon(Icons.receipt_long))
: null,
);
}
}
class TransactionCard extends StatelessWidget {
final Transaction t;
final Function refresh;
final Function(Transaction)? onTap;
final Function(Transaction)? onLongPress;
const TransactionCard(this.t, this.refresh,
{this.onTap, this.onLongPress, super.key});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () {
if (onTap != null) {
onTap!(t);
return;
}
Navigator.of(context).push(MaterialPageRoute(
builder: (context) {
return ConsumePage(t, refresh);
},
));
},
onLongPress: () {
if (onLongPress != null) {
onLongPress!(t);
return;
}
Navigator.of(context).push(MaterialPageRoute(
builder: (context) {
return TransactionPage(t);
},
));
},
child: Card(
color: t.expired ? Colors.red[100] : Colors.black,
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Text(
API().getItem(t.item).name,
style: const TextStyle(fontSize: 16),
),
const SizedBox(
width: 8,
),
Text(
API().getItem(t.item).variants[t.variant]!.name,
style: TextStyle(fontSize: 14, color: Colors.grey[400]),
),
],
),
if (t.timestamp != 0)
Text(
tsFormat(t.timestamp),
style: TextStyle(fontSize: 14, color: Colors.grey[700]),
),
],
),
if ((t.note ?? "").isNotEmpty) ...[
const SizedBox(
height: 4,
),
Text(t.note!)
],
const SizedBox(
height: 10,
),
IconText(Icons.money, "${t.price.toStringAsFixed(2)}",
color: Colors.green),
if (t.origin != null) ...[
const SizedBox(height: 8),
IconText(Icons.store, t.origin!, color: Colors.blue)
],
if (t.location != null) ...[
const SizedBox(
height: 8,
),
IconText(Icons.location_city, t.location!.name)
]
],
),
),
),
);
}
}
String tsFormat(int ts) {
DateTime dateTime = DateTime.fromMillisecondsSinceEpoch(ts * 1000);
return DateFormat('yyyy-MM-dd HH:mm:ss').format(dateTime);
}
class IconText extends StatelessWidget {
final IconData icon;
final String text;
final Color? color;
const IconText(this.icon, this.text, {super.key, this.color});
@override
Widget build(BuildContext context) {
return Row(
children: [
Icon(icon, size: 18, color: color),
const SizedBox(width: 6),
Text(
text,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
overflow: TextOverflow.fade,
),
],
);
}
}
class TransactionSelectPage extends StatelessWidget {
final Function(Transaction) onSelect;
final List<Transaction> selections;
final List<Transaction>? exclude;
const TransactionSelectPage(this.selections,
{super.key, required this.onSelect, this.exclude});
@override
Widget build(BuildContext context) {
var selectionList = [];
for (var s in selections) {
if (exclude?.any((x) => x.uuid == s.uuid) ?? false) {
continue;
}
selectionList.add(s);
}
return Scaffold(
appBar: AppBar(
title: const Text("Select a Transaction"),
),
body: ListView(
children: selectionList.isEmpty
? [
const ListTile(
title: Center(child: Text("No Transactions available")),
)
]
: selectionList
.map((x) => TransactionCard(
x,
() {},
onLongPress: (x) {},
onTap: (t) {
onSelect(t);
Navigator.of(context).pop();
},
))
.toList()),
);
}
}