diff options
| author | Joe Carstairs <me@joeac.net> | 2025-08-13 07:50:21 +0000 |
|---|---|---|
| committer | joeac <me@joeac.net> | 2025-08-13 07:50:21 +0000 |
| commit | ee3cd780e21260257b401c3f5cbababd9e27a15a (patch) | |
| tree | b0571649a1a1a7644dbc50183cc4c3a29966a963 /schist_desktop_gui/src/gui | |
| parent | 49342e8b553d5c92f1c043b3b0ce18bdf1c2cd44 (diff) | |
task-073
Co-authored-by: Joe Carstairs <jcarstairs@scottlogic.com>
Reviewed-on: https://git.joeac.net/joeac/schist/pulls/1
Co-authored-by: Joe Carstairs <me@joeac.net>
Co-committed-by: Joe Carstairs <me@joeac.net>
Diffstat (limited to 'schist_desktop_gui/src/gui')
20 files changed, 1211 insertions, 0 deletions
diff --git a/schist_desktop_gui/src/gui/components.rs b/schist_desktop_gui/src/gui/components.rs new file mode 100644 index 0000000..fc3c838 --- /dev/null +++ b/schist_desktop_gui/src/gui/components.rs @@ -0,0 +1,15 @@ +pub mod balances_view; +pub mod bucket_name_and_balance; +pub mod buckets_view; +pub mod button; +pub mod navigation; +pub mod text; +pub mod transactions_view; + +pub use balances_view::BalancesView; +pub use bucket_name_and_balance::BucketNameAndBalance; +pub use buckets_view::BucketsView; +pub use button::{active_button, inactive_button}; +pub use navigation::Navigation; +pub use text::Text; +pub use transactions_view::TransactionsView; diff --git a/schist_desktop_gui/src/gui/components/balances_view.rs b/schist_desktop_gui/src/gui/components/balances_view.rs new file mode 100644 index 0000000..2b72cd3 --- /dev/null +++ b/schist_desktop_gui/src/gui/components/balances_view.rs @@ -0,0 +1,63 @@ +mod make_balances_view_greeting; +mod update_balances_view; +mod view_balances_view; +mod view_group_names; + +use self::{ + update_balances_view::update_balances_view, view_balances_view::view_balances_view, + view_group_names::view_group_names, +}; + +use schist_models::bucket::Bucket; + +use crate::{ + gui::components::{ + balances_view::make_balances_view_greeting::make_balances_view_greeting, navigation, + Navigation, Text, + }, + traits::{Component, Viewable}, +}; + +#[derive(Clone, Debug)] +pub struct BalancesView { + buckets: Vec<Bucket>, + greeting: String, + navigation: Navigation<Text>, +} + +#[derive(Clone, Debug)] +pub enum Message { + NavigationMessage(navigation::Message<Text>), + SelectNextBucket, + SelectPrevBucket, + SetBuckets(Vec<Bucket>), +} + +pub enum Action { + None, +} + +impl BalancesView { + pub fn new(buckets: Vec<Bucket>) -> Self { + let groups = view_group_names(buckets.clone()); + let active_group = groups.clone().first().cloned(); + let navigation = Navigation::new(active_group.clone(), groups); + Self { + buckets: buckets.clone(), + greeting: make_balances_view_greeting(active_group), + navigation: navigation, + } + } +} + +impl<'a> Viewable<'a, Message> for BalancesView { + fn view(&'a self) -> iced::Element<'a, Message> { + view_balances_view(self) + } +} + +impl<'a> Component<'a, Message, Action> for BalancesView { + fn update(&mut self, message: Message) -> Action { + update_balances_view(self, message) + } +} diff --git a/schist_desktop_gui/src/gui/components/balances_view/make_balances_view_greeting.rs b/schist_desktop_gui/src/gui/components/balances_view/make_balances_view_greeting.rs new file mode 100644 index 0000000..4926046 --- /dev/null +++ b/schist_desktop_gui/src/gui/components/balances_view/make_balances_view_greeting.rs @@ -0,0 +1,9 @@ +use crate::gui::components::text::Text; + +pub fn make_balances_view_greeting(group_name: Option<Text>) -> String { + if let Some(group_name) = group_name { + format!("Hello, {} balances!", group_name.content) + } else { + String::from("Hello, balances!") + } +} diff --git a/schist_desktop_gui/src/gui/components/balances_view/update_balances_view.rs b/schist_desktop_gui/src/gui/components/balances_view/update_balances_view.rs new file mode 100644 index 0000000..100cbc9 --- /dev/null +++ b/schist_desktop_gui/src/gui/components/balances_view/update_balances_view.rs @@ -0,0 +1,121 @@ +use super::{ + make_balances_view_greeting::make_balances_view_greeting, view_group_names::view_group_names, + Action, BalancesView, Message, +}; +use crate::{ + gui::components::{navigation, Text}, + traits::Component, +}; + +pub fn update_balances_view(balances_view: &mut BalancesView, message: Message) -> Action { + match message { + Message::NavigationMessage(message) => update_navigation(balances_view, message), + Message::SetBuckets(buckets) => set_buckets(balances_view, buckets), + Message::SelectNextBucket => select_next_bucket(balances_view), + Message::SelectPrevBucket => select_prev_bucket(balances_view), + } +} + +fn select_prev_bucket(balances_view: &mut BalancesView) -> Action { + match balances_view + .navigation + .update(navigation::Message::SelectPrev) + { + navigation::Action::SelectOption(option) => { + balances_view.greeting = make_balances_view_greeting(Some(option)); + Action::None + } + navigation::Action::None => Action::None, + } +} + +fn select_next_bucket(balances_view: &mut BalancesView) -> Action { + match balances_view + .navigation + .update(navigation::Message::SelectNext) + { + navigation::Action::SelectOption(option) => { + balances_view.greeting = make_balances_view_greeting(Some(option)); + Action::None + } + navigation::Action::None => Action::None, + } +} + +fn set_buckets( + balances_view: &mut BalancesView, + buckets: Vec<schist_models::bucket::Bucket>, +) -> Action { + balances_view.buckets = buckets.clone(); + match balances_view + .navigation + .update(navigation::Message::SetOptions(view_group_names(buckets))) + { + navigation::Action::SelectOption(option) => { + balances_view.greeting = make_balances_view_greeting(Some(option)); + Action::None + } + navigation::Action::None => Action::None, + } +} + +fn update_navigation( + balances_view: &mut BalancesView, + message: navigation::Message<Text>, +) -> Action { + match balances_view.navigation.update(message) { + navigation::Action::SelectOption(group_name) => { + balances_view.greeting = make_balances_view_greeting(Some(group_name)); + Action::None + } + navigation::Action::None => Action::None, + } +} + +#[cfg(test)] +mod test { + use schist_fakes::bucket::make_fake_bucket_builder; + + use crate::{ + gui::components::{ + balances_view::{BalancesView, Message}, + navigation, + }, + traits::Component, + }; + + #[test] + fn update_navigation_updates_greeting() { + let groceries = make_fake_bucket_builder(0) + .group(String::from("normal expenses")) + .build() + .unwrap(); + let fragrance = make_fake_bucket_builder(1) + .group(String::from("luxuries")) + .build() + .unwrap(); + let maps = make_fake_bucket_builder(2) + .group(String::from("outdoors")) + .build() + .unwrap(); + let buckets = vec![groceries, fragrance.clone(), maps]; + + let mut balances_view = BalancesView::new(buckets); + assert_eq!("Hello, normal expenses balances!", balances_view.greeting); + + balances_view.update(Message::NavigationMessage(navigation::Message::SelectPrev)); + assert_eq!("Hello, normal expenses balances!", balances_view.greeting); + + balances_view.update(Message::NavigationMessage(navigation::Message::SelectPrev)); + assert_eq!("Hello, normal expenses balances!", balances_view.greeting); + + balances_view.update(Message::NavigationMessage(navigation::Message::SelectNext)); + assert_eq!("Hello, luxuries balances!", balances_view.greeting); + + balances_view.update(Message::NavigationMessage(navigation::Message::SelectNext)); + assert_eq!("Hello, outdoors balances!", balances_view.greeting); + + balances_view.update(Message::NavigationMessage(navigation::Message::SelectNext)); + assert_eq!("Hello, outdoors balances!", balances_view.greeting); + } +} diff --git a/schist_desktop_gui/src/gui/components/balances_view/view_balances_view.rs b/schist_desktop_gui/src/gui/components/balances_view/view_balances_view.rs new file mode 100644 index 0000000..e8a1e46 --- /dev/null +++ b/schist_desktop_gui/src/gui/components/balances_view/view_balances_view.rs @@ -0,0 +1,22 @@ +use iced::widget::{column, row, Container}; +use iced::{alignment, Element, Length}; + +use crate::gui::components::{balances_view::Message, BalancesView}; +use crate::style::SPACING_LG; +use crate::traits::Viewable; + +pub fn view_balances_view<'a>(balances_view: &'a BalancesView) -> Element<'a, Message> { + let navigation = balances_view + .navigation + .view() + .map(Message::NavigationMessage); + let main_content = column![iced::widget::text(balances_view.greeting.clone())]; + row![ + Container::new(navigation).width(Length::FillPortion(1)), + Container::new(main_content).width(Length::FillPortion(3)), + ] + .align_y(alignment::Vertical::Center) + .height(Length::Fill) + .spacing(SPACING_LG) + .into() +} diff --git a/schist_desktop_gui/src/gui/components/balances_view/view_group_names.rs b/schist_desktop_gui/src/gui/components/balances_view/view_group_names.rs new file mode 100644 index 0000000..02900e9 --- /dev/null +++ b/schist_desktop_gui/src/gui/components/balances_view/view_group_names.rs @@ -0,0 +1,143 @@ +use itertools::Itertools; +use schist_models::bucket::Bucket; + +use crate::gui::components::Text; + +pub fn view_group_names(buckets: Vec<Bucket>) -> Vec<Text> { + buckets + .iter() + .map(|b| b.group.clone()) + .unique() + .map(Text::new) + .collect() +} + +#[cfg(test)] +mod test { + use schist_fakes::bucket::make_fake_bucket_builder; + + use crate::gui::components::{balances_view::view_group_names::view_group_names, text::Text}; + + #[test] + fn preserves_order() { + let buckets = vec![ + make_fake_bucket_builder(0) + .group(String::from("hydrogen")) + .build() + .unwrap(), + make_fake_bucket_builder(1) + .group(String::from("helium")) + .build() + .unwrap(), + make_fake_bucket_builder(2) + .group(String::from("lithium")) + .build() + .unwrap(), + make_fake_bucket_builder(3) + .group(String::from("beryllium")) + .build() + .unwrap(), + make_fake_bucket_builder(4) + .group(String::from("boron")) + .build() + .unwrap(), + make_fake_bucket_builder(5) + .group(String::from("carbon")) + .build() + .unwrap(), + make_fake_bucket_builder(6) + .group(String::from("nitrogen")) + .build() + .unwrap(), + make_fake_bucket_builder(7) + .group(String::from("oxygen")) + .build() + .unwrap(), + make_fake_bucket_builder(8) + .group(String::from("flourine")) + .build() + .unwrap(), + make_fake_bucket_builder(8) + .group(String::from("flourine")) + .build() + .unwrap(), + make_fake_bucket_builder(8) + .group(String::from("neon")) + .build() + .unwrap(), + make_fake_bucket_builder(8) + .group(String::from("magnesium")) + .build() + .unwrap(), + make_fake_bucket_builder(8) + .group(String::from("aluminium")) + .build() + .unwrap(), + make_fake_bucket_builder(8) + .group(String::from("silicon")) + .build() + .unwrap(), + make_fake_bucket_builder(8) + .group(String::from("phosphorus")) + .build() + .unwrap(), + make_fake_bucket_builder(8) + .group(String::from("sulphur")) + .build() + .unwrap(), + make_fake_bucket_builder(8) + .group(String::from("chlorine")) + .build() + .unwrap(), + make_fake_bucket_builder(8) + .group(String::from("argon")) + .build() + .unwrap(), + ]; + let group_names = view_group_names(buckets); + assert_eq!(17, group_names.len()); + assert_eq!(group_names[0], Text::new(String::from("hydrogen"))); + assert_eq!(group_names[1], Text::new(String::from("helium"))); + assert_eq!(group_names[2], Text::new(String::from("lithium"))); + assert_eq!(group_names[3], Text::new(String::from("beryllium"))); + assert_eq!(group_names[4], Text::new(String::from("boron"))); + assert_eq!(group_names[5], Text::new(String::from("carbon"))); + assert_eq!(group_names[6], Text::new(String::from("nitrogen"))); + assert_eq!(group_names[7], Text::new(String::from("oxygen"))); + assert_eq!(group_names[8], Text::new(String::from("flourine"))); + assert_eq!(group_names[9], Text::new(String::from("neon"))); + assert_eq!(group_names[10], Text::new(String::from("magnesium"))); + assert_eq!(group_names[11], Text::new(String::from("aluminium"))); + assert_eq!(group_names[12], Text::new(String::from("silicon"))); + assert_eq!(group_names[13], Text::new(String::from("phosphorus"))); + assert_eq!(group_names[14], Text::new(String::from("sulphur"))); + assert_eq!(group_names[15], Text::new(String::from("chlorine"))); + assert_eq!(group_names[16], Text::new(String::from("argon"))); + } + + #[test] + fn removes_duplicates() { + let buckets = vec![ + make_fake_bucket_builder(0) + .group(String::from("hydrogen")) + .build() + .unwrap(), + make_fake_bucket_builder(1) + .group(String::from("helium")) + .build() + .unwrap(), + make_fake_bucket_builder(2) + .group(String::from("hydrogen")) + .build() + .unwrap(), + ]; + let group_names = view_group_names(buckets); + assert_eq!( + vec![ + Text::new(String::from("hydrogen")), + Text::new(String::from("helium")), + ], + group_names + ); + } +} diff --git a/schist_desktop_gui/src/gui/components/bucket_name_and_balance.rs b/schist_desktop_gui/src/gui/components/bucket_name_and_balance.rs new file mode 100644 index 0000000..fa2dcac --- /dev/null +++ b/schist_desktop_gui/src/gui/components/bucket_name_and_balance.rs @@ -0,0 +1,24 @@ +use iced::{ + widget::{row, text}, + Element, +}; +use schist_models::bucket::Bucket; + +use crate::gui::components::navigation; + +#[derive(Clone, Debug, PartialEq)] +pub struct BucketNameAndBalance { + pub bucket: Bucket, +} + +impl BucketNameAndBalance { + pub fn new(bucket: Bucket) -> Self { + Self { bucket } + } +} + +impl<'a> Into<Element<'a, navigation::Message<BucketNameAndBalance>>> for BucketNameAndBalance { + fn into(self) -> Element<'a, navigation::Message<BucketNameAndBalance>> { + row![text(self.bucket.name.clone()), text(self.bucket.balance)].into() + } +} diff --git a/schist_desktop_gui/src/gui/components/buckets_view.rs b/schist_desktop_gui/src/gui/components/buckets_view.rs new file mode 100644 index 0000000..d27b7ff --- /dev/null +++ b/schist_desktop_gui/src/gui/components/buckets_view.rs @@ -0,0 +1,105 @@ +use iced::{ + alignment, + widget::{column, row, Container}, + Length, +}; +use schist_models::bucket::Bucket; + +use crate::{ + gui::components::{navigation, BucketNameAndBalance, Navigation}, + style::SPACING_LG, + traits::{Component, Viewable}, +}; + +#[derive(Clone, Debug)] +pub struct BucketsView { + buckets: Vec<Bucket>, + greeting: String, + navigation: Navigation<BucketNameAndBalance>, +} + +#[derive(Clone, Debug)] +pub enum Message { + NavigationMessage(navigation::Message<BucketNameAndBalance>), + SelectNextBucket, + SelectPrevBucket, + SetBuckets(Vec<Bucket>), +} + +pub enum Action { + None, +} + +impl BucketsView { + pub fn new(buckets: Vec<Bucket>, greeting: &str) -> Self { + Self { + buckets: buckets.clone(), + greeting: greeting.to_owned(), + navigation: Navigation::new( + buckets.first().cloned().map(BucketNameAndBalance::new), + buckets.into_iter().map(BucketNameAndBalance::new).collect(), + ), + } + } + + fn update_greeting(&mut self, active_bucket: &Bucket) { + self.greeting = format!("Hello, {} bucket!", active_bucket.name); + } +} + +impl<'a> Viewable<'a, Message> for BucketsView { + fn view(&'a self) -> iced::Element<'a, Message> + where + Message: 'a, + { + let navigation = self.navigation.view().map(Message::NavigationMessage); + let main_content = column![iced::widget::text(self.greeting.clone())]; + row![ + Container::new(navigation).width(Length::FillPortion(1)), + Container::new(main_content).width(Length::FillPortion(3)), + ] + .align_y(alignment::Vertical::Center) + .height(Length::Fill) + .spacing(SPACING_LG) + .into() + } +} + +impl<'a> Component<'a, Message, Action> for BucketsView { + fn update(&mut self, message: Message) -> Action { + match message { + Message::SetBuckets(buckets) => { + self.buckets = buckets.clone(); + self.navigation.update(navigation::Message::SetOptions( + buckets.into_iter().map(BucketNameAndBalance::new).collect(), + )); + Action::None + } + Message::NavigationMessage(message) => match self.navigation.update(message) { + navigation::Action::SelectOption(bucket) => { + self.update_greeting(&bucket.bucket); + Action::None + } + navigation::Action::None => Action::None, + }, + Message::SelectNextBucket => { + match self.navigation.update(navigation::Message::SelectNext) { + navigation::Action::SelectOption(bucket) => { + self.update_greeting(&bucket.bucket); + Action::None + } + navigation::Action::None => Action::None, + } + } + Message::SelectPrevBucket => { + match self.navigation.update(navigation::Message::SelectPrev) { + navigation::Action::SelectOption(bucket) => { + self.update_greeting(&bucket.bucket); + Action::None + } + navigation::Action::None => Action::None, + } + } + } + } +} diff --git a/schist_desktop_gui/src/gui/components/button.rs b/schist_desktop_gui/src/gui/components/button.rs new file mode 100644 index 0000000..bc9e5f9 --- /dev/null +++ b/schist_desktop_gui/src/gui/components/button.rs @@ -0,0 +1,24 @@ +use iced::{widget::Button, Element}; + +pub fn active_button<'a, Content: Into<Element<'a, Message>>, Message>( + content: Content, +) -> Button<'a, Message> { + iced::widget::button(content) + .style(|theme: &iced::Theme, _status| iced::widget::button::Style { + background: Some(iced::Background::Color( + theme.extended_palette().primary.base.text, + )), + text_color: theme.extended_palette().primary.base.color, + ..Default::default() + }) + .width(iced::Length::Fill) +} + +pub fn inactive_button<'a, Content: Into<Element<'a, Message>>, Message>( + content: Content, + on_press: Message, +) -> Button<'a, Message> { + iced::widget::button(content) + .on_press(on_press) + .width(iced::Length::Fill) +} diff --git a/schist_desktop_gui/src/gui/components/navigation.rs b/schist_desktop_gui/src/gui/components/navigation.rs new file mode 100644 index 0000000..bed3221 --- /dev/null +++ b/schist_desktop_gui/src/gui/components/navigation.rs @@ -0,0 +1,68 @@ +mod new_navigation; +mod update_navigation; +mod view_navigation; +mod view_navigation_button; + +use new_navigation::new_navigation; +use update_navigation::update_navigation; +use view_navigation::view_navigation; +use view_navigation_button::view_navigation_button; + +use std::fmt::Debug; + +use iced::Element; + +use crate::traits::{Component, Viewable}; + +#[derive(Clone, Debug)] +pub struct Navigation<TOption> { + options_before_active: Vec<TOption>, + active_option: Option<TOption>, + options_after_active: Vec<TOption>, +} + +#[derive(Clone, Debug)] +pub enum Message<TOption> { + SelectNext, + SelectOption(TOption), + SelectPrev, + SetOptions(Vec<TOption>), +} + +pub enum Action<TOption> { + SelectOption(TOption), + None, +} + +impl<TOption: Clone + Debug + PartialEq> Navigation<TOption> { + pub fn new(active_option: Option<TOption>, options: Vec<TOption>) -> Self { + new_navigation(active_option, options) + } +} + +impl<'a, TOption> Navigation<TOption> +where + TOption: Clone + PartialEq + Into<Element<'a, Message<TOption>>>, +{ + pub fn button(&'a self, option: &'a TOption) -> Element<'a, Message<TOption>> { + view_navigation_button(self, option) + } +} + +impl<'a, TOption> Viewable<'a, Message<TOption>> for Navigation<TOption> +where + TOption: Clone + PartialEq + Into<Element<'a, Message<TOption>>>, +{ + fn view(&'a self) -> iced::Element<'a, Message<TOption>> { + view_navigation(self) + } +} + +impl<'a, TOption> Component<'a, Message<TOption>, Action<TOption>> for Navigation<TOption> +where + TOption: Clone + PartialEq + Into<Element<'a, Message<TOption>>>, +{ + fn update(&mut self, message: Message<TOption>) -> Action<TOption> { + update_navigation(self, message) + } +} diff --git a/schist_desktop_gui/src/gui/components/navigation/new_navigation.rs b/schist_desktop_gui/src/gui/components/navigation/new_navigation.rs new file mode 100644 index 0000000..b5c0c19 --- /dev/null +++ b/schist_desktop_gui/src/gui/components/navigation/new_navigation.rs @@ -0,0 +1,72 @@ +use super::Navigation; + +pub fn new_navigation<TOption>( + active_option: Option<TOption>, + options: Vec<TOption>, +) -> Navigation<TOption> +where + TOption: Clone + PartialEq, +{ + let active_option_index = active_option.clone().map_or(Option::None, |active_option| { + options.iter().position(|option| option.eq(&active_option)) + }); + if let Some(active_option_index) = active_option_index { + let split = options.split_at(active_option_index); + Navigation { + options_before_active: split.0.to_vec(), + active_option, + options_after_active: split.1.split_at(1).1.to_vec(), + } + } else { + Navigation { + options_before_active: options, + active_option, + options_after_active: vec![], + } + } +} + +#[cfg(test)] +mod test { + use super::Navigation; + + #[test] + fn when_active_option_at_start_of_options_then_finds_active_option() { + let navigation = Navigation::new(Some(1), vec![1, 2, 3, 4, 5]); + assert_eq!(Vec::<i32>::new(), navigation.options_before_active); + assert_eq!(Some(1), navigation.active_option); + assert_eq!(vec![2, 3, 4, 5], navigation.options_after_active); + } + + #[test] + fn when_active_option_in_middle_of_options_then_finds_active_option() { + let navigation = Navigation::new(Some(3), vec![1, 2, 3, 4, 5]); + assert_eq!(vec![1, 2], navigation.options_before_active); + assert_eq!(Some(3), navigation.active_option); + assert_eq!(vec![4, 5], navigation.options_after_active); + } + + #[test] + fn when_active_option_at_end_of_options_then_finds_active_option() { + let navigation = Navigation::new(Some(5), vec![1, 2, 3, 4, 5]); + assert_eq!(vec![1, 2, 3, 4], navigation.options_before_active); + assert_eq!(Some(5), navigation.active_option); + assert_eq!(Vec::<i32>::new(), navigation.options_after_active); + } + + #[test] + fn when_active_option_not_in_options_then_appends_active_option() { + let navigation = Navigation::new(Some(6), vec![1, 2, 3, 4, 5]); + assert_eq!(vec![1, 2, 3, 4, 5], navigation.options_before_active); + assert_eq!(Some(6), navigation.active_option); + assert_eq!(Vec::<i32>::new(), navigation.options_after_active); + } + + #[test] + fn when_no_active_option_then_all_options_are_before_active_option() { + let navigation = Navigation::new(None, vec![1, 2, 3, 4, 5]); + assert_eq!(vec![1, 2, 3, 4, 5], navigation.options_before_active); + assert_eq!(None, navigation.active_option); + assert_eq!(Vec::<i32>::new(), navigation.options_after_active); + } +} diff --git a/schist_desktop_gui/src/gui/components/navigation/update_navigation.rs b/schist_desktop_gui/src/gui/components/navigation/update_navigation.rs new file mode 100644 index 0000000..f86867f --- /dev/null +++ b/schist_desktop_gui/src/gui/components/navigation/update_navigation.rs @@ -0,0 +1,128 @@ +use iced::Element; + +use super::{Action, Message, Navigation}; + +pub fn update_navigation<'a, TOption>( + navigation: &mut Navigation<TOption>, + message: Message<TOption>, +) -> Action<TOption> +where + TOption: Clone + PartialEq + Into<Element<'a, Message<TOption>>>, +{ + match message { + Message::SelectOption(option) => { + if navigation + .active_option + .as_ref() + .is_some_and(|active_option| active_option.clone() == option) + { + Action::None + } else if let Some(index) = navigation + .options_before_active + .iter() + .position(|o| o.clone() == option) + { + let split = navigation.options_before_active.split_at(index); + navigation.options_after_active = vec![ + navigation + .active_option + .clone() + .map_or(vec![], |active_option| vec![active_option]), + split.1.split_at(1).1.to_vec(), + navigation.options_after_active.clone(), + ] + .concat(); + navigation.options_before_active = split.0.to_vec(); + navigation.active_option = Some(option.clone()); + Action::SelectOption(option) + } else if let Some(index) = navigation + .options_after_active + .iter() + .position(|o| o.clone() == option) + { + let split = navigation.options_after_active.split_at(index); + navigation.options_before_active = vec![ + navigation.options_before_active.clone(), + split.0.to_vec(), + navigation + .active_option + .clone() + .map_or(vec![], |active_option| vec![active_option]), + ] + .concat(); + navigation.active_option = Some(option.clone()); + navigation.options_after_active = split.1.split_at(1).1.to_vec(); + Action::SelectOption(option) + } else { + if let Some(active_option) = &navigation.active_option { + navigation.options_before_active.push(active_option.clone()); + } + navigation + .options_before_active + .append(&mut navigation.options_after_active); + navigation.active_option = Some(option.clone()); + navigation.options_after_active = vec![]; + Action::SelectOption(option) + } + } + Message::SetOptions(options) => { + let active_option_index = + navigation + .active_option + .clone() + .map_or(Option::None, |active_option| { + options + .iter() + .position(|option| active_option == option.clone()) + }); + if let Some(active_option_index) = active_option_index { + let split = options.split_at(active_option_index); + navigation.options_before_active = split.0.to_vec(); + navigation.options_after_active = split.1.split_at(1).1.to_vec(); + Action::None + } else { + navigation.options_before_active = options + .split_last() + .map_or_else(Vec::new, |(_last, rest)| rest.to_vec()); + navigation.active_option = options.last().cloned(); + options + .last() + .cloned() + .map_or(Action::None, Action::SelectOption) + } + } + Message::SelectNext => { + if let Some(next) = navigation.options_after_active.clone().first() { + navigation.options_before_active = vec![ + navigation.options_before_active.clone(), + navigation.active_option.clone().map_or(vec![], |o| vec![o]), + ] + .concat(); + navigation.active_option = Some(next.clone()); + navigation.options_after_active = + navigation.options_after_active.split_at(1).1.to_vec(); + Action::SelectOption(next.clone()) + } else { + Action::None + } + } + Message::SelectPrev => { + if let Some(prev) = navigation.options_before_active.clone().last() { + navigation.options_after_active = vec![ + navigation.active_option.clone().map_or(vec![], |o| vec![o]), + navigation.options_after_active.clone(), + ] + .concat(); + navigation.active_option = Some(prev.clone()); + navigation.options_before_active = navigation + .options_before_active + .split_last() + .map(|split| split.1.to_vec()) + .unwrap_or_else(Vec::new); + Action::SelectOption(prev.clone()) + } else { + Action::None + } + } + } +} diff --git a/schist_desktop_gui/src/gui/components/navigation/view_navigation.rs b/schist_desktop_gui/src/gui/components/navigation/view_navigation.rs new file mode 100644 index 0000000..beec818 --- /dev/null +++ b/schist_desktop_gui/src/gui/components/navigation/view_navigation.rs @@ -0,0 +1,26 @@ +use iced::{widget::column, Element}; + +use super::{Message, Navigation}; + +pub fn view_navigation<'a, TOption>( + navigation: &'a Navigation<TOption>, +) -> Element<'a, Message<TOption>> +where + TOption: Clone + PartialEq + Into<Element<'a, Message<TOption>>>, +{ + let mut options = Vec::new(); + navigation + .options_before_active + .iter() + .map(|option| navigation.button(&option)) + .for_each(|option| options.push(option)); + if let Some(active_option) = navigation.active_option.as_ref() { + options.push(navigation.button(&active_option)); + } + navigation + .options_after_active + .iter() + .map(|option| navigation.button(&option)) + .for_each(|option| options.push(option)); + column(options).into() +} diff --git a/schist_desktop_gui/src/gui/components/navigation/view_navigation_button.rs b/schist_desktop_gui/src/gui/components/navigation/view_navigation_button.rs new file mode 100644 index 0000000..3132bf1 --- /dev/null +++ b/schist_desktop_gui/src/gui/components/navigation/view_navigation_button.rs @@ -0,0 +1,27 @@ +use iced::Element; + +use crate::gui::components::{active_button, inactive_button}; + +use super::{Message, Navigation}; + +pub fn view_navigation_button<'a, TOption>( + navigation: &'a Navigation<TOption>, + option: &'a TOption, +) -> Element<'a, Message<TOption>> +where + TOption: Clone + PartialEq + Into<Element<'a, Message<TOption>>>, +{ + if navigation + .active_option + .as_ref() + .is_some_and(|o| *o == *option) + { + active_button(Into::<Element<'a, Message<TOption>>>::into(option.clone())).into() + } else { + inactive_button( + Into::<Element<'a, Message<TOption>>>::into(option.clone()), + Message::SelectOption(option.clone()), + ) + .into() + } +} diff --git a/schist_desktop_gui/src/gui/components/text.rs b/schist_desktop_gui/src/gui/components/text.rs new file mode 100644 index 0000000..d334de3 --- /dev/null +++ b/schist_desktop_gui/src/gui/components/text.rs @@ -0,0 +1,22 @@ +use iced::Element; + +use crate::gui::components::navigation; + +#[derive(Clone, Debug, PartialEq)] +pub struct Text { + pub content: String, +} + +impl Text { + pub fn new(group_name: String) -> Self { + Self { + content: group_name.clone(), + } + } +} + +impl<'a> Into<Element<'a, navigation::Message<Text>>> for Text { + fn into(self) -> Element<'a, navigation::Message<Text>> { + iced::widget::text(self.content.clone()).into() + } +} diff --git a/schist_desktop_gui/src/gui/components/transactions_view.rs b/schist_desktop_gui/src/gui/components/transactions_view.rs new file mode 100644 index 0000000..6181a14 --- /dev/null +++ b/schist_desktop_gui/src/gui/components/transactions_view.rs @@ -0,0 +1,118 @@ +use iced::{ + alignment, + widget::{column, row, Container}, + Element, Length, +}; +use schist_models::account::Account; + +use crate::{ + gui::components::{navigation, Navigation, Text}, + style::SPACING_LG, + traits::{Component, Viewable}, +}; + +#[derive(Clone, Debug)] +pub struct TransactionsView { + accounts: Vec<Account>, + greeting: String, + navigation: Navigation<Text>, +} + +#[derive(Clone, Debug)] +pub enum Message { + NavigationMessage(navigation::Message<Text>), + SelectNextTransaction, + SelectPrevTransaction, + SetAccounts(Vec<Account>), +} + +pub enum Action { + None, +} + +impl TransactionsView { + pub fn new(accounts: Vec<Account>, greeting: &str) -> Self { + let account_names = view_account_names(accounts.clone()); + let navigation = Navigation::new(account_names.clone().first().cloned(), account_names); + Self { + accounts: accounts, + greeting: greeting.to_owned(), + navigation: navigation, + } + } +} + +impl<'a> Viewable<'a, Message> for TransactionsView { + fn view(&self) -> Element<Message> { + let navigation = self.navigation.view().map(Message::NavigationMessage); + let main_content = column![iced::widget::text(self.greeting.clone())]; + row![ + Container::new(navigation).width(Length::FillPortion(1)), + Container::new(main_content).width(Length::FillPortion(3)), + ] + .align_y(alignment::Vertical::Center) + .height(Length::Fill) + .spacing(SPACING_LG) + .into() + } +} + +impl<'a> Component<'a, Message, Action> for TransactionsView { + fn update(&mut self, message: Message) -> Action { + match message { + Message::NavigationMessage(message) => { + match self.navigation.update(message) { + navigation::Action::SelectOption(group_name) => { + self.greeting = make_greeting(group_name); + Action::None + } + navigation::Action::None => Action::None, + }; + Action::None + } + Message::SetAccounts(accounts) => { + self.accounts = accounts.clone(); + match self + .navigation + .update(navigation::Message::SetOptions(view_account_names( + accounts, + ))) { + navigation::Action::SelectOption(option) => { + self.greeting = make_greeting(option); + Action::None + } + navigation::Action::None => Action::None, + } + } + Message::SelectNextTransaction => { + match self.navigation.update(navigation::Message::SelectNext) { + navigation::Action::SelectOption(option) => { + self.greeting = make_greeting(option); + Action::None + } + navigation::Action::None => Action::None, + } + } + Message::SelectPrevTransaction => { + match self.navigation.update(navigation::Message::SelectPrev) { + navigation::Action::SelectOption(option) => { + self.greeting = make_greeting(option); + Action::None + } + navigation::Action::None => Action::None, + } + } + } + } +} + +fn make_greeting(account_name: Text) -> String { + format!("Hello, {} account!", account_name.content) +} + +fn view_account_names(accounts: Vec<Account>) -> Vec<Text> { + accounts + .iter() + .map(|acc| Text::new(acc.name.clone())) + .collect() +} diff --git a/schist_desktop_gui/src/gui/screens.rs b/schist_desktop_gui/src/gui/screens.rs new file mode 100644 index 0000000..3ae97dd --- /dev/null +++ b/schist_desktop_gui/src/gui/screens.rs @@ -0,0 +1,9 @@ +pub mod main_screen; + +pub use main_screen::MainScreen; + +#[derive(Default)] +pub enum Screen { + #[default] + Main, +} diff --git a/schist_desktop_gui/src/gui/screens/main_screen.rs b/schist_desktop_gui/src/gui/screens/main_screen.rs new file mode 100644 index 0000000..21ef44a --- /dev/null +++ b/schist_desktop_gui/src/gui/screens/main_screen.rs @@ -0,0 +1,7 @@ +mod main_screen; +mod view; + +pub use main_screen::Action; +pub use main_screen::MainScreen; +pub use main_screen::Message; +pub use view::View; diff --git a/schist_desktop_gui/src/gui/screens/main_screen/main_screen.rs b/schist_desktop_gui/src/gui/screens/main_screen/main_screen.rs new file mode 100644 index 0000000..bad3dee --- /dev/null +++ b/schist_desktop_gui/src/gui/screens/main_screen/main_screen.rs @@ -0,0 +1,160 @@ +use super::View; +use iced::{ + alignment, + widget::{row, Container}, + Element, Length, +}; +use schist_models::{account::Account, bucket::Bucket}; + +use crate::{ + gui::components::{ + balances_view, buckets_view, navigation, transactions_view, BalancesView, BucketsView, + Navigation, TransactionsView, + }, + style::SPACING_LG, + traits::{Component, Viewable}, +}; + +pub struct MainScreen { + pub active_view: View, + pub buckets: Vec<Bucket>, + pub balances_view: BalancesView, + pub buckets_view: BucketsView, + pub transactions_view: TransactionsView, + pub view_navigation: Navigation<View>, +} + +#[derive(Clone, Debug)] +pub enum Message { + BalancesViewMessage(balances_view::Message), + BucketsViewMessage(buckets_view::Message), + NextItem, + PrevItem, + TransactionsViewMessage(transactions_view::Message), + SelectView(View), + SetAccounts(Vec<Account>), + SetBuckets(Vec<Bucket>), + ViewNavigationMessage(navigation::Message<View>), +} + +pub enum Action { + None, +} + +impl<'a> Viewable<'a, Message> for MainScreen { + fn view(&'a self) -> Element<'a, Message> { + let view_navigation = self + .view_navigation + .view() + .map(Message::ViewNavigationMessage); + let view = match &self.active_view { + View::Balances => self.balances_view.view().map(Message::BalancesViewMessage), + View::Buckets => self.buckets_view.view().map(Message::BucketsViewMessage), + View::Transactions => self + .transactions_view + .view() + .map(Message::TransactionsViewMessage), + }; + + row![ + Container::new(view_navigation).width(Length::FillPortion(1)), + Container::new(view).width(Length::FillPortion(4)), + ] + .align_y(alignment::Vertical::Center) + .height(Length::Fill) + .spacing(SPACING_LG) + .into() + } +} + +impl<'a> Component<'a, Message, Action> for MainScreen { + fn update(&mut self, message: Message) -> Action { + match message { + Message::SelectView(view) => { + self.active_view = view; + self.view_navigation + .update(navigation::Message::SelectOption(view)); + Action::None + } + Message::BalancesViewMessage(message) => match self.balances_view.update(message) { + balances_view::Action::None => Action::None, + }, + Message::BucketsViewMessage(message) => { + self.buckets_view.update(message); + Action::None + } + Message::TransactionsViewMessage(message) => { + match self.transactions_view.update(message) { + transactions_view::Action::None => Action::None, + } + } + Message::ViewNavigationMessage(message) => match self.view_navigation.update(message) { + navigation::Action::SelectOption(view) => { + self.active_view = view; + Action::None + } + navigation::Action::None => Action::None, + }, + Message::SetBuckets(buckets) => { + self.buckets = buckets.clone(); + self.balances_view + .update(balances_view::Message::SetBuckets(buckets.clone())); + self.buckets_view + .update(buckets_view::Message::SetBuckets(buckets)); + Action::None + } + Message::SetAccounts(accounts) => { + self.transactions_view + .update(transactions_view::Message::SetAccounts(accounts)); + Action::None + } + Message::NextItem => match self.active_view { + View::Balances => { + self.balances_view + .update(balances_view::Message::SelectNextBucket); + Action::None + } + View::Buckets => { + self.buckets_view + .update(buckets_view::Message::SelectNextBucket); + Action::None + } + View::Transactions => { + self.transactions_view + .update(transactions_view::Message::SelectNextTransaction); + Action::None + } + }, + Message::PrevItem => match self.active_view { + View::Balances => { + self.balances_view + .update(balances_view::Message::SelectPrevBucket); + Action::None + } + View::Buckets => { + self.buckets_view + .update(buckets_view::Message::SelectPrevBucket); + Action::None + } + View::Transactions => { + self.transactions_view + .update(transactions_view::Message::SelectPrevTransaction); + Action::None + } + }, + } + } +} + +impl MainScreen { + pub fn new(buckets: Vec<Bucket>, accounts: Vec<Account>) -> Self { + Self { + balances_view: BalancesView::new(buckets.clone()), + buckets: buckets.clone(), + buckets_view: BucketsView::new(buckets, "Hello, buckets!"), + transactions_view: TransactionsView::new(accounts, "Hello, transactions!"), + active_view: View::Balances, + view_navigation: Navigation::new(Some(View::Balances), View::views()), + } + } +} diff --git a/schist_desktop_gui/src/gui/screens/main_screen/view.rs b/schist_desktop_gui/src/gui/screens/main_screen/view.rs new file mode 100644 index 0000000..eb9c88e --- /dev/null +++ b/schist_desktop_gui/src/gui/screens/main_screen/view.rs @@ -0,0 +1,48 @@ +use iced::Element; + +use crate::gui::components::navigation; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum View { + Balances, + Buckets, + Transactions, +} + +impl View { + pub const VIEW_1: View = View::Buckets; + pub const VIEW_2: View = View::Balances; + pub const VIEW_3: View = View::Transactions; + + pub fn views() -> Vec<View> { + vec![Self::VIEW_1, Self::VIEW_2, Self::VIEW_3] + } + + pub const fn name(self) -> &'static str { + match self { + View::Balances => "balances", + View::Buckets => "buckets", + View::Transactions => "transactions", + } + } + + pub fn next(&self) -> View { + match *self { + Self::VIEW_1 => Self::VIEW_2, + Self::VIEW_2 | Self::VIEW_3 => Self::VIEW_3, + } + } + + pub fn prev(&self) -> View { + match *self { + Self::VIEW_1 | Self::VIEW_2 => Self::VIEW_1, + Self::VIEW_3 => Self::VIEW_2, + } + } +} + +impl<'a> Into<Element<'a, navigation::Message<View>>> for View { + fn into(self) -> Element<'a, navigation::Message<View>> { + iced::widget::text(self.name()).into() + } +} |
