601 lines
22 KiB
Rust
601 lines
22 KiB
Rust
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<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 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<Database>,
|
|
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<Database>,
|
|
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<Database>,
|
|
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<Database>,
|
|
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::<gtk::Widget>().first_child();
|
|
while let Some(c) = child {
|
|
if let Some(row) = c.downcast_ref::<adw::ActionRow>() {
|
|
rows.push(row.clone());
|
|
}
|
|
let mut inner = c.first_child();
|
|
while let Some(ic) = inner {
|
|
if let Some(row) = ic.downcast_ref::<adw::ActionRow>() {
|
|
rows.push(row.clone());
|
|
}
|
|
let mut inner2 = ic.first_child();
|
|
while let Some(ic2) = inner2 {
|
|
if let Some(row) = ic2.downcast_ref::<adw::ActionRow>() {
|
|
rows.push(row.clone());
|
|
}
|
|
inner2 = ic2.next_sibling();
|
|
}
|
|
inner = ic.next_sibling();
|
|
}
|
|
child = c.next_sibling();
|
|
}
|
|
for row in &rows {
|
|
group.remove(row);
|
|
}
|
|
}
|
|
}
|