init
rewrite of cdb_ui in dioxus rust. goal is to integrate into a single rust codebase
This commit is contained in:
commit
b3a96ed3e3
37 changed files with 9927 additions and 0 deletions
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# These are backup files generated by rustfmt
|
||||||
|
**/*.rs.bk
|
6119
Cargo.lock
generated
Normal file
6119
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
39
Cargo.toml
Normal file
39
Cargo.toml
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
[package]
|
||||||
|
name = "cdb_client"
|
||||||
|
version = "0.1.0"
|
||||||
|
authors = ["JMARyA <jmarya@hydrar.de>"]
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
dioxus = { version = "0.6.0", features = ["router"] }
|
||||||
|
web-sys = { version = "0.3", features = ["HtmlVideoElement", "MediaDevices", "MediaStream", "MediaStreamConstraints", "Navigator", "HtmlCanvasElement", "CanvasRenderingContext2d", "MediaStreamTrack", "ImageData"] }
|
||||||
|
wasm-bindgen = "0.2"
|
||||||
|
log = "0.4.27"
|
||||||
|
wasm-bindgen-futures = "0.4.50"
|
||||||
|
image = "0.24"
|
||||||
|
uuid = { version = "1.16.0", features = ["js", "v4"] }
|
||||||
|
gloo-timers = { version = "0.3.0", features = ["futures"] }
|
||||||
|
base64 = "0.22.1"
|
||||||
|
bardecoder = "0.5.0"
|
||||||
|
serde_json = "1.0.140"
|
||||||
|
dioxus-sdk = { version = "0.6.0", features = ["storage"] }
|
||||||
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
|
reqwest = { version = "0.12.15", features = ["json"] }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["web"]
|
||||||
|
web = ["dioxus/web"]
|
||||||
|
desktop = ["dioxus/desktop"]
|
||||||
|
mobile = ["dioxus/mobile"]
|
||||||
|
|
||||||
|
[profile.wasm-dev]
|
||||||
|
inherits = "dev"
|
||||||
|
opt-level = 1
|
||||||
|
|
||||||
|
[profile.server-dev]
|
||||||
|
inherits = "dev"
|
||||||
|
|
||||||
|
[profile.android-dev]
|
||||||
|
inherits = "dev"
|
38
Containerfile
Normal file
38
Containerfile
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
FROM git.hydrar.de/navos/navos:latest as builder
|
||||||
|
|
||||||
|
RUN pacman-key --init && pacman-key --populate archlinux && pacman-key --populate navos && pacman -Sy --noconfirm
|
||||||
|
|
||||||
|
RUN pacman -S --noconfirm tailwindcss-bin rustup
|
||||||
|
|
||||||
|
RUN pacman -S --noconfirm --needed \
|
||||||
|
webkit2gtk-4.1 \
|
||||||
|
base-devel \
|
||||||
|
curl \
|
||||||
|
wget \
|
||||||
|
file \
|
||||||
|
openssl \
|
||||||
|
appmenu-gtk-module \
|
||||||
|
libappindicator-gtk3 \
|
||||||
|
librsvg \
|
||||||
|
xdotool
|
||||||
|
|
||||||
|
RUN rustup default nightly
|
||||||
|
|
||||||
|
RUN cargo install dioxus-cli
|
||||||
|
|
||||||
|
COPY . /app
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN /root/.cargo/bin/dx bundle --platform web
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
RUN rm -rf /usr/share/nginx/html/*
|
||||||
|
|
||||||
|
COPY --from=builder /app/target/dx/cdb_client/release/web/public /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# Start nginx
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
21
Dioxus.toml
Normal file
21
Dioxus.toml
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
[application]
|
||||||
|
|
||||||
|
[web.app]
|
||||||
|
|
||||||
|
# HTML title tag content
|
||||||
|
title = "cdb_client"
|
||||||
|
|
||||||
|
# include `assets` in web platform
|
||||||
|
[web.resource]
|
||||||
|
|
||||||
|
# Additional CSS style files
|
||||||
|
style = []
|
||||||
|
|
||||||
|
# Additional JavaScript files
|
||||||
|
script = []
|
||||||
|
|
||||||
|
[web.resource.dev]
|
||||||
|
|
||||||
|
# Javascript code file
|
||||||
|
# serve: [dev-server] only
|
||||||
|
script = []
|
34
README.md
Normal file
34
README.md
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
# Development
|
||||||
|
|
||||||
|
Your new bare-bones project includes minimal organization with a single `main.rs` file and a few assets.
|
||||||
|
|
||||||
|
```
|
||||||
|
project/
|
||||||
|
├─ assets/ # Any assets that are used by the app should be placed here
|
||||||
|
├─ src/
|
||||||
|
│ ├─ main.rs # main.rs is the entry point to your application and currently contains all components for the app
|
||||||
|
├─ Cargo.toml # The Cargo.toml file defines the dependencies and feature flags for your project
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tailwind
|
||||||
|
1. Install npm: https://docs.npmjs.com/downloading-and-installing-node-js-and-npm
|
||||||
|
2. Install the Tailwind CSS CLI: https://tailwindcss.com/docs/installation
|
||||||
|
3. Run the following command in the root of the project to start the Tailwind CSS compiler:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx tailwindcss -i ./tailwind.css -o ./assets/tailwind.css --watch
|
||||||
|
```
|
||||||
|
|
||||||
|
### Serving Your App
|
||||||
|
|
||||||
|
Run the following command in the root of your project to start developing with the default platform:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dx serve
|
||||||
|
```
|
||||||
|
|
||||||
|
To run for a different platform, use the `--platform platform` flag. E.g.
|
||||||
|
```bash
|
||||||
|
dx serve --platform desktop
|
||||||
|
```
|
||||||
|
|
BIN
assets/favicon.ico
Normal file
BIN
assets/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 130 KiB |
71
assets/main.css
Normal file
71
assets/main.css
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
/* App-wide styling */
|
||||||
|
body {
|
||||||
|
background-color: #0f1116;
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
margin: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#hero {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#links {
|
||||||
|
width: 400px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: x-large;
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
#links a {
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-top: 20px;
|
||||||
|
margin: 10px 0px;
|
||||||
|
border: white 1px solid;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#links a:hover {
|
||||||
|
background-color: #1f1f1f;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#header {
|
||||||
|
max-width: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navbar */
|
||||||
|
#navbar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
#navbar a {
|
||||||
|
color: #ffffff;
|
||||||
|
margin-right: 20px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#navbar a:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #91a4d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blog page */
|
||||||
|
#blog {
|
||||||
|
margin-top: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blog a {
|
||||||
|
color: #ffffff;
|
||||||
|
margin-top: 50px;
|
||||||
|
}
|
2
assets/tailwind.css
Normal file
2
assets/tailwind.css
Normal file
File diff suppressed because one or more lines are too long
27
build.rs
Normal file
27
build.rs
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
use std::env;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let out_dir = env::var("OUT_DIR").unwrap();
|
||||||
|
|
||||||
|
let input = "tailwind.css";
|
||||||
|
let output = "assets/tailwind.css";
|
||||||
|
|
||||||
|
if !Path::new(input).exists() {
|
||||||
|
panic!("Input CSS file not found: {input}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the Tailwind CLI
|
||||||
|
let status = Command::new("tailwindcss")
|
||||||
|
.args(&["-i", input, "-o", output, "--minify"])
|
||||||
|
.status()
|
||||||
|
.expect("Failed to run Tailwind CLI. Is it installed?");
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
panic!("Tailwind build failed with exit code: {}", status);
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("cargo:rerun-if-changed={input}");
|
||||||
|
println!("cargo:rerun-if-changed=tailwind.config.js");
|
||||||
|
}
|
545
src/api.rs
Normal file
545
src/api.rs
Normal file
|
@ -0,0 +1,545 @@
|
||||||
|
/*
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'package:cdb_ui/pages/supply.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
// todo : api errors
|
||||||
|
|
||||||
|
class API {
|
||||||
|
SharedPreferences? pref;
|
||||||
|
static final API _instance = API._internal();
|
||||||
|
|
||||||
|
// cache
|
||||||
|
List<Item>? items;
|
||||||
|
Map<String, Location>? locations;
|
||||||
|
Map<String, FlowInfo>? flowInfos;
|
||||||
|
|
||||||
|
factory API() {
|
||||||
|
return _instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
API._internal();
|
||||||
|
|
||||||
|
Future<void> init(Function refresh) async {
|
||||||
|
pref = await SharedPreferences.getInstance();
|
||||||
|
instance = pref!.getString("instance") ?? "";
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isInit() {
|
||||||
|
if (pref == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pref!.containsKey("token") && pref!.containsKey("instance");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isPrefetched() {
|
||||||
|
return items != null && locations != null && flowInfos != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void save(String instance, String token) {
|
||||||
|
pref!.setString("instance", instance);
|
||||||
|
pref!.setString("token", token);
|
||||||
|
this.instance = instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
String instance = "";
|
||||||
|
|
||||||
|
Future<String> getRequest(String url) async {
|
||||||
|
var resp = await http.get(Uri.parse(url), headers: <String, String>{
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Content-Type': 'application/json; charset=UTF-8',
|
||||||
|
'Token': pref!.getString("token")!
|
||||||
|
});
|
||||||
|
|
||||||
|
return utf8.decode(resp.bodyBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> postRequest(String url, Map<String, dynamic> data) async {
|
||||||
|
var resp = await http.post(Uri.parse(url),
|
||||||
|
headers: <String, String>{
|
||||||
|
'Access-Control-Allow-Origin': '*',
|
||||||
|
'Content-Type': 'application/json; charset=UTF-8',
|
||||||
|
'Token': pref!.getString("token")!
|
||||||
|
},
|
||||||
|
body: jsonEncode(data));
|
||||||
|
|
||||||
|
return utf8.decode(resp.bodyBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use dioxus::signals::Readable;
|
||||||
|
use dioxus_sdk::storage::use_persistent;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::setup::Credentials;
|
||||||
|
|
||||||
|
use reqwest::header::{HeaderMap, HeaderValue};
|
||||||
|
use reqwest::Client;
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub async fn api_get_auth<T>(path: String) -> Result<T, reqwest::Error>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
{
|
||||||
|
let creds = use_persistent("creds", || Credentials::default());
|
||||||
|
let creds = creds.read();
|
||||||
|
let token = creds.token.as_str();
|
||||||
|
let instance = creds.instance_url.as_str();
|
||||||
|
|
||||||
|
let url = format!("{instance}{path}");
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert("Token", HeaderValue::from_str(token).unwrap());
|
||||||
|
|
||||||
|
let client = Client::new();
|
||||||
|
let res = client
|
||||||
|
.get(&url)
|
||||||
|
.headers(headers)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json::<T>()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn api_post_auth<T, X>(path: String, body: X) -> Result<T, reqwest::Error>
|
||||||
|
where
|
||||||
|
T: DeserializeOwned,
|
||||||
|
X: Serialize,
|
||||||
|
{
|
||||||
|
let creds = use_persistent("creds", || Credentials::default());
|
||||||
|
let creds = creds.read();
|
||||||
|
let token = creds.token.as_str();
|
||||||
|
let instance = creds.instance_url.as_str();
|
||||||
|
|
||||||
|
let url = format!("{instance}{path}");
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
headers.insert("Token", HeaderValue::from_str(token).unwrap());
|
||||||
|
|
||||||
|
let client = Client::new();
|
||||||
|
let res = client
|
||||||
|
.post(&url)
|
||||||
|
.headers(headers)
|
||||||
|
.json(&body)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json::<T>()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct API {
|
||||||
|
pub instance: String,
|
||||||
|
pub items: Vec<Item>,
|
||||||
|
pub locations: HashMap<String, Location>,
|
||||||
|
pub flow_info: HashMap<String, FlowInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl API {
|
||||||
|
pub async fn new(instance: String) -> Self {
|
||||||
|
let mut items: HashMap<String, Vec<Item>> =
|
||||||
|
api_get_auth("/items".to_string()).await.unwrap();
|
||||||
|
let items = items.remove("items").unwrap();
|
||||||
|
let locations = api_get_auth("/locations".to_string()).await.unwrap();
|
||||||
|
let flow_info = api_get_auth("/flows".to_string()).await.unwrap();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
instance,
|
||||||
|
items,
|
||||||
|
locations,
|
||||||
|
flow_info,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_items(&self) -> &[Item] {
|
||||||
|
&self.items
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_global_item_stat() -> GlobalItemStat {
|
||||||
|
api_get_auth("/items/stat".to_string()).await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_unique_field(item: String, variant: String, field: String) -> Vec<String> {
|
||||||
|
api_get_auth(format!("/item/{item}/{variant}/unique?field={field}"))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_locations(&self) -> &HashMap<String, Location> {
|
||||||
|
&self.locations
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_item(&self, item: String) -> Option<Item> {
|
||||||
|
self.items.iter().find(|x| x.uuid == item).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_transaction(id: String) -> Transaction {
|
||||||
|
api_get_auth(format!("/transaction/{id}")).await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_transactions_of_location(
|
||||||
|
location: String,
|
||||||
|
recursive: bool,
|
||||||
|
) -> Vec<Transaction> {
|
||||||
|
let mut url = format!("/location/{location}/inventory");
|
||||||
|
|
||||||
|
if recursive {
|
||||||
|
url.push_str("?recursive=true");
|
||||||
|
}
|
||||||
|
|
||||||
|
api_get_auth(url).await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_consumed_items(
|
||||||
|
item: String,
|
||||||
|
variant: String,
|
||||||
|
destination: Option<String>,
|
||||||
|
) -> Vec<Transaction> {
|
||||||
|
let mut url = format!("/item/{item}/{variant}/demand");
|
||||||
|
|
||||||
|
if let Some(dest) = destination {
|
||||||
|
url.push_str(&format!("?destination={dest}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
api_get_auth(url).await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_inventory(item: String, origin: Option<String>) -> Vec<Transaction> {
|
||||||
|
let mut url = format!("/item/{item}/inventory");
|
||||||
|
|
||||||
|
if let Some(origin) = origin {
|
||||||
|
url.push_str(&format!("?origin={origin}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
api_get_auth(url).await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_inventory_of_variant(item: String, variant: String) -> Vec<Transaction> {
|
||||||
|
api_get_auth(format!("/item/{item}/{variant}/inventory"))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn supply_item(
|
||||||
|
item: String,
|
||||||
|
variant: String,
|
||||||
|
price: f64,
|
||||||
|
origin: Option<String>,
|
||||||
|
location: Option<String>,
|
||||||
|
note: Option<String>,
|
||||||
|
) -> String {
|
||||||
|
let res: serde_json::Value = api_post_auth(
|
||||||
|
"/supply".to_string(),
|
||||||
|
json!({
|
||||||
|
"item": item,
|
||||||
|
"variant": variant,
|
||||||
|
"price": price,
|
||||||
|
"origin": origin,
|
||||||
|
"location": location,
|
||||||
|
"note": note,
|
||||||
|
"properties": serde_json::Value::Null,
|
||||||
|
"quanta": serde_json::Value::Null
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
res.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.get("uuid")
|
||||||
|
.unwrap()
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn consume_item(transaction: String, destination: String, price: f64) {
|
||||||
|
api_post_auth::<serde_json::Map<String, serde_json::Value>, _>(
|
||||||
|
"/demand".to_string(),
|
||||||
|
json!({
|
||||||
|
"uuid": transaction, "destination": destination, "price": price
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_stat(item: String, variant: String, full: bool) -> ItemVariantStat {
|
||||||
|
api_get_auth(format!(
|
||||||
|
"/item/{item}/{variant}/stat{}",
|
||||||
|
if full { "?full=true" } else { "" }
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_url_instance(&self, item: String) -> String {
|
||||||
|
format!("{}/{item}", self.instance)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_price_history(
|
||||||
|
item: String,
|
||||||
|
variant: String,
|
||||||
|
origin: Option<String>,
|
||||||
|
) -> Vec<f64> {
|
||||||
|
let mut url = format!("/item/{item}/{variant}/price_history");
|
||||||
|
|
||||||
|
if let Some(origin) = origin {
|
||||||
|
url.push_str(&format!("?origin={origin}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
api_get_auth(url).await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_latest_price(item: String, variant: String, origin: Option<String>) -> f64 {
|
||||||
|
let mut url = format!("/item/{item}/{variant}/price_latest");
|
||||||
|
|
||||||
|
if let Some(origin) = origin {
|
||||||
|
url.push_str(&format!("?origin={origin}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
api_get_auth(url).await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_flows(&self) -> &HashMap<String, FlowInfo> {
|
||||||
|
&self.flow_info
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_flow_info(&self, id: String) -> Option<&FlowInfo> {
|
||||||
|
self.flow_info.iter().find(|x| *x.0 == id).map(|x| x.1)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_flow(id: String) -> Flow {
|
||||||
|
api_get_auth(format!("/flow/{id}")).await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_active_flows_of(id: String) -> Vec<Flow> {
|
||||||
|
api_get_auth(format!("/flow/{id}/active")).await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start_flow(id: String, input: Option<Vec<String>>) -> String {
|
||||||
|
let res: serde_json::Value = api_post_auth(format!("/flow/{id}"), json!({"input": input}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
res.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.get("uuid")
|
||||||
|
.unwrap()
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
pub async fn end_flow(id: String, produced: Option<Vec<SupplyForm>>) -> HashMap<String, Vec<String>> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
// /flow/<id>/end
|
||||||
|
Future<Map<String, List<String>>?> endFlow(String id,
|
||||||
|
{List<SupplyForm>? produced}) async {
|
||||||
|
var resp = jsonDecode(await postRequest("$instance/flow/$id/end",
|
||||||
|
{"produced": produced?.map((x) => x.json()).toList()}));
|
||||||
|
|
||||||
|
if (produced != null) {
|
||||||
|
var produced = resp["produced"] as Map<String, dynamic>;
|
||||||
|
return produced.map(
|
||||||
|
(key, value) {
|
||||||
|
return MapEntry(key, (value as List<dynamic>).cast<String>());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
pub async fn continue_flow(id: String, input: Option<Vec<String>>) -> String {
|
||||||
|
let res: serde_json::Value =
|
||||||
|
api_post_auth(format!("/flow/{id}/continue"), json!({"input": input}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
res.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.get("uuid")
|
||||||
|
.unwrap()
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_expired_items() -> Vec<Transaction> {
|
||||||
|
api_get_auth("/items/expired".to_string()).await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_items_under_min() -> Vec<MinItem> {
|
||||||
|
api_get_auth("/items/min".to_string()).await.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn move_transaction(id: String, new_location: String) {
|
||||||
|
api_post_auth(
|
||||||
|
format!("/transaction/{id}/move"),
|
||||||
|
json!({"to": new_location}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_location(&self, id: String) -> Option<&Location> {
|
||||||
|
self.locations.get(&id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_note_to_flow(flow_id: String, content: String) -> String {
|
||||||
|
let res: serde_json::Value =
|
||||||
|
api_post_auth(format!("/flow/{flow_id}/note"), json!({"content": content}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
res.as_object()
|
||||||
|
.unwrap()
|
||||||
|
.get("uuid")
|
||||||
|
.unwrap()
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_notes_of_flow(flow_id: String) -> Vec<FlowNote> {
|
||||||
|
api_get_auth(format!("/flow/{flow_id}/notes"))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct FlowInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub depends: Vec<String>,
|
||||||
|
pub next: Option<String>,
|
||||||
|
pub produces: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||||
|
pub struct Item {
|
||||||
|
pub uuid: String,
|
||||||
|
pub image: Option<String>,
|
||||||
|
pub name: String,
|
||||||
|
pub category: Option<String>,
|
||||||
|
pub variants: HashMap<String, ItemVariant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||||
|
pub struct ItemVariant {
|
||||||
|
pub item: String,
|
||||||
|
pub variant: String,
|
||||||
|
pub name: String,
|
||||||
|
pub min: Option<i64>,
|
||||||
|
pub expiry: Option<i64>,
|
||||||
|
pub barcodes: Option<Vec<i64>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||||
|
pub struct Transaction {
|
||||||
|
pub uuid: String,
|
||||||
|
pub item: String,
|
||||||
|
pub variant: String,
|
||||||
|
pub price: f64,
|
||||||
|
pub origin: Option<String>,
|
||||||
|
pub timestamp: i64,
|
||||||
|
pub consumed: Option<ConsumeInfo>,
|
||||||
|
pub expired: bool,
|
||||||
|
pub note: Option<String>,
|
||||||
|
pub quanta: Option<i64>,
|
||||||
|
pub properties: Option<serde_json::Value>,
|
||||||
|
pub location: Option<Location>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||||
|
pub struct ConsumeInfo {
|
||||||
|
pub destination: String,
|
||||||
|
pub price: f64,
|
||||||
|
pub timestamp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ItemVariantStat {
|
||||||
|
pub amount: i64,
|
||||||
|
pub total_price: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||||
|
pub struct Location {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub parent: Option<String>,
|
||||||
|
pub conditions: Option<LocationCondition>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
|
||||||
|
pub struct LocationCondition {
|
||||||
|
pub temperature: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct MinItem {
|
||||||
|
pub item_variant: String,
|
||||||
|
pub need: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct Flow {
|
||||||
|
pub id: String,
|
||||||
|
pub started: i64,
|
||||||
|
pub kind: String,
|
||||||
|
pub input: Option<Vec<String>>,
|
||||||
|
pub done: Option<FlowDone>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct FlowDone {
|
||||||
|
pub ended: i64,
|
||||||
|
pub next: Option<String>,
|
||||||
|
pub produced: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct FlowNote {
|
||||||
|
pub uuid: String,
|
||||||
|
pub timestamp: i64,
|
||||||
|
pub content: String,
|
||||||
|
pub on_flow: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct GlobalItemStat {
|
||||||
|
pub item_count: i64,
|
||||||
|
pub total_transactions: i64,
|
||||||
|
pub total_price: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct FullItemVariantStat {
|
||||||
|
pub amount: i64,
|
||||||
|
pub total_price: f64,
|
||||||
|
pub expiry_rate: f64,
|
||||||
|
pub origins: HashMap<String, OriginStat>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct OriginStat {
|
||||||
|
pub average_price: f64,
|
||||||
|
pub inventory: i64,
|
||||||
|
}
|
101
src/main.dart
Normal file
101
src/main.dart
Normal file
|
@ -0,0 +1,101 @@
|
||||||
|
import 'package:cdb_ui/api.dart';
|
||||||
|
import 'package:cdb_ui/pages/flow/flows_page.dart';
|
||||||
|
import 'package:cdb_ui/pages/items.dart';
|
||||||
|
import 'package:cdb_ui/pages/locations.dart';
|
||||||
|
import 'package:cdb_ui/pages/setup.dart';
|
||||||
|
import 'package:cdb_ui/pages/home.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
Future<void> main() async {
|
||||||
|
runApp(const MyApp());
|
||||||
|
}
|
||||||
|
|
||||||
|
class MyApp extends StatefulWidget {
|
||||||
|
const MyApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MyApp> createState() => _MyAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MyAppState extends State<MyApp> {
|
||||||
|
bool init = false;
|
||||||
|
|
||||||
|
refresh() {
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
() async {
|
||||||
|
await API().init(refresh);
|
||||||
|
if (API().isInit()) {
|
||||||
|
await API().prefetch();
|
||||||
|
setState(() {
|
||||||
|
init = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MaterialApp(
|
||||||
|
title: 'CDB',
|
||||||
|
theme: ThemeData(
|
||||||
|
colorScheme: ColorScheme.fromSeed(
|
||||||
|
seedColor: Colors.deepPurple, brightness: Brightness.dark),
|
||||||
|
useMaterial3: true,
|
||||||
|
),
|
||||||
|
home: API().isInit()
|
||||||
|
? (API().isPrefetched()
|
||||||
|
? const MyHomePage()
|
||||||
|
: const Scaffold(
|
||||||
|
body: Center(child: CircularProgressIndicator()),
|
||||||
|
))
|
||||||
|
: const SetupPage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MyHomePage extends StatefulWidget {
|
||||||
|
const MyHomePage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MyHomePage> createState() => _MyHomePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MyHomePageState extends State<MyHomePage> {
|
||||||
|
int pageIndex = 0;
|
||||||
|
|
||||||
|
List<Widget> pages = [
|
||||||
|
const HomePage(),
|
||||||
|
const ItemsPage(),
|
||||||
|
const FlowsPage(),
|
||||||
|
const LocationsPage()
|
||||||
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
bottomNavigationBar: BottomNavigationBar(
|
||||||
|
fixedColor: Colors.white,
|
||||||
|
unselectedItemColor: Colors.white70,
|
||||||
|
items: const [
|
||||||
|
BottomNavigationBarItem(icon: Icon(Icons.home), label: "Home"),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.data_object), label: "Items"),
|
||||||
|
BottomNavigationBarItem(icon: Icon(Icons.receipt), label: "Flows"),
|
||||||
|
BottomNavigationBarItem(
|
||||||
|
icon: Icon(Icons.location_city), label: "Locations"),
|
||||||
|
],
|
||||||
|
currentIndex: pageIndex,
|
||||||
|
onTap: (value) {
|
||||||
|
setState(() {
|
||||||
|
pageIndex = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
body: pages[pageIndex],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
165
src/main.rs
Normal file
165
src/main.rs
Normal file
|
@ -0,0 +1,165 @@
|
||||||
|
use api::{Item, Transaction};
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_sdk::storage::use_persistent;
|
||||||
|
use qrscan::QRCodeScanPage;
|
||||||
|
use setup::{Credentials, SetupPage};
|
||||||
|
|
||||||
|
pub mod api;
|
||||||
|
pub mod page;
|
||||||
|
pub mod qrscan;
|
||||||
|
pub mod setup;
|
||||||
|
|
||||||
|
use page::{supply::SupplyPageParam, *};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Routable, PartialEq)]
|
||||||
|
#[rustfmt::skip]
|
||||||
|
enum Route {
|
||||||
|
#[layout(Navbar)]
|
||||||
|
#[route("/")]
|
||||||
|
Home {},
|
||||||
|
#[route("/items")]
|
||||||
|
ItemPage {},
|
||||||
|
#[route("/flows")]
|
||||||
|
FlowPage {},
|
||||||
|
#[route("/locations")]
|
||||||
|
LocationsPage {},
|
||||||
|
|
||||||
|
#[route("/item/:id")]
|
||||||
|
ItemDetailPage { id: String },
|
||||||
|
#[route("/transaction/:id")]
|
||||||
|
TransactionPage { id: String },
|
||||||
|
|
||||||
|
#[route("/item/:item/:variant/consume/:id")]
|
||||||
|
ConsumePage { id: String, item: String, variant: String },
|
||||||
|
|
||||||
|
|
||||||
|
#[route("/item/:item/supply?:param")]
|
||||||
|
SupplyPage { item: String, param: SupplyPageParam }
|
||||||
|
// #[route("/blog/:id")]
|
||||||
|
// Blog { id: i32 },
|
||||||
|
|
||||||
|
// Pages
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
const FAVICON: Asset = asset!("/assets/favicon.ico");
|
||||||
|
const MAIN_CSS: Asset = asset!("/assets/main.css");
|
||||||
|
const TAILWIND_CSS: Asset = asset!("/assets/tailwind.css");
|
||||||
|
|
||||||
|
pub static API: GlobalSignal<Option<api::API>> = Signal::global(|| None);
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
dioxus_sdk::set_dir!();
|
||||||
|
dioxus::launch(App);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn App() -> Element {
|
||||||
|
let creds = use_persistent("creds", || Credentials::default());
|
||||||
|
|
||||||
|
spawn(async move {
|
||||||
|
let res = use_persistent("creds", || Credentials::default());
|
||||||
|
if !res.read().empty() {
|
||||||
|
let api = api::API::new(res.read().instance_url.clone()).await;
|
||||||
|
*crate::API.write() = Some(api);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
document::Link { rel: "icon", href: FAVICON }
|
||||||
|
document::Link { rel: "stylesheet", href: MAIN_CSS }
|
||||||
|
document::Link { rel: "stylesheet", href: TAILWIND_CSS }
|
||||||
|
|
||||||
|
if creds.read().empty() {
|
||||||
|
SetupPage { }
|
||||||
|
} else {
|
||||||
|
Router::<Route> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn ItemTile(item: Item) -> Element {
|
||||||
|
rsx! {
|
||||||
|
Link {
|
||||||
|
to: Route::ItemDetailPage { id: item.uuid },
|
||||||
|
{item.name.as_str()}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn FlowPage() -> Element {
|
||||||
|
rsx! {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Home page
|
||||||
|
#[component]
|
||||||
|
fn QRTest() -> Element {
|
||||||
|
let found = use_signal(|| String::new());
|
||||||
|
let mut showQR = use_signal(|| false);
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
|
||||||
|
if !found.read().is_empty() {
|
||||||
|
p {
|
||||||
|
{format!("FOUND {}", found.read())}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
input {
|
||||||
|
type: "text"
|
||||||
|
}
|
||||||
|
|
||||||
|
if *showQR.read() {
|
||||||
|
QRCodeScanPage { result: found, show: showQR }
|
||||||
|
},
|
||||||
|
|
||||||
|
button {
|
||||||
|
onclick: move |_| {
|
||||||
|
showQR.set(true);
|
||||||
|
},
|
||||||
|
"Click"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn TransactionCard(t: Transaction) -> Element {
|
||||||
|
rsx! {
|
||||||
|
// TODO : transaction card
|
||||||
|
Link {
|
||||||
|
to: Route::TransactionPage { id: t.uuid.clone() },
|
||||||
|
p { {t.uuid.as_str()} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared navbar component.
|
||||||
|
#[component]
|
||||||
|
fn Navbar() -> Element {
|
||||||
|
rsx! {
|
||||||
|
div {
|
||||||
|
id: "navbar",
|
||||||
|
Link {
|
||||||
|
to: Route::Home {},
|
||||||
|
"Home"
|
||||||
|
}
|
||||||
|
Link {
|
||||||
|
to: Route::ItemPage { },
|
||||||
|
"Items"
|
||||||
|
}
|
||||||
|
Link {
|
||||||
|
to: Route::FlowPage { },
|
||||||
|
"Flows"
|
||||||
|
}
|
||||||
|
Link {
|
||||||
|
to: Route::LocationsPage { },
|
||||||
|
"Locations"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Outlet::<Route> {}
|
||||||
|
}
|
||||||
|
}
|
121
src/page/consume.rs
Normal file
121
src/page/consume.rs
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use crate::api;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ConsumePage(id: String, item: String, variant: String) -> Element {
|
||||||
|
println!("{item} {variant}");
|
||||||
|
let (item, variant) = (use_signal(|| item.clone()), use_signal(|| variant.clone()));
|
||||||
|
let destinations = use_resource(move || async move {
|
||||||
|
api::API::get_unique_field(item(), variant(), "destination".to_string()).await
|
||||||
|
});
|
||||||
|
|
||||||
|
let dest = use_signal(|| String::new());
|
||||||
|
let mut price = use_signal(|| 0.00);
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
|
||||||
|
p { "Item: {item}"},
|
||||||
|
p { "Variant: {variant}"}
|
||||||
|
|
||||||
|
// TODO : destination
|
||||||
|
PredefinedSelector {
|
||||||
|
name: "Destination",
|
||||||
|
value: dest,
|
||||||
|
predefined: destinations
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
"Price: ",
|
||||||
|
input {
|
||||||
|
r#type: "number",
|
||||||
|
value: "{price}",
|
||||||
|
oninput: move |e| price.set(e.value().parse().unwrap())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
button {
|
||||||
|
onclick: move |_| {
|
||||||
|
let id2 = id.clone();
|
||||||
|
spawn(async move {
|
||||||
|
api::API::consume_item(id2, dest(), price()).await;
|
||||||
|
navigator().go_back();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
"Consume"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn PredefinedSelector(
|
||||||
|
name: String,
|
||||||
|
value: Signal<String>,
|
||||||
|
predefined: Resource<Vec<String>>,
|
||||||
|
) -> Element {
|
||||||
|
let mut screen_visible = use_signal(|| false);
|
||||||
|
let mut input_value = use_signal(|| value.read().clone());
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
div {
|
||||||
|
class: "p-2",
|
||||||
|
button {
|
||||||
|
class: "cursor-pointer underline text-blue-600",
|
||||||
|
onclick: move |_| screen_visible.set(true),
|
||||||
|
"{name}: {value}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if *screen_visible.read() {
|
||||||
|
div {
|
||||||
|
class: "fixed inset-0 z-40 flex flex-col p-4 space-y-4 text-white",
|
||||||
|
style: "background-color: #0f1116;",
|
||||||
|
|
||||||
|
h2 { class: "text-xl font-bold", "Select a value for {name}" }
|
||||||
|
|
||||||
|
input {
|
||||||
|
class: "border p-2 text-lg",
|
||||||
|
r#type: "text",
|
||||||
|
value: "{input_value}",
|
||||||
|
oninput: move |evt| input_value.set(evt.value().clone()),
|
||||||
|
}
|
||||||
|
|
||||||
|
div {
|
||||||
|
class: "flex flex-wrap gap-2",
|
||||||
|
match &*predefined.read() {
|
||||||
|
Some(list) => rsx ! { {list.iter().map(|item| rsx! {
|
||||||
|
button {
|
||||||
|
class: "bg-gray-200 px-3 py-1 rounded hover:bg-gray-300",
|
||||||
|
onclick: {
|
||||||
|
let item = item.clone();
|
||||||
|
move |_| {
|
||||||
|
value.set(item.clone());
|
||||||
|
input_value.set(item.clone());
|
||||||
|
screen_visible.set(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"{item}"
|
||||||
|
}
|
||||||
|
})}},
|
||||||
|
None => rsx!(p { "Loading..." }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
class: "mt-auto bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600",
|
||||||
|
onclick: move |_| {
|
||||||
|
value.set(input_value.read().clone());
|
||||||
|
screen_visible.set(false);
|
||||||
|
},
|
||||||
|
"Confirm"
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
class: "text-gray-500 underline",
|
||||||
|
onclick: move |_| screen_visible.set(false),
|
||||||
|
"Cancel"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
52
src/page/home.rs
Normal file
52
src/page/home.rs
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use crate::TransactionCard;
|
||||||
|
|
||||||
|
/// Home Page
|
||||||
|
#[component]
|
||||||
|
pub fn Home() -> Element {
|
||||||
|
let min = use_resource(move || async move { crate::api::API::get_items_under_min().await });
|
||||||
|
let expired = use_resource(move || async move { crate::api::API::get_expired_items().await });
|
||||||
|
let global = use_resource(move || async move { crate::api::API::get_global_item_stat().await });
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
div {
|
||||||
|
h1 { "Home" },
|
||||||
|
div {
|
||||||
|
id: "column",
|
||||||
|
|
||||||
|
match &*global.read_unchecked() {
|
||||||
|
Some(resp) => rsx! {
|
||||||
|
div {
|
||||||
|
id: "card",
|
||||||
|
p { {format!("Items: {}", resp.item_count)} }
|
||||||
|
p { {format!("Inventory: {}", resp.total_transactions)} }
|
||||||
|
p { {format!("Price: {}", resp.total_price)} }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => rsx! {
|
||||||
|
div { "Loading dogs..." }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
if let Some(min) = &*min.read_unchecked() {
|
||||||
|
if !min.is_empty() {
|
||||||
|
h1 { "Items under Minimum" },
|
||||||
|
for item in min {
|
||||||
|
p { {format!("{} under minimum. Needs {} more.", item.item_variant, item.need)} }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(expired) = &*expired.read_unchecked() {
|
||||||
|
if !expired.is_empty() {
|
||||||
|
h1 { "Expired Items" },
|
||||||
|
for item in expired {
|
||||||
|
TransactionCard { t: item.clone() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
93
src/page/item_detail.rs
Normal file
93
src/page/item_detail.rs
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use crate::{page::supply::SupplyPageParam, Route, TransactionCard};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ItemDetailPage(id: String) -> Element {
|
||||||
|
let id = use_signal(|| id.clone());
|
||||||
|
let item = crate::API.read().as_ref().unwrap().get_item(id()).unwrap();
|
||||||
|
let inventory_future = use_resource(move || async move {
|
||||||
|
let item = crate::API.read().as_ref().unwrap().get_item(id()).unwrap();
|
||||||
|
crate::api::API::get_inventory(item.uuid.clone(), None).await
|
||||||
|
});
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
div {
|
||||||
|
class: "flex flex-col h-screen",
|
||||||
|
|
||||||
|
header {
|
||||||
|
class: "p-4 bg-blue-500 text-white text-lg font-bold",
|
||||||
|
{item.name.as_str()}
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
class: "p-6 flex flex-col space-y-4",
|
||||||
|
div {
|
||||||
|
class: "flex items-start space-x-4",
|
||||||
|
if let Some(image) = &item.image {
|
||||||
|
img {
|
||||||
|
src: crate::API.read().as_ref().unwrap().get_url_instance(image.to_string()),
|
||||||
|
width: "192",
|
||||||
|
class: "h-24 w-24"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
div {
|
||||||
|
strong { "{item.name}" }
|
||||||
|
if let Some(category) = &item.category {
|
||||||
|
p { "{category}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div {
|
||||||
|
class: "grid grid-cols-2 gap-4",
|
||||||
|
{item.variants.iter().map(|(key, variant)| {
|
||||||
|
rsx! {
|
||||||
|
div {
|
||||||
|
strong { {variant.name.as_str()} }
|
||||||
|
// TODO : stats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
div {
|
||||||
|
class: "flex-1 overflow-auto",
|
||||||
|
match &*inventory_future.read_unchecked() {
|
||||||
|
Some(inventory) => {
|
||||||
|
rsx! {
|
||||||
|
for t in inventory {
|
||||||
|
p {
|
||||||
|
key: {t.uuid.clone()},
|
||||||
|
TransactionCard { t: t.clone() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
rsx! { p { "Loading inventory..." } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
class: "fixed bottom-4 right-4 p-4 bg-green-500 text-white rounded-full shadow-lg",
|
||||||
|
onclick: move |_| {
|
||||||
|
navigator().push(
|
||||||
|
Route::SupplyPage {
|
||||||
|
item: item.uuid.clone(),
|
||||||
|
param: SupplyPageParam {
|
||||||
|
onlyVariants: None,
|
||||||
|
forcePrice: None,
|
||||||
|
forceOrigin: None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
},
|
||||||
|
"+"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
40
src/page/items.rs
Normal file
40
src/page/items.rs
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use crate::ItemTile;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn ItemPage() -> Element {
|
||||||
|
let items = use_resource(async || {
|
||||||
|
let api = crate::API.read();
|
||||||
|
let api = api.as_ref().unwrap();
|
||||||
|
let items = api.get_items().await;
|
||||||
|
|
||||||
|
items.iter().map(|x| x.clone()).collect::<Vec<_>>()
|
||||||
|
});
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
h1 { "Items" },
|
||||||
|
// barcode scan button
|
||||||
|
|
||||||
|
match &*items.read_unchecked() {
|
||||||
|
Some(items) => {
|
||||||
|
rsx! {
|
||||||
|
for item in items {
|
||||||
|
ItemTile { item: item.clone() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => { rsx! { p { "Loading" }}}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
button {
|
||||||
|
onclick: move |_| {
|
||||||
|
// TODO : scan transaction qr
|
||||||
|
},
|
||||||
|
"Scan QR Transaction"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
19
src/page/locations.rs
Normal file
19
src/page/locations.rs
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
fn build_tree(root: &str, locs: &HashMap<String, Location>) -> Element {
|
||||||
|
rsx! {}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn LocationsPage() -> Element {
|
||||||
|
let api = crate::API.read();
|
||||||
|
let locations = use_signal(|| api.as_ref().unwrap().get_locations().clone());
|
||||||
|
|
||||||
|
// TODO : find roots
|
||||||
|
// build them
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
h1 { "Locations" },
|
||||||
|
}
|
||||||
|
}
|
15
src/page/mod.rs
Normal file
15
src/page/mod.rs
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
pub mod consume;
|
||||||
|
pub mod home;
|
||||||
|
pub mod item_detail;
|
||||||
|
pub mod items;
|
||||||
|
pub mod locations;
|
||||||
|
pub mod supply;
|
||||||
|
pub mod transaction;
|
||||||
|
|
||||||
|
pub use consume::ConsumePage;
|
||||||
|
pub use home::Home;
|
||||||
|
pub use item_detail::ItemDetailPage;
|
||||||
|
pub use items::ItemPage;
|
||||||
|
pub use locations::LocationsPage;
|
||||||
|
pub use supply::SupplyPage;
|
||||||
|
pub use transaction::TransactionPage;
|
127
src/page/supply.rs
Normal file
127
src/page/supply.rs
Normal file
|
@ -0,0 +1,127 @@
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{api, page::consume::PredefinedSelector, Route};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn SupplyPage(item: String, param: SupplyPageParam) -> Element {
|
||||||
|
let item_spec = crate::API
|
||||||
|
.read()
|
||||||
|
.as_ref()
|
||||||
|
.unwrap()
|
||||||
|
.get_item(item.clone())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// TODO : filter with onlyVariants
|
||||||
|
let variants = use_signal(|| {
|
||||||
|
item_spec
|
||||||
|
.variants
|
||||||
|
.into_iter()
|
||||||
|
.map(|x| x.1)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
});
|
||||||
|
let variant = use_signal(|| variants.first().unwrap().clone());
|
||||||
|
let (item, variant) = (use_signal(|| item.clone()), use_signal(|| variant.clone()));
|
||||||
|
let origins = use_resource(move || async move {
|
||||||
|
api::API::get_unique_field(
|
||||||
|
item(),
|
||||||
|
variant().read().variant.clone(),
|
||||||
|
"origin".to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
});
|
||||||
|
|
||||||
|
let variants_str =
|
||||||
|
use_resource(
|
||||||
|
move || async move { variants.iter().map(|x| x.name.clone()).collect::<Vec<_>>() },
|
||||||
|
);
|
||||||
|
let variant_id = use_signal(|| String::new());
|
||||||
|
|
||||||
|
let origin = use_signal(|| String::new());
|
||||||
|
|
||||||
|
let mut price = use_signal(|| 0.0);
|
||||||
|
let location = use_signal(|| String::new());
|
||||||
|
let mut note = use_signal(|| String::new());
|
||||||
|
|
||||||
|
let mut state = use_signal(|| String::new());
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
h1 { "Add new Item" },
|
||||||
|
|
||||||
|
p { {state} },
|
||||||
|
|
||||||
|
// Variant Selection
|
||||||
|
PredefinedSelector {
|
||||||
|
name: "Variant",
|
||||||
|
value: variant_id,
|
||||||
|
predefined: variants_str
|
||||||
|
}
|
||||||
|
|
||||||
|
// Origin Field with Dropdown and Text Input
|
||||||
|
PredefinedSelector {
|
||||||
|
name: "Origin",
|
||||||
|
value: origin,
|
||||||
|
predefined: origins
|
||||||
|
}
|
||||||
|
|
||||||
|
// Price Field
|
||||||
|
label {
|
||||||
|
"Price: ",
|
||||||
|
input {
|
||||||
|
r#type: "number",
|
||||||
|
value: "{price}",
|
||||||
|
oninput: move |e| price.set(e.value().parse().unwrap())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Location Dropdown
|
||||||
|
|
||||||
|
// Note
|
||||||
|
label {
|
||||||
|
"Note: ",
|
||||||
|
input {
|
||||||
|
r#type: "text",
|
||||||
|
value: "{note}",
|
||||||
|
oninput: move |e| note.set(e.value())
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Submit Button
|
||||||
|
|
||||||
|
button {
|
||||||
|
onclick: move |_| {
|
||||||
|
spawn(async move {
|
||||||
|
let variant = variants.iter().find(|x| x.name == variant_id()).unwrap().variant.clone();
|
||||||
|
let s = format!("{} {} {} {} {} {}", item(), variant, price(), origin(), location(), note());
|
||||||
|
state.set(s);
|
||||||
|
let tid = crate::api::API::supply_item(item(), variant, price(), Some(origin()), Some(location()), Some(note())).await;
|
||||||
|
navigator().replace(Route::TransactionPage { id: tid });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
"Create"
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Debug, Default, Deserialize, Serialize)]
|
||||||
|
pub struct SupplyPageParam {
|
||||||
|
pub onlyVariants: Option<Vec<String>>,
|
||||||
|
pub forcePrice: Option<f64>,
|
||||||
|
pub forceOrigin: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for SupplyPageParam {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(&serde_json::to_string(self).unwrap())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for SupplyPageParam {
|
||||||
|
type Err = serde_json::Error;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
serde_json::from_str(s)
|
||||||
|
}
|
||||||
|
}
|
60
src/page/transaction.rs
Normal file
60
src/page/transaction.rs
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use crate::Route;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn TransactionPage(id: String) -> Element {
|
||||||
|
let id = use_signal(|| id.clone());
|
||||||
|
let transaction = use_resource(move || async move {
|
||||||
|
crate::api::API::get_transaction(id.read().to_string()).await
|
||||||
|
});
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
|
||||||
|
match transaction.value()() {
|
||||||
|
Some(transaction) => rsx! {
|
||||||
|
|
||||||
|
button {
|
||||||
|
onclick: move |_| {
|
||||||
|
navigator().push(Route::ConsumePage { id: transaction.uuid.clone(), item: transaction.item.clone(), variant: transaction.variant.clone() });
|
||||||
|
},
|
||||||
|
"Consume"
|
||||||
|
},
|
||||||
|
|
||||||
|
div {
|
||||||
|
id: "column",
|
||||||
|
|
||||||
|
div {
|
||||||
|
id: "row",
|
||||||
|
|
||||||
|
// TODO : transaction qr code
|
||||||
|
|
||||||
|
div {
|
||||||
|
id: "col-1",
|
||||||
|
|
||||||
|
p { {crate::API.read().as_ref().unwrap().get_item(transaction.item.clone()).unwrap().name.as_str()} }
|
||||||
|
// TODO : variant name
|
||||||
|
// timestamp
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// TODO : expired notify
|
||||||
|
|
||||||
|
// icon texts
|
||||||
|
|
||||||
|
// price
|
||||||
|
p { {transaction.price.to_string()} },
|
||||||
|
// origin
|
||||||
|
// location
|
||||||
|
// note
|
||||||
|
|
||||||
|
|
||||||
|
// consume info
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => rsx! { p { "Loading..." }}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
115
src/pages/consume.dart
Normal file
115
src/pages/consume.dart
Normal 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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
60
src/pages/expandable_list.dart
Normal file
60
src/pages/expandable_list.dart
Normal 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
144
src/pages/flow/active_flow_page.dart
Normal file
144
src/pages/flow/active_flow_page.dart
Normal 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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
135
src/pages/flow/create_flow_page.dart
Normal file
135
src/pages/flow/create_flow_page.dart
Normal 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"))
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
96
src/pages/flow/end_flow_page.dart
Normal file
96
src/pages/flow/end_flow_page.dart
Normal 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"))
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
85
src/pages/flow/flow_info_page.dart
Normal file
85
src/pages/flow/flow_info_page.dart
Normal 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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
58
src/pages/flow/flow_note.dart
Normal file
58
src/pages/flow/flow_note.dart
Normal 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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
175
src/pages/flow/flows_page.dart
Normal file
175
src/pages/flow/flows_page.dart
Normal 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
118
src/pages/itemview.dart
Normal 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
165
src/pages/locations.dart
Normal 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} C°")
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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
354
src/pages/supply.dart
Normal 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
378
src/pages/transaction.dart
Normal 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()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
238
src/qrscan.rs
Normal file
238
src/qrscan.rs
Normal file
|
@ -0,0 +1,238 @@
|
||||||
|
use bardecoder::prepare::BlockedMean;
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
use gloo_timers::future::TimeoutFuture;
|
||||||
|
use image::{ImageBuffer, Luma, Rgba};
|
||||||
|
use std::io::Cursor;
|
||||||
|
use wasm_bindgen::{JsCast, JsValue};
|
||||||
|
use web_sys::{
|
||||||
|
window, CanvasRenderingContext2d, HtmlCanvasElement, HtmlVideoElement, MediaStreamConstraints,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn QRCodeScanPage(result: Signal<String>, show: Signal<bool>) -> Element {
|
||||||
|
let state = use_signal(|| "active".to_string());
|
||||||
|
let mut state2 = state.clone();
|
||||||
|
|
||||||
|
let imurl = use_signal(|| String::new());
|
||||||
|
let mut imurl2 = imurl.clone();
|
||||||
|
|
||||||
|
if *show.read() {
|
||||||
|
use_effect(move || {
|
||||||
|
let window = window().unwrap();
|
||||||
|
let nav = window.navigator();
|
||||||
|
let media_devices = nav.media_devices().unwrap();
|
||||||
|
|
||||||
|
let mut constraints = MediaStreamConstraints::new();
|
||||||
|
constraints.set_video(&JsValue::TRUE); // request camera access
|
||||||
|
|
||||||
|
let promise = media_devices
|
||||||
|
.get_user_media_with_constraints(&constraints)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let future = wasm_bindgen_futures::JsFuture::from(promise);
|
||||||
|
let res = wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let stream = match future.await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to get user media: {:?}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let video_element = window
|
||||||
|
.document()
|
||||||
|
.unwrap()
|
||||||
|
.get_element_by_id("cam")
|
||||||
|
.unwrap()
|
||||||
|
.dyn_into::<HtmlVideoElement>()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let media_stream = stream.dyn_into::<web_sys::MediaStream>().unwrap();
|
||||||
|
video_element.set_src_object(Some(&media_stream));
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if !*show.read() {
|
||||||
|
video_element.set_src_object(None);
|
||||||
|
stop_camera_stream(&media_stream);
|
||||||
|
result.set(String::new());
|
||||||
|
show.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(frame) = grab_frame(&video_element) {
|
||||||
|
let image = image_data_to_image_buffer(&frame).unwrap();
|
||||||
|
state2.set("processing".to_string());
|
||||||
|
if let Ok(durl) = image_buffer_to_data_url(&image) {
|
||||||
|
imurl2.set(durl);
|
||||||
|
}
|
||||||
|
|
||||||
|
let qr = scan_qr(image);
|
||||||
|
if let Ok(qr_res) = qr {
|
||||||
|
state2.set(format!("FOUND QR!!! {qr_res}"));
|
||||||
|
println!("FOUND QR!!! {qr_res}");
|
||||||
|
video_element.set_src_object(None);
|
||||||
|
stop_camera_stream(&media_stream);
|
||||||
|
result.set(qr_res);
|
||||||
|
show.set(false);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
let err = qr.unwrap_err();
|
||||||
|
state2.set(format!("got {err}"));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log::error!("Grabing frame failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("Processed frame!");
|
||||||
|
|
||||||
|
TimeoutFuture::new(16).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
|
||||||
|
if *show.read() {
|
||||||
|
|
||||||
|
div {
|
||||||
|
style: "position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; \
|
||||||
|
z-index: 9999; background-color: rgba(0, 0, 0, 0.7); \
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;",
|
||||||
|
|
||||||
|
div {
|
||||||
|
onclick: move |_| {
|
||||||
|
show.set(false);
|
||||||
|
},
|
||||||
|
{state} }
|
||||||
|
|
||||||
|
img {
|
||||||
|
src: imurl2
|
||||||
|
}
|
||||||
|
|
||||||
|
video {
|
||||||
|
id: "cam",
|
||||||
|
style: "display: hidden",
|
||||||
|
autoplay: true,
|
||||||
|
width: "640",
|
||||||
|
height: "480"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop_camera_stream(media_stream: &web_sys::MediaStream) {
|
||||||
|
let tracks = media_stream.get_tracks();
|
||||||
|
|
||||||
|
for i in 0..tracks.length() {
|
||||||
|
let track = tracks
|
||||||
|
.get(i)
|
||||||
|
.dyn_into::<web_sys::MediaStreamTrack>()
|
||||||
|
.unwrap();
|
||||||
|
track.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn image_buffer_to_data_url(
|
||||||
|
img: &ImageBuffer<Rgba<u8>, Vec<u8>>,
|
||||||
|
) -> Result<String, Box<dyn std::error::Error>> {
|
||||||
|
// Create an in-memory buffer
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
|
||||||
|
let img = gray(img.clone());
|
||||||
|
|
||||||
|
// Encode the image buffer as PNG into the in-memory buffer
|
||||||
|
{
|
||||||
|
let mut cursor = Cursor::new(&mut buf);
|
||||||
|
img.write_to(&mut cursor, image::ImageOutputFormat::Png)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base64 encode the PNG bytes
|
||||||
|
let encoded = base64::encode(&buf);
|
||||||
|
|
||||||
|
// Format as data URL
|
||||||
|
let data_url = format!("data:image/png;base64,{}", encoded);
|
||||||
|
|
||||||
|
Ok(data_url)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn gray(image: ImageBuffer<Rgba<u8>, Vec<u8>>) -> ImageBuffer<Luma<u8>, Vec<u8>> {
|
||||||
|
ImageBuffer::from_fn(image.width(), image.height(), |x, y| {
|
||||||
|
let pixel = image.get_pixel(x, y);
|
||||||
|
// Convert RGBA to grayscale using standard luminance formula
|
||||||
|
let Rgba(data) = *pixel;
|
||||||
|
let gray_value =
|
||||||
|
(0.299 * data[0] as f32 + 0.587 * data[1] as f32 + 0.114 * data[2] as f32) as u8;
|
||||||
|
Luma([gray_value])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scan_qr(image: ImageBuffer<Rgba<u8>, Vec<u8>>) -> Result<String, String> {
|
||||||
|
let mut db = bardecoder::default_builder();
|
||||||
|
|
||||||
|
// Use some different arguments in one of the default components
|
||||||
|
db.prepare(Box::new(BlockedMean::new(7, 9)));
|
||||||
|
|
||||||
|
// Build the actual decoder
|
||||||
|
let decoder = db.build();
|
||||||
|
|
||||||
|
let results = decoder.decode(&image);
|
||||||
|
for result in results {
|
||||||
|
return match result {
|
||||||
|
Ok(res) => Ok(res),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Err("oh no".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert `web_sys::ImageData` to `image::ImageBuffer<Rgba<u8>, Vec<u8>>`
|
||||||
|
pub fn image_data_to_image_buffer(
|
||||||
|
image_data: &web_sys::ImageData,
|
||||||
|
) -> Option<ImageBuffer<Rgba<u8>, Vec<u8>>> {
|
||||||
|
// Get raw RGBA pixel data as a Vec<u8>
|
||||||
|
let data = image_data.data();
|
||||||
|
let pixels = &data;
|
||||||
|
|
||||||
|
// The width and height of the image
|
||||||
|
let width = image_data.width();
|
||||||
|
let height = image_data.height();
|
||||||
|
|
||||||
|
// Create ImageBuffer from raw pixels (RGBA)
|
||||||
|
ImageBuffer::from_vec(width, height, pixels.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn grab_frame(video_element: &HtmlVideoElement) -> Option<web_sys::ImageData> {
|
||||||
|
let width = video_element.video_width();
|
||||||
|
let height = video_element.video_height();
|
||||||
|
|
||||||
|
if width == 0 || height == 0 {
|
||||||
|
// Video not ready yet
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let document = web_sys::window()?.document()?;
|
||||||
|
let canvas = document
|
||||||
|
.create_element("canvas")
|
||||||
|
.ok()?
|
||||||
|
.dyn_into::<HtmlCanvasElement>()
|
||||||
|
.ok()?;
|
||||||
|
canvas.set_width(width);
|
||||||
|
canvas.set_height(height);
|
||||||
|
|
||||||
|
let ctx = canvas
|
||||||
|
.get_context("2d")
|
||||||
|
.ok()??
|
||||||
|
.dyn_into::<CanvasRenderingContext2d>()
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
// Draw the current video frame
|
||||||
|
ctx.draw_image_with_html_video_element(video_element, 0.0, 0.0)
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
// Extract the frame as ImageData
|
||||||
|
ctx.get_image_data(0.0, 0.0, width as f64, height as f64)
|
||||||
|
.ok()
|
||||||
|
}
|
100
src/setup.rs
Normal file
100
src/setup.rs
Normal file
|
@ -0,0 +1,100 @@
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
use dioxus_sdk::storage::use_persistent;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Default, Serialize, Deserialize, Clone, PartialEq)]
|
||||||
|
pub struct Credentials {
|
||||||
|
pub instance_url: String,
|
||||||
|
pub token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Credentials {
|
||||||
|
pub fn empty(&self) -> bool {
|
||||||
|
self.instance_url.is_empty() && self.token.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Clone)]
|
||||||
|
struct FormErrors {
|
||||||
|
instance_url: Option<String>,
|
||||||
|
token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn SetupPage() -> Element {
|
||||||
|
let mut form_data = use_signal(Credentials::default);
|
||||||
|
let mut form_data2 = form_data.clone();
|
||||||
|
let mut form_errors = use_signal(FormErrors::default);
|
||||||
|
let submitted = use_signal(|| false);
|
||||||
|
|
||||||
|
let mut validate_form = move || {
|
||||||
|
let mut errors = FormErrors::default();
|
||||||
|
if form_data2.read().instance_url.is_empty() {
|
||||||
|
errors.instance_url = Some("Instance URL is required".to_string());
|
||||||
|
}
|
||||||
|
if form_data2.read().token.is_empty() {
|
||||||
|
errors.token = Some("Token is required".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
form_errors.set(errors.clone());
|
||||||
|
errors.instance_url.is_none() && errors.token.is_none()
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_submit = move |evt: FormEvent| {
|
||||||
|
evt.prevent_default();
|
||||||
|
|
||||||
|
if validate_form() {
|
||||||
|
let mut creds = use_persistent("creds", || Credentials::default());
|
||||||
|
|
||||||
|
spawn(async move {
|
||||||
|
let api = crate::api::API::new(creds.read().instance_url.clone()).await;
|
||||||
|
*crate::API.write() = Some(api);
|
||||||
|
});
|
||||||
|
|
||||||
|
creds.set(form_data.read().clone());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
div {
|
||||||
|
h1 { "Setup Page" }
|
||||||
|
form {
|
||||||
|
onsubmit: on_submit,
|
||||||
|
div {
|
||||||
|
label { "Instance URL:" }
|
||||||
|
input {
|
||||||
|
value: "{form_data.read().instance_url}",
|
||||||
|
oninput: move |evt| {
|
||||||
|
let mut form_data = form_data.write();
|
||||||
|
form_data.instance_url = evt.value();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{form_errors.read().instance_url.as_ref().map(|error| rsx! {
|
||||||
|
p { class: "error", "{error}" }
|
||||||
|
})}
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
label { "Token:" }
|
||||||
|
input {
|
||||||
|
r#type: "password",
|
||||||
|
value: "{form_data.read().token}",
|
||||||
|
oninput: move |evt| {
|
||||||
|
let mut form_data = form_data.write();
|
||||||
|
form_data.token = evt.value();
|
||||||
|
},
|
||||||
|
}
|
||||||
|
{form_errors.read().token.as_ref().map(|error| rsx! {
|
||||||
|
p { class: "error", "{error}" }
|
||||||
|
})}
|
||||||
|
}
|
||||||
|
button { type: "submit", "Complete Setup" }
|
||||||
|
}
|
||||||
|
if *submitted.read() {
|
||||||
|
div {
|
||||||
|
h3 { "Setup Complete!" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
9
tailwind.config.js
Normal file
9
tailwind.config.js
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
mode: "all",
|
||||||
|
content: ["./src/**/*.{rs,html,css}", "./dist/**/*.html"],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
1
tailwind.css
Normal file
1
tailwind.css
Normal file
|
@ -0,0 +1 @@
|
||||||
|
@import "tailwindcss";
|
Loading…
Add table
Add a link
Reference in a new issue