diff --git a/outlay-gtk/src/budgets_view.rs b/outlay-gtk/src/budgets_view.rs new file mode 100644 index 0000000..8bf8b86 --- /dev/null +++ b/outlay-gtk/src/budgets_view.rs @@ -0,0 +1,509 @@ +use adw::prelude::*; +use chrono::{Datelike, Local}; +use outlay_core::db::Database; +use outlay_core::models::TransactionType; +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +pub struct BudgetsView { + pub container: gtk::Box, +} + +impl BudgetsView { + pub fn new(db: Rc) -> 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(12); + clamp.set_margin_end(12); + + let inner = gtk::Box::new(gtk::Orientation::Vertical, 16); + inner.set_margin_top(16); + inner.set_margin_bottom(16); + + let today = Local::now().date_naive(); + let current_year = Rc::new(Cell::new(today.year())); + let current_month = Rc::new(Cell::new(today.month())); + + // Month navigation + let nav_box = gtk::Box::new(gtk::Orientation::Horizontal, 8); + nav_box.set_halign(gtk::Align::Center); + + let prev_btn = gtk::Button::from_icon_name("go-previous-symbolic"); + prev_btn.add_css_class("flat"); + + let month_label = gtk::Label::new(None); + month_label.add_css_class("title-3"); + month_label.set_width_chars(16); + month_label.set_xalign(0.5); + + let next_btn = gtk::Button::from_icon_name("go-next-symbolic"); + next_btn.add_css_class("flat"); + + nav_box.append(&prev_btn); + nav_box.append(&month_label); + nav_box.append(&next_btn); + + // Summary + let summary_label = gtk::Label::new(None); + summary_label.add_css_class("dim-label"); + summary_label.set_halign(gtk::Align::Center); + + // Budget list + let budget_group = adw::PreferencesGroup::builder() + .title("Budgets") + .build(); + + // Add budget button + let add_btn = gtk::Button::with_label("Add Budget"); + add_btn.add_css_class("suggested-action"); + add_btn.add_css_class("pill"); + add_btn.set_halign(gtk::Align::Center); + add_btn.set_margin_top(8); + + // Initial load + Self::update_month_label(&month_label, current_year.get(), current_month.get()); + Self::load_budgets( + &db, &budget_group, &summary_label, &toast_overlay, + current_year.get(), current_month.get(), + ); + + // Navigation + { + let db_ref = db.clone(); + let year_ref = current_year.clone(); + let month_ref = current_month.clone(); + let label_ref = month_label.clone(); + let group_ref = budget_group.clone(); + let summary_ref = summary_label.clone(); + let toast_ref = toast_overlay.clone(); + prev_btn.connect_clicked(move |_| { + let mut y = year_ref.get(); + let mut m = month_ref.get(); + if m == 1 { m = 12; y -= 1; } else { m -= 1; } + year_ref.set(y); + month_ref.set(m); + Self::update_month_label(&label_ref, y, m); + Self::load_budgets(&db_ref, &group_ref, &summary_ref, &toast_ref, y, m); + }); + } + { + let db_ref = db.clone(); + let year_ref = current_year.clone(); + let month_ref = current_month.clone(); + let label_ref = month_label.clone(); + let group_ref = budget_group.clone(); + let summary_ref = summary_label.clone(); + let toast_ref = toast_overlay.clone(); + next_btn.connect_clicked(move |_| { + let mut y = year_ref.get(); + let mut m = month_ref.get(); + if m == 12 { m = 1; y += 1; } else { m += 1; } + year_ref.set(y); + month_ref.set(m); + Self::update_month_label(&label_ref, y, m); + Self::load_budgets(&db_ref, &group_ref, &summary_ref, &toast_ref, y, m); + }); + } + + // Add budget button + { + let db_ref = db.clone(); + let year_ref = current_year.clone(); + let month_ref = current_month.clone(); + let group_ref = budget_group.clone(); + let summary_ref = summary_label.clone(); + let toast_ref = toast_overlay.clone(); + add_btn.connect_clicked(move |btn| { + let y = year_ref.get(); + let m = month_ref.get(); + Self::show_add_dialog( + btn, &db_ref, &group_ref, &summary_ref, &toast_ref, y, m, + ); + }); + } + + inner.append(&nav_box); + inner.append(&summary_label); + inner.append(&budget_group); + inner.append(&add_btn); + + 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); + + BudgetsView { container } + } + + fn update_month_label(label: >k::Label, year: i32, month: u32) { + let month_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", + }; + label.set_label(&format!("{} {}", month_name, year)); + } + + fn month_str(year: i32, month: u32) -> String { + format!("{:04}-{:02}", year, month) + } + + fn load_budgets( + db: &Rc, + group: &adw::PreferencesGroup, + summary_label: >k::Label, + toast_overlay: &adw::ToastOverlay, + year: i32, + month: u32, + ) { + // Clear existing rows + while let Some(child) = group.first_child() { + if let Some(row) = child.downcast_ref::() { + group.remove(row); + } else if let Some(row) = child.downcast_ref::() { + if let Some(parent) = row.parent() { + if let Some(listbox) = parent.downcast_ref::() { + listbox.remove(row); + } + } + } else { + break; + } + } + + let month_str = Self::month_str(year, month); + let budgets = db.list_budgets_for_month(&month_str).unwrap_or_default(); + + if budgets.is_empty() { + let row = adw::ActionRow::builder() + .title("No budgets set for this month") + .build(); + row.add_css_class("dim-label"); + group.add(&row); + summary_label.set_label(""); + return; + } + + let mut total_budgeted = 0.0_f64; + let mut total_spent = 0.0_f64; + + for budget in &budgets { + let progress = db + .get_budget_progress(budget.category_id, &month_str) + .unwrap_or(None); + + let cat = db.get_category(budget.category_id).ok(); + let cat_name = cat.as_ref().map(|c| { + match &c.icon { + Some(icon) => format!("{} {}", icon, c.name), + None => c.name.clone(), + } + }).unwrap_or_else(|| "Unknown".to_string()); + + let (budget_amt, spent, pct) = progress.unwrap_or((budget.amount, 0.0, 0.0)); + total_budgeted += budget_amt; + total_spent += spent; + + let status = format!("{:.2} / {:.2} ({:.0}%)", spent, budget_amt, pct); + + let row = adw::ActionRow::builder() + .title(&cat_name) + .subtitle(&status) + .activatable(true) + .build(); + + // Progress bar + let level = gtk::LevelBar::builder() + .min_value(0.0) + .max_value(1.0) + .value((pct / 100.0).min(1.0)) + .hexpand(true) + .valign(gtk::Align::Center) + .build(); + level.set_width_request(120); + + // Color based on percentage + if pct >= 100.0 { + level.add_css_class("error"); + } else if pct >= 75.0 { + level.add_css_class("warning"); + } + + row.add_suffix(&level); + + // Click to edit + let budget_id = budget.id; + let cat_id = budget.category_id; + let current_amount = budget.amount; + let db_ref = db.clone(); + let group_ref = group.clone(); + let summary_ref = summary_label.clone(); + let toast_ref = toast_overlay.clone(); + row.connect_activated(move |row| { + Self::show_edit_dialog( + row, budget_id, cat_id, current_amount, + &db_ref, &group_ref, &summary_ref, &toast_ref, year, month, + ); + }); + + group.add(&row); + } + + summary_label.set_label(&format!( + "Total budgeted: {:.2} - Spent: {:.2}", + total_budgeted, total_spent + )); + } + + fn show_add_dialog( + parent: >k::Button, + db: &Rc, + group: &adw::PreferencesGroup, + summary_label: >k::Label, + toast_overlay: &adw::ToastOverlay, + year: i32, + month: u32, + ) { + let dialog = adw::Dialog::builder() + .title("Add Budget") + .content_width(360) + .content_height(300) + .build(); + + let toolbar = adw::ToolbarView::new(); + toolbar.add_top_bar(&adw::HeaderBar::new()); + + let content = gtk::Box::new(gtk::Orientation::Vertical, 16); + content.set_margin_top(16); + content.set_margin_bottom(16); + content.set_margin_start(16); + content.set_margin_end(16); + + // Category selector + let cats = db + .list_categories(Some(TransactionType::Expense)) + .unwrap_or_default(); + let cat_ids: Rc>> = Rc::new(RefCell::new(Vec::new())); + let cat_model = gtk::StringList::new(&[]); + for cat in &cats { + let display = match &cat.icon { + Some(icon) => format!("{} {}", icon, cat.name), + None => cat.name.clone(), + }; + cat_model.append(&display); + cat_ids.borrow_mut().push(cat.id); + } + let cat_row = adw::ComboRow::builder() + .title("Category") + .model(&cat_model) + .build(); + + let amount_row = adw::EntryRow::builder() + .title("Budget Amount") + .build(); + amount_row.set_input_purpose(gtk::InputPurpose::Number); + + let form = adw::PreferencesGroup::new(); + form.add(&cat_row); + form.add(&amount_row); + + let save_btn = gtk::Button::with_label("Save"); + save_btn.add_css_class("suggested-action"); + save_btn.add_css_class("pill"); + save_btn.set_halign(gtk::Align::Center); + + content.append(&form); + content.append(&save_btn); + toolbar.set_content(Some(&content)); + dialog.set_child(Some(&toolbar)); + + { + let db_ref = db.clone(); + let dialog_ref = dialog.clone(); + let group_ref = group.clone(); + let summary_ref = summary_label.clone(); + let toast_ref = toast_overlay.clone(); + let ids_ref = cat_ids.clone(); + save_btn.connect_clicked(move |_| { + let amount: f64 = match amount_row.text().trim().parse() { + Ok(v) if v > 0.0 => v, + _ => { + let toast = adw::Toast::new("Please enter a valid amount"); + toast_ref.add_toast(toast); + return; + } + }; + + let idx = cat_row.selected() as usize; + let ids = ids_ref.borrow(); + let cat_id = match ids.get(idx) { + Some(&id) => id, + None => return, + }; + + let month_str = Self::month_str(year, month); + match db_ref.set_budget(cat_id, &month_str, amount) { + Ok(()) => { + dialog_ref.close(); + let toast = adw::Toast::new("Budget added"); + toast_ref.add_toast(toast); + Self::load_budgets(&db_ref, &group_ref, &summary_ref, &toast_ref, year, month); + } + Err(e) => { + let toast = adw::Toast::new(&format!("Error: {}", e)); + toast_ref.add_toast(toast); + } + } + }); + } + + dialog.present(Some(parent)); + } + + fn show_edit_dialog( + parent: &adw::ActionRow, + budget_id: i64, + cat_id: i64, + current_amount: f64, + db: &Rc, + group: &adw::PreferencesGroup, + summary_label: >k::Label, + toast_overlay: &adw::ToastOverlay, + year: i32, + month: u32, + ) { + let dialog = adw::Dialog::builder() + .title("Edit Budget") + .content_width(360) + .content_height(250) + .build(); + + let toolbar = adw::ToolbarView::new(); + toolbar.add_top_bar(&adw::HeaderBar::new()); + + let content = gtk::Box::new(gtk::Orientation::Vertical, 16); + content.set_margin_top(16); + content.set_margin_bottom(16); + content.set_margin_start(16); + content.set_margin_end(16); + + let amount_row = adw::EntryRow::builder() + .title("Budget Amount") + .text(&format!("{:.2}", current_amount)) + .build(); + amount_row.set_input_purpose(gtk::InputPurpose::Number); + + let form = adw::PreferencesGroup::new(); + form.add(&amount_row); + + let btn_box = gtk::Box::new(gtk::Orientation::Horizontal, 12); + btn_box.set_halign(gtk::Align::Center); + + let delete_btn = gtk::Button::with_label("Delete"); + delete_btn.add_css_class("destructive-action"); + delete_btn.add_css_class("pill"); + + let save_btn = gtk::Button::with_label("Save"); + save_btn.add_css_class("suggested-action"); + save_btn.add_css_class("pill"); + + btn_box.append(&delete_btn); + btn_box.append(&save_btn); + + content.append(&form); + content.append(&btn_box); + toolbar.set_content(Some(&content)); + dialog.set_child(Some(&toolbar)); + + // Save + { + let db_ref = db.clone(); + let dialog_ref = dialog.clone(); + let group_ref = group.clone(); + let summary_ref = summary_label.clone(); + let toast_ref = toast_overlay.clone(); + let amount_ref = amount_row.clone(); + save_btn.connect_clicked(move |_| { + let amount: f64 = match amount_ref.text().trim().parse() { + Ok(v) if v > 0.0 => v, + _ => { + let toast = adw::Toast::new("Please enter a valid amount"); + toast_ref.add_toast(toast); + return; + } + }; + + let month_str = Self::month_str(year, month); + match db_ref.set_budget(cat_id, &month_str, amount) { + Ok(()) => { + dialog_ref.close(); + let toast = adw::Toast::new("Budget updated"); + toast_ref.add_toast(toast); + Self::load_budgets(&db_ref, &group_ref, &summary_ref, &toast_ref, year, month); + } + Err(e) => { + let toast = adw::Toast::new(&format!("Error: {}", e)); + toast_ref.add_toast(toast); + } + } + }); + } + + // Delete + { + let db_ref = db.clone(); + let dialog_ref = dialog.clone(); + let group_ref = group.clone(); + let summary_ref = summary_label.clone(); + let toast_ref = toast_overlay.clone(); + delete_btn.connect_clicked(move |btn| { + let alert = adw::AlertDialog::new( + Some("Delete this budget?"), + Some("This will remove the budget for this category."), + ); + alert.add_response("cancel", "Cancel"); + alert.add_response("delete", "Delete"); + alert.set_response_appearance("delete", adw::ResponseAppearance::Destructive); + alert.set_default_response(Some("cancel")); + alert.set_close_response("cancel"); + + let db_del = db_ref.clone(); + let dialog_del = dialog_ref.clone(); + let group_del = group_ref.clone(); + let summary_del = summary_ref.clone(); + let toast_del = toast_ref.clone(); + alert.connect_response(None, move |_, response| { + if response == "delete" { + match db_del.delete_budget(budget_id) { + Ok(()) => { + dialog_del.close(); + let toast = adw::Toast::new("Budget deleted"); + toast_del.add_toast(toast); + Self::load_budgets(&db_del, &group_del, &summary_del, &toast_del, year, month); + } + Err(e) => { + let toast = adw::Toast::new(&format!("Error: {}", e)); + toast_del.add_toast(toast); + } + } + } + }); + + alert.present(Some(btn)); + }); + } + + dialog.present(Some(parent)); + } +} diff --git a/outlay-gtk/src/main.rs b/outlay-gtk/src/main.rs index d6b8b1c..c5ee78e 100644 --- a/outlay-gtk/src/main.rs +++ b/outlay-gtk/src/main.rs @@ -1,3 +1,4 @@ +mod budgets_view; mod charts_view; mod history_view; mod log_view; diff --git a/outlay-gtk/src/window.rs b/outlay-gtk/src/window.rs index 4b44d19..685b4b1 100644 --- a/outlay-gtk/src/window.rs +++ b/outlay-gtk/src/window.rs @@ -2,6 +2,7 @@ use adw::prelude::*; use outlay_core::db::Database; use std::rc::Rc; +use crate::budgets_view::BudgetsView; use crate::charts_view::ChartsView; use crate::history_view::HistoryView; use crate::log_view::LogView; @@ -53,8 +54,12 @@ impl MainWindow { let charts_view = ChartsView::new(db.clone()); content_stack.add_named(&charts_view.container, Some("charts")); + // Budgets view + let budgets_view = BudgetsView::new(db.clone()); + content_stack.add_named(&budgets_view.container, Some("budgets")); + // Remaining pages are placeholders for now - for item in &SIDEBAR_ITEMS[3..] { + for item in &SIDEBAR_ITEMS[4..] { let page = adw::StatusPage::builder() .title(item.label) .icon_name(item.icon)