3453 lines
130 KiB
Rust
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(¤cy_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(¤cy_model)
|
|
.selected(base_idx as u32)
|
|
.build();
|
|
|
|
{
|
|
let db_ref = db.clone();
|
|
let codes = currency_codes.clone();
|
|
let toast_ref = toast_overlay.clone();
|
|
currency_row.connect_selected_notify(move |row| {
|
|
let idx = row.selected() as usize;
|
|
if let Some(code) = codes.get(idx) {
|
|
match db_ref.set_setting("base_currency", code) {
|
|
Ok(()) => {
|
|
let toast = adw::Toast::new(&format!("Base currency set to {}", code));
|
|
toast_ref.add_toast(toast);
|
|
}
|
|
Err(e) => {
|
|
let toast = adw::Toast::new(&format!("Error: {}", e));
|
|
toast_ref.add_toast(toast);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
currency_group.add(¤cy_row);
|
|
|
|
// Secondary display currency
|
|
let mut secondary_labels: Vec<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(¤t_theme);
|
|
|
|
// Categories group
|
|
let categories_group = adw::PreferencesGroup::builder()
|
|
.title("CATEGORIES")
|
|
.build();
|
|
|
|
let expense_expander = adw::ExpanderRow::builder()
|
|
.title("Expense Categories")
|
|
.build();
|
|
Self::populate_category_expander(
|
|
&db, &expense_expander, TransactionType::Expense, &toast_overlay,
|
|
);
|
|
categories_group.add(&expense_expander);
|
|
|
|
let income_expander = adw::ExpanderRow::builder()
|
|
.title("Income Categories")
|
|
.build();
|
|
Self::populate_category_expander(
|
|
&db, &income_expander, TransactionType::Income, &toast_overlay,
|
|
);
|
|
categories_group.add(&income_expander);
|
|
|
|
let add_cat_btn = gtk::Button::with_label("Add Category");
|
|
add_cat_btn.add_css_class("pill");
|
|
add_cat_btn.set_halign(gtk::Align::Center);
|
|
add_cat_btn.set_margin_top(8);
|
|
|
|
{
|
|
let db_ref = db.clone();
|
|
let expense_ref = expense_expander.clone();
|
|
let income_ref = income_expander.clone();
|
|
let toast_ref = toast_overlay.clone();
|
|
add_cat_btn.connect_clicked(move |btn| {
|
|
Self::show_add_category_dialog(
|
|
btn, &db_ref, &expense_ref, &income_ref, &toast_ref,
|
|
);
|
|
});
|
|
}
|
|
|
|
// Export group
|
|
let export_group = adw::PreferencesGroup::builder()
|
|
.title("EXPORT")
|
|
.build();
|
|
|
|
let export_csv_row = adw::ActionRow::builder()
|
|
.title("Export CSV")
|
|
.subtitle("Export all transactions as CSV file")
|
|
.activatable(true)
|
|
.build();
|
|
export_csv_row.add_suffix(>k::Image::from_icon_name("outlay-export"));
|
|
|
|
let export_json_row = adw::ActionRow::builder()
|
|
.title("Export JSON")
|
|
.subtitle("Export all data as JSON file")
|
|
.activatable(true)
|
|
.build();
|
|
export_json_row.add_suffix(>k::Image::from_icon_name("outlay-export"));
|
|
|
|
let export_qif_row = adw::ActionRow::builder()
|
|
.title("Export QIF")
|
|
.subtitle("Quicken interchange format")
|
|
.activatable(true)
|
|
.build();
|
|
export_qif_row.add_suffix(>k::Image::from_icon_name("outlay-export"));
|
|
|
|
let export_ofx_row = adw::ActionRow::builder()
|
|
.title("Export OFX")
|
|
.subtitle("Open financial exchange format")
|
|
.activatable(true)
|
|
.build();
|
|
export_ofx_row.add_suffix(>k::Image::from_icon_name("outlay-export"));
|
|
|
|
let export_pdf_row = adw::ActionRow::builder()
|
|
.title("Export PDF Report")
|
|
.subtitle("Generate a monthly PDF report")
|
|
.activatable(true)
|
|
.build();
|
|
export_pdf_row.add_suffix(>k::Image::from_icon_name("outlay-export"));
|
|
|
|
export_group.add(&export_csv_row);
|
|
export_group.add(&export_json_row);
|
|
export_group.add(&export_qif_row);
|
|
export_group.add(&export_ofx_row);
|
|
export_group.add(&export_pdf_row);
|
|
|
|
// Import group
|
|
let import_group = adw::PreferencesGroup::builder()
|
|
.title("IMPORT")
|
|
.build();
|
|
|
|
let import_csv_row = adw::ActionRow::builder()
|
|
.title("Import CSV")
|
|
.subtitle("Import transactions from a CSV file")
|
|
.activatable(true)
|
|
.build();
|
|
import_csv_row.add_suffix(>k::Image::from_icon_name("outlay-import"));
|
|
|
|
let import_json_row = adw::ActionRow::builder()
|
|
.title("Import JSON")
|
|
.subtitle("Import data from a JSON export")
|
|
.activatable(true)
|
|
.build();
|
|
import_json_row.add_suffix(>k::Image::from_icon_name("outlay-import"));
|
|
|
|
let import_qif_row = adw::ActionRow::builder()
|
|
.title("Import QIF")
|
|
.subtitle("Import from Quicken interchange format")
|
|
.activatable(true)
|
|
.build();
|
|
import_qif_row.add_suffix(>k::Image::from_icon_name("outlay-import"));
|
|
|
|
let import_ofx_row = adw::ActionRow::builder()
|
|
.title("Import OFX")
|
|
.subtitle("Import from open financial exchange format")
|
|
.activatable(true)
|
|
.build();
|
|
import_ofx_row.add_suffix(>k::Image::from_icon_name("outlay-import"));
|
|
|
|
let import_pdf_row = adw::ActionRow::builder()
|
|
.title("Import PDF Statement")
|
|
.subtitle("Parse transactions from a bank/credit card PDF statement")
|
|
.activatable(true)
|
|
.build();
|
|
import_pdf_row.add_suffix(>k::Image::from_icon_name("outlay-import"));
|
|
|
|
import_group.add(&import_csv_row);
|
|
import_group.add(&import_json_row);
|
|
import_group.add(&import_qif_row);
|
|
import_group.add(&import_ofx_row);
|
|
import_group.add(&import_pdf_row);
|
|
|
|
// Backup group
|
|
let backup_group = adw::PreferencesGroup::builder()
|
|
.title("BACKUP")
|
|
.build();
|
|
|
|
let backup_row = adw::ActionRow::builder()
|
|
.title("Full Backup")
|
|
.subtitle("Create a backup of all data")
|
|
.activatable(true)
|
|
.build();
|
|
backup_row.add_suffix(>k::Image::from_icon_name("outlay-export"));
|
|
|
|
let restore_row = adw::ActionRow::builder()
|
|
.title("Restore from Backup")
|
|
.subtitle("Restore data from a .outlay backup file")
|
|
.activatable(true)
|
|
.build();
|
|
restore_row.add_suffix(>k::Image::from_icon_name("outlay-import"));
|
|
|
|
backup_group.add(&backup_row);
|
|
backup_group.add(&restore_row);
|
|
|
|
// Wire export buttons
|
|
{
|
|
let db_ref = db.clone();
|
|
let toast_ref = toast_overlay.clone();
|
|
export_csv_row.connect_activated(move |row| {
|
|
Self::export_csv_action(row, &db_ref, &toast_ref);
|
|
});
|
|
}
|
|
{
|
|
let db_ref = db.clone();
|
|
let toast_ref = toast_overlay.clone();
|
|
export_json_row.connect_activated(move |row| {
|
|
Self::export_json_action(row, &db_ref, &toast_ref);
|
|
});
|
|
}
|
|
{
|
|
let db_ref = db.clone();
|
|
let toast_ref = toast_overlay.clone();
|
|
export_qif_row.connect_activated(move |row| {
|
|
Self::export_qif_action(row, &db_ref, &toast_ref);
|
|
});
|
|
}
|
|
{
|
|
let db_ref = db.clone();
|
|
let toast_ref = toast_overlay.clone();
|
|
export_ofx_row.connect_activated(move |row| {
|
|
Self::export_ofx_action(row, &db_ref, &toast_ref);
|
|
});
|
|
}
|
|
{
|
|
let db_ref = db.clone();
|
|
let toast_ref = toast_overlay.clone();
|
|
export_pdf_row.connect_activated(move |row| {
|
|
Self::export_pdf_action(row, &db_ref, &toast_ref);
|
|
});
|
|
}
|
|
{
|
|
let db_ref = db.clone();
|
|
let toast_ref = toast_overlay.clone();
|
|
backup_row.connect_activated(move |row| {
|
|
Self::backup_action(row, &db_ref, &toast_ref);
|
|
});
|
|
}
|
|
{
|
|
let toast_ref = toast_overlay.clone();
|
|
let app_ref = app.clone();
|
|
restore_row.connect_activated(move |row| {
|
|
Self::restore_action(row, &toast_ref, &app_ref);
|
|
});
|
|
}
|
|
{
|
|
let db_ref = db.clone();
|
|
let toast_ref = toast_overlay.clone();
|
|
import_csv_row.connect_activated(move |row| {
|
|
Self::import_csv_action(row, &db_ref, &toast_ref);
|
|
});
|
|
}
|
|
{
|
|
let db_ref = db.clone();
|
|
let toast_ref = toast_overlay.clone();
|
|
import_json_row.connect_activated(move |row| {
|
|
Self::import_json_action(row, &db_ref, &toast_ref);
|
|
});
|
|
}
|
|
{
|
|
let db_ref = db.clone();
|
|
let toast_ref = toast_overlay.clone();
|
|
import_qif_row.connect_activated(move |row| {
|
|
Self::import_qif_action(row, &db_ref, &toast_ref);
|
|
});
|
|
}
|
|
{
|
|
let db_ref = db.clone();
|
|
let toast_ref = toast_overlay.clone();
|
|
import_ofx_row.connect_activated(move |row| {
|
|
Self::import_ofx_action(row, &db_ref, &toast_ref);
|
|
});
|
|
}
|
|
{
|
|
let db_ref = db.clone();
|
|
let toast_ref = toast_overlay.clone();
|
|
import_pdf_row.connect_activated(move |row| {
|
|
Self::import_pdf_action(row, &db_ref, &toast_ref);
|
|
});
|
|
}
|
|
|
|
// Automatic backup group
|
|
let auto_backup_group = adw::PreferencesGroup::builder()
|
|
.title("AUTOMATIC BACKUPS")
|
|
.description("Automatically back up your database on a schedule")
|
|
.build();
|
|
|
|
let auto_backup_enabled = db.get_setting("auto_backup_enabled")
|
|
.ok().flatten().unwrap_or_else(|| "0".to_string()) == "1";
|
|
|
|
let auto_backup_toggle = adw::SwitchRow::builder()
|
|
.title("Enable Automatic Backups")
|
|
.active(auto_backup_enabled)
|
|
.build();
|
|
|
|
let freq_model = gtk::StringList::new(&["Daily", "Weekly", "Monthly"]);
|
|
let auto_backup_freq = adw::ComboRow::builder()
|
|
.title("Backup Frequency")
|
|
.model(&freq_model)
|
|
.sensitive(auto_backup_enabled)
|
|
.build();
|
|
|
|
let current_freq = db.get_setting("auto_backup_frequency")
|
|
.ok().flatten().unwrap_or_else(|| "weekly".to_string());
|
|
let freq_idx = match current_freq.as_str() {
|
|
"daily" => 0,
|
|
"monthly" => 2,
|
|
_ => 1,
|
|
};
|
|
auto_backup_freq.set_selected(freq_idx);
|
|
|
|
let retention_row = adw::SpinRow::builder()
|
|
.title("Keep Last N Backups")
|
|
.subtitle("Older backups will be deleted automatically")
|
|
.sensitive(auto_backup_enabled)
|
|
.build();
|
|
let retention_adj = gtk::Adjustment::new(
|
|
db.get_setting("auto_backup_retention")
|
|
.ok().flatten()
|
|
.and_then(|v| v.parse::<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(>k::Image::from_icon_name("folder-symbolic"));
|
|
|
|
let last_auto_backup = db.get_setting("auto_backup_last")
|
|
.ok().flatten().unwrap_or_else(|| "Never".to_string());
|
|
let last_backup_row = adw::ActionRow::builder()
|
|
.title("Last Automatic Backup")
|
|
.subtitle(&last_auto_backup)
|
|
.build();
|
|
|
|
let backup_now_btn = gtk::Button::with_label("Back Up Now");
|
|
backup_now_btn.add_css_class("pill");
|
|
backup_now_btn.set_halign(gtk::Align::Center);
|
|
backup_now_btn.set_margin_top(8);
|
|
|
|
auto_backup_group.add(&auto_backup_toggle);
|
|
auto_backup_group.add(&auto_backup_freq);
|
|
auto_backup_group.add(&retention_row);
|
|
auto_backup_group.add(&dir_row);
|
|
auto_backup_group.add(&last_backup_row);
|
|
|
|
// Auto-backup toggle handler
|
|
{
|
|
let db_ref = db.clone();
|
|
let freq_ref = auto_backup_freq.clone();
|
|
let retention_ref = retention_row.clone();
|
|
auto_backup_toggle.connect_active_notify(move |row| {
|
|
let val = if row.is_active() { "1" } else { "0" };
|
|
let _ = db_ref.set_setting("auto_backup_enabled", val);
|
|
freq_ref.set_sensitive(row.is_active());
|
|
retention_ref.set_sensitive(row.is_active());
|
|
});
|
|
}
|
|
|
|
// Frequency change handler
|
|
{
|
|
let db_ref = db.clone();
|
|
auto_backup_freq.connect_selected_notify(move |row| {
|
|
let val = match row.selected() {
|
|
0 => "daily",
|
|
2 => "monthly",
|
|
_ => "weekly",
|
|
};
|
|
let _ = db_ref.set_setting("auto_backup_frequency", val);
|
|
});
|
|
}
|
|
|
|
// Retention change handler
|
|
{
|
|
let db_ref = db.clone();
|
|
retention_adj.connect_value_changed(move |adj| {
|
|
let val = adj.value() as i32;
|
|
let _ = db_ref.set_setting("auto_backup_retention", &val.to_string());
|
|
});
|
|
}
|
|
|
|
// Directory chooser handler
|
|
{
|
|
let db_ref = db.clone();
|
|
let dir_row_ref = dir_row.clone();
|
|
let toast_ref = toast_overlay.clone();
|
|
dir_row.connect_activated(move |row| {
|
|
let dialog = gtk::FileDialog::builder()
|
|
.title("Choose Backup Directory")
|
|
.build();
|
|
let window = row.root().and_then(|r| r.downcast::<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(¬ifications_row);
|
|
budget_group.add(&recurring_notif_row);
|
|
|
|
// Budget period change handler
|
|
{
|
|
let db_ref = db.clone();
|
|
let week_row = week_start_row.clone();
|
|
period_row.connect_selected_notify(move |row| {
|
|
let val = match row.selected() {
|
|
1 => "weekly",
|
|
2 => "biweekly",
|
|
_ => "monthly",
|
|
};
|
|
let _ = db_ref.set_setting("budget_period", val);
|
|
week_row.set_visible(val == "weekly" || val == "biweekly");
|
|
});
|
|
}
|
|
|
|
// Week start change handler
|
|
{
|
|
let db_ref = db.clone();
|
|
week_start_row.connect_selected_notify(move |row| {
|
|
let val = match row.selected() {
|
|
1 => "tuesday",
|
|
2 => "wednesday",
|
|
3 => "thursday",
|
|
4 => "friday",
|
|
5 => "saturday",
|
|
6 => "sunday",
|
|
_ => "monday",
|
|
};
|
|
let _ = db_ref.set_setting("week_start_day", val);
|
|
});
|
|
}
|
|
|
|
// Notifications toggle handler
|
|
{
|
|
let db_ref = db.clone();
|
|
notifications_row.connect_active_notify(move |row| {
|
|
let val = if row.is_active() { "1" } else { "0" };
|
|
let _ = db_ref.set_setting("budget_notifications", val);
|
|
});
|
|
}
|
|
{
|
|
let db_ref = db.clone();
|
|
recurring_notif_row.connect_active_notify(move |row| {
|
|
let val = if row.is_active() { "1" } else { "0" };
|
|
let _ = db_ref.set_setting("notify_recurring", val);
|
|
});
|
|
}
|
|
|
|
// Subscription categories group
|
|
let sub_cat_group = adw::PreferencesGroup::builder()
|
|
.title("SUBSCRIPTION CATEGORIES")
|
|
.build();
|
|
|
|
let sub_cat_expander = adw::ExpanderRow::builder()
|
|
.title("Subscription Categories")
|
|
.build();
|
|
Self::populate_subscription_categories(&db, &sub_cat_expander, &toast_overlay);
|
|
sub_cat_group.add(&sub_cat_expander);
|
|
|
|
let add_sub_cat_btn = gtk::Button::with_label("Add Subscription Category");
|
|
add_sub_cat_btn.add_css_class("pill");
|
|
add_sub_cat_btn.set_halign(gtk::Align::Center);
|
|
add_sub_cat_btn.set_margin_top(8);
|
|
{
|
|
let db_ref = db.clone();
|
|
let expander_ref = sub_cat_expander.clone();
|
|
let toast_ref = toast_overlay.clone();
|
|
add_sub_cat_btn.connect_clicked(move |btn| {
|
|
Self::show_add_subscription_category_dialog(
|
|
btn, &db_ref, &expander_ref, &toast_ref,
|
|
);
|
|
});
|
|
}
|
|
|
|
// Back button helper
|
|
let make_back = |stack: >k::Stack| -> gtk::Button {
|
|
let btn = gtk::Button::new();
|
|
btn.add_css_class("flat");
|
|
let content = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
|
content.append(>k::Image::from_icon_name("go-previous-symbolic"));
|
|
content.append(>k::Label::new(Some("Settings")));
|
|
btn.set_child(Some(&content));
|
|
btn.set_halign(gtk::Align::Start);
|
|
let s = stack.clone();
|
|
btn.connect_clicked(move |_| {
|
|
s.set_visible_child_name("root");
|
|
});
|
|
btn
|
|
};
|
|
|
|
// ===== Root page =====
|
|
let root_inner = gtk::Box::new(gtk::Orientation::Vertical, 20);
|
|
root_inner.set_margin_top(20);
|
|
root_inner.set_margin_bottom(20);
|
|
|
|
let root_group = adw::PreferencesGroup::new();
|
|
|
|
let nav_general = adw::ActionRow::builder()
|
|
.title("General")
|
|
.subtitle("Currency, appearance, budget settings")
|
|
.activatable(true)
|
|
.build();
|
|
nav_general.add_prefix(>k::Image::from_icon_name("emblem-system-symbolic"));
|
|
nav_general.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
|
|
|
let nav_categories = adw::ActionRow::builder()
|
|
.title("Categories")
|
|
.subtitle("Manage categories, rules, and templates")
|
|
.activatable(true)
|
|
.build();
|
|
nav_categories.add_prefix(>k::Image::from_icon_name("view-list-symbolic"));
|
|
nav_categories.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
|
|
|
let nav_import_export = adw::ActionRow::builder()
|
|
.title("Import / Export")
|
|
.subtitle("Export data or import from files")
|
|
.activatable(true)
|
|
.build();
|
|
nav_import_export.add_prefix(>k::Image::from_icon_name("document-send-symbolic"));
|
|
nav_import_export.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
|
|
|
let nav_backup = adw::ActionRow::builder()
|
|
.title("Backup")
|
|
.subtitle("Back up, restore, and manage data")
|
|
.activatable(true)
|
|
.build();
|
|
nav_backup.add_prefix(>k::Image::from_icon_name("drive-harddisk-symbolic"));
|
|
nav_backup.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
|
|
|
root_group.add(&nav_general);
|
|
root_group.add(&nav_categories);
|
|
root_group.add(&nav_import_export);
|
|
root_group.add(&nav_backup);
|
|
|
|
root_inner.append(&root_group);
|
|
|
|
let root_clamp = adw::Clamp::new();
|
|
root_clamp.set_maximum_size(700);
|
|
root_clamp.set_margin_start(16);
|
|
root_clamp.set_margin_end(16);
|
|
root_clamp.set_child(Some(&root_inner));
|
|
|
|
let root_scroll = gtk::ScrolledWindow::builder()
|
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
|
.vexpand(true)
|
|
.child(&root_clamp)
|
|
.build();
|
|
settings_stack.add_named(&root_scroll, Some("root"));
|
|
|
|
// ===== General sub-page =====
|
|
let general_inner = gtk::Box::new(gtk::Orientation::Vertical, 20);
|
|
general_inner.set_margin_top(20);
|
|
general_inner.set_margin_bottom(20);
|
|
general_inner.prepend(&make_back(&settings_stack));
|
|
general_inner.append(¤cy_group);
|
|
general_inner.append(&appearance_group);
|
|
general_inner.append(&budget_group);
|
|
|
|
let general_clamp = adw::Clamp::new();
|
|
general_clamp.set_maximum_size(700);
|
|
general_clamp.set_margin_start(16);
|
|
general_clamp.set_margin_end(16);
|
|
general_clamp.set_child(Some(&general_inner));
|
|
|
|
let general_scroll = gtk::ScrolledWindow::builder()
|
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
|
.vexpand(true)
|
|
.child(&general_clamp)
|
|
.build();
|
|
settings_stack.add_named(&general_scroll, Some("general"));
|
|
|
|
// ===== Categories sub-page =====
|
|
let categories_inner = gtk::Box::new(gtk::Orientation::Vertical, 20);
|
|
categories_inner.set_margin_top(20);
|
|
categories_inner.set_margin_bottom(20);
|
|
categories_inner.prepend(&make_back(&settings_stack));
|
|
categories_inner.append(&categories_group);
|
|
categories_inner.append(&add_cat_btn);
|
|
categories_inner.append(&sub_cat_group);
|
|
categories_inner.append(&add_sub_cat_btn);
|
|
categories_inner.append(&rules_group);
|
|
categories_inner.append(&add_rule_btn);
|
|
categories_inner.append(&templates_group);
|
|
categories_inner.append(&add_template_btn);
|
|
|
|
let categories_clamp = adw::Clamp::new();
|
|
categories_clamp.set_maximum_size(700);
|
|
categories_clamp.set_margin_start(16);
|
|
categories_clamp.set_margin_end(16);
|
|
categories_clamp.set_child(Some(&categories_inner));
|
|
|
|
let categories_scroll = gtk::ScrolledWindow::builder()
|
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
|
.vexpand(true)
|
|
.child(&categories_clamp)
|
|
.build();
|
|
settings_stack.add_named(&categories_scroll, Some("categories"));
|
|
|
|
// ===== Import / Export sub-page =====
|
|
let ie_inner = gtk::Box::new(gtk::Orientation::Vertical, 20);
|
|
ie_inner.set_margin_top(20);
|
|
ie_inner.set_margin_bottom(20);
|
|
ie_inner.prepend(&make_back(&settings_stack));
|
|
ie_inner.append(&export_group);
|
|
ie_inner.append(&import_group);
|
|
|
|
let ie_clamp = adw::Clamp::new();
|
|
ie_clamp.set_maximum_size(700);
|
|
ie_clamp.set_margin_start(16);
|
|
ie_clamp.set_margin_end(16);
|
|
ie_clamp.set_child(Some(&ie_inner));
|
|
|
|
let ie_scroll = gtk::ScrolledWindow::builder()
|
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
|
.vexpand(true)
|
|
.child(&ie_clamp)
|
|
.build();
|
|
settings_stack.add_named(&ie_scroll, Some("import_export"));
|
|
|
|
// ===== Backup sub-page =====
|
|
let backup_inner = gtk::Box::new(gtk::Orientation::Vertical, 20);
|
|
backup_inner.set_margin_top(20);
|
|
backup_inner.set_margin_bottom(20);
|
|
backup_inner.prepend(&make_back(&settings_stack));
|
|
backup_inner.append(&backup_group);
|
|
backup_inner.append(&auto_backup_group);
|
|
backup_inner.append(&backup_now_btn);
|
|
backup_inner.append(&reset_group);
|
|
|
|
let backup_clamp = adw::Clamp::new();
|
|
backup_clamp.set_maximum_size(700);
|
|
backup_clamp.set_margin_start(16);
|
|
backup_clamp.set_margin_end(16);
|
|
backup_clamp.set_child(Some(&backup_inner));
|
|
|
|
let backup_scroll = gtk::ScrolledWindow::builder()
|
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
|
.vexpand(true)
|
|
.child(&backup_clamp)
|
|
.build();
|
|
settings_stack.add_named(&backup_scroll, Some("backup"));
|
|
|
|
// ===== Wire navigation rows =====
|
|
{
|
|
let s = settings_stack.clone();
|
|
nav_general.connect_activated(move |_| {
|
|
s.set_visible_child_name("general");
|
|
});
|
|
}
|
|
{
|
|
let s = settings_stack.clone();
|
|
nav_categories.connect_activated(move |_| {
|
|
s.set_visible_child_name("categories");
|
|
});
|
|
}
|
|
{
|
|
let s = settings_stack.clone();
|
|
nav_import_export.connect_activated(move |_| {
|
|
s.set_visible_child_name("import_export");
|
|
});
|
|
}
|
|
{
|
|
let s = settings_stack.clone();
|
|
nav_backup.connect_activated(move |_| {
|
|
s.set_visible_child_name("backup");
|
|
});
|
|
}
|
|
|
|
toast_overlay.set_child(Some(&settings_stack));
|
|
container.append(&toast_overlay);
|
|
|
|
SettingsView { container, on_data_reset }
|
|
}
|
|
|
|
pub fn set_on_data_reset<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: >k::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(¤t_color);
|
|
color_button.set_valign(gtk::Align::Center);
|
|
|
|
let color_row = adw::ActionRow::builder()
|
|
.title("Color")
|
|
.build();
|
|
color_row.add_suffix(&color_button);
|
|
form.add(&color_row);
|
|
|
|
let save_btn = gtk::Button::with_label("Save");
|
|
save_btn.add_css_class("suggested-action");
|
|
save_btn.add_css_class("pill");
|
|
save_btn.set_halign(gtk::Align::Center);
|
|
|
|
content.append(&form);
|
|
content.append(&save_btn);
|
|
toolbar.set_content(Some(&content));
|
|
dialog.set_child(Some(&toolbar));
|
|
|
|
{
|
|
let cat_id = cat.id;
|
|
let sort_order = cat.sort_order;
|
|
let is_default = cat.is_default;
|
|
let db_ref = db.clone();
|
|
let dialog_ref = dialog.clone();
|
|
let expander_ref = expander.clone();
|
|
let toast_ref = toast_overlay.clone();
|
|
let selected_icon_ref = selected_icon.clone();
|
|
save_btn.connect_clicked(move |_| {
|
|
let name = name_row.text().to_string();
|
|
if name.trim().is_empty() {
|
|
let toast = adw::Toast::new("Please enter a category name");
|
|
toast_ref.add_toast(toast);
|
|
return;
|
|
}
|
|
|
|
let icon = selected_icon_ref.borrow().clone();
|
|
let color = Some(Self::rgba_to_hex(&color_button.rgba()));
|
|
|
|
let updated = Category {
|
|
id: cat_id,
|
|
name: name.trim().to_string(),
|
|
icon,
|
|
color,
|
|
transaction_type: txn_type,
|
|
is_default,
|
|
sort_order,
|
|
parent_id: None,
|
|
};
|
|
|
|
match db_ref.update_category(&updated) {
|
|
Ok(()) => {
|
|
dialog_ref.close();
|
|
let toast = adw::Toast::new("Category updated");
|
|
toast_ref.add_toast(toast);
|
|
Self::populate_category_expander(
|
|
&db_ref, &expander_ref, txn_type, &toast_ref,
|
|
);
|
|
}
|
|
Err(e) => {
|
|
let toast = adw::Toast::new(&format!("Error: {}", e));
|
|
toast_ref.add_toast(toast);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
dialog.present(Some(parent));
|
|
}
|
|
|
|
fn rgba_to_hex(rgba: &gdk::RGBA) -> String {
|
|
let r = (rgba.red() * 255.0).round() as u8;
|
|
let g = (rgba.green() * 255.0).round() as u8;
|
|
let b = (rgba.blue() * 255.0).round() as u8;
|
|
format!("#{:02x}{:02x}{:02x}", r, g, b)
|
|
}
|
|
|
|
fn show_add_category_dialog(
|
|
parent: >k::Button,
|
|
db: &Rc<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: >k::Image,
|
|
icon_row: &adw::ActionRow,
|
|
) {
|
|
let picker = adw::Dialog::builder()
|
|
.title("Choose Icon")
|
|
.content_width(480)
|
|
.content_height(520)
|
|
.build();
|
|
|
|
let toolbar = adw::ToolbarView::new();
|
|
toolbar.add_top_bar(&adw::HeaderBar::new());
|
|
|
|
let vbox = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
|
vbox.set_margin_start(12);
|
|
vbox.set_margin_end(12);
|
|
vbox.set_margin_top(8);
|
|
vbox.set_margin_bottom(8);
|
|
|
|
let search = gtk::SearchEntry::builder()
|
|
.placeholder_text("Search icons...")
|
|
.build();
|
|
vbox.append(&search);
|
|
|
|
let flow = gtk::FlowBox::builder()
|
|
.homogeneous(true)
|
|
.min_children_per_line(6)
|
|
.max_children_per_line(12)
|
|
.selection_mode(gtk::SelectionMode::None)
|
|
.build();
|
|
flow.add_css_class("icon-picker-grid");
|
|
|
|
let icons = icon_theme::list_tabler_icons();
|
|
for icon_name in &icons {
|
|
let image = gtk::Image::builder()
|
|
.icon_name(icon_name)
|
|
.pixel_size(24)
|
|
.build();
|
|
|
|
let display_name = icon_name.strip_prefix("tabler-").unwrap_or(icon_name);
|
|
let btn = gtk::Button::builder()
|
|
.child(&image)
|
|
.tooltip_text(display_name)
|
|
.build();
|
|
btn.add_css_class("flat");
|
|
btn.add_css_class("icon-picker-btn");
|
|
|
|
let picker_ref = picker.clone();
|
|
let selected_ref = selected_icon.clone();
|
|
let preview_ref = icon_preview.clone();
|
|
let row_ref = icon_row.clone();
|
|
let name = icon_name.clone();
|
|
btn.connect_clicked(move |_| {
|
|
*selected_ref.borrow_mut() = Some(name.clone());
|
|
preview_ref.set_icon_name(Some(&name));
|
|
row_ref.set_subtitle(&name);
|
|
picker_ref.close();
|
|
});
|
|
|
|
let child = gtk::FlowBoxChild::new();
|
|
child.set_child(Some(&btn));
|
|
child.set_widget_name(icon_name);
|
|
flow.append(&child);
|
|
}
|
|
|
|
// Search filtering
|
|
{
|
|
let flow_ref = flow.clone();
|
|
search.connect_search_changed(move |entry| {
|
|
let query = entry.text().to_string().to_lowercase();
|
|
let flow_filter = flow_ref.clone();
|
|
flow_filter.set_filter_func(move |child| {
|
|
if query.is_empty() {
|
|
return true;
|
|
}
|
|
child.widget_name().to_lowercase().contains(&query)
|
|
});
|
|
});
|
|
}
|
|
|
|
let scroll = gtk::ScrolledWindow::builder()
|
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
|
.vexpand(true)
|
|
.child(&flow)
|
|
.build();
|
|
|
|
vbox.append(&scroll);
|
|
toolbar.set_content(Some(&vbox));
|
|
picker.set_child(Some(&toolbar));
|
|
picker.present(Some(parent));
|
|
}
|
|
|
|
fn export_csv_action(
|
|
row: &adw::ActionRow,
|
|
db: &Rc<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: >k::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: >k::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(¬e_row);
|
|
|
|
let save_btn = gtk::Button::with_label("Save");
|
|
save_btn.add_css_class("suggested-action");
|
|
save_btn.add_css_class("pill");
|
|
save_btn.set_halign(gtk::Align::Center);
|
|
|
|
content.append(&form);
|
|
content.append(&save_btn);
|
|
|
|
let scroll = gtk::ScrolledWindow::builder()
|
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
|
.child(&content)
|
|
.build();
|
|
|
|
toolbar.set_content(Some(&scroll));
|
|
dialog.set_child(Some(&toolbar));
|
|
|
|
let base_currency = db
|
|
.get_setting("base_currency")
|
|
.ok()
|
|
.flatten()
|
|
.unwrap_or_else(|| "USD".to_string());
|
|
|
|
{
|
|
let db_ref = db.clone();
|
|
let dialog_ref = dialog.clone();
|
|
let group_ref = group.clone();
|
|
let toast_ref = toast_overlay.clone();
|
|
let cat_ids = cat_ids.clone();
|
|
let base_currency = base_currency.clone();
|
|
save_btn.connect_clicked(move |_| {
|
|
let name = name_row.text().to_string();
|
|
if name.trim().is_empty() {
|
|
let toast = adw::Toast::new("Please enter a template name");
|
|
toast_ref.add_toast(toast);
|
|
return;
|
|
}
|
|
|
|
let txn_type = if type_row.selected() == 0 {
|
|
TransactionType::Expense
|
|
} else {
|
|
TransactionType::Income
|
|
};
|
|
|
|
let amount_text = amount_row.text();
|
|
let amount: Option<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: >k::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: >k::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
|
|
}
|
|
}
|