use adw::prelude::*; use chrono::{Datelike, Local}; use gtk::{gdk, gio, glib}; use outlay_core::backup; use outlay_core::db::Database; use outlay_core::exchange::ExchangeRateService; use outlay_core::export_csv; use outlay_core::export_json; use outlay_core::export_ofx; use outlay_core::export_pdf; use outlay_core::export_qif; use outlay_core::import_csv; use outlay_core::import_json; use outlay_core::import_pdf; use outlay_core::models::{Category, NewCategory, NewTransaction, TransactionType}; use std::cell::RefCell; use std::rc::Rc; use crate::icon_theme; pub struct SettingsView { pub container: gtk::Box, on_data_reset: Rc>>>, } impl SettingsView { pub fn new(db: Rc, app: &adw::Application) -> Self { let container = gtk::Box::new(gtk::Orientation::Vertical, 0); let toast_overlay = adw::ToastOverlay::new(); let settings_stack = gtk::Stack::new(); settings_stack.set_transition_type(gtk::StackTransitionType::SlideLeftRight); // Currency group let currency_group = adw::PreferencesGroup::builder() .title("CURRENCY") .build(); 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("Base Currency") .subtitle("Used for reports and budget calculations") .model(¤cy_model) .selected(base_idx as u32) .build(); { let db_ref = db.clone(); let codes = currency_codes.clone(); let toast_ref = toast_overlay.clone(); currency_row.connect_selected_notify(move |row| { let idx = row.selected() as usize; if let Some(code) = codes.get(idx) { match db_ref.set_setting("base_currency", code) { Ok(()) => { let toast = adw::Toast::new(&format!("Base currency set to {}", code)); toast_ref.add_toast(toast); } Err(e) => { let toast = adw::Toast::new(&format!("Error: {}", e)); toast_ref.add_toast(toast); } } } }); } currency_group.add(¤cy_row); // Secondary display currency let mut secondary_labels: Vec = vec!["None".to_string()]; secondary_labels.extend(currency_labels.iter().cloned()); let secondary_label_refs: Vec<&str> = secondary_labels.iter().map(|s| s.as_str()).collect(); let secondary_model = gtk::StringList::new(&secondary_label_refs); let secondary_currency = db .get_setting("secondary_currency") .ok() .flatten() .unwrap_or_default(); let secondary_idx = if secondary_currency.is_empty() { 0 } else { currency_codes .iter() .position(|c| c.eq_ignore_ascii_case(&secondary_currency)) .map(|i| i + 1) .unwrap_or(0) }; let secondary_row = adw::ComboRow::builder() .title("Secondary Display Currency") .subtitle("Show totals in a second currency alongside your base") .model(&secondary_model) .selected(secondary_idx as u32) .build(); { let db_ref = db.clone(); let codes = currency_codes.clone(); let toast_ref = toast_overlay.clone(); secondary_row.connect_selected_notify(move |row| { let idx = row.selected() as usize; let value = if idx == 0 { "" } else { codes.get(idx - 1).map(|s| s.as_str()).unwrap_or("") }; match db_ref.set_setting("secondary_currency", value) { Ok(()) => { if value.is_empty() { toast_ref.add_toast(adw::Toast::new("Secondary currency cleared")); } else { toast_ref.add_toast(adw::Toast::new(&format!("Secondary currency set to {}", value))); } } Err(e) => toast_ref.add_toast(adw::Toast::new(&format!("Error: {}", e))), } }); } currency_group.add(&secondary_row); // Appearance group let appearance_group = adw::PreferencesGroup::builder() .title("APPEARANCE") .build(); let theme_labels = ["System", "Light", "Dark"]; let theme_model = gtk::StringList::new(&theme_labels); let current_theme = db .get_setting("theme") .ok() .flatten() .unwrap_or_else(|| "system".to_string()); let theme_idx = match current_theme.as_str() { "light" => 1, "dark" => 2, _ => 0, }; let theme_row = adw::ComboRow::builder() .title("Theme") .model(&theme_model) .selected(theme_idx) .build(); { let db_ref = db.clone(); theme_row.connect_selected_notify(move |row| { let theme = match row.selected() { 1 => "light", 2 => "dark", _ => "system", }; db_ref.set_setting("theme", theme).ok(); Self::apply_theme(theme); }); } // Date format let today = chrono::Local::now().date_naive(); let date_format_options = [ format!("{} (YYYY-MM-DD)", today.format("%Y-%m-%d")), format!("{} (DD/MM/YYYY)", today.format("%d/%m/%Y")), format!("{} (MM/DD/YYYY)", today.format("%m/%d/%Y")), ]; let date_format_refs: Vec<&str> = date_format_options.iter().map(|s| s.as_str()).collect(); let date_format_model = gtk::StringList::new(&date_format_refs); let date_format_row = adw::ComboRow::builder() .title("Date Format") .model(&date_format_model) .build(); // Prevent truncation of the date format display let date_factory = gtk::SignalListItemFactory::new(); date_factory.connect_setup(|_, item| { let item = item.downcast_ref::().unwrap(); let label = gtk::Label::new(None); label.set_halign(gtk::Align::Start); label.set_ellipsize(gtk::pango::EllipsizeMode::None); item.set_child(Some(&label)); }); date_factory.connect_bind(|_, item| { let item = item.downcast_ref::().unwrap(); let string_obj = item.item().and_downcast::().unwrap(); let label = item.child().and_downcast::().unwrap(); label.set_label(&string_obj.string()); }); date_format_row.set_factory(Some(&date_factory)); date_format_row.set_list_factory(Some(&date_factory)); let current_date_fmt = db.get_setting("date_format") .ok().flatten().unwrap_or_else(|| "ymd".to_string()); let date_fmt_idx = match current_date_fmt.as_str() { "dmy" => 1, "mdy" => 2, _ => 0, }; date_format_row.set_selected(date_fmt_idx); { let db_ref = db.clone(); let toast_ref = toast_overlay.clone(); date_format_row.connect_selected_notify(move |row| { let val = match row.selected() { 1 => "dmy", 2 => "mdy", _ => "ymd", }; if let Ok(()) = db_ref.set_setting("date_format", val) { let toast = adw::Toast::new("Date format updated - reopen views to see changes"); toast_ref.add_toast(toast); } }); } appearance_group.add(&theme_row); appearance_group.add(&date_format_row); // Apply current theme on load Self::apply_theme(¤t_theme); // Categories group let categories_group = adw::PreferencesGroup::builder() .title("CATEGORIES") .build(); let expense_expander = adw::ExpanderRow::builder() .title("Expense Categories") .build(); Self::populate_category_expander( &db, &expense_expander, TransactionType::Expense, &toast_overlay, ); categories_group.add(&expense_expander); let income_expander = adw::ExpanderRow::builder() .title("Income Categories") .build(); Self::populate_category_expander( &db, &income_expander, TransactionType::Income, &toast_overlay, ); categories_group.add(&income_expander); let add_cat_btn = gtk::Button::with_label("Add Category"); add_cat_btn.add_css_class("pill"); add_cat_btn.set_halign(gtk::Align::Center); add_cat_btn.set_margin_top(8); { let db_ref = db.clone(); let expense_ref = expense_expander.clone(); let income_ref = income_expander.clone(); let toast_ref = toast_overlay.clone(); add_cat_btn.connect_clicked(move |btn| { Self::show_add_category_dialog( btn, &db_ref, &expense_ref, &income_ref, &toast_ref, ); }); } // Export group let export_group = adw::PreferencesGroup::builder() .title("EXPORT") .build(); let export_csv_row = adw::ActionRow::builder() .title("Export CSV") .subtitle("Export all transactions as CSV file") .activatable(true) .build(); export_csv_row.add_suffix(>k::Image::from_icon_name("outlay-export")); let export_json_row = adw::ActionRow::builder() .title("Export JSON") .subtitle("Export all data as JSON file") .activatable(true) .build(); export_json_row.add_suffix(>k::Image::from_icon_name("outlay-export")); let export_qif_row = adw::ActionRow::builder() .title("Export QIF") .subtitle("Quicken interchange format") .activatable(true) .build(); export_qif_row.add_suffix(>k::Image::from_icon_name("outlay-export")); let export_ofx_row = adw::ActionRow::builder() .title("Export OFX") .subtitle("Open financial exchange format") .activatable(true) .build(); export_ofx_row.add_suffix(>k::Image::from_icon_name("outlay-export")); let export_pdf_row = adw::ActionRow::builder() .title("Export PDF Report") .subtitle("Generate a monthly PDF report") .activatable(true) .build(); export_pdf_row.add_suffix(>k::Image::from_icon_name("outlay-export")); export_group.add(&export_csv_row); export_group.add(&export_json_row); export_group.add(&export_qif_row); export_group.add(&export_ofx_row); export_group.add(&export_pdf_row); // Import group let import_group = adw::PreferencesGroup::builder() .title("IMPORT") .build(); let import_csv_row = adw::ActionRow::builder() .title("Import CSV") .subtitle("Import transactions from a CSV file") .activatable(true) .build(); import_csv_row.add_suffix(>k::Image::from_icon_name("outlay-import")); let import_json_row = adw::ActionRow::builder() .title("Import JSON") .subtitle("Import data from a JSON export") .activatable(true) .build(); import_json_row.add_suffix(>k::Image::from_icon_name("outlay-import")); let import_qif_row = adw::ActionRow::builder() .title("Import QIF") .subtitle("Import from Quicken interchange format") .activatable(true) .build(); import_qif_row.add_suffix(>k::Image::from_icon_name("outlay-import")); let import_ofx_row = adw::ActionRow::builder() .title("Import OFX") .subtitle("Import from open financial exchange format") .activatable(true) .build(); import_ofx_row.add_suffix(>k::Image::from_icon_name("outlay-import")); let import_pdf_row = adw::ActionRow::builder() .title("Import PDF Statement") .subtitle("Parse transactions from a bank/credit card PDF statement") .activatable(true) .build(); import_pdf_row.add_suffix(>k::Image::from_icon_name("outlay-import")); import_group.add(&import_csv_row); import_group.add(&import_json_row); import_group.add(&import_qif_row); import_group.add(&import_ofx_row); import_group.add(&import_pdf_row); // Backup group let backup_group = adw::PreferencesGroup::builder() .title("BACKUP") .build(); let backup_row = adw::ActionRow::builder() .title("Full Backup") .subtitle("Create a backup of all data") .activatable(true) .build(); backup_row.add_suffix(>k::Image::from_icon_name("outlay-export")); let restore_row = adw::ActionRow::builder() .title("Restore from Backup") .subtitle("Restore data from a .outlay backup file") .activatable(true) .build(); restore_row.add_suffix(>k::Image::from_icon_name("outlay-import")); backup_group.add(&backup_row); backup_group.add(&restore_row); // Wire export buttons { let db_ref = db.clone(); let toast_ref = toast_overlay.clone(); export_csv_row.connect_activated(move |row| { Self::export_csv_action(row, &db_ref, &toast_ref); }); } { let db_ref = db.clone(); let toast_ref = toast_overlay.clone(); export_json_row.connect_activated(move |row| { Self::export_json_action(row, &db_ref, &toast_ref); }); } { let db_ref = db.clone(); let toast_ref = toast_overlay.clone(); export_qif_row.connect_activated(move |row| { Self::export_qif_action(row, &db_ref, &toast_ref); }); } { let db_ref = db.clone(); let toast_ref = toast_overlay.clone(); export_ofx_row.connect_activated(move |row| { Self::export_ofx_action(row, &db_ref, &toast_ref); }); } { let db_ref = db.clone(); let toast_ref = toast_overlay.clone(); export_pdf_row.connect_activated(move |row| { Self::export_pdf_action(row, &db_ref, &toast_ref); }); } { let db_ref = db.clone(); let toast_ref = toast_overlay.clone(); backup_row.connect_activated(move |row| { Self::backup_action(row, &db_ref, &toast_ref); }); } { let toast_ref = toast_overlay.clone(); let app_ref = app.clone(); restore_row.connect_activated(move |row| { Self::restore_action(row, &toast_ref, &app_ref); }); } { let db_ref = db.clone(); let toast_ref = toast_overlay.clone(); import_csv_row.connect_activated(move |row| { Self::import_csv_action(row, &db_ref, &toast_ref); }); } { let db_ref = db.clone(); let toast_ref = toast_overlay.clone(); import_json_row.connect_activated(move |row| { Self::import_json_action(row, &db_ref, &toast_ref); }); } { let db_ref = db.clone(); let toast_ref = toast_overlay.clone(); import_qif_row.connect_activated(move |row| { Self::import_qif_action(row, &db_ref, &toast_ref); }); } { let db_ref = db.clone(); let toast_ref = toast_overlay.clone(); import_ofx_row.connect_activated(move |row| { Self::import_ofx_action(row, &db_ref, &toast_ref); }); } { let db_ref = db.clone(); let toast_ref = toast_overlay.clone(); import_pdf_row.connect_activated(move |row| { Self::import_pdf_action(row, &db_ref, &toast_ref); }); } // Automatic backup group let auto_backup_group = adw::PreferencesGroup::builder() .title("AUTOMATIC BACKUPS") .description("Automatically back up your database on a schedule") .build(); let auto_backup_enabled = db.get_setting("auto_backup_enabled") .ok().flatten().unwrap_or_else(|| "0".to_string()) == "1"; let auto_backup_toggle = adw::SwitchRow::builder() .title("Enable Automatic Backups") .active(auto_backup_enabled) .build(); let freq_model = gtk::StringList::new(&["Daily", "Weekly", "Monthly"]); let auto_backup_freq = adw::ComboRow::builder() .title("Backup Frequency") .model(&freq_model) .sensitive(auto_backup_enabled) .build(); let current_freq = db.get_setting("auto_backup_frequency") .ok().flatten().unwrap_or_else(|| "weekly".to_string()); let freq_idx = match current_freq.as_str() { "daily" => 0, "monthly" => 2, _ => 1, }; auto_backup_freq.set_selected(freq_idx); let retention_row = adw::SpinRow::builder() .title("Keep Last N Backups") .subtitle("Older backups will be deleted automatically") .sensitive(auto_backup_enabled) .build(); let retention_adj = gtk::Adjustment::new( db.get_setting("auto_backup_retention") .ok().flatten() .and_then(|v| v.parse::().ok()) .unwrap_or(5.0), 1.0, 50.0, 1.0, 5.0, 0.0, ); retention_row.set_adjustment(Some(&retention_adj)); let auto_backup_dir = db.get_setting("auto_backup_dir") .ok().flatten() .unwrap_or_default(); let dir_subtitle = if auto_backup_dir.is_empty() { "Default: ~/.local/share/outlay/backups".to_string() } else { auto_backup_dir.clone() }; let dir_row = adw::ActionRow::builder() .title("Backup Directory") .subtitle(&dir_subtitle) .activatable(true) .build(); dir_row.add_suffix(>k::Image::from_icon_name("folder-symbolic")); let last_auto_backup = db.get_setting("auto_backup_last") .ok().flatten().unwrap_or_else(|| "Never".to_string()); let last_backup_row = adw::ActionRow::builder() .title("Last Automatic Backup") .subtitle(&last_auto_backup) .build(); let backup_now_btn = gtk::Button::with_label("Back Up Now"); backup_now_btn.add_css_class("pill"); backup_now_btn.set_halign(gtk::Align::Center); backup_now_btn.set_margin_top(8); auto_backup_group.add(&auto_backup_toggle); auto_backup_group.add(&auto_backup_freq); auto_backup_group.add(&retention_row); auto_backup_group.add(&dir_row); auto_backup_group.add(&last_backup_row); // Auto-backup toggle handler { let db_ref = db.clone(); let freq_ref = auto_backup_freq.clone(); let retention_ref = retention_row.clone(); auto_backup_toggle.connect_active_notify(move |row| { let val = if row.is_active() { "1" } else { "0" }; let _ = db_ref.set_setting("auto_backup_enabled", val); freq_ref.set_sensitive(row.is_active()); retention_ref.set_sensitive(row.is_active()); }); } // Frequency change handler { let db_ref = db.clone(); auto_backup_freq.connect_selected_notify(move |row| { let val = match row.selected() { 0 => "daily", 2 => "monthly", _ => "weekly", }; let _ = db_ref.set_setting("auto_backup_frequency", val); }); } // Retention change handler { let db_ref = db.clone(); retention_adj.connect_value_changed(move |adj| { let val = adj.value() as i32; let _ = db_ref.set_setting("auto_backup_retention", &val.to_string()); }); } // Directory chooser handler { let db_ref = db.clone(); let dir_row_ref = dir_row.clone(); let toast_ref = toast_overlay.clone(); dir_row.connect_activated(move |row| { let dialog = gtk::FileDialog::builder() .title("Choose Backup Directory") .build(); let window = row.root().and_then(|r| r.downcast::().ok()); let db_save = db_ref.clone(); let row_save = dir_row_ref.clone(); let toast_save = toast_ref.clone(); dialog.select_folder(window.as_ref(), gio::Cancellable::NONE, move |result| { if let Ok(file) = result { if let Some(path) = file.path() { let path_str = path.to_string_lossy().to_string(); let _ = db_save.set_setting("auto_backup_dir", &path_str); row_save.set_subtitle(&path_str); let toast = adw::Toast::new(&format!("Backup directory set to {}", path_str)); toast_save.add_toast(toast); } } }); }); } // Back up now handler { let db_ref = db.clone(); let toast_ref = toast_overlay.clone(); let last_row_ref = last_backup_row.clone(); backup_now_btn.connect_clicked(move |_| { let backup_dir = db_ref.get_setting("auto_backup_dir") .ok().flatten() .unwrap_or_default(); let dir = if backup_dir.is_empty() { glib::user_data_dir().join("outlay").join("backups") } else { std::path::PathBuf::from(&backup_dir) }; match Self::run_auto_backup(&db_ref, &dir) { Ok(path) => { let now_str = Local::now().format("%Y-%m-%d %H:%M").to_string(); last_row_ref.set_subtitle(&now_str); let toast = adw::Toast::new(&format!("Backup saved to {}", path.display())); toast_ref.add_toast(toast); } Err(e) => { let toast = adw::Toast::new(&format!("Backup error: {}", e)); toast_ref.add_toast(toast); } } }); } // Reset group let reset_group = adw::PreferencesGroup::builder() .title("DANGER ZONE") .build(); let reset_row = adw::ActionRow::builder() .title("Reset All Data") .subtitle("Delete all transactions, budgets, and settings") .activatable(true) .build(); reset_row.add_css_class("error"); let on_data_reset: Rc>>> = Rc::new(RefCell::new(None)); { let db_ref = db.clone(); let toast_ref = toast_overlay.clone(); let reset_cb = on_data_reset.clone(); reset_row.connect_activated(move |row| { Self::reset_action(row, &db_ref, &toast_ref, &reset_cb); }); } reset_group.add(&reset_row); // -- Auto-categorization rules -- let rules_group = adw::PreferencesGroup::builder() .title("AUTO-CATEGORIZATION RULES") .description("Automatically assign categories based on note or payee text") .build(); Self::populate_rules(&db, &rules_group, &toast_overlay); let add_rule_btn = gtk::Button::with_label("Add Rule"); add_rule_btn.add_css_class("pill"); add_rule_btn.set_halign(gtk::Align::Center); add_rule_btn.set_margin_top(8); { let db_ref = db.clone(); let group_ref = rules_group.clone(); let toast_ref = toast_overlay.clone(); add_rule_btn.connect_clicked(move |btn| { Self::show_add_rule_dialog(btn, &db_ref, &group_ref, &toast_ref); }); } // -- Transaction templates -- let templates_group = adw::PreferencesGroup::builder() .title("TRANSACTION TEMPLATES") .description("Save frequent transactions for quick entry") .build(); Self::populate_templates(&db, &templates_group, &toast_overlay); let add_template_btn = gtk::Button::with_label("Add Template"); add_template_btn.add_css_class("pill"); add_template_btn.set_halign(gtk::Align::Center); add_template_btn.set_margin_top(8); { let db_ref = db.clone(); let group_ref = templates_group.clone(); let toast_ref = toast_overlay.clone(); add_template_btn.connect_clicked(move |btn| { Self::show_add_template_dialog(btn, &db_ref, &group_ref, &toast_ref); }); } // -- Budget settings -- let budget_group = adw::PreferencesGroup::builder() .title("BUDGET SETTINGS") .description("Configure budget periods and notifications") .build(); let period_model = gtk::StringList::new(&["Monthly", "Weekly", "Biweekly"]); let period_row = adw::ComboRow::builder() .title("Budget Period") .model(&period_model) .build(); let current_period = db.get_setting("budget_period") .ok().flatten().unwrap_or_else(|| "monthly".to_string()); let period_idx = match current_period.as_str() { "weekly" => 1, "biweekly" => 2, _ => 0, }; period_row.set_selected(period_idx); let week_start_model = gtk::StringList::new(&[ "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday", ]); let week_start_row = adw::ComboRow::builder() .title("Week Starts On") .model(&week_start_model) .build(); let current_week_start = db.get_setting("week_start_day") .ok().flatten().unwrap_or_else(|| "monday".to_string()); let week_idx = match current_week_start.as_str() { "tuesday" => 1, "wednesday" => 2, "thursday" => 3, "friday" => 4, "saturday" => 5, "sunday" => 6, _ => 0, }; week_start_row.set_selected(week_idx); week_start_row.set_visible(current_period == "weekly" || current_period == "biweekly"); let notifications_row = adw::SwitchRow::builder() .title("Budget Notifications") .subtitle("Alert when approaching or exceeding budget limits") .build(); let notif_enabled = db.get_setting("budget_notifications") .ok().flatten().map(|s| s == "1").unwrap_or(false); notifications_row.set_active(notif_enabled); let recurring_notif_row = adw::SwitchRow::builder() .title("Recurring Transaction Notifications") .subtitle("Notify when recurring transactions are auto-generated") .build(); let recurring_notif_enabled = db.get_setting("notify_recurring") .ok().flatten().map(|s| s == "1").unwrap_or(true); recurring_notif_row.set_active(recurring_notif_enabled); budget_group.add(&period_row); budget_group.add(&week_start_row); budget_group.add(¬ifications_row); budget_group.add(&recurring_notif_row); // Budget period change handler { let db_ref = db.clone(); let week_row = week_start_row.clone(); period_row.connect_selected_notify(move |row| { let val = match row.selected() { 1 => "weekly", 2 => "biweekly", _ => "monthly", }; let _ = db_ref.set_setting("budget_period", val); week_row.set_visible(val == "weekly" || val == "biweekly"); }); } // Week start change handler { let db_ref = db.clone(); week_start_row.connect_selected_notify(move |row| { let val = match row.selected() { 1 => "tuesday", 2 => "wednesday", 3 => "thursday", 4 => "friday", 5 => "saturday", 6 => "sunday", _ => "monday", }; let _ = db_ref.set_setting("week_start_day", val); }); } // Notifications toggle handler { let db_ref = db.clone(); notifications_row.connect_active_notify(move |row| { let val = if row.is_active() { "1" } else { "0" }; let _ = db_ref.set_setting("budget_notifications", val); }); } { let db_ref = db.clone(); recurring_notif_row.connect_active_notify(move |row| { let val = if row.is_active() { "1" } else { "0" }; let _ = db_ref.set_setting("notify_recurring", val); }); } // Subscription categories group let sub_cat_group = adw::PreferencesGroup::builder() .title("SUBSCRIPTION CATEGORIES") .build(); let sub_cat_expander = adw::ExpanderRow::builder() .title("Subscription Categories") .build(); Self::populate_subscription_categories(&db, &sub_cat_expander, &toast_overlay); sub_cat_group.add(&sub_cat_expander); let add_sub_cat_btn = gtk::Button::with_label("Add Subscription Category"); add_sub_cat_btn.add_css_class("pill"); add_sub_cat_btn.set_halign(gtk::Align::Center); add_sub_cat_btn.set_margin_top(8); { let db_ref = db.clone(); let expander_ref = sub_cat_expander.clone(); let toast_ref = toast_overlay.clone(); add_sub_cat_btn.connect_clicked(move |btn| { Self::show_add_subscription_category_dialog( btn, &db_ref, &expander_ref, &toast_ref, ); }); } // Back button helper let make_back = |stack: >k::Stack| -> gtk::Button { let btn = gtk::Button::new(); btn.add_css_class("flat"); let content = gtk::Box::new(gtk::Orientation::Horizontal, 6); content.append(>k::Image::from_icon_name("go-previous-symbolic")); content.append(>k::Label::new(Some("Settings"))); btn.set_child(Some(&content)); btn.set_halign(gtk::Align::Start); let s = stack.clone(); btn.connect_clicked(move |_| { s.set_visible_child_name("root"); }); btn }; // ===== Root page ===== let root_inner = gtk::Box::new(gtk::Orientation::Vertical, 20); root_inner.set_margin_top(20); root_inner.set_margin_bottom(20); let root_group = adw::PreferencesGroup::new(); let nav_general = adw::ActionRow::builder() .title("General") .subtitle("Currency, appearance, budget settings") .activatable(true) .build(); nav_general.add_prefix(>k::Image::from_icon_name("emblem-system-symbolic")); nav_general.add_suffix(>k::Image::from_icon_name("go-next-symbolic")); let nav_categories = adw::ActionRow::builder() .title("Categories") .subtitle("Manage categories, rules, and templates") .activatable(true) .build(); nav_categories.add_prefix(>k::Image::from_icon_name("view-list-symbolic")); nav_categories.add_suffix(>k::Image::from_icon_name("go-next-symbolic")); let nav_import_export = adw::ActionRow::builder() .title("Import / Export") .subtitle("Export data or import from files") .activatable(true) .build(); nav_import_export.add_prefix(>k::Image::from_icon_name("document-send-symbolic")); nav_import_export.add_suffix(>k::Image::from_icon_name("go-next-symbolic")); let nav_backup = adw::ActionRow::builder() .title("Backup") .subtitle("Back up, restore, and manage data") .activatable(true) .build(); nav_backup.add_prefix(>k::Image::from_icon_name("drive-harddisk-symbolic")); nav_backup.add_suffix(>k::Image::from_icon_name("go-next-symbolic")); root_group.add(&nav_general); root_group.add(&nav_categories); root_group.add(&nav_import_export); root_group.add(&nav_backup); root_inner.append(&root_group); let root_clamp = adw::Clamp::new(); root_clamp.set_maximum_size(700); root_clamp.set_margin_start(16); root_clamp.set_margin_end(16); root_clamp.set_child(Some(&root_inner)); let root_scroll = gtk::ScrolledWindow::builder() .hscrollbar_policy(gtk::PolicyType::Never) .vexpand(true) .child(&root_clamp) .build(); settings_stack.add_named(&root_scroll, Some("root")); // ===== General sub-page ===== let general_inner = gtk::Box::new(gtk::Orientation::Vertical, 20); general_inner.set_margin_top(20); general_inner.set_margin_bottom(20); general_inner.prepend(&make_back(&settings_stack)); general_inner.append(¤cy_group); general_inner.append(&appearance_group); general_inner.append(&budget_group); let general_clamp = adw::Clamp::new(); general_clamp.set_maximum_size(700); general_clamp.set_margin_start(16); general_clamp.set_margin_end(16); general_clamp.set_child(Some(&general_inner)); let general_scroll = gtk::ScrolledWindow::builder() .hscrollbar_policy(gtk::PolicyType::Never) .vexpand(true) .child(&general_clamp) .build(); settings_stack.add_named(&general_scroll, Some("general")); // ===== Categories sub-page ===== let categories_inner = gtk::Box::new(gtk::Orientation::Vertical, 20); categories_inner.set_margin_top(20); categories_inner.set_margin_bottom(20); categories_inner.prepend(&make_back(&settings_stack)); categories_inner.append(&categories_group); categories_inner.append(&add_cat_btn); categories_inner.append(&sub_cat_group); categories_inner.append(&add_sub_cat_btn); categories_inner.append(&rules_group); categories_inner.append(&add_rule_btn); categories_inner.append(&templates_group); categories_inner.append(&add_template_btn); let categories_clamp = adw::Clamp::new(); categories_clamp.set_maximum_size(700); categories_clamp.set_margin_start(16); categories_clamp.set_margin_end(16); categories_clamp.set_child(Some(&categories_inner)); let categories_scroll = gtk::ScrolledWindow::builder() .hscrollbar_policy(gtk::PolicyType::Never) .vexpand(true) .child(&categories_clamp) .build(); settings_stack.add_named(&categories_scroll, Some("categories")); // ===== Import / Export sub-page ===== let ie_inner = gtk::Box::new(gtk::Orientation::Vertical, 20); ie_inner.set_margin_top(20); ie_inner.set_margin_bottom(20); ie_inner.prepend(&make_back(&settings_stack)); ie_inner.append(&export_group); ie_inner.append(&import_group); let ie_clamp = adw::Clamp::new(); ie_clamp.set_maximum_size(700); ie_clamp.set_margin_start(16); ie_clamp.set_margin_end(16); ie_clamp.set_child(Some(&ie_inner)); let ie_scroll = gtk::ScrolledWindow::builder() .hscrollbar_policy(gtk::PolicyType::Never) .vexpand(true) .child(&ie_clamp) .build(); settings_stack.add_named(&ie_scroll, Some("import_export")); // ===== Backup sub-page ===== let backup_inner = gtk::Box::new(gtk::Orientation::Vertical, 20); backup_inner.set_margin_top(20); backup_inner.set_margin_bottom(20); backup_inner.prepend(&make_back(&settings_stack)); backup_inner.append(&backup_group); backup_inner.append(&auto_backup_group); backup_inner.append(&backup_now_btn); backup_inner.append(&reset_group); let backup_clamp = adw::Clamp::new(); backup_clamp.set_maximum_size(700); backup_clamp.set_margin_start(16); backup_clamp.set_margin_end(16); backup_clamp.set_child(Some(&backup_inner)); let backup_scroll = gtk::ScrolledWindow::builder() .hscrollbar_policy(gtk::PolicyType::Never) .vexpand(true) .child(&backup_clamp) .build(); settings_stack.add_named(&backup_scroll, Some("backup")); // ===== Wire navigation rows ===== { let s = settings_stack.clone(); nav_general.connect_activated(move |_| { s.set_visible_child_name("general"); }); } { let s = settings_stack.clone(); nav_categories.connect_activated(move |_| { s.set_visible_child_name("categories"); }); } { let s = settings_stack.clone(); nav_import_export.connect_activated(move |_| { s.set_visible_child_name("import_export"); }); } { let s = settings_stack.clone(); nav_backup.connect_activated(move |_| { s.set_visible_child_name("backup"); }); } toast_overlay.set_child(Some(&settings_stack)); container.append(&toast_overlay); SettingsView { container, on_data_reset } } pub fn set_on_data_reset(&self, cb: F) { *self.on_data_reset.borrow_mut() = Some(Box::new(cb)); } fn apply_theme(theme: &str) { let style_manager = adw::StyleManager::default(); match theme { "light" => style_manager.set_color_scheme(adw::ColorScheme::ForceLight), "dark" => style_manager.set_color_scheme(adw::ColorScheme::ForceDark), _ => style_manager.set_color_scheme(adw::ColorScheme::Default), } } fn populate_category_expander( db: &Rc, expander: &adw::ExpanderRow, txn_type: TransactionType, toast_overlay: &adw::ToastOverlay, ) { // Remove existing category rows by walking the widget tree // Only remove rows we tagged with "cat-row-*" to avoid touching internal widgets fn collect_tagged_rows(widget: >k::Widget, rows: &mut Vec) { let mut child = widget.first_child(); while let Some(c) = child { if c.widget_name().starts_with("cat-row-") { if let Some(ar) = c.downcast_ref::() { rows.push(ar.clone()); } } collect_tagged_rows(&c, rows); child = c.next_sibling(); } } let mut to_remove = Vec::new(); collect_tagged_rows(expander.upcast_ref(), &mut to_remove); for row in &to_remove { expander.remove(row); } let cats = db.list_categories(Some(txn_type)).unwrap_or_default(); for cat in &cats { let row = adw::ActionRow::builder() .title(&cat.name) .activatable(true) .build(); row.set_widget_name(&format!("cat-row-{}", cat.id)); // Drag handle (leftmost prefix) let handle = gtk::Image::from_icon_name("list-drag-handle-symbolic"); handle.set_pixel_size(16); handle.set_opacity(0.75); row.add_prefix(&handle); let icon_name = icon_theme::resolve_category_icon(&cat.icon, &cat.color); if let Some(name) = &icon_name { let icon = gtk::Image::from_icon_name(name); icon.set_pixel_size(24); row.add_prefix(&icon); } // Edit on click { let cat_clone = cat.clone(); let db_ref = db.clone(); let expander_ref = expander.clone(); let toast_ref = toast_overlay.clone(); row.connect_activated(move |row| { Self::show_edit_category_dialog( row, &cat_clone, &db_ref, &expander_ref, txn_type, &toast_ref, ); }); } // Drag-to-reorder { let cat_id = cat.id; let drag_source = gtk::DragSource::new(); drag_source.set_actions(gdk::DragAction::MOVE); drag_source.connect_prepare(move |_, _, _| { Some(gdk::ContentProvider::for_value(&cat_id.to_value())) }); let row_ref = row.clone(); drag_source.connect_drag_begin(move |src, _| { let paintable = gtk::WidgetPaintable::new(Some(&row_ref)); src.set_icon(Some(&paintable), 0, 0); }); handle.add_controller(drag_source); let drop_target = gtk::DropTarget::new(i64::static_type(), gdk::DragAction::MOVE); let db_ref = db.clone(); let expander_ref = expander.clone(); let toast_ref = toast_overlay.clone(); let target_cat_id = cat.id; drop_target.connect_drop(move |_, value, _, _| { if let Ok(source_id) = value.get::() { if source_id != target_cat_id { let mut cats = db_ref.list_categories(Some(txn_type)).unwrap_or_default(); let src_idx = cats.iter().position(|c| c.id == source_id); let tgt_idx = cats.iter().position(|c| c.id == target_cat_id); if let (Some(si), Some(ti)) = (src_idx, tgt_idx) { let removed = cats.remove(si); cats.insert(ti, removed); for (i, c) in cats.iter_mut().enumerate() { c.sort_order = i as i32; let _ = db_ref.update_category(c); } Self::populate_category_expander( &db_ref, &expander_ref, txn_type, &toast_ref, ); } } true } else { false } }); row.add_controller(drop_target); } if !cat.is_default { let delete_btn = gtk::Button::from_icon_name("outlay-delete"); delete_btn.add_css_class("flat"); delete_btn.set_valign(gtk::Align::Center); delete_btn.set_tooltip_text(Some("Delete category")); let cat_id = cat.id; let db_ref = db.clone(); let expander_ref = expander.clone(); let toast_ref = toast_overlay.clone(); delete_btn.connect_clicked(move |btn| { let alert = adw::AlertDialog::new( Some("Delete this category?"), Some("Transactions using this category will not be deleted."), ); 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 exp_del = expander_ref.clone(); let toast_del = toast_ref.clone(); alert.connect_response(None, move |_, response| { if response == "delete" { match db_del.delete_category(cat_id) { Ok(()) => { let toast = adw::Toast::new("Category deleted"); toast_del.add_toast(toast); Self::populate_category_expander( &db_del, &exp_del, txn_type, &toast_del, ); } Err(e) => { let toast = adw::Toast::new(&format!("Error: {}", e)); toast_del.add_toast(toast); } } } }); alert.present(Some(btn)); }); row.add_suffix(&delete_btn); } let chevron = gtk::Image::from_icon_name("go-next-symbolic"); row.add_suffix(&chevron); expander.add_row(&row); } } fn show_edit_category_dialog( parent: &adw::ActionRow, cat: &Category, db: &Rc, expander: &adw::ExpanderRow, txn_type: TransactionType, toast_overlay: &adw::ToastOverlay, ) { let dialog = adw::Dialog::builder() .title("Edit Category") .content_width(400) .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 form = adw::PreferencesGroup::new(); let name_row = adw::EntryRow::builder() .title("Name") .text(&cat.name) .build(); form.add(&name_row); let selected_icon: Rc>> = Rc::new(RefCell::new(cat.icon.clone())); let initial_icon = icon_theme::resolve_category_icon(&cat.icon, &cat.color) .unwrap_or_else(|| "outlay-list".to_string()); let icon_preview = gtk::Image::builder() .icon_name(&initial_icon) .pixel_size(24) .build(); let icon_subtitle = cat.icon.as_deref().unwrap_or("None selected"); let icon_row = adw::ActionRow::builder() .title("Icon") .subtitle(icon_subtitle) .activatable(true) .build(); icon_row.add_prefix(&icon_preview); let chevron = gtk::Image::from_icon_name("go-next-symbolic"); icon_row.add_suffix(&chevron); { let selected_icon_ref = selected_icon.clone(); let icon_preview_ref = icon_preview.clone(); let icon_row_ref = icon_row.clone(); let dialog_ref = dialog.clone(); icon_row.connect_activated(move |_| { Self::show_icon_picker_dialog( &dialog_ref, &selected_icon_ref, &icon_preview_ref, &icon_row_ref, ); }); } form.add(&icon_row); let current_color = cat.color.as_deref() .and_then(|hex| { let hex = hex.trim_start_matches('#'); if hex.len() != 6 { return None; } let r = u8::from_str_radix(&hex[0..2], 16).ok()? as f32 / 255.0; let g = u8::from_str_radix(&hex[2..4], 16).ok()? as f32 / 255.0; let b = u8::from_str_radix(&hex[4..6], 16).ok()? as f32 / 255.0; Some(gdk::RGBA::new(r, g, b, 1.0)) }) .unwrap_or_else(|| gdk::RGBA::new(0.204, 0.596, 0.859, 1.0)); let color_dialog = gtk::ColorDialog::new(); let color_button = gtk::ColorDialogButton::new(Some(color_dialog)); color_button.set_rgba(¤t_color); color_button.set_valign(gtk::Align::Center); let color_row = adw::ActionRow::builder() .title("Color") .build(); color_row.add_suffix(&color_button); form.add(&color_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 cat_id = cat.id; let sort_order = cat.sort_order; let is_default = cat.is_default; let db_ref = db.clone(); let dialog_ref = dialog.clone(); let expander_ref = expander.clone(); let toast_ref = toast_overlay.clone(); let selected_icon_ref = selected_icon.clone(); save_btn.connect_clicked(move |_| { let name = name_row.text().to_string(); if name.trim().is_empty() { let toast = adw::Toast::new("Please enter a category name"); toast_ref.add_toast(toast); return; } let icon = selected_icon_ref.borrow().clone(); let color = Some(Self::rgba_to_hex(&color_button.rgba())); let updated = Category { id: cat_id, name: name.trim().to_string(), icon, color, transaction_type: txn_type, is_default, sort_order, parent_id: None, }; match db_ref.update_category(&updated) { Ok(()) => { dialog_ref.close(); let toast = adw::Toast::new("Category updated"); toast_ref.add_toast(toast); Self::populate_category_expander( &db_ref, &expander_ref, txn_type, &toast_ref, ); } Err(e) => { let toast = adw::Toast::new(&format!("Error: {}", e)); toast_ref.add_toast(toast); } } }); } dialog.present(Some(parent)); } fn rgba_to_hex(rgba: &gdk::RGBA) -> String { let r = (rgba.red() * 255.0).round() as u8; let g = (rgba.green() * 255.0).round() as u8; let b = (rgba.blue() * 255.0).round() as u8; format!("#{:02x}{:02x}{:02x}", r, g, b) } fn show_add_category_dialog( parent: >k::Button, db: &Rc, expense_expander: &adw::ExpanderRow, income_expander: &adw::ExpanderRow, toast_overlay: &adw::ToastOverlay, ) { let dialog = adw::Dialog::builder() .title("Add Category") .content_width(400) .content_height(420) .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(); let name_row = adw::EntryRow::builder() .title("Name") .build(); form.add(&name_row); // Icon row - ActionRow that opens icon picker let selected_icon: Rc>> = Rc::new(RefCell::new(None)); let icon_preview = gtk::Image::builder() .icon_name("outlay-list") .pixel_size(24) .build(); let icon_row = adw::ActionRow::builder() .title("Icon") .subtitle("None selected") .activatable(true) .build(); icon_row.add_prefix(&icon_preview); let chevron = gtk::Image::from_icon_name("go-next-symbolic"); icon_row.add_suffix(&chevron); { let selected_icon_ref = selected_icon.clone(); let icon_preview_ref = icon_preview.clone(); let icon_row_ref = icon_row.clone(); let dialog_ref = dialog.clone(); icon_row.connect_activated(move |_| { Self::show_icon_picker_dialog( &dialog_ref, &selected_icon_ref, &icon_preview_ref, &icon_row_ref, ); }); } form.add(&icon_row); // Color row - with ColorDialogButton let default_color = gdk::RGBA::new(0.204, 0.596, 0.859, 1.0); // #3498db let color_dialog = gtk::ColorDialog::new(); let color_button = gtk::ColorDialogButton::new(Some(color_dialog)); color_button.set_rgba(&default_color); color_button.set_valign(gtk::Align::Center); let color_row = adw::ActionRow::builder() .title("Color") .build(); color_row.add_suffix(&color_button); form.add(&color_row); // Type row let type_labels = ["Expense", "Income"]; let type_model = gtk::StringList::new(&type_labels); let type_row = adw::ComboRow::builder() .title("Type") .model(&type_model) .build(); form.add(&type_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 expense_ref = expense_expander.clone(); let income_ref = income_expander.clone(); let toast_ref = toast_overlay.clone(); let selected_icon_ref = selected_icon.clone(); save_btn.connect_clicked(move |_| { let name = name_row.text().to_string(); if name.trim().is_empty() { let toast = adw::Toast::new("Please enter a category name"); toast_ref.add_toast(toast); return; } let icon = selected_icon_ref.borrow().clone(); let color = Some(Self::rgba_to_hex(&color_button.rgba())); let txn_type = if type_row.selected() == 0 { TransactionType::Expense } else { TransactionType::Income }; let new_cat = NewCategory { name: name.trim().to_string(), icon, color, transaction_type: txn_type, sort_order: 100, parent_id: None, }; match db_ref.insert_category(&new_cat) { Ok(_) => { dialog_ref.close(); let toast = adw::Toast::new("Category added"); toast_ref.add_toast(toast); Self::populate_category_expander( &db_ref, &expense_ref, TransactionType::Expense, &toast_ref, ); Self::populate_category_expander( &db_ref, &income_ref, TransactionType::Income, &toast_ref, ); } Err(e) => { let toast = adw::Toast::new(&format!("Error: {}", e)); toast_ref.add_toast(toast); } } }); } dialog.present(Some(parent)); } fn show_icon_picker_dialog( parent: &adw::Dialog, selected_icon: &Rc>>, icon_preview: >k::Image, icon_row: &adw::ActionRow, ) { let picker = adw::Dialog::builder() .title("Choose Icon") .content_width(480) .content_height(520) .build(); let toolbar = adw::ToolbarView::new(); toolbar.add_top_bar(&adw::HeaderBar::new()); let vbox = gtk::Box::new(gtk::Orientation::Vertical, 8); vbox.set_margin_start(12); vbox.set_margin_end(12); vbox.set_margin_top(8); vbox.set_margin_bottom(8); let search = gtk::SearchEntry::builder() .placeholder_text("Search icons...") .build(); vbox.append(&search); let flow = gtk::FlowBox::builder() .homogeneous(true) .min_children_per_line(6) .max_children_per_line(12) .selection_mode(gtk::SelectionMode::None) .build(); flow.add_css_class("icon-picker-grid"); let icons = icon_theme::list_tabler_icons(); for icon_name in &icons { let image = gtk::Image::builder() .icon_name(icon_name) .pixel_size(24) .build(); let display_name = icon_name.strip_prefix("tabler-").unwrap_or(icon_name); let btn = gtk::Button::builder() .child(&image) .tooltip_text(display_name) .build(); btn.add_css_class("flat"); btn.add_css_class("icon-picker-btn"); let picker_ref = picker.clone(); let selected_ref = selected_icon.clone(); let preview_ref = icon_preview.clone(); let row_ref = icon_row.clone(); let name = icon_name.clone(); btn.connect_clicked(move |_| { *selected_ref.borrow_mut() = Some(name.clone()); preview_ref.set_icon_name(Some(&name)); row_ref.set_subtitle(&name); picker_ref.close(); }); let child = gtk::FlowBoxChild::new(); child.set_child(Some(&btn)); child.set_widget_name(icon_name); flow.append(&child); } // Search filtering { let flow_ref = flow.clone(); search.connect_search_changed(move |entry| { let query = entry.text().to_string().to_lowercase(); let flow_filter = flow_ref.clone(); flow_filter.set_filter_func(move |child| { if query.is_empty() { return true; } child.widget_name().to_lowercase().contains(&query) }); }); } let scroll = gtk::ScrolledWindow::builder() .hscrollbar_policy(gtk::PolicyType::Never) .vexpand(true) .child(&flow) .build(); vbox.append(&scroll); toolbar.set_content(Some(&vbox)); picker.set_child(Some(&toolbar)); picker.present(Some(parent)); } fn export_csv_action( row: &adw::ActionRow, db: &Rc, toast_overlay: &adw::ToastOverlay, ) { // 6.1: Export preview dialog let (txn_count, cat_count, earliest, latest) = db.get_export_stats().unwrap_or((0, 0, None, None)); if txn_count == 0 { let toast = adw::Toast::new("No transactions to export"); toast_overlay.add_toast(toast); return; } let date_range = match (&earliest, &latest) { (Some(e), Some(l)) => format!("{} to {}", e, l), _ => "Unknown".to_string(), }; let est_size = txn_count as f64 * 100.0 / 1024.0; let alert = adw::AlertDialog::new( Some("Export preview"), Some(&format!( "Date range: {}\nTransactions: {}\nCategories: {}\nEstimated size: ~{:.0} KB", date_range, txn_count, cat_count, est_size )), ); alert.add_response("cancel", "Cancel"); alert.add_response("export", "Export"); alert.set_response_appearance("export", adw::ResponseAppearance::Suggested); alert.set_default_response(Some("export")); alert.set_close_response("cancel"); let db_ref = db.clone(); let toast_ref = toast_overlay.clone(); let row_ref = row.clone(); alert.connect_response(None, move |_, response| { if response != "export" { return; } let filter = gtk::FileFilter::new(); filter.add_pattern("*.csv"); filter.set_name(Some("CSV files")); let filters = gio::ListStore::new::(); filters.append(&filter); let dialog = gtk::FileDialog::builder() .title("Export CSV") .initial_name("outlay-export.csv") .default_filter(&filter) .filters(&filters) .build(); let db_save = db_ref.clone(); let toast_save = toast_ref.clone(); let window = row_ref.root().and_then(|r| r.downcast::().ok()); dialog.save(window.as_ref(), gio::Cancellable::NONE, move |result: Result| { if let Ok(file) = result { if let Some(path) = file.path() { match std::fs::File::create(&path) { Ok(f) => { match export_csv::export_transactions_csv(&db_save, f, None, None) { Ok(count) => { let display_path = path.display(); let toast = adw::Toast::new(&format!( "Exported {} transactions to {}", count, display_path )); toast_save.add_toast(toast); } Err(e) => { let toast = adw::Toast::new(&format!("Export error: {}", e)); toast_save.add_toast(toast); } } } Err(e) => { let toast = adw::Toast::new(&format!("File error: {}", e)); toast_save.add_toast(toast); } } } } }); }); alert.present(Some(row)); } fn export_json_action( row: &adw::ActionRow, db: &Rc, toast_overlay: &adw::ToastOverlay, ) { // 6.1: Export preview dialog let (txn_count, cat_count, earliest, latest) = db.get_export_stats().unwrap_or((0, 0, None, None)); if txn_count == 0 { let toast = adw::Toast::new("No transactions to export"); toast_overlay.add_toast(toast); return; } let date_range = match (&earliest, &latest) { (Some(e), Some(l)) => format!("{} to {}", e, l), _ => "Unknown".to_string(), }; let est_size = txn_count as f64 * 200.0 / 1024.0; let alert = adw::AlertDialog::new( Some("Export preview"), Some(&format!( "Date range: {}\nTransactions: {}\nCategories: {}\nEstimated size: ~{:.0} KB", date_range, txn_count, cat_count, est_size )), ); alert.add_response("cancel", "Cancel"); alert.add_response("export", "Export"); alert.set_response_appearance("export", adw::ResponseAppearance::Suggested); alert.set_default_response(Some("export")); alert.set_close_response("cancel"); let db_ref = db.clone(); let toast_ref = toast_overlay.clone(); let row_ref = row.clone(); alert.connect_response(None, move |_, response| { if response != "export" { return; } let filter = gtk::FileFilter::new(); filter.add_pattern("*.json"); filter.set_name(Some("JSON files")); let filters = gio::ListStore::new::(); filters.append(&filter); let dialog = gtk::FileDialog::builder() .title("Export JSON") .initial_name("outlay-export.json") .default_filter(&filter) .filters(&filters) .build(); let db_save = db_ref.clone(); let toast_save = toast_ref.clone(); let window = row_ref.root().and_then(|r| r.downcast::().ok()); dialog.save(window.as_ref(), gio::Cancellable::NONE, move |result: Result| { if let Ok(file) = result { if let Some(path) = file.path() { match std::fs::File::create(&path) { Ok(f) => match export_json::export_json(&db_save, f) { Ok(data) => { let display_path = path.display(); let toast = adw::Toast::new(&format!( "Exported {} transactions to {}", data.transactions.len(), display_path )); toast_save.add_toast(toast); } Err(e) => { let toast = adw::Toast::new(&format!("Export error: {}", e)); toast_save.add_toast(toast); } }, Err(e) => { let toast = adw::Toast::new(&format!("File error: {}", e)); toast_save.add_toast(toast); } } } } }); }); alert.present(Some(row)); } fn export_pdf_action( row: &adw::ActionRow, db: &Rc, toast_overlay: &adw::ToastOverlay, ) { let dialog = adw::Dialog::builder() .title("Export PDF Report") .content_width(340) .content_height(200) .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 today = Local::now().date_naive(); let year = Rc::new(std::cell::Cell::new(today.year())); let month = Rc::new(std::cell::Cell::new(today.month())); let nav = gtk::Box::new(gtk::Orientation::Horizontal, 12); nav.set_halign(gtk::Align::Center); let prev_btn = gtk::Button::from_icon_name("go-previous-symbolic"); prev_btn.add_css_class("flat"); prev_btn.add_css_class("circular"); prev_btn.set_tooltip_text(Some("Previous month")); let month_names = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ]; let label = gtk::Label::new(Some(&format!( "{} {}", month_names[(today.month() - 1) as usize], today.year() ))); label.add_css_class("title-3"); let next_btn = gtk::Button::from_icon_name("go-next-symbolic"); next_btn.add_css_class("flat"); next_btn.add_css_class("circular"); next_btn.set_tooltip_text(Some("Next month")); { let y = year.clone(); let m = month.clone(); let lbl = label.clone(); prev_btn.connect_clicked(move |_| { let mut mm = m.get(); let mut yy = y.get(); if mm == 1 { mm = 12; yy -= 1; } else { mm -= 1; } m.set(mm); y.set(yy); lbl.set_text(&format!("{} {}", month_names[(mm - 1) as usize], yy)); }); } { let y = year.clone(); let m = month.clone(); let lbl = label.clone(); next_btn.connect_clicked(move |_| { let mut mm = m.get(); let mut yy = y.get(); if mm == 12 { mm = 1; yy += 1; } else { mm += 1; } m.set(mm); y.set(yy); lbl.set_text(&format!("{} {}", month_names[(mm - 1) as usize], yy)); }); } nav.append(&prev_btn); nav.append(&label); nav.append(&next_btn); content.append(&nav); let export_btn = gtk::Button::with_label("Export"); export_btn.add_css_class("suggested-action"); export_btn.add_css_class("pill"); export_btn.set_halign(gtk::Align::Center); export_btn.set_margin_top(8); let db_ref = db.clone(); let toast_ref = toast_overlay.clone(); let dialog_ref = dialog.clone(); let parent_row = row.clone(); export_btn.connect_clicked(move |_| { dialog_ref.close(); let sel_year = year.get(); let sel_month = month.get(); let base_currency = db_ref .get_setting("base_currency") .ok() .flatten() .unwrap_or_else(|| "USD".to_string()); let filter = gtk::FileFilter::new(); filter.add_pattern("*.pdf"); filter.set_name(Some("PDF files")); let filters = gio::ListStore::new::(); filters.append(&filter); let default_name = format!( "outlay-report-{:04}-{:02}.pdf", sel_year, sel_month ); let file_dialog = gtk::FileDialog::builder() .title("Export PDF Report") .initial_name(&default_name) .default_filter(&filter) .filters(&filters) .build(); let db_save = db_ref.clone(); let toast_save = toast_ref.clone(); let window = parent_row.root().and_then(|r| r.downcast::().ok()); file_dialog.save(window.as_ref(), gio::Cancellable::NONE, move |result: Result| { if let Ok(file) = result { if let Some(path) = file.path() { match export_pdf::generate_monthly_report( &db_save, sel_year, sel_month, &base_currency, &path, ) { Ok(()) => { let display_path = path.display(); let toast = adw::Toast::new(&format!( "PDF report exported to {}", display_path )); toast_save.add_toast(toast); } Err(e) => { let toast = adw::Toast::new(&format!("PDF error: {}", e)); toast_save.add_toast(toast); } } } } }); }); content.append(&export_btn); toolbar.set_content(Some(&content)); dialog.set_child(Some(&toolbar)); dialog.present(Some(row)); } fn export_qif_action( row: &adw::ActionRow, db: &Rc, toast_overlay: &adw::ToastOverlay, ) { let filter = gtk::FileFilter::new(); filter.add_pattern("*.qif"); filter.set_name(Some("QIF files")); let filters = gio::ListStore::new::(); filters.append(&filter); let today = Local::now().date_naive(); let default_name = format!("outlay-{}.qif", today.format("%Y-%m-%d")); let dialog = gtk::FileDialog::builder() .title("Export QIF") .initial_name(&default_name) .default_filter(&filter) .filters(&filters) .build(); let db_ref = db.clone(); let toast_ref = toast_overlay.clone(); let window = row.root().and_then(|r| r.downcast::().ok()); dialog.save(window.as_ref(), gio::Cancellable::NONE, move |result: Result| { if let Ok(file) = result { if let Some(path) = file.path() { match std::fs::File::create(&path) { Ok(mut f) => match export_qif::export_qif(&db_ref, &mut f, None, None) { Ok(count) => { let toast = adw::Toast::new(&format!( "Exported {} transactions to {}", count, path.display() )); toast_ref.add_toast(toast); } Err(e) => { let toast = adw::Toast::new(&format!("QIF export error: {}", e)); toast_ref.add_toast(toast); } }, Err(e) => { let toast = adw::Toast::new(&format!("File error: {}", e)); toast_ref.add_toast(toast); } } } } }); } fn export_ofx_action( row: &adw::ActionRow, db: &Rc, toast_overlay: &adw::ToastOverlay, ) { let filter = gtk::FileFilter::new(); filter.add_pattern("*.ofx"); filter.set_name(Some("OFX files")); let filters = gio::ListStore::new::(); filters.append(&filter); let today = Local::now().date_naive(); let default_name = format!("outlay-{}.ofx", today.format("%Y-%m-%d")); let dialog = gtk::FileDialog::builder() .title("Export OFX") .initial_name(&default_name) .default_filter(&filter) .filters(&filters) .build(); let db_ref = db.clone(); let toast_ref = toast_overlay.clone(); let window = row.root().and_then(|r| r.downcast::().ok()); dialog.save(window.as_ref(), gio::Cancellable::NONE, move |result: Result| { if let Ok(file) = result { if let Some(path) = file.path() { match std::fs::File::create(&path) { Ok(mut f) => match export_ofx::export_ofx(&db_ref, &mut f, None, None) { Ok(count) => { let toast = adw::Toast::new(&format!( "Exported {} transactions to {}", count, path.display() )); toast_ref.add_toast(toast); } Err(e) => { let toast = adw::Toast::new(&format!("OFX export error: {}", e)); toast_ref.add_toast(toast); } }, Err(e) => { let toast = adw::Toast::new(&format!("File error: {}", e)); toast_ref.add_toast(toast); } } } } }); } fn run_auto_backup(db: &Database, backup_dir: &std::path::Path) -> Result { std::fs::create_dir_all(backup_dir).map_err(|e| format!("Could not create backup directory: {}", e))?; let now = Local::now(); let filename = format!("outlay-auto-{}.db", now.format("%Y-%m-%d-%H%M%S")); let backup_path = backup_dir.join(&filename); // Use SQLite backup API via VACUUM INTO let path_str = backup_path.to_string_lossy(); db.execute_raw(&format!("VACUUM INTO '{}'", path_str.replace('\'', "''"))) .map_err(|e| format!("Backup failed: {}", e))?; let _ = db.set_setting("auto_backup_last", &now.format("%Y-%m-%d %H:%M").to_string()); let _ = db.set_setting("last_backup_date", &now.format("%Y-%m-%d").to_string()); let retention = db.get_setting("auto_backup_retention") .ok().flatten() .and_then(|v| v.parse::().ok()) .unwrap_or(5); let mut backups: Vec<_> = std::fs::read_dir(backup_dir) .map_err(|e| format!("Could not read backup dir: {}", e))? .filter_map(|e| e.ok()) .filter(|e| { e.file_name().to_string_lossy().starts_with("outlay-auto-") && e.file_name().to_string_lossy().ends_with(".db") }) .collect(); backups.sort_by_key(|e| e.file_name()); if backups.len() > retention { let to_delete = backups.len() - retention; for entry in backups.iter().take(to_delete) { let _ = std::fs::remove_file(entry.path()); } } Ok(backup_path) } pub fn check_and_run_auto_backup(db: &Database) -> Option { let enabled = db.get_setting("auto_backup_enabled") .ok().flatten().unwrap_or_default(); if enabled != "1" { return None; } let frequency = db.get_setting("auto_backup_frequency") .ok().flatten().unwrap_or_else(|| "weekly".to_string()); let last_backup = db.get_setting("auto_backup_last").ok().flatten(); let today = Local::now().date_naive(); let should_backup = match &last_backup { Some(date_str) => { // Parse "YYYY-MM-DD HH:MM" or "YYYY-MM-DD" let date_part = date_str.split(' ').next().unwrap_or(date_str); if let Ok(last) = chrono::NaiveDate::parse_from_str(date_part, "%Y-%m-%d") { let days = (today - last).num_days(); match frequency.as_str() { "daily" => days >= 1, "monthly" => days >= 30, _ => days >= 7, // weekly } } else { true } } None => true, }; if !should_backup { return None; } let backup_dir_setting = db.get_setting("auto_backup_dir").ok().flatten().unwrap_or_default(); let backup_dir = if backup_dir_setting.is_empty() { gtk::glib::user_data_dir().join("outlay").join("backups") } else { std::path::PathBuf::from(&backup_dir_setting) }; Self::run_auto_backup(db, &backup_dir).ok() } fn backup_action( row: &adw::ActionRow, db: &Rc, toast_overlay: &adw::ToastOverlay, ) { let filter = gtk::FileFilter::new(); filter.add_pattern("*.outlay"); filter.set_name(Some("Outlay backup files")); let filters = gio::ListStore::new::(); filters.append(&filter); let today = Local::now().date_naive(); let default_name = format!( "outlay-backup-{}.outlay", today.format("%Y-%m-%d") ); let dialog = gtk::FileDialog::builder() .title("Create Backup") .initial_name(&default_name) .default_filter(&filter) .filters(&filters) .build(); let db_ref = db.clone(); let toast_ref = toast_overlay.clone(); let window = row.root().and_then(|r| r.downcast::().ok()); dialog.save(window.as_ref(), gio::Cancellable::NONE, move |result: Result| { if let Ok(file) = result { if let Some(path) = file.path() { match backup::create_backup(&db_ref, &path) { Ok(meta) => { let today = Local::now().date_naive().format("%Y-%m-%d").to_string(); let _ = db_ref.set_setting("last_backup_date", &today); let toast = adw::Toast::new(&format!( "Backup created ({} transactions) at {}", meta.transaction_count, path.display() )); toast_ref.add_toast(toast); } Err(e) => { let toast = adw::Toast::new(&format!("Backup error: {}", e)); toast_ref.add_toast(toast); } } } } }); } fn restore_action( row: &adw::ActionRow, toast_overlay: &adw::ToastOverlay, app: &adw::Application, ) { let filter = gtk::FileFilter::new(); filter.add_pattern("*.outlay"); filter.set_name(Some("Outlay backup files")); let filters = gio::ListStore::new::(); filters.append(&filter); let dialog = gtk::FileDialog::builder() .title("Restore from Backup") .default_filter(&filter) .filters(&filters) .build(); let toast_ref = toast_overlay.clone(); let app_ref = app.clone(); let window = row.root().and_then(|r| r.downcast::().ok()); dialog.open(window.as_ref(), gio::Cancellable::NONE, move |result: Result| { if let Ok(file) = result { if let Some(path) = file.path() { // Read meta first match backup::read_backup_meta(&path) { Ok(meta) => { let msg = format!( "This backup contains {} transactions and {} categories.\nCreated on {}.\n\nRestore will replace all current data.", meta.transaction_count, meta.category_count, meta.export_date, ); let alert = adw::AlertDialog::new( Some("Restore from Backup?"), Some(&msg), ); alert.add_response("cancel", "Cancel"); alert.add_response("restore", "Restore"); alert.set_response_appearance( "restore", adw::ResponseAppearance::Destructive, ); alert.set_default_response(Some("cancel")); alert.set_close_response("cancel"); let toast_alert = toast_ref.clone(); let app_alert = app_ref.clone(); let path_clone = path.to_path_buf(); alert.connect_response(None, move |_, response| { if response == "restore" { let db_path = glib::user_data_dir() .join("outlay") .join("outlay.db"); match backup::restore_backup(&path_clone, &db_path) { Ok(_) => { let toast = adw::Toast::new( "Backup restored. Please restart the app.", ); toast_alert.add_toast(toast); // Close app after short delay let app_quit = app_alert.clone(); glib::timeout_add_local_once( std::time::Duration::from_secs(2), move || { app_quit.quit(); }, ); } Err(e) => { let toast = adw::Toast::new(&format!( "Restore error: {}", e )); toast_alert.add_toast(toast); } } } }); if let Some(w) = app_ref.active_window() { alert.present(Some(&w)); } } Err(e) => { let toast = adw::Toast::new(&format!("Invalid backup file: {}", e)); toast_ref.add_toast(toast); } } } } }); } fn reset_action( row: &adw::ActionRow, db: &Rc, toast_overlay: &adw::ToastOverlay, on_reset: &Rc>>>, ) { let alert = adw::AlertDialog::new( Some("Reset All Data?"), Some("This will permanently delete all transactions, budgets, recurring transactions, and settings. This cannot be undone."), ); alert.add_response("cancel", "Cancel"); alert.add_response("reset", "Reset Everything"); alert.set_response_appearance("reset", adw::ResponseAppearance::Destructive); alert.set_default_response(Some("cancel")); alert.set_close_response("cancel"); let db_ref = db.clone(); let toast_ref = toast_overlay.clone(); let reset_cb = on_reset.clone(); alert.connect_response(None, move |_, response| { if response == "reset" { match db_ref.reset_all_data() { Ok(()) => { let toast = adw::Toast::new("All data has been reset"); toast_ref.add_toast(toast); if let Some(cb) = reset_cb.borrow().as_ref() { cb(); } } Err(e) => { let toast = adw::Toast::new(&format!("Error: {}", e)); toast_ref.add_toast(toast); } } } }); alert.present(Some(row)); } fn import_csv_action( row: &adw::ActionRow, db: &Rc, toast_overlay: &adw::ToastOverlay, ) { Self::show_import_dialog(row, db, toast_overlay, "CSV", "*.csv", "CSV files"); } fn import_json_action( row: &adw::ActionRow, db: &Rc, toast_overlay: &adw::ToastOverlay, ) { Self::show_import_dialog(row, db, toast_overlay, "JSON", "*.json", "JSON files"); } fn import_qif_action( row: &adw::ActionRow, db: &Rc, toast_overlay: &adw::ToastOverlay, ) { Self::show_import_dialog(row, db, toast_overlay, "QIF", "*.qif", "QIF files"); } fn import_ofx_action( row: &adw::ActionRow, db: &Rc, toast_overlay: &adw::ToastOverlay, ) { Self::show_import_dialog(row, db, toast_overlay, "OFX", "*.ofx", "OFX files"); } fn import_pdf_action( row: &adw::ActionRow, db: &Rc, toast_overlay: &adw::ToastOverlay, ) { let filter = gtk::FileFilter::new(); filter.add_pattern("*.pdf"); filter.set_name(Some("PDF files")); let filters = gio::ListStore::new::(); filters.append(&filter); let file_dialog = gtk::FileDialog::builder() .title("Select PDF Statement") .default_filter(&filter) .filters(&filters) .build(); let db_ref = db.clone(); let toast_ref = toast_overlay.clone(); let parent_row = row.clone(); let window = row.root().and_then(|r| r.downcast::().ok()); file_dialog.open(window.as_ref(), gio::Cancellable::NONE, move |result| { if let Ok(file) = result { if let Some(path) = file.path() { let bytes = match std::fs::read(&path) { Ok(b) => b, Err(e) => { let toast = adw::Toast::new(&format!("Error reading file: {}", e)); toast_ref.add_toast(toast); return; } }; let rows = match import_pdf::extract_transactions_from_pdf(&bytes) { Ok(r) if r.is_empty() => { let toast = adw::Toast::new("No transactions found in PDF"); toast_ref.add_toast(toast); return; } Ok(r) => r, Err(e) => { let toast = adw::Toast::new(&format!("PDF parse error: {}", e)); toast_ref.add_toast(toast); return; } }; Self::show_pdf_preview_dialog(&parent_row, &db_ref, &toast_ref, rows); } } }); } fn show_pdf_preview_dialog( parent: &adw::ActionRow, db: &Rc, toast_overlay: &adw::ToastOverlay, rows: Vec, ) { let dialog = adw::Dialog::builder() .title("Import PDF Statement") .content_width(500) .content_height(500) .build(); let toolbar = adw::ToolbarView::new(); toolbar.add_top_bar(&adw::HeaderBar::new()); let content = gtk::Box::new(gtk::Orientation::Vertical, 8); content.set_margin_top(8); content.set_margin_bottom(8); content.set_margin_start(12); content.set_margin_end(12); let info_label = gtk::Label::new(Some(&format!( "Found {} transaction(s). Select which to import.", rows.len() ))); info_label.set_xalign(0.0); info_label.add_css_class("dim-label"); content.append(&info_label); // Merge/Replace toggle let merge_check = gtk::CheckButton::with_label("Merge (skip duplicates)"); merge_check.set_active(true); let replace_check = gtk::CheckButton::with_label("Replace all data"); replace_check.set_group(Some(&merge_check)); let mode_box = gtk::Box::new(gtk::Orientation::Horizontal, 12); mode_box.append(&merge_check); mode_box.append(&replace_check); content.append(&mode_box); // Scrollable list of parsed rows let scrolled = gtk::ScrolledWindow::builder() .vexpand(true) .hscrollbar_policy(gtk::PolicyType::Never) .build(); let list_box = gtk::ListBox::new(); list_box.set_selection_mode(gtk::SelectionMode::None); list_box.add_css_class("boxed-list"); // Load categories for the combo boxes let all_cats = db.list_categories(None).unwrap_or_default(); // Store checkboxes and category combos for each row let check_buttons: Rc>> = Rc::new(RefCell::new(Vec::new())); for (i, parsed) in rows.iter().enumerate() { let check = gtk::CheckButton::new(); check.set_active(true); let date_str = parsed .date .map(|d| d.format("%Y-%m-%d").to_string()) .unwrap_or_else(|| "No date".to_string()); let type_str = if parsed.is_credit { "Income" } else { "Expense" }; let subtitle = format!( "{} - {} {:.2}", date_str, type_str, parsed.amount ); let action_row = adw::ActionRow::builder() .title(&parsed.description) .subtitle(&subtitle) .build(); action_row.add_prefix(&check); // Category combo let filtered_cats: Vec<&Category> = all_cats .iter() .filter(|c| { if parsed.is_credit { c.transaction_type == TransactionType::Income } else { c.transaction_type == TransactionType::Expense } }) .collect(); let cat_labels: Vec = filtered_cats .iter() .map(|c| { let icon = crate::icon_theme::resolve_category_icon(&c.icon, &c.color); match icon { Some(i) => format!("{}\t{}", i, c.name), None => c.name.clone(), } }) .collect(); let label_refs: Vec<&str> = cat_labels.iter().map(|s| s.as_str()).collect(); let cat_model = gtk::StringList::new(&label_refs); let cat_combo = gtk::DropDown::builder() .model(&cat_model) .valign(gtk::Align::Center) .build(); cat_combo.set_factory(Some(&crate::category_combo::make_category_factory())); cat_combo.set_list_factory(Some(&crate::category_combo::make_category_factory())); // Try auto-match via rules let mut matched_idx = 0u32; if let Ok(Some(cat_id)) = db.match_category(Some(&parsed.description), None) { if let Some(pos) = filtered_cats.iter().position(|c| c.id == cat_id) { matched_idx = pos as u32; } } cat_combo.set_selected(matched_idx); action_row.add_suffix(&cat_combo); list_box.append(&action_row); check_buttons.borrow_mut().push((check, cat_combo, i)); } scrolled.set_child(Some(&list_box)); content.append(&scrolled); // Bottom buttons let btn_box = gtk::Box::new(gtk::Orientation::Horizontal, 8); btn_box.set_halign(gtk::Align::End); btn_box.set_margin_top(8); let select_all_btn = gtk::Button::with_label("Select All"); let deselect_all_btn = gtk::Button::with_label("Deselect All"); { let checks = check_buttons.clone(); select_all_btn.connect_clicked(move |_| { for (cb, _, _) in checks.borrow().iter() { cb.set_active(true); } }); } { let checks = check_buttons.clone(); deselect_all_btn.connect_clicked(move |_| { for (cb, _, _) in checks.borrow().iter() { cb.set_active(false); } }); } let import_btn = gtk::Button::with_label("Import Selected"); import_btn.add_css_class("suggested-action"); btn_box.append(&select_all_btn); btn_box.append(&deselect_all_btn); btn_box.append(&import_btn); content.append(&btn_box); // Wire import button let db_ref = db.clone(); let toast_ref = toast_overlay.clone(); let dialog_ref = dialog.clone(); let checks = check_buttons.clone(); let rows_clone = rows.clone(); let all_cats_clone = all_cats.clone(); import_btn.connect_clicked(move |_| { let merge = merge_check.is_active(); let today = chrono::Local::now().date_naive(); let base_currency = db_ref .get_setting("base_currency") .ok() .flatten() .unwrap_or_else(|| "USD".to_string()); let mut imported = 0u32; let mut skipped = 0u32; for (cb, cat_combo, idx) in checks.borrow().iter() { if !cb.is_active() { continue; } let parsed = &rows_clone[*idx]; let txn_type = if parsed.is_credit { TransactionType::Income } else { TransactionType::Expense }; let date = parsed.date.unwrap_or(today); // Get selected category from combo let filtered_cats: Vec<&Category> = all_cats_clone .iter() .filter(|c| c.transaction_type == txn_type) .collect(); let cat_idx = cat_combo.selected() as usize; let category_id = filtered_cats .get(cat_idx) .map(|c| c.id) .unwrap_or(1); if merge { if let Ok(true) = db_ref.find_duplicate_transaction( parsed.amount, txn_type, category_id, date, ) { skipped += 1; continue; } } let txn = NewTransaction { amount: parsed.amount, transaction_type: txn_type, category_id, currency: base_currency.clone(), exchange_rate: 1.0, note: Some(parsed.description.clone()), date, recurring_id: None, payee: None, }; match db_ref.insert_transaction(&txn) { Ok(_) => imported += 1, Err(e) => { let toast = adw::Toast::new(&format!("Error: {}", e)); toast_ref.add_toast(toast); } } } dialog_ref.close(); let msg = if skipped > 0 { format!( "Imported {} transaction(s), skipped {} duplicate(s)", imported, skipped ) } else { format!("Imported {} transaction(s) from PDF", imported) }; let toast = adw::Toast::new(&msg); toast_ref.add_toast(toast); }); toolbar.set_content(Some(&content)); dialog.set_child(Some(&toolbar)); dialog.present(Some(parent)); } fn show_import_dialog( row: &adw::ActionRow, db: &Rc, toast_overlay: &adw::ToastOverlay, format_name: &str, pattern: &str, filter_name: &str, ) { let dialog = adw::Dialog::builder() .title(&format!("Import {}", format_name)) .content_width(360) .content_height(220) .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 desc = gtk::Label::new(Some(&format!( "Choose how to handle existing data when importing from {}.", format_name ))); desc.set_wrap(true); desc.set_xalign(0.0); content.append(&desc); let merge_check = gtk::CheckButton::with_label("Merge (skip duplicates)"); merge_check.set_active(true); let replace_check = gtk::CheckButton::with_label("Replace all data"); replace_check.set_group(Some(&merge_check)); content.append(&merge_check); content.append(&replace_check); let choose_btn = gtk::Button::with_label("Choose File"); choose_btn.add_css_class("suggested-action"); choose_btn.add_css_class("pill"); choose_btn.set_halign(gtk::Align::Center); choose_btn.set_margin_top(8); let db_ref = db.clone(); let toast_ref = toast_overlay.clone(); let dialog_ref = dialog.clone(); let parent_row = row.clone(); let fmt = format_name.to_string(); let pat = pattern.to_string(); let fn_name = filter_name.to_string(); choose_btn.connect_clicked(move |_| { let merge = merge_check.is_active(); dialog_ref.close(); let filter = gtk::FileFilter::new(); filter.add_pattern(&pat); filter.set_name(Some(&fn_name)); let filters = gio::ListStore::new::(); filters.append(&filter); let file_dialog = gtk::FileDialog::builder() .title(&format!("Select {} File", fmt)) .default_filter(&filter) .filters(&filters) .build(); let db_import = db_ref.clone(); let toast_import = toast_ref.clone(); let fmt_clone = fmt.clone(); let window = parent_row.root().and_then(|r| r.downcast::().ok()); file_dialog.open(window.as_ref(), gio::Cancellable::NONE, move |result: Result| { if let Ok(file) = result { if let Some(path) = file.path() { // 6.2: Show importing toast let progress_toast = adw::Toast::new(&format!("Importing {}...", fmt_clone)); progress_toast.set_timeout(5); toast_import.add_toast(progress_toast); let result = match fmt_clone.as_str() { "CSV" => import_csv::import_csv(&db_import, &path, merge), "JSON" => import_json::import_json(&db_import, &path, merge), "QIF" => outlay_core::import_qif::import_qif(&db_import, &path, merge), "OFX" => outlay_core::import_ofx::import_ofx(&db_import, &path, merge), _ => Err("Unknown format".into()), }; match result { Ok(count) => { let toast = adw::Toast::new(&format!( "Imported {} transaction(s) from {}", count, path.display() )); toast_import.add_toast(toast); } Err(e) => { let toast = adw::Toast::new(&format!("Import error: {}", e)); toast_import.add_toast(toast); } } } } }); }); content.append(&choose_btn); toolbar.set_content(Some(&content)); dialog.set_child(Some(&toolbar)); dialog.present(Some(row)); } // -- Rules helpers -- fn populate_rules( db: &Rc, group: &adw::PreferencesGroup, toast_overlay: &adw::ToastOverlay, ) { let mut rows_to_remove = Vec::new(); let mut child = group.first_child(); while let Some(widget) = child { let next = widget.next_sibling(); Self::collect_action_rows_recursive(&widget, &mut rows_to_remove); child = next; } for row in rows_to_remove { if let Some(r) = row.downcast_ref::() { group.remove(r); } } if let Ok(rules) = db.list_rules() { for rule in &rules { let cat_name = db .get_category(rule.category_id) .map(|c| c.name) .unwrap_or_else(|_| "Unknown".to_string()); let subtitle = format!( "If {} contains \"{}\" -> {}", rule.field, rule.pattern, cat_name ); let row = adw::ActionRow::builder() .title(&format!("Rule #{}", rule.id)) .subtitle(&subtitle) .build(); let del_btn = gtk::Button::from_icon_name("outlay-delete"); del_btn.add_css_class("flat"); del_btn.set_valign(gtk::Align::Center); del_btn.set_tooltip_text(Some("Delete rule")); { let rule_id = rule.id; let db_ref = db.clone(); let group_ref = group.clone(); let toast_ref = toast_overlay.clone(); del_btn.connect_clicked(move |_| { let _ = db_ref.delete_rule(rule_id); Self::populate_rules(&db_ref, &group_ref, &toast_ref); let toast = adw::Toast::new("Rule deleted"); toast_ref.add_toast(toast); }); } row.add_suffix(&del_btn); group.add(&row); } if rules.is_empty() { let placeholder = adw::ActionRow::builder() .title("No rules yet") .build(); placeholder.add_css_class("dim-label"); group.add(&placeholder); } } } fn show_add_rule_dialog( parent: >k::Button, db: &Rc, group: &adw::PreferencesGroup, toast_overlay: &adw::ToastOverlay, ) { let dialog = adw::Dialog::builder() .title("Add Rule") .content_width(400) .content_height(420) .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(); let type_model = gtk::StringList::new(&["Expense", "Income"]); let type_row = adw::ComboRow::builder() .title("Type") .model(&type_model) .build(); form.add(&type_row); // Field selector (note or payee) let field_model = gtk::StringList::new(&["Note", "Payee"]); let field_row = adw::ComboRow::builder() .title("Match field") .model(&field_model) .build(); form.add(&field_row); let pattern_row = adw::EntryRow::builder() .title("Contains text") .build(); form.add(&pattern_row); let cat_model = gtk::StringList::new(&[]); let cat_ids: Rc>> = Rc::new(RefCell::new(Vec::new())); if let Ok(cats) = db.list_categories(Some(TransactionType::Expense)) { for cat in &cats { let icon_name = icon_theme::resolve_category_icon(&cat.icon, &cat.color); let entry = match &icon_name { Some(icon) => format!("{}\t{}", icon, cat.name), None => cat.name.clone(), }; cat_model.append(&entry); cat_ids.borrow_mut().push(cat.id); } } let cat_row = adw::ComboRow::builder() .title("Assign category") .model(&cat_model) .build(); cat_row.set_factory(Some(&Self::make_category_factory())); cat_row.set_list_factory(Some(&Self::make_category_factory())); form.add(&cat_row); // Update categories when type changes { let db_ref = db.clone(); let cat_model = cat_model.clone(); let cat_ids = cat_ids.clone(); type_row.connect_selected_notify(move |row| { let txn_type = if row.selected() == 0 { TransactionType::Expense } else { TransactionType::Income }; let n = cat_model.n_items(); if n > 0 { cat_model.splice(0, n, &[] as &[&str]); } cat_ids.borrow_mut().clear(); if let Ok(cats) = db_ref.list_categories(Some(txn_type)) { for cat in &cats { let icon_name = icon_theme::resolve_category_icon(&cat.icon, &cat.color); let entry = match &icon_name { Some(icon) => format!("{}\t{}", icon, cat.name), None => cat.name.clone(), }; cat_model.append(&entry); cat_ids.borrow_mut().push(cat.id); } } }); } 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 toast_ref = toast_overlay.clone(); let cat_ids = cat_ids.clone(); save_btn.connect_clicked(move |_| { let pattern = pattern_row.text().to_string(); if pattern.trim().is_empty() { let toast = adw::Toast::new("Please enter a pattern"); toast_ref.add_toast(toast); return; } let field = if field_row.selected() == 0 { "note" } else { "payee" }; let cat_idx = cat_row.selected() as usize; let ids = cat_ids.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; } }; match db_ref.insert_rule(field, pattern.trim(), category_id, 0) { Ok(_) => { dialog_ref.close(); Self::populate_rules(&db_ref, &group_ref, &toast_ref); let toast = adw::Toast::new("Rule added"); toast_ref.add_toast(toast); } Err(e) => { let toast = adw::Toast::new(&format!("Error: {}", e)); toast_ref.add_toast(toast); } } }); } dialog.present(Some(parent)); } // -- Templates helpers -- fn populate_templates( db: &Rc, group: &adw::PreferencesGroup, toast_overlay: &adw::ToastOverlay, ) { let mut rows_to_remove = Vec::new(); let mut child = group.first_child(); while let Some(widget) = child { let next = widget.next_sibling(); Self::collect_action_rows_recursive(&widget, &mut rows_to_remove); child = next; } for row in rows_to_remove { if let Some(r) = row.downcast_ref::() { group.remove(r); } } if let Ok(templates) = db.list_templates() { for tmpl in &templates { let cat_name = db .get_category(tmpl.category_id) .map(|c| c.name) .unwrap_or_else(|_| "Unknown".to_string()); let amount_str = tmpl .amount .map(|a| format!("{:.2}", a)) .unwrap_or_else(|| "any".to_string()); let subtitle = format!( "{} {} - {}", tmpl.transaction_type.as_str(), amount_str, cat_name ); let row = adw::ActionRow::builder() .title(&tmpl.name) .subtitle(&subtitle) .build(); let del_btn = gtk::Button::from_icon_name("outlay-delete"); del_btn.add_css_class("flat"); del_btn.set_valign(gtk::Align::Center); del_btn.set_tooltip_text(Some("Delete template")); { let tmpl_id = tmpl.id; let db_ref = db.clone(); let group_ref = group.clone(); let toast_ref = toast_overlay.clone(); del_btn.connect_clicked(move |_| { let _ = db_ref.delete_template(tmpl_id); Self::populate_templates(&db_ref, &group_ref, &toast_ref); let toast = adw::Toast::new("Template deleted"); toast_ref.add_toast(toast); }); } row.add_suffix(&del_btn); group.add(&row); } if templates.is_empty() { let placeholder = adw::ActionRow::builder() .title("No templates yet") .build(); placeholder.add_css_class("dim-label"); group.add(&placeholder); } } } fn show_add_template_dialog( parent: >k::Button, db: &Rc, group: &adw::PreferencesGroup, toast_overlay: &adw::ToastOverlay, ) { let dialog = adw::Dialog::builder() .title("Add Template") .content_width(400) .content_height(450) .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(); let name_row = adw::EntryRow::builder() .title("Template name") .build(); form.add(&name_row); let type_model = gtk::StringList::new(&["Expense", "Income"]); let type_row = adw::ComboRow::builder() .title("Type") .model(&type_model) .build(); form.add(&type_row); let amount_row = adw::EntryRow::builder() .title("Amount (optional)") .build(); amount_row.set_input_purpose(gtk::InputPurpose::Number); crate::numpad::attach_numpad(&amount_row); form.add(&amount_row); let cat_model = gtk::StringList::new(&[]); let cat_ids: Rc>> = Rc::new(RefCell::new(Vec::new())); if let Ok(cats) = db.list_categories(Some(TransactionType::Expense)) { for cat in &cats { let icon_name = icon_theme::resolve_category_icon(&cat.icon, &cat.color); let entry = match &icon_name { Some(icon) => format!("{}\t{}", icon, cat.name), None => cat.name.clone(), }; cat_model.append(&entry); cat_ids.borrow_mut().push(cat.id); } } let cat_row = adw::ComboRow::builder() .title("Category") .model(&cat_model) .build(); cat_row.set_factory(Some(&Self::make_category_factory())); cat_row.set_list_factory(Some(&Self::make_category_factory())); form.add(&cat_row); // Update categories when type changes { let db_ref = db.clone(); let cat_model = cat_model.clone(); let cat_ids = cat_ids.clone(); type_row.connect_selected_notify(move |row| { let txn_type = if row.selected() == 0 { TransactionType::Expense } else { TransactionType::Income }; let n = cat_model.n_items(); if n > 0 { cat_model.splice(0, n, &[] as &[&str]); } cat_ids.borrow_mut().clear(); if let Ok(cats) = db_ref.list_categories(Some(txn_type)) { for cat in &cats { let icon_name = icon_theme::resolve_category_icon(&cat.icon, &cat.color); let entry = match &icon_name { Some(icon) => format!("{}\t{}", icon, cat.name), None => cat.name.clone(), }; cat_model.append(&entry); cat_ids.borrow_mut().push(cat.id); } } }); } let payee_row = adw::EntryRow::builder() .title("Payee (optional)") .build(); form.add(&payee_row); let note_row = adw::EntryRow::builder() .title("Note (optional)") .build(); form.add(¬e_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); let scroll = gtk::ScrolledWindow::builder() .hscrollbar_policy(gtk::PolicyType::Never) .child(&content) .build(); toolbar.set_content(Some(&scroll)); dialog.set_child(Some(&toolbar)); let base_currency = db .get_setting("base_currency") .ok() .flatten() .unwrap_or_else(|| "USD".to_string()); { let db_ref = db.clone(); let dialog_ref = dialog.clone(); let group_ref = group.clone(); let toast_ref = toast_overlay.clone(); let cat_ids = cat_ids.clone(); let base_currency = base_currency.clone(); save_btn.connect_clicked(move |_| { let name = name_row.text().to_string(); if name.trim().is_empty() { let toast = adw::Toast::new("Please enter a template name"); toast_ref.add_toast(toast); return; } let txn_type = if type_row.selected() == 0 { TransactionType::Expense } else { TransactionType::Income }; let amount_text = amount_row.text(); let amount: Option = if amount_text.is_empty() { None } else { amount_text.parse().ok() }; let cat_idx = cat_row.selected() as usize; let ids = cat_ids.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 payee_text = payee_row.text(); let payee = if payee_text.is_empty() { None } else { Some(payee_text.to_string()) }; let note_text = note_row.text(); let note = if note_text.is_empty() { None } else { Some(note_text.to_string()) }; match db_ref.insert_template( name.trim(), amount, txn_type, category_id, &base_currency, payee.as_deref(), note.as_deref(), None, ) { Ok(_) => { dialog_ref.close(); Self::populate_templates(&db_ref, &group_ref, &toast_ref); let toast = adw::Toast::new("Template added"); toast_ref.add_toast(toast); } Err(e) => { let toast = adw::Toast::new(&format!("Error: {}", e)); toast_ref.add_toast(toast); } } }); } dialog.present(Some(parent)); } // -- Subscription category helpers -- fn populate_subscription_categories( db: &Rc, expander: &adw::ExpanderRow, toast_overlay: &adw::ToastOverlay, ) { // Remove existing rows from expander let mut children = Vec::new(); let mut child = expander.first_child(); while let Some(w) = child { let next = w.next_sibling(); if let Some(row) = w.downcast_ref::() { children.push(row.clone()); } child = next; } for row in &children { expander.remove(row); } if let Ok(cats) = db.list_subscription_categories() { for cat in &cats { let row = adw::ActionRow::builder() .title(&cat.name) .build(); let icon_name = icon_theme::resolve_category_icon(&cat.icon, &cat.color); if let Some(name) = &icon_name { let icon = gtk::Image::from_icon_name(name); icon.set_pixel_size(24); row.add_prefix(&icon); } let del_btn = gtk::Button::from_icon_name("outlay-delete"); del_btn.add_css_class("flat"); del_btn.set_valign(gtk::Align::Center); del_btn.set_tooltip_text(Some("Delete")); { let cat_id = cat.id; let db_ref = db.clone(); let expander_ref = expander.clone(); let toast_ref = toast_overlay.clone(); del_btn.connect_clicked(move |_| { let _ = db_ref.delete_subscription_category(cat_id); Self::populate_subscription_categories(&db_ref, &expander_ref, &toast_ref); toast_ref.add_toast(adw::Toast::new("Category deleted")); }); } row.add_suffix(&del_btn); expander.add_row(&row); } } } fn show_add_subscription_category_dialog( parent: >k::Button, db: &Rc, expander: &adw::ExpanderRow, toast_overlay: &adw::ToastOverlay, ) { let dialog = adw::Dialog::builder() .title("Add Subscription Category") .content_width(400) .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 form = adw::PreferencesGroup::new(); let name_row = adw::EntryRow::builder() .title("Name") .build(); form.add(&name_row); let icon_row = adw::ActionRow::builder() .title("Icon") .subtitle("None") .activatable(true) .build(); let chosen_icon: Rc>> = Rc::new(RefCell::new(None)); let icon_preview = gtk::Image::new(); icon_preview.set_pixel_size(24); icon_row.add_suffix(&icon_preview); { let chosen_icon = chosen_icon.clone(); let icon_row_ref = icon_row.clone(); let icon_preview = icon_preview.clone(); let dialog_ref = dialog.clone(); icon_row.connect_activated(move |_| { Self::show_icon_picker_dialog( &dialog_ref, &chosen_icon, &icon_preview, &icon_row_ref, ); }); } form.add(&icon_row); let color_btn = gtk::ColorDialogButton::new(Some(gtk::ColorDialog::new())); color_btn.set_valign(gtk::Align::Center); let default_color = gdk::RGBA::parse("#95a5a6").unwrap_or(gdk::RGBA::BLACK); color_btn.set_rgba(&default_color); let color_row = adw::ActionRow::builder() .title("Color") .build(); color_row.add_suffix(&color_btn); form.add(&color_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 expander_ref = expander.clone(); let toast_ref = toast_overlay.clone(); let chosen_icon = chosen_icon.clone(); save_btn.connect_clicked(move |_| { let name = name_row.text().to_string(); if name.trim().is_empty() { toast_ref.add_toast(adw::Toast::new("Please enter a name")); return; } let icon = chosen_icon.borrow().clone(); let rgba = color_btn.rgba(); let color = Self::rgba_to_hex(&rgba); match db_ref.insert_subscription_category( name.trim(), icon.as_deref(), Some(&color), ) { Ok(_) => { dialog_ref.close(); Self::populate_subscription_categories(&db_ref, &expander_ref, &toast_ref); toast_ref.add_toast(adw::Toast::new("Category added")); } Err(e) => { toast_ref.add_toast(adw::Toast::new(&format!("Error: {}", e))); } } }); } dialog.present(Some(parent)); } fn collect_action_rows_recursive(widget: >k::Widget, rows: &mut Vec) { let mut child = widget.first_child(); while let Some(w) = child { let next = w.next_sibling(); if w.downcast_ref::().is_some() { rows.push(w.clone()); } else { Self::collect_action_rows_recursive(&w, rows); } child = next; } } fn make_category_factory() -> gtk::SignalListItemFactory { let factory = gtk::SignalListItemFactory::new(); factory.connect_setup(|_, item| { let item = item.downcast_ref::().unwrap(); let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 8); let icon = gtk::Image::new(); icon.set_pixel_size(20); let label = gtk::Label::new(None); hbox.append(&icon); hbox.append(&label); item.set_child(Some(&hbox)); }); factory.connect_bind(|_, item| { let item = item.downcast_ref::().unwrap(); let string_obj = item.item().and_downcast::().unwrap(); let text = string_obj.string(); let hbox = item.child().and_downcast::().unwrap(); let icon = hbox.first_child().and_downcast::().unwrap(); let label = icon.next_sibling().and_downcast::().unwrap(); if let Some((icon_name, name)) = text.split_once('\t') { icon.set_icon_name(Some(icon_name)); icon.set_visible(true); label.set_label(name); } else { icon.set_visible(false); label.set_label(&text); } }); factory } }