From f12edd8ea1ff774ff13b398f0f9bc953adc57c08 Mon Sep 17 00:00:00 2001 From: lashman Date: Mon, 2 Mar 2026 00:37:30 +0200 Subject: [PATCH] Add recurring transactions view and launch catch-up --- outlay-gtk/src/main.rs | 14 + outlay-gtk/src/recurring_view.rs | 754 +++++++++++++++++++++++++++++++ outlay-gtk/src/window.rs | 9 +- 3 files changed, 775 insertions(+), 2 deletions(-) create mode 100644 outlay-gtk/src/recurring_view.rs diff --git a/outlay-gtk/src/main.rs b/outlay-gtk/src/main.rs index c5ee78e..a9a2902 100644 --- a/outlay-gtk/src/main.rs +++ b/outlay-gtk/src/main.rs @@ -2,12 +2,14 @@ mod budgets_view; mod charts_view; mod history_view; mod log_view; +mod recurring_view; mod window; use adw::prelude::*; use adw::Application; use gtk::glib; use outlay_core::db::Database; +use outlay_core::recurring::generate_missed_transactions; use std::rc::Rc; const APP_ID: &str = "io.github.outlay"; @@ -27,8 +29,20 @@ fn build_ui(app: &Application) { let db_path = data_dir.join("outlay.db"); let db = Database::open(&db_path).expect("Failed to open database"); + + // Generate any missed recurring transactions on launch + let recurring_count = generate_missed_transactions(&db, chrono::Local::now().date_naive()) + .unwrap_or(0); + let db = Rc::new(db); let main_window = window::MainWindow::new(app, db); + + if recurring_count > 0 { + let msg = format!("Added {} recurring transaction(s)", recurring_count); + let toast = adw::Toast::new(&msg); + main_window.log_view.toast_overlay.add_toast(toast); + } + main_window.window.present(); } diff --git a/outlay-gtk/src/recurring_view.rs b/outlay-gtk/src/recurring_view.rs new file mode 100644 index 0000000..9b91e20 --- /dev/null +++ b/outlay-gtk/src/recurring_view.rs @@ -0,0 +1,754 @@ +use adw::prelude::*; +use chrono::NaiveDate; +use outlay_core::db::Database; +use outlay_core::exchange::ExchangeRateService; +use outlay_core::models::{ + Frequency, NewRecurringTransaction, RecurringTransaction, TransactionType, +}; +use std::cell::RefCell; +use std::rc::Rc; + +pub struct RecurringView { + pub container: gtk::Box, +} + +impl RecurringView { + 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); + + // Active section + let active_group = adw::PreferencesGroup::builder() + .title("Active") + .build(); + + // Paused section + let paused_group = adw::PreferencesGroup::builder() + .title("Paused") + .build(); + + // Add button + let add_btn = gtk::Button::with_label("Add Recurring"); + 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_recurring(&db, &active_group, &paused_group, &toast_overlay); + + // Wire add button + { + let db_ref = db.clone(); + let active_ref = active_group.clone(); + let paused_ref = paused_group.clone(); + let toast_ref = toast_overlay.clone(); + add_btn.connect_clicked(move |btn| { + Self::show_add_dialog(btn, &db_ref, &active_ref, &paused_ref, &toast_ref); + }); + } + + inner.append(&active_group); + inner.append(&paused_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); + + RecurringView { container } + } + + fn load_recurring( + db: &Rc, + active_group: &adw::PreferencesGroup, + paused_group: &adw::PreferencesGroup, + toast_overlay: &adw::ToastOverlay, + ) { + Self::clear_group(active_group); + Self::clear_group(paused_group); + + let all = db.list_recurring(false).unwrap_or_default(); + let active: Vec<&RecurringTransaction> = all.iter().filter(|r| r.active).collect(); + let paused: Vec<&RecurringTransaction> = all.iter().filter(|r| !r.active).collect(); + + if active.is_empty() { + let row = adw::ActionRow::builder() + .title("No active recurring transactions") + .build(); + row.add_css_class("dim-label"); + active_group.add(&row); + } else { + for rec in &active { + let row = Self::make_row(db, rec, active_group, paused_group, toast_overlay); + active_group.add(&row); + } + } + + if paused.is_empty() { + let row = adw::ActionRow::builder() + .title("No paused recurring transactions") + .build(); + row.add_css_class("dim-label"); + paused_group.add(&row); + } else { + for rec in &paused { + let row = Self::make_row(db, rec, active_group, paused_group, toast_overlay); + paused_group.add(&row); + } + } + } + + fn make_row( + db: &Rc, + rec: &RecurringTransaction, + active_group: &adw::PreferencesGroup, + paused_group: &adw::PreferencesGroup, + toast_overlay: &adw::ToastOverlay, + ) -> adw::ActionRow { + let cat_name = db + .get_category(rec.category_id) + .map(|c| match &c.icon { + Some(icon) => format!("{} {}", icon, c.name), + None => c.name, + }) + .unwrap_or_else(|_| "Unknown".to_string()); + + let type_prefix = match rec.transaction_type { + TransactionType::Expense => "-", + TransactionType::Income => "+", + }; + let freq_label = Self::frequency_label(rec.frequency); + let subtitle = format!( + "{}{:.2} {} - {}", + type_prefix, rec.amount, rec.currency, freq_label + ); + + let row = adw::ActionRow::builder() + .title(&cat_name) + .subtitle(&subtitle) + .activatable(true) + .build(); + + if !rec.active { + row.add_css_class("dim-label"); + } + + // Next date indicator + let next_text = match rec.end_date { + Some(end) if end < chrono::Local::now().date_naive() => "Ended".to_string(), + _ => { + if let Some(note) = &rec.note { + note.clone() + } else { + format!("from {}", rec.start_date) + } + } + }; + let next_label = gtk::Label::new(Some(&next_text)); + next_label.add_css_class("dim-label"); + next_label.add_css_class("caption"); + row.add_suffix(&next_label); + + let rec_id = rec.id; + let db_ref = db.clone(); + let active_ref = active_group.clone(); + let paused_ref = paused_group.clone(); + let toast_ref = toast_overlay.clone(); + row.connect_activated(move |row| { + Self::show_edit_dialog(row, rec_id, &db_ref, &active_ref, &paused_ref, &toast_ref); + }); + + row + } + + fn show_add_dialog( + parent: >k::Button, + db: &Rc, + active_group: &adw::PreferencesGroup, + paused_group: &adw::PreferencesGroup, + toast_overlay: &adw::ToastOverlay, + ) { + let dialog = adw::Dialog::builder() + .title("Add Recurring Transaction") + .content_width(400) + .content_height(500) + .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); + + // Type toggle + let type_box = gtk::Box::new(gtk::Orientation::Horizontal, 0); + type_box.add_css_class("linked"); + type_box.set_halign(gtk::Align::Center); + + let expense_btn = gtk::ToggleButton::with_label("Expense"); + expense_btn.set_active(true); + expense_btn.set_hexpand(true); + + let income_btn = gtk::ToggleButton::with_label("Income"); + income_btn.set_group(Some(&expense_btn)); + income_btn.set_hexpand(true); + + type_box.append(&expense_btn); + type_box.append(&income_btn); + + let form = adw::PreferencesGroup::new(); + + // Amount + let amount_row = adw::EntryRow::builder() + .title("Amount") + .build(); + amount_row.set_input_purpose(gtk::InputPurpose::Number); + form.add(&amount_row); + + // Currency + let base_currency = db + .get_setting("base_currency") + .ok() + .flatten() + .unwrap_or_else(|| "USD".to_string()); + let currencies = ExchangeRateService::supported_currencies(); + let currency_labels: Vec = currencies + .iter() + .map(|(code, name)| format!("{} - {}", code, name)) + .collect(); + let currency_label_refs: Vec<&str> = currency_labels.iter().map(|s| s.as_str()).collect(); + let currency_model = gtk::StringList::new(¤cy_label_refs); + let currency_codes: Vec = currencies.iter().map(|(c, _)| c.to_string()).collect(); + let base_idx = currency_codes + .iter() + .position(|c| c.eq_ignore_ascii_case(&base_currency)) + .unwrap_or(0); + let currency_row = adw::ComboRow::builder() + .title("Currency") + .model(¤cy_model) + .selected(base_idx as u32) + .build(); + form.add(¤cy_row); + + // Category + let category_model = gtk::StringList::new(&[]); + let category_ids: Rc>> = Rc::new(RefCell::new(Vec::new())); + let category_row = adw::ComboRow::builder() + .title("Category") + .model(&category_model) + .build(); + Self::populate_categories(db, &category_model, &category_ids, TransactionType::Expense); + form.add(&category_row); + + // Frequency + let freq_labels = ["Daily", "Weekly", "Biweekly", "Monthly", "Yearly"]; + let freq_model = gtk::StringList::new(&freq_labels); + let freq_row = adw::ComboRow::builder() + .title("Frequency") + .model(&freq_model) + .selected(3) // Monthly default + .build(); + form.add(&freq_row); + + // Start date + let today_str = chrono::Local::now().date_naive().format("%Y-%m-%d").to_string(); + let start_row = adw::EntryRow::builder() + .title("Start Date (YYYY-MM-DD)") + .text(&today_str) + .build(); + form.add(&start_row); + + // End date (optional) + let end_row = adw::EntryRow::builder() + .title("End Date (optional, YYYY-MM-DD)") + .build(); + form.add(&end_row); + + // Note + let note_row = adw::EntryRow::builder() + .title("Note (optional)") + .build(); + form.add(¬e_row); + + // Wire type toggle to filter categories + { + let db_ref = db.clone(); + let model_ref = category_model.clone(); + let ids_ref = category_ids.clone(); + expense_btn.connect_toggled(move |btn| { + if btn.is_active() { + Self::populate_categories( + &db_ref, &model_ref, &ids_ref, TransactionType::Expense, + ); + } + }); + } + { + let db_ref = db.clone(); + let model_ref = category_model.clone(); + let ids_ref = category_ids.clone(); + income_btn.connect_toggled(move |btn| { + if btn.is_active() { + Self::populate_categories( + &db_ref, &model_ref, &ids_ref, TransactionType::Income, + ); + } + }); + } + + 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(&type_box); + content.append(&form); + content.append(&save_btn); + toolbar.set_content(Some(&content)); + dialog.set_child(Some(&toolbar)); + + // Wire save + { + let db_ref = db.clone(); + let dialog_ref = dialog.clone(); + let active_ref = active_group.clone(); + let paused_ref = paused_group.clone(); + let toast_ref = toast_overlay.clone(); + let ids_ref = category_ids.clone(); + let currency_codes_ref = currency_codes.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 txn_type = if expense_btn.is_active() { + TransactionType::Expense + } else { + TransactionType::Income + }; + + let cat_idx = category_row.selected() as usize; + let ids = ids_ref.borrow(); + let category_id = match ids.get(cat_idx) { + Some(&id) => id, + None => { + let toast = adw::Toast::new("Please select a category"); + toast_ref.add_toast(toast); + return; + } + }; + + let curr_idx = currency_row.selected() as usize; + let currency = currency_codes_ref + .get(curr_idx) + .cloned() + .unwrap_or_else(|| "USD".to_string()); + + let freq = match freq_row.selected() { + 0 => Frequency::Daily, + 1 => Frequency::Weekly, + 2 => Frequency::Biweekly, + 3 => Frequency::Monthly, + _ => Frequency::Yearly, + }; + + let start_text = start_row.text(); + let start_date = match NaiveDate::parse_from_str(start_text.trim(), "%Y-%m-%d") { + Ok(d) => d, + Err(_) => { + let toast = adw::Toast::new("Invalid start date format"); + toast_ref.add_toast(toast); + return; + } + }; + + let end_text = end_row.text(); + let end_date = if end_text.trim().is_empty() { + None + } else { + match NaiveDate::parse_from_str(end_text.trim(), "%Y-%m-%d") { + Ok(d) => Some(d), + Err(_) => { + let toast = adw::Toast::new("Invalid end date format"); + toast_ref.add_toast(toast); + return; + } + } + }; + + let note_text = note_row.text(); + let note = if note_text.is_empty() { + None + } else { + Some(note_text.to_string()) + }; + + let new_rec = NewRecurringTransaction { + amount, + transaction_type: txn_type, + category_id, + currency, + note, + frequency: freq, + start_date, + end_date, + }; + + match db_ref.insert_recurring(&new_rec) { + Ok(_) => { + dialog_ref.close(); + let toast = adw::Toast::new("Recurring transaction added"); + toast_ref.add_toast(toast); + Self::load_recurring(&db_ref, &active_ref, &paused_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, + rec_id: i64, + db: &Rc, + active_group: &adw::PreferencesGroup, + paused_group: &adw::PreferencesGroup, + toast_overlay: &adw::ToastOverlay, + ) { + let rec = match db.get_recurring(rec_id) { + Ok(r) => r, + Err(_) => return, + }; + + let dialog = adw::Dialog::builder() + .title("Edit Recurring Transaction") + .content_width(400) + .content_height(500) + .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 form = adw::PreferencesGroup::new(); + + // Amount + let amount_row = adw::EntryRow::builder() + .title("Amount") + .text(&format!("{:.2}", rec.amount)) + .build(); + amount_row.set_input_purpose(gtk::InputPurpose::Number); + form.add(&amount_row); + + // Frequency + let freq_labels = ["Daily", "Weekly", "Biweekly", "Monthly", "Yearly"]; + let freq_model = gtk::StringList::new(&freq_labels); + let freq_idx = match rec.frequency { + Frequency::Daily => 0, + Frequency::Weekly => 1, + Frequency::Biweekly => 2, + Frequency::Monthly => 3, + Frequency::Yearly => 4, + }; + let freq_row = adw::ComboRow::builder() + .title("Frequency") + .model(&freq_model) + .selected(freq_idx) + .build(); + form.add(&freq_row); + + // Start date + let start_row = adw::EntryRow::builder() + .title("Start Date (YYYY-MM-DD)") + .text(&rec.start_date.format("%Y-%m-%d").to_string()) + .build(); + form.add(&start_row); + + // End date + let end_text = rec.end_date.map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_default(); + let end_row = adw::EntryRow::builder() + .title("End Date (optional, YYYY-MM-DD)") + .text(&end_text) + .build(); + form.add(&end_row); + + // Note + let note_row = adw::EntryRow::builder() + .title("Note (optional)") + .text(rec.note.as_deref().unwrap_or("")) + .build(); + form.add(¬e_row); + + // Buttons + 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 pause_label = if rec.active { "Pause" } else { "Resume" }; + let pause_btn = gtk::Button::with_label(pause_label); + pause_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(&pause_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 active_ref = active_group.clone(); + let paused_ref = paused_group.clone(); + let toast_ref = toast_overlay.clone(); + let rec_clone = rec.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 freq = match freq_row.selected() { + 0 => Frequency::Daily, + 1 => Frequency::Weekly, + 2 => Frequency::Biweekly, + 3 => Frequency::Monthly, + _ => Frequency::Yearly, + }; + + let start_text = start_row.text(); + let start_date = match NaiveDate::parse_from_str(start_text.trim(), "%Y-%m-%d") { + Ok(d) => d, + Err(_) => { + let toast = adw::Toast::new("Invalid start date format"); + toast_ref.add_toast(toast); + return; + } + }; + + let end_text_val = end_row.text(); + let end_date = if end_text_val.trim().is_empty() { + None + } else { + match NaiveDate::parse_from_str(end_text_val.trim(), "%Y-%m-%d") { + Ok(d) => Some(d), + Err(_) => { + let toast = adw::Toast::new("Invalid end date format"); + toast_ref.add_toast(toast); + return; + } + } + }; + + let note_text_val = note_row.text(); + let note = if note_text_val.is_empty() { + None + } else { + Some(note_text_val.to_string()) + }; + + let updated = RecurringTransaction { + id: rec_clone.id, + amount, + transaction_type: rec_clone.transaction_type, + category_id: rec_clone.category_id, + currency: rec_clone.currency.clone(), + note, + frequency: freq, + start_date, + end_date, + last_generated: rec_clone.last_generated, + active: rec_clone.active, + }; + + match db_ref.update_recurring(&updated) { + Ok(()) => { + dialog_ref.close(); + let toast = adw::Toast::new("Recurring transaction updated"); + toast_ref.add_toast(toast); + Self::load_recurring(&db_ref, &active_ref, &paused_ref, &toast_ref); + } + Err(e) => { + let toast = adw::Toast::new(&format!("Error: {}", e)); + toast_ref.add_toast(toast); + } + } + }); + } + + // Pause/Resume + { + let db_ref = db.clone(); + let dialog_ref = dialog.clone(); + let active_ref = active_group.clone(); + let paused_ref = paused_group.clone(); + let toast_ref = toast_overlay.clone(); + let is_active = rec.active; + pause_btn.connect_clicked(move |_| { + match db_ref.toggle_recurring_active(rec_id, !is_active) { + Ok(()) => { + dialog_ref.close(); + let msg = if is_active { + "Recurring transaction paused" + } else { + "Recurring transaction resumed" + }; + let toast = adw::Toast::new(msg); + toast_ref.add_toast(toast); + Self::load_recurring(&db_ref, &active_ref, &paused_ref, &toast_ref); + } + 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 active_ref = active_group.clone(); + let paused_ref = paused_group.clone(); + let toast_ref = toast_overlay.clone(); + delete_btn.connect_clicked(move |btn| { + let alert = adw::AlertDialog::new( + Some("Delete this recurring transaction?"), + Some("This will not remove previously generated transactions."), + ); + 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 paused_del = paused_ref.clone(); + let toast_del = toast_ref.clone(); + alert.connect_response(None, move |_, response| { + if response == "delete" { + match db_del.delete_recurring(rec_id) { + Ok(()) => { + dialog_del.close(); + let toast = adw::Toast::new("Recurring transaction deleted"); + toast_del.add_toast(toast); + Self::load_recurring( + &db_del, &active_del, &paused_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 populate_categories( + db: &Database, + model: >k::StringList, + ids: &Rc>>, + txn_type: TransactionType, + ) { + while model.n_items() > 0 { + model.remove(0); + } + let mut id_list = ids.borrow_mut(); + id_list.clear(); + + if let Ok(cats) = db.list_categories(Some(txn_type)) { + for cat in cats { + let display = match &cat.icon { + Some(icon) => format!("{} {}", icon, cat.name), + None => cat.name.clone(), + }; + model.append(&display); + id_list.push(cat.id); + } + } + } + + fn clear_group(group: &adw::PreferencesGroup) { + 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; + } + } + } + + fn frequency_label(freq: Frequency) -> &'static str { + match freq { + Frequency::Daily => "Daily", + Frequency::Weekly => "Weekly", + Frequency::Biweekly => "Biweekly", + Frequency::Monthly => "Monthly", + Frequency::Yearly => "Yearly", + } + } +} diff --git a/outlay-gtk/src/window.rs b/outlay-gtk/src/window.rs index df14939..b3a1b2f 100644 --- a/outlay-gtk/src/window.rs +++ b/outlay-gtk/src/window.rs @@ -6,6 +6,7 @@ use crate::budgets_view::BudgetsView; use crate::charts_view::ChartsView; use crate::history_view::HistoryView; use crate::log_view::LogView; +use crate::recurring_view::RecurringView; pub struct MainWindow { pub window: adw::ApplicationWindow, @@ -58,8 +59,12 @@ impl MainWindow { 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[4..] { + // Recurring view + let recurring_view = RecurringView::new(db.clone()); + content_stack.add_named(&recurring_view.container, Some("recurring")); + + // Settings placeholder + for item in &SIDEBAR_ITEMS[5..] { let page = adw::StatusPage::builder() .title(item.label) .icon_name(item.icon)