Initial commit
This commit is contained in:
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
|
||||
5775
Cargo.lock
generated
Normal file
5775
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
Cargo.toml
Normal file
36
Cargo.toml
Normal file
@@ -0,0 +1,36 @@
|
||||
[package]
|
||||
name = "borrow_checker"
|
||||
version = "0.1.0"
|
||||
authors = ["Avinash Mallya <avimallu.github.io>"]
|
||||
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"]}
|
||||
dioxus-free-icons = { version = "0.9", features = ["lucide"] }
|
||||
rust_decimal = { version = "1.37.2", features = ["macros"] }
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
gloo-storage = "0.3.0"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
comfy-table = "7.1.4"
|
||||
|
||||
[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"
|
||||
21
Dioxus.toml
Normal file
21
Dioxus.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[application]
|
||||
|
||||
[web.app]
|
||||
|
||||
# HTML title tag content
|
||||
title = "iron-abacus"
|
||||
|
||||
# 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 = []
|
||||
29
README.md
Normal file
29
README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
|
||||
# BorrowChecker
|
||||
|
||||
A simple app to help you split your bills and receipts with multiple people fairy.
|
||||
|
||||
## Screenshots
|
||||
|
||||
<p float="left">
|
||||
<img src="/assets/screenshots/screen_1.jpeg" width=15%>
|
||||
<img src="/assets/screenshots/screen_2.jpeg" width=15%>
|
||||
<img src="/assets/screenshots/screen_3.jpeg" width=15%>
|
||||
</p>
|
||||
|
||||
# Development
|
||||
|
||||
Run the following command in the root of your project to start developing with the default platform:
|
||||
|
||||
```bash
|
||||
dx serve --port 8123
|
||||
```
|
||||
|
||||
If you want to make it available to the local network, run:
|
||||
|
||||
```bash
|
||||
dx serve --port 8123 --addr 0.0.0.0
|
||||
```
|
||||
|
||||
The choice of the `port` argument is up to you. Currently, this app is configured to only be compilable on the `web` platform.
|
||||
|
||||
21572
assets/bulma.css
vendored
Normal file
21572
assets/bulma.css
vendored
Normal file
File diff suppressed because it is too large
Load Diff
BIN
assets/screenshots/screen_1.jpeg
Normal file
BIN
assets/screenshots/screen_1.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 56 KiB |
BIN
assets/screenshots/screen_2.jpeg
Normal file
BIN
assets/screenshots/screen_2.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 55 KiB |
BIN
assets/screenshots/screen_3.jpeg
Normal file
BIN
assets/screenshots/screen_3.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
8
clippy.toml
Normal file
8
clippy.toml
Normal file
@@ -0,0 +1,8 @@
|
||||
await-holding-invalid-types = [
|
||||
"generational_box::GenerationalRef",
|
||||
{ path = "generational_box::GenerationalRef", reason = "Reads should not be held over an await point. This will cause any writes to fail while the await is pending since the read borrow is still active." },
|
||||
"generational_box::GenerationalRefMut",
|
||||
{ path = "generational_box::GenerationalRefMut", reason = "Write should not be held over an await point. This will cause any reads or writes to fail while the await is pending since the write borrow is still active." },
|
||||
"dioxus_signals::Write",
|
||||
{ path = "dioxus_signals::Write", reason = "Write should not be held over an await point. This will cause any reads or writes to fail while the await is pending since the write borrow is still active." },
|
||||
]
|
||||
76
src/app/display.rs
Normal file
76
src/app/display.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
use crate::app::{Route, RECEIPT_STATE};
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn DisplaySplits() -> Element {
|
||||
let nav = navigator();
|
||||
if let Some(receipt) = RECEIPT_STATE.read().as_ref() {
|
||||
let mut header = receipt.shared_by.clone();
|
||||
header.insert(0, "Item Name".into());
|
||||
header.push("Total".into());
|
||||
|
||||
let (item_names, item_splits) = receipt.calculate_splits()?;
|
||||
|
||||
let rows: Vec<Vec<String>> = item_names
|
||||
.into_iter()
|
||||
.zip(item_splits.into_iter())
|
||||
.map(|(item_name, splits)| {
|
||||
let mut splits_as_str: Vec<String> = splits.iter().map(|x| x.to_string()).collect();
|
||||
splits_as_str.insert(0, item_name.into());
|
||||
splits_as_str
|
||||
})
|
||||
.collect();
|
||||
|
||||
rsx! {
|
||||
document::Title { "BorrowChecker | View" }
|
||||
header { class: "hero is-small is-primary",
|
||||
div { class: "hero-body has-text-centered",
|
||||
p { class: "title", "Here's your split!" }
|
||||
p { class: "subtitle is-size-6",
|
||||
"Balance leftover is distributed proportionally."
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "is-flex is-justify-content-center",
|
||||
div { class: "table-container ",
|
||||
table { class: "table",
|
||||
thead {
|
||||
tr {
|
||||
for val in header.iter() {
|
||||
th { scope: "col", "{val}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for row in rows.iter() {
|
||||
tr {
|
||||
for (idx , val) in row.iter().enumerate() {
|
||||
if idx == 0 {
|
||||
th { scope: "row", "{val}" }
|
||||
} else {
|
||||
td { "{val}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
footer { class: "hero is-small is-primary",
|
||||
div { class: "hero-body has-text-centered is-flex is-justify-content-center",
|
||||
p { class: "subtitle is-size-7 mr-1", "Built with Rust & Dioxus | " }
|
||||
p { class: "subtitle is-size-7 mr-1", "👾🤖👻 |" }
|
||||
a {
|
||||
class: "subtitle is-size-7 mr-1",
|
||||
href: "https://avimallu.github.io",
|
||||
"avimallu.github.io "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
nav.push(Route::CreateReceiptSplash);
|
||||
rsx! {}
|
||||
}
|
||||
}
|
||||
28
src/app/frontend.rs
Normal file
28
src/app/frontend.rs
Normal file
@@ -0,0 +1,28 @@
|
||||
use crate::app::display::DisplaySplits;
|
||||
use crate::app::splash::CreateReceiptSplash;
|
||||
use crate::app::split::SplitUI;
|
||||
use crate::core::receipt::Receipt;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
static CSS: Asset = asset!("/assets/bulma.css");
|
||||
pub static RECEIPT_STATE: GlobalSignal<Option<Receipt>> = Signal::global(|| None);
|
||||
|
||||
#[derive(Routable, Clone, Debug)]
|
||||
#[rustfmt::skip]
|
||||
pub enum Route {
|
||||
#[route("/create")]
|
||||
#[redirect("/", || Route::CreateReceiptSplash {})]
|
||||
CreateReceiptSplash,
|
||||
#[route("/split")]
|
||||
SplitUI,
|
||||
#[route("/display")]
|
||||
DisplaySplits,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn App() -> Element {
|
||||
rsx! {
|
||||
document::Stylesheet { href: CSS }
|
||||
Router::<Route> {}
|
||||
}
|
||||
}
|
||||
7
src/app/mod.rs
Normal file
7
src/app/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod display;
|
||||
pub mod frontend;
|
||||
pub mod splash;
|
||||
pub mod split;
|
||||
pub mod storage;
|
||||
|
||||
pub use frontend::*;
|
||||
244
src/app/splash.rs
Normal file
244
src/app/splash.rs
Normal file
@@ -0,0 +1,244 @@
|
||||
use crate::app::storage::use_persistent;
|
||||
use crate::app::{Route, RECEIPT_STATE};
|
||||
use crate::core::receipt::Receipt;
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::icons::ld_icons;
|
||||
use dioxus_free_icons::Icon;
|
||||
use rust_decimal::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn CreateReceiptSplash() -> Element {
|
||||
let receipt_value: Signal<Option<Decimal>> = use_signal(|| None);
|
||||
let people_input: Signal<Vec<String>> = use_signal(|| vec!["".to_string()]);
|
||||
let people_list: Memo<Vec<String>> = use_memo(move || {
|
||||
people_input
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|&name| !name.is_empty())
|
||||
.cloned()
|
||||
.collect()
|
||||
});
|
||||
|
||||
rsx! {
|
||||
document::Title { "BorrowChecker | Create" }
|
||||
header { class: "hero is-small is-primary",
|
||||
div { class: "hero-body has-text-centered",
|
||||
p { class: "title", "BorrowChecker" }
|
||||
p { class: "subtitle is-size-6", "A utility to determine how who owes what" }
|
||||
}
|
||||
}
|
||||
div { class: "section is-small",
|
||||
ReceiptValue { receipt_value }
|
||||
hr {}
|
||||
ReceiptPeopleList { people_input }
|
||||
}
|
||||
div { class: "section",
|
||||
SubmitReceipt { receipt_value, people_list }
|
||||
RetrieveCache { people_input }
|
||||
}
|
||||
footer { class: "hero is-small is-primary",
|
||||
div { class: "hero-body has-text-centered is-flex is-justify-content-center",
|
||||
p { class: "subtitle is-size-7 mr-1", "Built with Rust & Dioxus | " }
|
||||
p { class: "subtitle is-size-7 mr-1", "👾🤖👻 |" }
|
||||
a {
|
||||
class: "subtitle is-size-7 mr-1",
|
||||
href: "https://avimallu.github.io",
|
||||
"avimallu.github.io "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ReceiptValue(mut receipt_value: Signal<Option<Decimal>>) -> Element {
|
||||
rsx! {
|
||||
div { class: "container is-fluid",
|
||||
input {
|
||||
class: "input is-primary",
|
||||
min: 0.01,
|
||||
placeholder: "Enter receipt total",
|
||||
step: "0.01",
|
||||
required: "true",
|
||||
r#type: "number",
|
||||
inputmode: "decimal",
|
||||
value: "{receipt_value.read().map_or_else(String::new, |d| d.to_string())}",
|
||||
oninput: move |event| {
|
||||
if let Ok(value) = event.value().parse::<Decimal>() {
|
||||
receipt_value.set(Some(value));
|
||||
} else {
|
||||
receipt_value.set(None);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ReceiptPeopleList(mut people_input: Signal<Vec<String>>) -> Element {
|
||||
rsx! {
|
||||
div { class: "container is-fluid",
|
||||
for (idx , person) in people_input().iter().enumerate() {
|
||||
div { key: "people_input_div_{idx}", class: "columns is-mobile",
|
||||
|
||||
div { class: "column is-9",
|
||||
input {
|
||||
class: "input is-primary",
|
||||
key: "people_input_text_{idx}",
|
||||
r#type: "text",
|
||||
minlength: 0,
|
||||
placeholder: "Enter person {idx+1} name",
|
||||
value: "{person}",
|
||||
oninput: move |evt| { people_input.with_mut(|list| list[idx] = evt.value()) },
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "column is-1 is-narrow",
|
||||
// Render ❌ only one of the two conditions is true:
|
||||
// 1) The current item is not the last item in the list and
|
||||
// 2) There are more than 2 items in the list
|
||||
//
|
||||
// Otherwise, always render ➕ unless the input is an empty string.
|
||||
if people_input.len() > 1 && (idx + 1) != people_input.len() {
|
||||
button {
|
||||
class: "button is-danger is-dark is-rounded",
|
||||
key: "people_input_remove_button_{idx}",
|
||||
onclick: move |_| {
|
||||
people_input.with_mut(|list| list.remove(idx));
|
||||
},
|
||||
Icon {
|
||||
width: 24,
|
||||
height: 24,
|
||||
fill: "white",
|
||||
icon: ld_icons::LdCircleX,
|
||||
}
|
||||
}
|
||||
} else if person != "" {
|
||||
button {
|
||||
class: "button is-primary is-dark is-rounded",
|
||||
key: "people_input_add_button_{idx}",
|
||||
onclick: move |_| {
|
||||
people_input.push("".to_string());
|
||||
},
|
||||
Icon {
|
||||
width: 24,
|
||||
height: 24,
|
||||
fill: "white",
|
||||
icon: ld_icons::LdPlus,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn SubmitReceipt(
|
||||
receipt_value: Signal<Option<Decimal>>,
|
||||
people_list: Memo<Vec<String>>,
|
||||
) -> Element {
|
||||
let nav = navigator();
|
||||
if !receipt_value().is_none() && people_list.read().len() > 0 {
|
||||
let generated_receipt = Receipt::new(
|
||||
receipt_value().unwrap(),
|
||||
people_list().iter().map(|x| x.as_str()).collect(),
|
||||
);
|
||||
match generated_receipt {
|
||||
Ok(valid_receipt) => {
|
||||
rsx! {
|
||||
div { class: "container is-fluid",
|
||||
button {
|
||||
class: "button is-success is-dark is-large is-fullwidth",
|
||||
key: "submit_receipt",
|
||||
onclick: move |_| {
|
||||
set_people(people_list().clone());
|
||||
*RECEIPT_STATE.write() = Some(valid_receipt.clone());
|
||||
nav.push(Route::SplitUI);
|
||||
},
|
||||
"Submit"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => rsx! {
|
||||
div { class: "panel-heading has-text-centered", "{error.to_string()}" }
|
||||
},
|
||||
}
|
||||
} else {
|
||||
rsx! {
|
||||
div { class: "panel-heading has-text-centered",
|
||||
"Provide a total amount and at least two people to start splitting!"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn retrieve_people() -> Vec<Vec<String>> {
|
||||
let init_people_list: Vec<Vec<String>> = vec![]; // temp;
|
||||
use_persistent("people_list", || init_people_list).get()
|
||||
}
|
||||
|
||||
fn set_people(new_people_list: Vec<String>) {
|
||||
let mut cached_people_list = retrieve_people();
|
||||
let empty: Vec<Vec<String>> = vec![];
|
||||
if cached_people_list.len() > 1 {
|
||||
cached_people_list.remove(0);
|
||||
}
|
||||
// Very inefficient, but shouldn't make a difference for such a small cache:
|
||||
let mut is_present = false;
|
||||
for people_list in cached_people_list.iter() {
|
||||
if *people_list == new_people_list {
|
||||
is_present = true;
|
||||
}
|
||||
}
|
||||
if !is_present {
|
||||
cached_people_list.push(new_people_list.clone());
|
||||
use_persistent("people_list", || empty).set(cached_people_list)
|
||||
};
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn RetrieveCache(people_input: Signal<Vec<String>>) -> Element {
|
||||
let cache_people_list = retrieve_people();
|
||||
rsx! {
|
||||
if cache_people_list.len() > 0 {
|
||||
hr {}
|
||||
div { class: "panel-heading", "Or pick from recently used groups:" }
|
||||
for (idx , people) in cache_people_list.clone().into_iter().rev().enumerate() {
|
||||
if idx < 3 {
|
||||
div { class: "columns",
|
||||
div { class: "column",
|
||||
div { class: "buttons",
|
||||
for person in people.iter() {
|
||||
div {
|
||||
button {
|
||||
class: "button is-link",
|
||||
disabled: "true",
|
||||
"{person}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "column is-2 is-narrow",
|
||||
button {
|
||||
class: "button is-primary",
|
||||
onclick: move |_| {
|
||||
people_input.set(people.clone());
|
||||
},
|
||||
"Select this group"
|
||||
}
|
||||
}
|
||||
hr {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
212
src/app/split.rs
Normal file
212
src/app/split.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
use crate::app::{Route, RECEIPT_STATE};
|
||||
use dioxus::prelude::*;
|
||||
use dioxus_free_icons::icons::ld_icons;
|
||||
use dioxus_free_icons::Icon;
|
||||
use rust_decimal::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn SplitUI() -> Element {
|
||||
let nav = navigator();
|
||||
if let Some(receipt) = RECEIPT_STATE.read().as_ref() {
|
||||
let (_, balance) = receipt.get_itemized_total_and_leftover();
|
||||
let item_count = receipt.items.len();
|
||||
rsx! {
|
||||
document::Title { "BorrowChecker | Split" }
|
||||
header { class: "hero is-small is-primary",
|
||||
div { class: "hero-body has-text-centered",
|
||||
ColorBalanceTitle { balance }
|
||||
}
|
||||
}
|
||||
div { class: "section",
|
||||
div { class: "container is-fluid",
|
||||
for item_idx in 0..item_count {
|
||||
SplitItemUI { item_idx }
|
||||
}
|
||||
}
|
||||
div { class: "is-flex is-justify-content-center",
|
||||
div { class: "buttons",
|
||||
div {
|
||||
button {
|
||||
class: "button is-primary is-dark",
|
||||
key: "item_add_button",
|
||||
onclick: move |_| {
|
||||
if let Some(r) = RECEIPT_STATE.write().as_mut() {
|
||||
let people_list = r.shared_by.clone();
|
||||
r.add_item_split_by_ratio(
|
||||
Decimal::ZERO,
|
||||
format!("Item {}", item_count + 1),
|
||||
people_list,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
},
|
||||
Icon {
|
||||
width: 24,
|
||||
height: 24,
|
||||
fill: "white",
|
||||
icon: ld_icons::LdBookPlus,
|
||||
}
|
||||
span { class: "ml-2", "Add Item" }
|
||||
|
||||
}
|
||||
}
|
||||
div {
|
||||
if receipt.items.len() > 0 && receipt.items.iter().all(|x| x.value > Decimal::ZERO)
|
||||
&& receipt.calculate_splits().is_ok()
|
||||
{
|
||||
button {
|
||||
class: "button is-link is-dark",
|
||||
key: "show_calculated_table",
|
||||
onclick: move |_| {
|
||||
nav.push(Route::DisplaySplits);
|
||||
},
|
||||
Icon {
|
||||
width: 24,
|
||||
height: 24,
|
||||
fill: "white",
|
||||
icon: ld_icons::LdScale,
|
||||
}
|
||||
span { class: "ml-2", "Show Splits" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
footer { class: "hero is-small is-primary",
|
||||
div { class: "hero-body has-text-centered is-flex is-justify-content-center",
|
||||
p { class: "subtitle is-size-7 mr-1", "Built with Rust & Dioxus | " }
|
||||
p { class: "subtitle is-size-7 mr-1", "👾🤖👻 |" }
|
||||
a {
|
||||
class: "subtitle is-size-7 mr-1",
|
||||
href: "https://avimallu.github.io",
|
||||
"avimallu.github.io "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
nav.push(Route::CreateReceiptSplash);
|
||||
rsx! {}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ColorBalanceTitle(balance: Decimal) -> Element {
|
||||
rsx! {
|
||||
if balance > Decimal::ZERO {
|
||||
p { class: "title has-text-dark is-size-4", "+{balance}" }
|
||||
p { class: "subtitle is-size-5", "left to balance" }
|
||||
} else if balance < Decimal::ZERO {
|
||||
p { class: "title has-text-danger is-size-4", "Remaining: {balance}" }
|
||||
p { class: "subtitle is-size-6", "Item total exceeds receipt total." }
|
||||
} else {
|
||||
p { class: "title has-text-link is-size-4", "0" }
|
||||
p { class: "subtitle is-size-6", "Perfectly balanced, as all things should be." }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn SplitItemUI(item_idx: usize) -> Element {
|
||||
let people_list = (*RECEIPT_STATE.read()).as_ref().unwrap().shared_by.clone();
|
||||
|
||||
let (item_name, item_value, item_shared_by) = &RECEIPT_STATE
|
||||
.read()
|
||||
.as_ref()
|
||||
.and_then(|r| r.items.get(item_idx))
|
||||
.map(|item| {
|
||||
(
|
||||
item.name.clone(),
|
||||
item.value.clone(),
|
||||
item.shared_by.clone(),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let item_value = if item_value > &Decimal::ZERO {
|
||||
item_value.to_string()
|
||||
} else {
|
||||
"-".to_string()
|
||||
};
|
||||
|
||||
if true {
|
||||
rsx! {
|
||||
div { class: "columns is-mobile",
|
||||
div { class: "column is-two-thirds",
|
||||
input {
|
||||
class: "input is-primary",
|
||||
key: "item_input_name_{item_idx}",
|
||||
r#type: "text",
|
||||
value: "{item_name}",
|
||||
oninput: move |evt| {
|
||||
if let Some(r) = RECEIPT_STATE.write().as_mut() {
|
||||
if let Some(item) = r.items.get_mut(item_idx) {
|
||||
item.name = evt.value();
|
||||
}
|
||||
}
|
||||
},
|
||||
placeholder: "item name",
|
||||
}
|
||||
}
|
||||
div { class: "column is-one-third",
|
||||
input {
|
||||
class: "input is-primary",
|
||||
key: "item_input_value_{item_idx}",
|
||||
min: "0.00",
|
||||
step: "0.01",
|
||||
inputmode: "decimal",
|
||||
required: "true",
|
||||
r#type: "number",
|
||||
value: "{item_value}",
|
||||
oninput: move |evt| {
|
||||
if let Some(r) = RECEIPT_STATE.write().as_mut() {
|
||||
if let Some(item) = r.items.get_mut(item_idx) {
|
||||
if let Ok(valid_decimal) = evt.value().parse::<Decimal>() {
|
||||
item.value = valid_decimal;
|
||||
} else {
|
||||
item.value = Decimal::ZERO;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
placeholder: "amount",
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "buttons",
|
||||
for (person_idx , person) in people_list.clone().into_iter().enumerate() {
|
||||
div {
|
||||
button {
|
||||
class: if item_shared_by.contains(&person) { "button is-primary is-dark is-fullwidth" } else { "button is-primary is-outlined is-dark is-fullwidth" },
|
||||
key: "item_{item_idx}_person_{person_idx}",
|
||||
onclick: move |_| {
|
||||
if let Some(r) = RECEIPT_STATE.write().as_mut() {
|
||||
if let Some(item) = r.items.get_mut(item_idx) {
|
||||
if item.shared_by.contains(&person) && item.shared_by.len() > 1 {
|
||||
let shared_by_idx = item
|
||||
.shared_by
|
||||
.iter()
|
||||
.position(|name| *name == *person)
|
||||
.unwrap();
|
||||
item.shared_by.remove(shared_by_idx);
|
||||
item.share_ratio.remove(shared_by_idx);
|
||||
} else {
|
||||
item.shared_by.push(person.clone());
|
||||
item.share_ratio.push(Decimal::ONE);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"{person}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
hr {}
|
||||
}
|
||||
} else {
|
||||
rsx! { "Unhandled error 2" }
|
||||
}
|
||||
}
|
||||
58
src/app/storage.rs
Normal file
58
src/app/storage.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use dioxus::prelude::*;
|
||||
use gloo_storage::{LocalStorage, Storage};
|
||||
use serde::{de::DeserializeOwned, Serialize};
|
||||
|
||||
// Copied directly from https://dioxuslabs.com/learn/0.6/cookbook/state/custom_hooks/
|
||||
|
||||
/// A persistent storage hook that can be used to store data across application reloads.
|
||||
#[allow(clippy::needless_return)]
|
||||
pub fn use_persistent<T: Serialize + DeserializeOwned + Default + 'static>(
|
||||
// A unique key for the storage entry
|
||||
key: impl ToString,
|
||||
// A function that returns the initial value if the storage entry is empty
|
||||
init: impl FnOnce() -> T,
|
||||
) -> UsePersistent<T> {
|
||||
// Use the use_signal hook to create a mutable state for the storage entry
|
||||
let state = use_signal(move || {
|
||||
// This closure will run when the hook is created
|
||||
let key = key.to_string();
|
||||
let value = LocalStorage::get(key.as_str()).ok().unwrap_or_else(init);
|
||||
StorageEntry { key, value }
|
||||
});
|
||||
|
||||
// Wrap the state in a new struct with a custom API
|
||||
UsePersistent { inner: state }
|
||||
}
|
||||
|
||||
struct StorageEntry<T> {
|
||||
key: String,
|
||||
value: T,
|
||||
}
|
||||
|
||||
/// Storage that persists across application reloads
|
||||
pub struct UsePersistent<T: 'static> {
|
||||
inner: Signal<StorageEntry<T>>,
|
||||
}
|
||||
|
||||
impl<T> Clone for UsePersistent<T> {
|
||||
fn clone(&self) -> Self {
|
||||
*self
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Copy for UsePersistent<T> {}
|
||||
|
||||
impl<T: Serialize + DeserializeOwned + Clone + 'static> UsePersistent<T> {
|
||||
/// Returns a reference to the value
|
||||
pub fn get(&self) -> T {
|
||||
self.inner.read().value.clone()
|
||||
}
|
||||
|
||||
/// Sets the value
|
||||
pub fn set(&mut self, value: T) {
|
||||
let mut inner = self.inner.write();
|
||||
// Write the new value to local storage
|
||||
let _ = LocalStorage::set(inner.key.as_str(), &value);
|
||||
inner.value = value;
|
||||
}
|
||||
}
|
||||
110
src/app/ui.rs
Normal file
110
src/app/ui.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
use crate::core::receipt::Receipt;
|
||||
use dioxus::prelude::*;
|
||||
use rust_decimal::prelude::*;
|
||||
|
||||
static CSS: Asset = asset!("/assets/bulma.css");
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
enum AppState {
|
||||
CreatingReceipt,
|
||||
SplittingItems,
|
||||
DisplayingSplits,
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn App() -> Element {
|
||||
let app_state = use_signal(|| AppState::CreatingReceipt);
|
||||
|
||||
// Variables to create the essential values of the receipt
|
||||
let receipt_value: Signal<Option<Decimal>> = use_signal(|| None);
|
||||
let people_input: Signal<Vec<String>> = use_signal(|| vec!["".to_string()]);
|
||||
let people_list: Memo<Vec<String>> = use_memo(move || {
|
||||
people_input
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|name| !name.is_empty())
|
||||
.cloned()
|
||||
.collect()
|
||||
});
|
||||
|
||||
// Variables to input and handle receipt items
|
||||
let receipt: Signal<Option<Receipt>> = use_signal(|| None);
|
||||
|
||||
rsx! {
|
||||
document::Stylesheet { href: CSS }
|
||||
section { class: "hero is-primary",
|
||||
div { class: "hero-body",
|
||||
p { class: "title", "Iron Abacus" }
|
||||
p { class: "subtitle", "A splitting helper" }
|
||||
}
|
||||
}
|
||||
if app_state() == AppState::CreatingReceipt {
|
||||
CreateReceipt {
|
||||
app_state,
|
||||
receipt_value,
|
||||
people_input,
|
||||
people_list,
|
||||
receipt,
|
||||
}
|
||||
} else if app_state() == AppState::SplittingItems {
|
||||
SplitReceipt {
|
||||
receipt_value,
|
||||
people_list,
|
||||
app_state,
|
||||
receipt,
|
||||
}
|
||||
} else {
|
||||
DisplayTable { app_state, receipt }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn DisplayTable(mut app_state: Signal<AppState>, mut receipt: Signal<Option<Receipt>>) -> Element {
|
||||
if let Some(valid_receipt) = receipt().as_ref() {
|
||||
let mut header = valid_receipt.shared_by.clone();
|
||||
header.insert(0, "Item Name".into());
|
||||
header.push("Total".into());
|
||||
|
||||
let (item_names, item_splits) = valid_receipt.calculate_splits()?;
|
||||
|
||||
let rows: Vec<Vec<String>> = item_names
|
||||
.into_iter()
|
||||
.zip(item_splits.into_iter())
|
||||
.map(|(item_name, splits)| {
|
||||
let mut splits_as_str: Vec<String> = splits.iter().map(|x| x.to_string()).collect();
|
||||
splits_as_str.insert(0, item_name.into());
|
||||
splits_as_str
|
||||
})
|
||||
.collect();
|
||||
|
||||
rsx! {
|
||||
div { class: "table-container",
|
||||
table { class: "table",
|
||||
thead {
|
||||
tr {
|
||||
for val in header.iter() {
|
||||
th { scope: "col", "{val}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for row in rows.iter() {
|
||||
tr {
|
||||
for (idx , val) in row.iter().enumerate() {
|
||||
if idx == 0 {
|
||||
th { scope: "row", "{val}" }
|
||||
} else {
|
||||
td { "{val}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
rsx! { "No table to show yet, bitches!" }
|
||||
}
|
||||
}
|
||||
41
src/cli/arg_parser.rs
Normal file
41
src/cli/arg_parser.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use crate::core::receipt::{Receipt, SplittingError};
|
||||
use std::env;
|
||||
|
||||
// Super-basic parsing, advanced parsing packages are not needed
|
||||
pub fn parse_args() -> Result<Receipt, SplittingError> {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
|
||||
// dbg!(&args);
|
||||
|
||||
if args.len() < 2 {
|
||||
return Err(SplittingError::InvalidArgument(format!(
|
||||
"You have specified only the receipt's total value and people sharing it \
|
||||
but not any item within it to split. Please do so"
|
||||
)));
|
||||
} else {
|
||||
let mut receipt = Receipt::parse_create_receipt(&args[1])?;
|
||||
let mut curr_arg: Option<&str> = None;
|
||||
for (arg_idx, arg) in args[2..].iter().enumerate() {
|
||||
if curr_arg.is_none() {
|
||||
if arg.starts_with("--") {
|
||||
curr_arg = Some(&arg[2..]);
|
||||
} else if arg.starts_with("-") {
|
||||
curr_arg = Some(&arg[1..]);
|
||||
continue;
|
||||
} else {
|
||||
return Err(SplittingError::InvalidArgument(format!(
|
||||
"Argument {} is expected (in this case) to be an item name, \
|
||||
and must be prefixed with a dash (-) or a double dash (--). Currently, \
|
||||
it is {}",
|
||||
arg_idx + 1,
|
||||
arg
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
receipt.parse_add_named_item(curr_arg.unwrap(), arg)?;
|
||||
curr_arg = None
|
||||
}
|
||||
}
|
||||
Ok(receipt)
|
||||
}
|
||||
}
|
||||
113
src/cli/display.rs
Normal file
113
src/cli/display.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
use crate::core::receipt::{Receipt, SplittingError};
|
||||
use comfy_table::{Cell, Table, modifiers::UTF8_ROUND_CORNERS, presets::UTF8_FULL};
|
||||
|
||||
impl Receipt {
|
||||
fn create_table(&self) -> Result<Table, SplittingError> {
|
||||
let mut header = self.shared_by.clone();
|
||||
header.insert(0, "Item".into());
|
||||
header.push("Total".into());
|
||||
|
||||
let (item_names, item_splits) = self.calculate_splits()?;
|
||||
|
||||
let rows: Vec<Vec<String>> = item_names
|
||||
.into_iter()
|
||||
.zip(item_splits.into_iter())
|
||||
.map(|(item_name, splits)| {
|
||||
let mut splits_as_str: Vec<String> = splits.iter().map(|x| x.to_string()).collect();
|
||||
splits_as_str.insert(0, item_name.into());
|
||||
splits_as_str
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut table = Table::new();
|
||||
table
|
||||
.load_preset(UTF8_FULL)
|
||||
.apply_modifier(UTF8_ROUND_CORNERS)
|
||||
.set_header(header);
|
||||
|
||||
for (idx, column) in table.column_iter_mut().enumerate() {
|
||||
if idx != 0 {
|
||||
column.set_cell_alignment(comfy_table::CellAlignment::Right);
|
||||
}
|
||||
}
|
||||
|
||||
for row in rows.iter() {
|
||||
if row[0] == "<total>" || row[0] == "<leftover>" {
|
||||
let fg_col = if row[0] == "<total>" {
|
||||
comfy_table::Color::Green
|
||||
} else {
|
||||
comfy_table::Color::DarkGrey
|
||||
};
|
||||
|
||||
let row: Vec<Cell> = row.iter().map(|x| Cell::new(x).fg(fg_col)).collect();
|
||||
table.add_row(row);
|
||||
} else {
|
||||
table.add_row(row);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(table)
|
||||
}
|
||||
|
||||
pub fn display_splits(&self) -> Result<(), SplittingError> {
|
||||
let table = self.create_table()?;
|
||||
print!("\n{table}\n");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::core::receipt::Receipt;
|
||||
use crate::utils;
|
||||
use rust_decimal::prelude::*;
|
||||
|
||||
#[test]
|
||||
fn test_create_table() {
|
||||
let mut receipt = Receipt::new(
|
||||
Decimal::from_str("300").unwrap(),
|
||||
vec!["Alice", "Bob", "Marshall"],
|
||||
)
|
||||
.unwrap();
|
||||
receipt
|
||||
.add_item_split_by_ratio(
|
||||
dec![200],
|
||||
"Food".into(),
|
||||
utils::strs_to_strings(vec!["Alice", "Marshall", "Bob"]),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
receipt
|
||||
.add_item_split_by_ratio(
|
||||
dec![50],
|
||||
"Drinks".into(),
|
||||
utils::strs_to_strings(vec!["Alice", "Bob"]),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
println!(
|
||||
"Receipt Item Status Before Calculating Splits:\n\n{:#?}\n\n",
|
||||
receipt.items
|
||||
);
|
||||
|
||||
let mut table = receipt.create_table().unwrap();
|
||||
|
||||
table.force_no_tty();
|
||||
|
||||
let expected = "
|
||||
╭────────────┬────────┬────────┬──────────┬───────╮
|
||||
│ Item ┆ Alice ┆ Bob ┆ Marshall ┆ Total │
|
||||
╞════════════╪════════╪════════╪══════════╪═══════╡
|
||||
│ Food ┆ 66.67 ┆ 66.67 ┆ 66.67 ┆ 200 │
|
||||
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ Drinks ┆ 25 ┆ 25 ┆ 0 ┆ 50 │
|
||||
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ <leftover> ┆ 18.33 ┆ 18.33 ┆ 13.33 ┆ 50 │
|
||||
├╌╌╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌╌╌╌┼╌╌╌╌╌╌╌┤
|
||||
│ <total> ┆ 110.00 ┆ 110.00 ┆ 80.00 ┆ 300 │
|
||||
╰────────────┴────────┴────────┴──────────┴───────╯";
|
||||
let actual = "\n".to_string() + &table.to_string();
|
||||
assert_eq!(expected, actual)
|
||||
}
|
||||
}
|
||||
4
src/cli/mod.rs
Normal file
4
src/cli/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod arg_parser;
|
||||
pub mod display;
|
||||
pub mod pattern_parser;
|
||||
pub mod utils;
|
||||
201
src/cli/pattern_parser.rs
Normal file
201
src/cli/pattern_parser.rs
Normal file
@@ -0,0 +1,201 @@
|
||||
use crate::cli::utils as parse_utils;
|
||||
use crate::core::receipt::{Receipt, SplittingError};
|
||||
use crate::utils;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
// Contains any pattern based parsing of inputs for the package.
|
||||
|
||||
impl Receipt {
|
||||
pub fn parse_create_receipt(amount_shared_by: &str) -> Result<Receipt, SplittingError> {
|
||||
let (total, shared_by) = parse_utils::split_by_comma(
|
||||
amount_shared_by,
|
||||
"Input must have pattern 'Total,Person_1[,Person_2,...]', but you have not provided the starting comma.",
|
||||
)?;
|
||||
let total = total.parse()?;
|
||||
let shared_by: Vec<&str> = shared_by.split(",").collect();
|
||||
Receipt::new(total, shared_by)
|
||||
}
|
||||
|
||||
fn align_to_shared_by(&mut self, abbrevs: &str) -> Result<Vec<String>, SplittingError> {
|
||||
let abbrevs: Vec<&str> = abbrevs.split(",").collect();
|
||||
|
||||
utils::is_string_vec_unique(
|
||||
&abbrevs,
|
||||
SplittingError::InvalidAbbreviation(format!(
|
||||
"The abbreviation string: {} has duplicates.",
|
||||
abbrevs.join(",")
|
||||
)),
|
||||
)?;
|
||||
|
||||
let mut matched_names: Vec<String> = Vec::new();
|
||||
|
||||
// Case is important - Don vs. don can be considered different people.
|
||||
// Minimal disruption to user, less code to peruse.
|
||||
for abbrev in abbrevs {
|
||||
// If the abbreviation is already mapped to an existing name:
|
||||
if let Some(existing_name) = self.mapped_abbreviations.get(abbrev) {
|
||||
// If it doesn't map to another one, add this as a mapped name.
|
||||
if matched_names.contains(existing_name) {
|
||||
return Err(SplittingError::DuplicatePeopleError(format!(
|
||||
"{} maps to {}, which has already been specified once.",
|
||||
abbrev, existing_name
|
||||
)));
|
||||
} else {
|
||||
matched_names.push(existing_name.clone());
|
||||
}
|
||||
} else {
|
||||
// If the abbreviation is not mapped, try to find a map.
|
||||
let mut found = false;
|
||||
|
||||
for name in &self.shared_by {
|
||||
if utils::is_abbrev_match_to_string(abbrev, name)
|
||||
& !matched_names.contains(name)
|
||||
{
|
||||
self.mapped_abbreviations
|
||||
.insert(abbrev.to_string(), name.clone());
|
||||
found = true;
|
||||
matched_names.push(name.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Not finding a match is an error.
|
||||
if !found {
|
||||
return Err(SplittingError::InvalidAbbreviation(format!(
|
||||
"{} does not match to a provided person name.",
|
||||
abbrev
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(matched_names)
|
||||
}
|
||||
|
||||
pub fn parse_add_named_item(
|
||||
&mut self,
|
||||
item_name: &str,
|
||||
item_pattern: &str,
|
||||
) -> Result<(), SplittingError> {
|
||||
let (value, abbrevs) = parse_utils::split_by_comma(
|
||||
item_pattern,
|
||||
&format!(
|
||||
"The first argument must have pattern 'Value,Person_1[,Person_2,...]', but you have {}",
|
||||
item_pattern
|
||||
),
|
||||
)?;
|
||||
let value: Decimal = value.parse()?;
|
||||
let shared_by = self.align_to_shared_by(&abbrevs)?;
|
||||
// Todo: Add parsing of ratios specified in item names
|
||||
self.add_item_split_by_ratio(value, item_name.to_string(), shared_by, None)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use crate::core::receipt::{Receipt, SplittingError};
|
||||
use rust_decimal::prelude::*;
|
||||
|
||||
#[test]
|
||||
fn test_no_people_to_share_with() {
|
||||
let receipt = Receipt::parse_create_receipt("300,");
|
||||
assert!(matches!(
|
||||
receipt,
|
||||
Err(SplittingError::NotEnoughPeopleError(_))
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_only_one_person_to_share_with() {
|
||||
let receipt = Receipt::parse_create_receipt("300,Alice");
|
||||
assert!(matches!(
|
||||
receipt,
|
||||
Err(SplittingError::NotEnoughPeopleError(_))
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_decimal_total() {
|
||||
let receipt = Receipt::parse_create_receipt("wowza,Alice");
|
||||
assert!(matches!(
|
||||
receipt,
|
||||
Err(SplittingError::DecimalParsingError(_))
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_two_people() {
|
||||
let receipt = Receipt::parse_create_receipt("300,Alice,Sam").unwrap();
|
||||
assert_eq!(receipt.value, "300".parse::<Decimal>().unwrap());
|
||||
assert_eq!(receipt.shared_by, vec!["Alice", "Sam"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_duplicate_people() {
|
||||
let receipt = Receipt::parse_create_receipt("300,Alice,Sam,Alice");
|
||||
let _ = String::from("The provided names are duplicate.");
|
||||
assert!(matches!(
|
||||
receipt,
|
||||
Err(SplittingError::DuplicatePeopleError(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_duplicate_people_cased() {
|
||||
let receipt = Receipt::parse_create_receipt("300,Alice,Sam,alice").unwrap();
|
||||
assert_eq!(receipt.value, dec![300]);
|
||||
assert_eq!(receipt.shared_by, vec!["Alice", "Sam", "alice"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aligning_to_extant_shared_people_fail() {
|
||||
let mut receipt = Receipt::parse_create_receipt("300,Alice,Sam,Samuel").unwrap();
|
||||
let val = receipt.align_to_shared_by("Al,S,S");
|
||||
let _ = "The abbreviation string: Al,S,S has duplicates.".to_string();
|
||||
assert!(matches!(val, Err(SplittingError::InvalidAbbreviation(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aligning_to_extant_shared_people_pass() {
|
||||
let mut receipt = Receipt::parse_create_receipt("300,Alice,Sam,Samuel").unwrap();
|
||||
let val = receipt.align_to_shared_by("Al,S,Su").unwrap();
|
||||
assert_eq!(val, vec!["Alice", "Sam", "Samuel"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_aligning_to_extant_shared_people_different_order_pass() {
|
||||
let mut receipt = Receipt::parse_create_receipt("300,Alice,Sam,Samuel").unwrap();
|
||||
let val = receipt.align_to_shared_by("Su,Al,S").unwrap();
|
||||
assert_eq!(val, vec!["Samuel", "Alice", "Sam"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_person_by_abbreviation() {
|
||||
let mut receipt = Receipt::parse_create_receipt("300,Alice,Sam,Marshall").unwrap();
|
||||
receipt
|
||||
.parse_add_named_item("Caviar", "150,Al,S,M")
|
||||
.unwrap();
|
||||
receipt.parse_add_named_item("Drinks", "90,S,A").unwrap();
|
||||
assert_eq!(receipt.items[0].shared_by, vec!["Alice", "Sam", "Marshall"]);
|
||||
assert_eq!(receipt.items[1].shared_by, vec!["Sam", "Alice"]);
|
||||
|
||||
let val = receipt.parse_add_named_item("More Drinks", "10,S,Sa,Al");
|
||||
let _ = format!("Sa maps to Sam, which has already been specified once.");
|
||||
assert!(matches!(val, Err(SplittingError::InvalidAbbreviation(_))));
|
||||
}
|
||||
|
||||
// #[test]
|
||||
// fn add_tip_and_tax() {
|
||||
// let mut receipt = Receipt::parse_create_receipt("300,Alice,Sam,Marshall").unwrap();
|
||||
// receipt.parse_tip_or_tax(ItemType::Tip, "25").unwrap();
|
||||
// receipt.parse_tip_or_tax(ItemType::Tax, "35").unwrap();
|
||||
// assert_eq!(receipt.items[0].shared_by, vec!["Alice", "Sam", "Marshall"]);
|
||||
// assert_eq!(receipt.items[0].value, ItemType::Tip);
|
||||
// assert_eq!(receipt.items[0].value, Decimal::from_str("25").unwrap());
|
||||
// assert_eq!(receipt.items[1].shared_by, vec!["Alice", "Sam", "Marshall"]);
|
||||
// assert_eq!(receipt.items[1].item, ItemType::Tax);
|
||||
// assert_eq!(receipt.items[1].value, Decimal::from_str("35").unwrap());
|
||||
// }
|
||||
}
|
||||
11
src/cli/utils.rs
Normal file
11
src/cli/utils.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
use crate::core::receipt::SplittingError;
|
||||
|
||||
pub fn split_by_comma(
|
||||
input_str: &str,
|
||||
error_message: &str,
|
||||
) -> Result<(String, String), SplittingError> {
|
||||
input_str
|
||||
.split_once(",")
|
||||
.map(|(value, other)| (value.to_string(), other.to_string()))
|
||||
.ok_or_else(|| SplittingError::DecimalParsingError(error_message.to_string()))
|
||||
}
|
||||
1
src/core/mod.rs
Normal file
1
src/core/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod receipt;
|
||||
335
src/core/receipt.rs
Normal file
335
src/core/receipt.rs
Normal file
@@ -0,0 +1,335 @@
|
||||
use crate::utils;
|
||||
use rust_decimal::prelude::*;
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::HashMap;
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
|
||||
type Person = String;
|
||||
const LEFTOVER_ITEM_NAME: &'static str = "<leftover>";
|
||||
const TOTAL_ITEM_NAME: &'static str = "<total>";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Receipt {
|
||||
pub value: Decimal,
|
||||
pub shared_by: Vec<Person>,
|
||||
pub mapped_abbreviations: HashMap<Person, String>,
|
||||
pub items: Vec<ReceiptItem>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ReceiptItem {
|
||||
pub value: Decimal,
|
||||
pub name: String,
|
||||
pub shared_by: Vec<Person>,
|
||||
pub share_ratio: Vec<Decimal>,
|
||||
// is_proportionally_distributed
|
||||
pub is_prop_dist: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum SplittingError {
|
||||
DuplicatePeopleError(String),
|
||||
NotEnoughPeopleError(String),
|
||||
InvalidShareConfiguration(String),
|
||||
InvalidFieldError(String),
|
||||
InvalidAbbreviation(String),
|
||||
InternalError(String),
|
||||
ItemTotalExceedsReceiptTotal(String),
|
||||
DecimalParsingError(String),
|
||||
InvalidArgument(String),
|
||||
}
|
||||
|
||||
impl From<rust_decimal::Error> for SplittingError {
|
||||
fn from(e: rust_decimal::Error) -> SplittingError {
|
||||
SplittingError::DecimalParsingError(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
// Required for main and Box<dyn std::error::Error>> returns to not complain
|
||||
impl Error for SplittingError {}
|
||||
|
||||
impl fmt::Display for SplittingError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::DuplicatePeopleError(msg) => write!(f, "{}", msg),
|
||||
Self::NotEnoughPeopleError(msg) => write!(f, "{}", msg),
|
||||
Self::InvalidShareConfiguration(msg) => write!(f, "{}", msg),
|
||||
Self::InvalidFieldError(msg) => write!(f, "{}", msg),
|
||||
Self::InvalidAbbreviation(msg) => write!(f, "{}", msg),
|
||||
Self::InternalError(msg) => write!(f, "{}", msg),
|
||||
Self::ItemTotalExceedsReceiptTotal(msg) => write!(f, "{}", msg),
|
||||
Self::DecimalParsingError(msg) => write!(f, "{}", msg),
|
||||
Self::InvalidArgument(msg) => write!(f, "{}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Receipt {
|
||||
// Creates a new Receipt with just the (total) value and the people sharing it.
|
||||
// Mapping is defaulted to a new, empty HashMap.
|
||||
// Items is an empty vector.
|
||||
pub fn new(value: Decimal, shared_by: Vec<&str>) -> Result<Receipt, SplittingError> {
|
||||
utils::is_string_vec_unique(
|
||||
&shared_by,
|
||||
SplittingError::DuplicatePeopleError(
|
||||
"The list of people sharing the receipt is duplicated. Please disambiguate.".into(),
|
||||
),
|
||||
)?;
|
||||
utils::is_vec_len_gt_1(
|
||||
&shared_by,
|
||||
SplittingError::NotEnoughPeopleError(
|
||||
"A receipt has to be shared by at least 2 people.".into(),
|
||||
),
|
||||
)?;
|
||||
|
||||
Ok(Receipt {
|
||||
value,
|
||||
shared_by: shared_by.iter().map(|&x| x.to_string()).collect(),
|
||||
mapped_abbreviations: HashMap::new(),
|
||||
items: vec![],
|
||||
})
|
||||
}
|
||||
|
||||
pub fn add_item_split_by_ratio(
|
||||
&mut self,
|
||||
value: Decimal,
|
||||
name: String,
|
||||
shared_by: Vec<String>,
|
||||
share_ratio: Option<Vec<Decimal>>,
|
||||
) -> Result<&mut Self, SplittingError> {
|
||||
let share_ratio = share_ratio.unwrap_or(vec![Decimal::ONE; shared_by.len()]);
|
||||
|
||||
if shared_by.len() != share_ratio.len() {
|
||||
return Err(SplittingError::InvalidShareConfiguration(format!(
|
||||
"Length mismatch: people sharing {} and the ratios of the shares {} have differing lengths.",
|
||||
shared_by.len(),
|
||||
share_ratio.len()
|
||||
)));
|
||||
} else if shared_by.len() == 0 {
|
||||
return Err(SplittingError::NotEnoughPeopleError(format!(
|
||||
"The number of people sharing the item {} is {}. It must be shared by at least 1 person.",
|
||||
name,
|
||||
shared_by.len()
|
||||
)));
|
||||
}
|
||||
|
||||
if name.is_empty() {
|
||||
return Err(SplittingError::InvalidFieldError(
|
||||
"Item name cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
|
||||
self.items.push(ReceiptItem {
|
||||
value,
|
||||
name,
|
||||
shared_by,
|
||||
share_ratio,
|
||||
is_prop_dist: false,
|
||||
});
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
// Obtain a single vector with the exact splits, with or without items with
|
||||
// the is_prop_dist attribute as true
|
||||
fn calculate_overall_proportion(&self, remove_proportional_items: bool) -> Vec<Decimal> {
|
||||
let items = self
|
||||
.items
|
||||
.iter()
|
||||
.filter(|&x| !remove_proportional_items || !x.is_prop_dist);
|
||||
|
||||
let mut receipt_split: Vec<Decimal> = vec![Decimal::ZERO; self.shared_by.len()];
|
||||
for item in items {
|
||||
let denominator: Decimal = item.share_ratio.iter().sum();
|
||||
|
||||
// Split each item.value proportional to the share ratios of the people sharing
|
||||
// the item, in the order in which these people appear in self.shared_by
|
||||
let item_split: Vec<Decimal> = self
|
||||
.shared_by
|
||||
.iter()
|
||||
.map(|person| {
|
||||
item.shared_by
|
||||
.iter()
|
||||
.zip(item.share_ratio.iter())
|
||||
// The first match is all that is required because other operations guarantee
|
||||
// that duplicate names do not exist in either self.shared_by or item.shared_by
|
||||
.find(|&(sharer, _)| *person == *sharer)
|
||||
.map(|(_, &numerator)| (numerator / denominator * item.value))
|
||||
.unwrap_or_else(|| Decimal::ZERO)
|
||||
})
|
||||
.collect();
|
||||
|
||||
for (idx, split) in item_split.iter().enumerate() {
|
||||
receipt_split[idx] += split
|
||||
}
|
||||
}
|
||||
receipt_split
|
||||
}
|
||||
|
||||
pub fn add_item_split_by_proportion(
|
||||
&mut self,
|
||||
value: Decimal,
|
||||
name: String,
|
||||
shared_by: Vec<String>,
|
||||
) -> Result<&mut Self, SplittingError> {
|
||||
if shared_by.len() == 0 {
|
||||
return Err(SplittingError::NotEnoughPeopleError(format!(
|
||||
"The number of people sharing the item {} is currently {}. It must be shared by at least 1 person.",
|
||||
name,
|
||||
shared_by.len()
|
||||
)));
|
||||
} else if name.is_empty() {
|
||||
return Err(SplittingError::InvalidFieldError(
|
||||
"Item name cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// These vectors are aligned with the order of self.shared_by
|
||||
let pre_prop_splits = self.calculate_overall_proportion(true);
|
||||
let pre_prop_split_total: Decimal = pre_prop_splits.iter().sum();
|
||||
let pre_prop_ratios: Vec<Decimal> = pre_prop_splits
|
||||
.iter()
|
||||
.map(|x| x / pre_prop_split_total)
|
||||
.collect();
|
||||
|
||||
// This vector's length will be identical to shared_by
|
||||
let share_ratio: Vec<Decimal> = shared_by
|
||||
.iter()
|
||||
.map(|sharer| {
|
||||
pre_prop_ratios
|
||||
.iter()
|
||||
.zip(self.shared_by.iter())
|
||||
.find(|&(_, person)| *sharer == *person)
|
||||
.map(|(&ratio, _)| ratio * value)
|
||||
.ok_or_else(|| {
|
||||
SplittingError::InternalError(format!(
|
||||
"The sharer {} was not found among the original sharers.",
|
||||
sharer,
|
||||
))
|
||||
})
|
||||
})
|
||||
.collect::<Result<_, _>>()?;
|
||||
|
||||
self.items.push(ReceiptItem {
|
||||
value,
|
||||
name,
|
||||
shared_by,
|
||||
share_ratio,
|
||||
is_prop_dist: true,
|
||||
});
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
pub fn get_itemized_total_and_leftover(&self) -> (Decimal, Decimal) {
|
||||
let itemized_total: Decimal = self.items.iter().map(|x| x.value).sum();
|
||||
let leftover_amount: Decimal = self.value - itemized_total;
|
||||
(itemized_total, leftover_amount)
|
||||
}
|
||||
|
||||
// Get a vector of item names (including leftovers and totals), as well as the splits
|
||||
// by each item so that they can be eventually displayed in a table easily, or used
|
||||
// for any other purpose.
|
||||
pub fn calculate_splits(&self) -> Result<(Vec<&str>, Vec<Vec<Decimal>>), SplittingError> {
|
||||
// let itemized_total: Decimal = self.items.iter().map(|x| x.value).sum();
|
||||
// let leftover_amount: Decimal = self.value - itemized_total;
|
||||
let (itemized_total, leftover_amount) = self.get_itemized_total_and_leftover();
|
||||
match leftover_amount.cmp(&Decimal::ZERO) {
|
||||
// There is a problem only if the leftover amount is negative
|
||||
Ordering::Greater | Ordering::Equal => {}
|
||||
Ordering::Less => {
|
||||
return Err(SplittingError::ItemTotalExceedsReceiptTotal(format!(
|
||||
"The itemized total amount {} exceeds the receipt's total amount {} by {}",
|
||||
itemized_total, self.value, leftover_amount
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let mut all_splits: Vec<Vec<Decimal>> = Vec::new();
|
||||
|
||||
// Refactor needed - Receipts are short lived, so there is no point in
|
||||
// converting between ReceiptItem.shared_by and Receipt.shared_by - just
|
||||
// store shared_by in the same order as the receipt and display to the
|
||||
// user all the shared_by values that don't have 0 share ratio.
|
||||
for item in self.items.iter() {
|
||||
let mut splits: Vec<Decimal> = self
|
||||
.shared_by
|
||||
.iter()
|
||||
.map(|x| match item.shared_by.iter().position(|name| name == x) {
|
||||
Some(pos) => (item.value * item.share_ratio[pos]
|
||||
/ item.share_ratio.iter().sum::<Decimal>())
|
||||
.round_dp(2),
|
||||
None => Decimal::ZERO.round_dp(2),
|
||||
})
|
||||
.collect();
|
||||
splits.push(item.value);
|
||||
all_splits.push(splits);
|
||||
}
|
||||
|
||||
let mut item_names: Vec<&str> = self.items.iter().map(|x| x.name.as_str()).collect();
|
||||
|
||||
// Add unaccounted item, if present
|
||||
if leftover_amount > Decimal::ZERO {
|
||||
let overall_prop = self.calculate_overall_proportion(true);
|
||||
let overall_prop_sum: Decimal = overall_prop.iter().sum();
|
||||
let mut splits: Vec<Decimal> = overall_prop
|
||||
.iter()
|
||||
.map(|x| (x * leftover_amount / overall_prop_sum).round_dp(2))
|
||||
.collect();
|
||||
splits.push(leftover_amount);
|
||||
all_splits.push(splits);
|
||||
item_names.push(LEFTOVER_ITEM_NAME);
|
||||
}
|
||||
|
||||
// Range from 0 to len + 1 to account for total added at the end of each item's share
|
||||
let total_split: Vec<Decimal> = (0..(self.shared_by.len() + 1))
|
||||
.map(|i| all_splits.iter().map(|v| v[i]).sum::<Decimal>().round_dp(2))
|
||||
.collect();
|
||||
all_splits.push(total_split);
|
||||
item_names.push(TOTAL_ITEM_NAME);
|
||||
|
||||
Ok((item_names, all_splits))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::core::receipt::Receipt;
|
||||
use crate::utils;
|
||||
use rust_decimal::prelude::*;
|
||||
|
||||
fn f64s_to_decimals(values: &[f64]) -> Vec<Decimal> {
|
||||
values
|
||||
.iter()
|
||||
.map(|x| Decimal::from_f64(*x).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_calculate_splits() {
|
||||
let mut receipt = Receipt::new(dec![300], vec!["Alice", "Bob", "Marshall"]).unwrap();
|
||||
let _ = receipt
|
||||
.add_item_split_by_ratio(
|
||||
dec![200],
|
||||
"Food".into(),
|
||||
utils::strs_to_strings(vec!["Alice", "Bob", "Marshall"]),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = receipt
|
||||
.add_item_split_by_ratio(
|
||||
dec![50],
|
||||
"Drinks".into(),
|
||||
utils::strs_to_strings(vec!["Alice", "Bob"]),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let (_, expected_splits) = receipt.calculate_splits().unwrap();
|
||||
let actual_splits: Vec<Vec<Decimal>> = vec![
|
||||
f64s_to_decimals(&[66.67, 66.67, 66.67, 200.0]),
|
||||
f64s_to_decimals(&[25.0, 25.0, 0.0, 50.0]),
|
||||
f64s_to_decimals(&[18.33, 18.33, 13.33, 50.0]),
|
||||
f64s_to_decimals(&[110.0, 110.0, 80.0, 300.0]),
|
||||
];
|
||||
assert_eq!(expected_splits, actual_splits);
|
||||
}
|
||||
}
|
||||
6
src/lib.rs
Normal file
6
src/lib.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub mod app;
|
||||
pub mod core;
|
||||
pub mod utils;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod cli;
|
||||
6
src/main.rs
Normal file
6
src/main.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
use borrow_checker::app::frontend::App;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
fn main() {
|
||||
dioxus::launch(App);
|
||||
}
|
||||
40
src/utils/mod.rs
Normal file
40
src/utils/mod.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
pub fn is_string_vec_unique<E>(vec: &[&str], error: E) -> Result<bool, E> {
|
||||
let mut seen = HashSet::new();
|
||||
if vec.iter().all(|&s| seen.insert(s)) {
|
||||
Ok(true)
|
||||
} else {
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_vec_len_gt_1<E>(vec: &[&str], error: E) -> Result<bool, E> {
|
||||
if vec.is_empty() || vec.len() == 1 {
|
||||
Err(error)
|
||||
} else {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_abbrev_match_to_string(abbrev: &str, name: &str) -> bool {
|
||||
// Check if at least one or more characters provided as an "abbreviation"
|
||||
// are present in a string i.e. it is a valid abbreviation.
|
||||
abbrev.chars().all(|c| name.chars().any(|nc| nc == c))
|
||||
}
|
||||
|
||||
pub fn strs_to_strings(values: Vec<&str>) -> Vec<String> {
|
||||
values.iter().map(|x| x.to_string()).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_abbrev_match_to_string;
|
||||
|
||||
#[test]
|
||||
fn match_person_to_name() {
|
||||
assert_eq!(is_abbrev_match_to_string("Hn", "Hannah"), true);
|
||||
assert_eq!(is_abbrev_match_to_string("Hh", "Hannah"), true);
|
||||
assert_eq!(is_abbrev_match_to_string("Hb", "Hannah"), false);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user