Files
outlay/outlay-gtk/src/settings_view.rs

3453 lines
130 KiB
Rust

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<RefCell<Option<Box<dyn Fn()>>>>,
}
impl SettingsView {
pub fn new(db: Rc<Database>, 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<String> = 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(&currency_label_refs);
let currency_codes: Vec<String> = 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(&currency_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(&currency_row);
// Secondary display currency
let mut secondary_labels: Vec<String> = 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::<gtk::ListItem>().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::<gtk::ListItem>().unwrap();
let string_obj = item.item().and_downcast::<gtk::StringObject>().unwrap();
let label = item.child().and_downcast::<gtk::Label>().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(&current_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(&gtk::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(&gtk::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(&gtk::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(&gtk::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(&gtk::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(&gtk::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(&gtk::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(&gtk::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(&gtk::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(&gtk::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(&gtk::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(&gtk::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::<f64>().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(&gtk::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::<gtk::Window>().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<RefCell<Option<Box<dyn Fn()>>>> = 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(&notifications_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: &gtk::Stack| -> gtk::Button {
let btn = gtk::Button::new();
btn.add_css_class("flat");
let content = gtk::Box::new(gtk::Orientation::Horizontal, 6);
content.append(&gtk::Image::from_icon_name("go-previous-symbolic"));
content.append(&gtk::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(&gtk::Image::from_icon_name("emblem-system-symbolic"));
nav_general.add_suffix(&gtk::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(&gtk::Image::from_icon_name("view-list-symbolic"));
nav_categories.add_suffix(&gtk::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(&gtk::Image::from_icon_name("document-send-symbolic"));
nav_import_export.add_suffix(&gtk::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(&gtk::Image::from_icon_name("drive-harddisk-symbolic"));
nav_backup.add_suffix(&gtk::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(&currency_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<F: Fn() + 'static>(&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<Database>,
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: &gtk::Widget, rows: &mut Vec<adw::ActionRow>) {
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::<adw::ActionRow>() {
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::<i64>() {
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<Database>,
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<RefCell<Option<String>>> = 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(&current_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: &gtk::Button,
db: &Rc<Database>,
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<RefCell<Option<String>>> = 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<RefCell<Option<String>>>,
icon_preview: &gtk::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<Database>,
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::<gtk::FileFilter>();
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::<gtk::Window>().ok());
dialog.save(window.as_ref(), gio::Cancellable::NONE, move |result: Result<gio::File, glib::Error>| {
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<Database>,
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::<gtk::FileFilter>();
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::<gtk::Window>().ok());
dialog.save(window.as_ref(), gio::Cancellable::NONE, move |result: Result<gio::File, glib::Error>| {
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<Database>,
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::<gtk::FileFilter>();
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::<gtk::Window>().ok());
file_dialog.save(window.as_ref(), gio::Cancellable::NONE, move |result: Result<gio::File, glib::Error>| {
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<Database>,
toast_overlay: &adw::ToastOverlay,
) {
let filter = gtk::FileFilter::new();
filter.add_pattern("*.qif");
filter.set_name(Some("QIF files"));
let filters = gio::ListStore::new::<gtk::FileFilter>();
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::<gtk::Window>().ok());
dialog.save(window.as_ref(), gio::Cancellable::NONE, move |result: Result<gio::File, glib::Error>| {
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<Database>,
toast_overlay: &adw::ToastOverlay,
) {
let filter = gtk::FileFilter::new();
filter.add_pattern("*.ofx");
filter.set_name(Some("OFX files"));
let filters = gio::ListStore::new::<gtk::FileFilter>();
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::<gtk::Window>().ok());
dialog.save(window.as_ref(), gio::Cancellable::NONE, move |result: Result<gio::File, glib::Error>| {
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::path::PathBuf, String> {
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::<usize>().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<std::path::PathBuf> {
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<Database>,
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::<gtk::FileFilter>();
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::<gtk::Window>().ok());
dialog.save(window.as_ref(), gio::Cancellable::NONE, move |result: Result<gio::File, glib::Error>| {
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::<gtk::FileFilter>();
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::<gtk::Window>().ok());
dialog.open(window.as_ref(), gio::Cancellable::NONE, move |result: Result<gio::File, glib::Error>| {
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<Database>,
toast_overlay: &adw::ToastOverlay,
on_reset: &Rc<RefCell<Option<Box<dyn Fn()>>>>,
) {
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<Database>,
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<Database>,
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<Database>,
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<Database>,
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<Database>,
toast_overlay: &adw::ToastOverlay,
) {
let filter = gtk::FileFilter::new();
filter.add_pattern("*.pdf");
filter.set_name(Some("PDF files"));
let filters = gio::ListStore::new::<gtk::FileFilter>();
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::<gtk::Window>().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<Database>,
toast_overlay: &adw::ToastOverlay,
rows: Vec<outlay_core::models::PdfParsedRow>,
) {
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<RefCell<Vec<(gtk::CheckButton, gtk::DropDown, usize)>>> =
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<String> = 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<Database>,
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::<gtk::FileFilter>();
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::<gtk::Window>().ok());
file_dialog.open(window.as_ref(), gio::Cancellable::NONE, move |result: Result<gio::File, glib::Error>| {
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<Database>,
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::<adw::ActionRow>() {
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: &gtk::Button,
db: &Rc<Database>,
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<RefCell<Vec<i64>>> = 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<Database>,
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::<adw::ActionRow>() {
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: &gtk::Button,
db: &Rc<Database>,
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<RefCell<Vec<i64>>> = 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(&note_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<f64> = 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<Database>,
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::<adw::ActionRow>() {
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: &gtk::Button,
db: &Rc<Database>,
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<RefCell<Option<String>>> = 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: &gtk::Widget, rows: &mut Vec<gtk::Widget>) {
let mut child = widget.first_child();
while let Some(w) = child {
let next = w.next_sibling();
if w.downcast_ref::<adw::ActionRow>().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::<gtk::ListItem>().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::<gtk::ListItem>().unwrap();
let string_obj = item.item().and_downcast::<gtk::StringObject>().unwrap();
let text = string_obj.string();
let hbox = item.child().and_downcast::<gtk::Box>().unwrap();
let icon = hbox.first_child().and_downcast::<gtk::Image>().unwrap();
let label = icon.next_sibling().and_downcast::<gtk::Label>().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
}
}