Add feature batch 2, subscription/recurring sync, smooth charts, and app icon
This commit is contained in:
154
outlay-gtk/src/forecast_view.rs
Normal file
154
outlay-gtk/src/forecast_view.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
use adw::prelude::*;
|
||||
use outlay_core::db::Database;
|
||||
use std::rc::Rc;
|
||||
|
||||
pub struct ForecastView {
|
||||
pub container: gtk::Box,
|
||||
}
|
||||
|
||||
impl ForecastView {
|
||||
pub fn new(db: Rc<Database>) -> Self {
|
||||
let container = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
let toast_overlay = adw::ToastOverlay::new();
|
||||
|
||||
let clamp = adw::Clamp::new();
|
||||
clamp.set_maximum_size(700);
|
||||
clamp.set_margin_start(16);
|
||||
clamp.set_margin_end(16);
|
||||
|
||||
let inner = gtk::Box::new(gtk::Orientation::Vertical, 20);
|
||||
inner.set_margin_top(20);
|
||||
inner.set_margin_bottom(20);
|
||||
|
||||
let base_currency = db.get_setting("base_currency")
|
||||
.ok().flatten()
|
||||
.unwrap_or_else(|| "USD".to_string());
|
||||
|
||||
let summary_card = gtk::Box::new(gtk::Orientation::Vertical, 4);
|
||||
summary_card.add_css_class("card");
|
||||
summary_card.set_margin_start(4);
|
||||
summary_card.set_margin_end(4);
|
||||
|
||||
let summary_title = gtk::Label::new(Some("CASH FLOW FORECAST"));
|
||||
summary_title.add_css_class("caption");
|
||||
summary_title.add_css_class("dim-label");
|
||||
summary_title.set_halign(gtk::Align::Start);
|
||||
summary_title.set_margin_top(12);
|
||||
summary_title.set_margin_start(12);
|
||||
|
||||
let summary_detail = gtk::Label::new(Some("Based on recurring transactions and 3-month averages"));
|
||||
summary_detail.add_css_class("caption");
|
||||
summary_detail.add_css_class("dim-label");
|
||||
summary_detail.set_halign(gtk::Align::Start);
|
||||
summary_detail.set_margin_start(12);
|
||||
summary_detail.set_margin_bottom(12);
|
||||
|
||||
summary_card.append(&summary_title);
|
||||
summary_card.append(&summary_detail);
|
||||
|
||||
let forecast_group = adw::PreferencesGroup::builder()
|
||||
.title("MONTHLY PROJECTION")
|
||||
.build();
|
||||
|
||||
let forecast_data = db.forecast_cash_flow(6).unwrap_or_default();
|
||||
for (i, (month_key, income, expenses, balance)) in forecast_data.iter().enumerate() {
|
||||
let month_name = Self::month_name(month_key);
|
||||
let is_current = i == 0;
|
||||
let label = if is_current {
|
||||
format!("{} (current)", month_name)
|
||||
} else {
|
||||
format!("{} (projected)", month_name)
|
||||
};
|
||||
|
||||
let net = income - expenses;
|
||||
let balance_str = if *balance >= 0.0 {
|
||||
format!("+{:.0} {}", balance, base_currency)
|
||||
} else {
|
||||
format!("{:.0} {}", balance, base_currency)
|
||||
};
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&label)
|
||||
.subtitle(&format!(
|
||||
"Income: {:.0} - Expenses: {:.0} = Net: {:.0}",
|
||||
income, expenses, net,
|
||||
))
|
||||
.build();
|
||||
|
||||
let balance_label = gtk::Label::new(Some(&balance_str));
|
||||
balance_label.add_css_class("amount-display");
|
||||
if *balance >= 0.0 {
|
||||
balance_label.add_css_class("amount-income");
|
||||
} else {
|
||||
balance_label.add_css_class("amount-expense");
|
||||
}
|
||||
row.add_suffix(&balance_label);
|
||||
|
||||
if !is_current {
|
||||
row.add_css_class("dim-label");
|
||||
}
|
||||
|
||||
forecast_group.add(&row);
|
||||
}
|
||||
|
||||
if forecast_data.is_empty() {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("No data available for forecast")
|
||||
.subtitle("Add some transactions to see projections")
|
||||
.build();
|
||||
row.add_css_class("dim-label");
|
||||
forecast_group.add(&row);
|
||||
}
|
||||
|
||||
// Net cash flow summary
|
||||
if forecast_data.len() >= 2 {
|
||||
let last = &forecast_data[forecast_data.len() - 1];
|
||||
let net_summary = if last.3 >= 0.0 {
|
||||
format!("Projected cumulative balance in 6 months: +{:.0} {}", last.3, base_currency)
|
||||
} else {
|
||||
format!("Projected cumulative balance in 6 months: {:.0} {}", last.3, base_currency)
|
||||
};
|
||||
|
||||
let net_label = gtk::Label::new(Some(&net_summary));
|
||||
net_label.add_css_class("heading");
|
||||
net_label.set_halign(gtk::Align::Start);
|
||||
net_label.set_margin_start(4);
|
||||
net_label.set_margin_top(8);
|
||||
|
||||
inner.append(&summary_card);
|
||||
inner.append(&forecast_group);
|
||||
inner.append(&net_label);
|
||||
} else {
|
||||
inner.append(&summary_card);
|
||||
inner.append(&forecast_group);
|
||||
}
|
||||
|
||||
clamp.set_child(Some(&inner));
|
||||
|
||||
let scroll = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
.vexpand(true)
|
||||
.child(&clamp)
|
||||
.build();
|
||||
|
||||
toast_overlay.set_child(Some(&scroll));
|
||||
container.append(&toast_overlay);
|
||||
|
||||
ForecastView { container }
|
||||
}
|
||||
|
||||
fn month_name(key: &str) -> String {
|
||||
let parts: Vec<&str> = key.split('-').collect();
|
||||
if parts.len() != 2 { return key.to_string(); }
|
||||
let month: u32 = parts[1].parse().unwrap_or(0);
|
||||
let year = parts[0];
|
||||
let name = match month {
|
||||
1 => "January", 2 => "February", 3 => "March",
|
||||
4 => "April", 5 => "May", 6 => "June",
|
||||
7 => "July", 8 => "August", 9 => "September",
|
||||
10 => "October", 11 => "November", 12 => "December",
|
||||
_ => "Unknown",
|
||||
};
|
||||
format!("{} {}", name, year)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user