use adw::prelude::*; use chrono::{Datelike, Local}; use gtk::{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_pdf; use outlay_core::models::{NewCategory, TransactionType}; use std::rc::Rc; pub struct SettingsView { pub container: gtk::Box, } 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 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); // 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); // 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); }); } appearance_group.add(&theme_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, ); }); } // Data group let data_group = adw::PreferencesGroup::builder() .title("Data") .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("document-save-symbolic")); 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("document-save-symbolic")); 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("document-save-symbolic")); 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("document-save-symbolic")); 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("document-open-symbolic")); data_group.add(&export_csv_row); data_group.add(&export_json_row); data_group.add(&export_pdf_row); data_group.add(&backup_row); data_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_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); }); } // 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 db_ref = db.clone(); let toast_ref = toast_overlay.clone(); reset_row.connect_activated(move |row| { Self::reset_action(row, &db_ref, &toast_ref); }); } reset_group.add(&reset_row); inner.append(¤cy_group); inner.append(&appearance_group); inner.append(&categories_group); inner.append(&add_cat_btn); inner.append(&data_group); inner.append(&reset_group); clamp.set_child(Some(&inner)); let scroll = gtk::ScrolledWindow::builder() .hscrollbar_policy(gtk::PolicyType::Never) .vexpand(true) .child(&clamp) .build(); toast_overlay.set_child(Some(&scroll)); container.append(&toast_overlay); SettingsView { container } } 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 children while let Some(child) = expander.first_child() { if let Some(child) = child.next_sibling() { if let Some(row) = child.downcast_ref::() { expander.remove(row); } else { break; } } else { break; } } let cats = db.list_categories(Some(txn_type)).unwrap_or_default(); for cat in &cats { let display = match &cat.icon { Some(icon) => format!("{} {}", icon, cat.name), None => cat.name.clone(), }; let row = adw::ActionRow::builder() .title(&display) .build(); if !cat.is_default { let delete_btn = gtk::Button::from_icon_name("edit-delete-symbolic"); delete_btn.add_css_class("flat"); delete_btn.set_valign(gtk::Align::Center); 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); } expander.add_row(&row); } } 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(360) .content_height(350) .build(); let toolbar = adw::ToolbarView::new(); toolbar.add_top_bar(&adw::HeaderBar::new()); let content = gtk::Box::new(gtk::Orientation::Vertical, 16); content.set_margin_top(16); content.set_margin_bottom(16); content.set_margin_start(16); content.set_margin_end(16); let form = adw::PreferencesGroup::new(); let name_row = adw::EntryRow::builder() .title("Name") .build(); form.add(&name_row); let icon_row = adw::EntryRow::builder() .title("Icon (emoji)") .build(); form.add(&icon_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(); 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_text = icon_row.text().to_string(); let icon = if icon_text.trim().is_empty() { None } else { Some(icon_text.trim().to_string()) }; let txn_type = if type_row.selected() == 0 { TransactionType::Expense } else { TransactionType::Income }; let new_cat = NewCategory { name: name.trim().to_string(), icon, color: None, transaction_type: txn_type, sort_order: 100, }; 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 export_csv_action( row: &adw::ActionRow, db: &Rc, toast_overlay: &adw::ToastOverlay, ) { 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_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(f) => { match export_csv::export_transactions_csv(&db_ref, f, None, None) { Ok(count) => { let toast = adw::Toast::new(&format!( "Exported {} transactions to CSV", count )); toast_ref.add_toast(toast); } Err(e) => { let toast = adw::Toast::new(&format!("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_json_action( row: &adw::ActionRow, db: &Rc, toast_overlay: &adw::ToastOverlay, ) { 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_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(f) => match export_json::export_json(&db_ref, f) { Ok(data) => { let toast = adw::Toast::new(&format!( "Exported {} transactions to JSON", data.transactions.len() )); toast_ref.add_toast(toast); } Err(e) => { let toast = adw::Toast::new(&format!("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_pdf_action( row: &adw::ActionRow, db: &Rc, toast_overlay: &adw::ToastOverlay, ) { let today = Local::now().date_naive(); let base_currency = db .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", today.year(), today.month() ); let dialog = gtk::FileDialog::builder() .title("Export PDF Report") .initial_name(&default_name) .default_filter(&filter) .filters(&filters) .build(); let db_ref = db.clone(); let toast_ref = toast_overlay.clone(); let year = today.year(); let month = today.month(); 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 export_pdf::generate_monthly_report( &db_ref, year, month, &base_currency, &path, ) { Ok(()) => { let toast = adw::Toast::new("PDF report exported"); toast_ref.add_toast(toast); } Err(e) => { let toast = adw::Toast::new(&format!("PDF error: {}", e)); toast_ref.add_toast(toast); } } } } }); } 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 toast = adw::Toast::new(&format!( "Backup created ({} transactions)", meta.transaction_count )); 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, ) { 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(); 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); } Err(e) => { let toast = adw::Toast::new(&format!("Error: {}", e)); toast_ref.add_toast(toast); } } } }); alert.present(Some(row)); } }