use adw::prelude::*; use outlay_core::db::Database; use std::rc::Rc; pub struct GoalsView { pub container: gtk::Box, } impl GoalsView { 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(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 active_group = adw::PreferencesGroup::builder() .title("SAVINGS GOALS") .build(); let completed_group = adw::PreferencesGroup::builder() .title("COMPLETED") .build(); completed_group.set_visible(false); let add_btn = gtk::Button::with_label("Add Goal"); 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); Self::load_goals(&db, &active_group, &completed_group, &toast_overlay); { let db_ref = db.clone(); let active_ref = active_group.clone(); let completed_ref = completed_group.clone(); let toast_ref = toast_overlay.clone(); add_btn.connect_clicked(move |btn| { Self::show_add_dialog(btn, &db_ref, &active_ref, &completed_ref, &toast_ref); }); } inner.append(&active_group); inner.append(&add_btn); inner.append(&completed_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); GoalsView { container } } fn load_goals( db: &Rc, active_group: &adw::PreferencesGroup, completed_group: &adw::PreferencesGroup, toast_overlay: &adw::ToastOverlay, ) { Self::clear_group(active_group); Self::clear_group(completed_group); let goals = db.list_goals().unwrap_or_default(); let mut has_completed = false; let base_currency = db .get_setting("base_currency") .ok() .flatten() .unwrap_or_else(|| "USD".to_string()); for goal in &goals { let pct = if goal.target > 0.0 { (goal.saved / goal.target * 100.0).min(100.0) } else { 0.0 }; let is_complete = goal.saved >= goal.target; let remaining = goal.target - goal.saved; let subtitle = if is_complete { format!("Completed - {:.2} {}", goal.target, goal.currency) } else { let mut parts = vec![ format!("{:.2} / {:.2} {} ({:.0}%)", goal.saved, goal.target, goal.currency, pct), ]; if remaining > 0.0 { parts.push(format!("{:.2} remaining", remaining)); } if let Some(deadline) = goal.deadline { let today = chrono::Local::now().date_naive(); let days_left = (deadline - today).num_days(); if days_left > 0 { parts.push(format!("{} days left", days_left)); } else if days_left == 0 { parts.push("Due today".to_string()); } else { parts.push(format!("{} days overdue", days_left.abs())); } } // Monthly amount needed if let Ok(Some(monthly)) = db.get_required_monthly(goal.id) { if monthly > 0.0 { parts.push(format!("Need {:.2}/month", monthly)); } } // Projection based on average contribution rate if let Ok(avg_rate) = db.get_goal_avg_monthly_contribution(goal.id) { if avg_rate > 0.0 && remaining > 0.0 { let months_remaining = (remaining / avg_rate).ceil() as i32; let today = chrono::Local::now().date_naive(); let projected = today + chrono::Months::new(months_remaining as u32); if let Some(deadline) = goal.deadline { let margin_days = (deadline - projected).num_days(); if margin_days >= 0 { let margin_months = margin_days / 30; parts.push(format!("On track - ~{} month{} ahead", margin_months, if margin_months == 1 { "" } else { "s" })); } else { let catch_up = remaining / ((deadline - today).num_days().max(1) as f64 / 30.0); parts.push(format!("Behind - need {:.2}/month to catch up", catch_up)); } } else { parts.push(format!("Reachable by {}", projected.format("%b %Y"))); } } else if remaining > 0.0 { parts.push("Start contributing to see projection".to_string()); } } parts.join(" - ") }; let row = adw::ActionRow::builder() .title(&goal.name) .subtitle(&subtitle) .activatable(true) .build(); // Progress bar let level = gtk::LevelBar::builder() .min_value(0.0) .max_value(1.0) .value(pct / 100.0) .hexpand(true) .valign(gtk::Align::Center) .build(); level.set_width_request(120); if is_complete { let check = gtk::Image::from_icon_name("object-select-symbolic"); check.add_css_class("success"); row.add_prefix(&check); } else if let Some(ref icon_name) = goal.icon { let icon = gtk::Image::from_icon_name(icon_name); icon.set_pixel_size(24); row.add_prefix(&icon); } let suffix_box = gtk::Box::new(gtk::Orientation::Horizontal, 8); suffix_box.set_valign(gtk::Align::Center); if !is_complete { let contribute_btn = gtk::Button::from_icon_name("list-add-symbolic"); contribute_btn.add_css_class("flat"); contribute_btn.set_tooltip_text(Some("Add funds")); contribute_btn.set_valign(gtk::Align::Center); let goal_id = goal.id; let db_contribute = db.clone(); let active_c = active_group.clone(); let completed_c = completed_group.clone(); let toast_c = toast_overlay.clone(); let currency = goal.currency.clone(); contribute_btn.connect_clicked(move |btn| { Self::show_contribute_dialog( btn, goal_id, ¤cy, &db_contribute, &active_c, &completed_c, &toast_c, ); }); suffix_box.append(&contribute_btn); } suffix_box.append(&level); row.add_suffix(&suffix_box); // Click to edit let goal_id = goal.id; let db_edit = db.clone(); let active_e = active_group.clone(); let completed_e = completed_group.clone(); let toast_e = toast_overlay.clone(); row.connect_activated(move |row| { Self::show_edit_dialog( row, goal_id, &db_edit, &active_e, &completed_e, &toast_e, ); }); if is_complete { completed_group.add(&row); has_completed = true; } else { active_group.add(&row); } } completed_group.set_visible(has_completed); if goals.iter().all(|g| g.saved >= g.target) && !goals.is_empty() { // All goals completed - no empty state needed } else if goals.iter().filter(|g| g.saved < g.target).count() == 0 && goals.is_empty() { let empty_row = adw::ActionRow::builder() .title("No savings goals yet") .subtitle("Set a goal and track your progress") .activatable(false) .build(); empty_row.add_css_class("dim-label"); active_group.add(&empty_row); } } fn show_add_dialog( parent: >k::Button, db: &Rc, active_group: &adw::PreferencesGroup, completed_group: &adw::PreferencesGroup, toast_overlay: &adw::ToastOverlay, ) { let dialog = adw::Dialog::builder() .title("Add Savings Goal") .content_width(360) .content_height(350) .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 name_row = adw::EntryRow::builder() .title("Goal Name") .build(); let target_row = adw::EntryRow::builder() .title("Target Amount") .build(); target_row.set_input_purpose(gtk::InputPurpose::Number); crate::numpad::attach_numpad(&target_row); let base_currency = db .get_setting("base_currency") .ok() .flatten() .unwrap_or_else(|| "USD".to_string()); let (deadline_row, deadline_label) = crate::date_picker::make_date_row("Deadline (optional)", ""); let form = adw::PreferencesGroup::new(); form.add(&name_row); form.add(&target_row); form.add(&deadline_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 active_ref = active_group.clone(); let completed_ref = completed_group.clone(); let toast_ref = toast_overlay.clone(); let bc = base_currency.clone(); save_btn.connect_clicked(move |_| { let name = name_row.text(); if name.is_empty() { let toast = adw::Toast::new("Please enter a goal name"); toast_ref.add_toast(toast); return; } let target: f64 = match target_row.text().trim().parse() { Ok(v) if v > 0.0 => v, _ => { let toast = adw::Toast::new("Please enter a valid target amount"); toast_ref.add_toast(toast); return; } }; let deadline_text = deadline_label.label(); let deadline = if deadline_text.is_empty() { None } else { chrono::NaiveDate::parse_from_str(deadline_text.trim(), "%Y-%m-%d").ok() }; match db_ref.insert_goal(&name, target, &bc, deadline, None, None) { Ok(_) => { dialog_ref.close(); let toast = adw::Toast::new("Goal created"); toast_ref.add_toast(toast); Self::load_goals(&db_ref, &active_ref, &completed_ref, &toast_ref); } Err(e) => { let toast = adw::Toast::new(&format!("Error: {}", e)); toast_ref.add_toast(toast); } } }); } dialog.present(Some(parent)); } fn show_contribute_dialog( parent: >k::Button, goal_id: i64, currency: &str, db: &Rc, active_group: &adw::PreferencesGroup, completed_group: &adw::PreferencesGroup, toast_overlay: &adw::ToastOverlay, ) { let dialog = adw::Dialog::builder() .title("Add Funds") .content_width(320) .content_height(180) .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(&format!("Amount ({})", currency)) .build(); amount_row.set_input_purpose(gtk::InputPurpose::Number); crate::numpad::attach_numpad(&amount_row); let form = adw::PreferencesGroup::new(); form.add(&amount_row); let save_btn = gtk::Button::with_label("Add"); 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 active_ref = active_group.clone(); let completed_ref = completed_group.clone(); let toast_ref = toast_overlay.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; } }; match db_ref.contribute_to_goal(goal_id, amount) { Ok(()) => { dialog_ref.close(); let toast = adw::Toast::new(&format!("Added {:.2}", amount)); toast_ref.add_toast(toast); Self::load_goals(&db_ref, &active_ref, &completed_ref, &toast_ref); } 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, goal_id: i64, db: &Rc, active_group: &adw::PreferencesGroup, completed_group: &adw::PreferencesGroup, toast_overlay: &adw::ToastOverlay, ) { let goal = match db.get_goal(goal_id) { Ok(g) => g, Err(_) => return, }; let dialog = adw::Dialog::builder() .title("Edit Goal") .content_width(360) .content_height(350) .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 name_row = adw::EntryRow::builder() .title("Goal Name") .text(&goal.name) .build(); let target_row = adw::EntryRow::builder() .title("Target Amount") .text(&format!("{:.2}", goal.target)) .build(); target_row.set_input_purpose(gtk::InputPurpose::Number); crate::numpad::attach_numpad(&target_row); let initial_deadline = goal.deadline .map(|d| d.format("%Y-%m-%d").to_string()) .unwrap_or_default(); let (deadline_row, deadline_label) = crate::date_picker::make_date_row("Deadline (optional)", &initial_deadline); let form = adw::PreferencesGroup::new(); form.add(&name_row); form.add(&target_row); form.add(&deadline_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)); { let db_ref = db.clone(); let dialog_ref = dialog.clone(); let active_ref = active_group.clone(); let completed_ref = completed_group.clone(); let toast_ref = toast_overlay.clone(); let currency = goal.currency.clone(); save_btn.connect_clicked(move |_| { let name = name_row.text(); if name.is_empty() { let toast = adw::Toast::new("Please enter a goal name"); toast_ref.add_toast(toast); return; } let target: f64 = match target_row.text().trim().parse() { Ok(v) if v > 0.0 => v, _ => { let toast = adw::Toast::new("Please enter a valid target amount"); toast_ref.add_toast(toast); return; } }; let deadline_text = deadline_label.label(); let deadline = if deadline_text.is_empty() { None } else { chrono::NaiveDate::parse_from_str(deadline_text.trim(), "%Y-%m-%d").ok() }; match db_ref.update_goal(goal_id, &name, target, ¤cy, deadline, None, None) { Ok(()) => { dialog_ref.close(); let toast = adw::Toast::new("Goal updated"); toast_ref.add_toast(toast); Self::load_goals(&db_ref, &active_ref, &completed_ref, &toast_ref); } Err(e) => { let toast = adw::Toast::new(&format!("Error: {}", e)); toast_ref.add_toast(toast); } } }); } { let db_ref = db.clone(); let dialog_ref = dialog.clone(); let active_ref = active_group.clone(); let completed_ref = completed_group.clone(); let toast_ref = toast_overlay.clone(); delete_btn.connect_clicked(move |btn| { let alert = adw::AlertDialog::new( Some("Delete this goal?"), Some("This will permanently remove this savings goal."), ); 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 active_del = active_ref.clone(); let completed_del = completed_ref.clone(); let toast_del = toast_ref.clone(); alert.connect_response(None, move |_, response| { if response == "delete" { match db_del.delete_goal(goal_id) { Ok(()) => { dialog_del.close(); let toast = adw::Toast::new("Goal deleted"); toast_del.add_toast(toast); Self::load_goals(&db_del, &active_del, &completed_del, &toast_del); } Err(e) => { let toast = adw::Toast::new(&format!("Error: {}", e)); toast_del.add_toast(toast); } } } }); alert.present(Some(btn)); }); } dialog.present(Some(parent)); } fn clear_group(group: &adw::PreferencesGroup) { let mut rows = Vec::new(); let mut child = group.upcast_ref::().first_child(); while let Some(c) = child { if let Some(row) = c.downcast_ref::() { rows.push(row.clone()); } let mut inner = c.first_child(); while let Some(ic) = inner { if let Some(row) = ic.downcast_ref::() { rows.push(row.clone()); } let mut inner2 = ic.first_child(); while let Some(ic2) = inner2 { if let Some(row) = ic2.downcast_ref::() { rows.push(row.clone()); } inner2 = ic2.next_sibling(); } inner = ic.next_sibling(); } child = c.next_sibling(); } for row in &rows { group.remove(row); } } }