Add settings view with theme, categories, export, and backup
This commit is contained in:
@@ -734,6 +734,21 @@ impl Database {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn reset_all_data(&self) -> SqlResult<()> {
|
||||||
|
self.conn.execute_batch(
|
||||||
|
"DELETE FROM transactions;
|
||||||
|
DELETE FROM budgets;
|
||||||
|
DELETE FROM recurring_transactions;
|
||||||
|
DELETE FROM budget_notifications;
|
||||||
|
DELETE FROM categories;
|
||||||
|
DELETE FROM exchange_rate_cache;
|
||||||
|
DELETE FROM settings;"
|
||||||
|
)?;
|
||||||
|
self.seed_default_categories()?;
|
||||||
|
self.set_setting("schema_version", "1")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn seed_default_categories(&self) -> SqlResult<()> {
|
fn seed_default_categories(&self) -> SqlResult<()> {
|
||||||
let expense_categories = [
|
let expense_categories = [
|
||||||
("Food & Dining", "\u{1f354}", "#e74c3c"),
|
("Food & Dining", "\u{1f354}", "#e74c3c"),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ edition.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
outlay-core = { path = "../outlay-core" }
|
outlay-core = { path = "../outlay-core" }
|
||||||
gtk = { package = "gtk4", version = "0.11" }
|
gtk = { package = "gtk4", version = "0.11", features = ["v4_10"] }
|
||||||
adw = { package = "libadwaita", version = "0.9", features = ["v1_8"] }
|
adw = { package = "libadwaita", version = "0.9", features = ["v1_8"] }
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
gdk = { package = "gdk4", version = "0.11" }
|
gdk = { package = "gdk4", version = "0.11" }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ mod charts_view;
|
|||||||
mod history_view;
|
mod history_view;
|
||||||
mod log_view;
|
mod log_view;
|
||||||
mod recurring_view;
|
mod recurring_view;
|
||||||
|
mod settings_view;
|
||||||
mod window;
|
mod window;
|
||||||
|
|
||||||
use adw::prelude::*;
|
use adw::prelude::*;
|
||||||
|
|||||||
828
outlay-gtk/src/settings_view.rs
Normal file
828
outlay-gtk/src/settings_view.rs
Normal file
@@ -0,0 +1,828 @@
|
|||||||
|
use adw::prelude::*;
|
||||||
|
use chrono::{Datelike, Local};
|
||||||
|
use gtk::{gio, glib};
|
||||||
|
use outlay_core::backup;
|
||||||
|
use outlay_core::db::Database;
|
||||||
|
use outlay_core::exchange::ExchangeRateService;
|
||||||
|
use outlay_core::export_csv;
|
||||||
|
use outlay_core::export_json;
|
||||||
|
use outlay_core::export_pdf;
|
||||||
|
use outlay_core::models::{NewCategory, TransactionType};
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
pub struct SettingsView {
|
||||||
|
pub container: gtk::Box,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SettingsView {
|
||||||
|
pub fn new(db: Rc<Database>, app: &adw::Application) -> Self {
|
||||||
|
let container = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||||
|
let toast_overlay = adw::ToastOverlay::new();
|
||||||
|
|
||||||
|
let clamp = adw::Clamp::new();
|
||||||
|
clamp.set_maximum_size(700);
|
||||||
|
clamp.set_margin_start(12);
|
||||||
|
clamp.set_margin_end(12);
|
||||||
|
|
||||||
|
let inner = gtk::Box::new(gtk::Orientation::Vertical, 16);
|
||||||
|
inner.set_margin_top(16);
|
||||||
|
inner.set_margin_bottom(16);
|
||||||
|
|
||||||
|
// Currency group
|
||||||
|
let currency_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Currency")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let base_currency = db
|
||||||
|
.get_setting("base_currency")
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.unwrap_or_else(|| "USD".to_string());
|
||||||
|
|
||||||
|
let currencies = ExchangeRateService::supported_currencies();
|
||||||
|
let currency_labels: Vec<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);
|
||||||
|
|
||||||
|
// Appearance group
|
||||||
|
let appearance_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Appearance")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let theme_labels = ["System", "Light", "Dark"];
|
||||||
|
let theme_model = gtk::StringList::new(&theme_labels);
|
||||||
|
|
||||||
|
let current_theme = db
|
||||||
|
.get_setting("theme")
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.unwrap_or_else(|| "system".to_string());
|
||||||
|
let theme_idx = match current_theme.as_str() {
|
||||||
|
"light" => 1,
|
||||||
|
"dark" => 2,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let theme_row = adw::ComboRow::builder()
|
||||||
|
.title("Theme")
|
||||||
|
.model(&theme_model)
|
||||||
|
.selected(theme_idx)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
{
|
||||||
|
let db_ref = db.clone();
|
||||||
|
theme_row.connect_selected_notify(move |row| {
|
||||||
|
let theme = match row.selected() {
|
||||||
|
1 => "light",
|
||||||
|
2 => "dark",
|
||||||
|
_ => "system",
|
||||||
|
};
|
||||||
|
db_ref.set_setting("theme", theme).ok();
|
||||||
|
Self::apply_theme(theme);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
appearance_group.add(&theme_row);
|
||||||
|
|
||||||
|
// Apply current theme on load
|
||||||
|
Self::apply_theme(¤t_theme);
|
||||||
|
|
||||||
|
// Categories group
|
||||||
|
let categories_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Categories")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let expense_expander = adw::ExpanderRow::builder()
|
||||||
|
.title("Expense Categories")
|
||||||
|
.build();
|
||||||
|
Self::populate_category_expander(
|
||||||
|
&db, &expense_expander, TransactionType::Expense, &toast_overlay,
|
||||||
|
);
|
||||||
|
categories_group.add(&expense_expander);
|
||||||
|
|
||||||
|
let income_expander = adw::ExpanderRow::builder()
|
||||||
|
.title("Income Categories")
|
||||||
|
.build();
|
||||||
|
Self::populate_category_expander(
|
||||||
|
&db, &income_expander, TransactionType::Income, &toast_overlay,
|
||||||
|
);
|
||||||
|
categories_group.add(&income_expander);
|
||||||
|
|
||||||
|
let add_cat_btn = gtk::Button::with_label("Add Category");
|
||||||
|
add_cat_btn.add_css_class("pill");
|
||||||
|
add_cat_btn.set_halign(gtk::Align::Center);
|
||||||
|
add_cat_btn.set_margin_top(8);
|
||||||
|
|
||||||
|
{
|
||||||
|
let db_ref = db.clone();
|
||||||
|
let expense_ref = expense_expander.clone();
|
||||||
|
let income_ref = income_expander.clone();
|
||||||
|
let toast_ref = toast_overlay.clone();
|
||||||
|
add_cat_btn.connect_clicked(move |btn| {
|
||||||
|
Self::show_add_category_dialog(
|
||||||
|
btn, &db_ref, &expense_ref, &income_ref, &toast_ref,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data group
|
||||||
|
let data_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Data")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let export_csv_row = adw::ActionRow::builder()
|
||||||
|
.title("Export CSV")
|
||||||
|
.subtitle("Export all transactions as CSV file")
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
export_csv_row.add_suffix(>k::Image::from_icon_name("document-save-symbolic"));
|
||||||
|
|
||||||
|
let export_json_row = adw::ActionRow::builder()
|
||||||
|
.title("Export JSON")
|
||||||
|
.subtitle("Export all data as JSON file")
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
export_json_row.add_suffix(>k::Image::from_icon_name("document-save-symbolic"));
|
||||||
|
|
||||||
|
let export_pdf_row = adw::ActionRow::builder()
|
||||||
|
.title("Export PDF Report")
|
||||||
|
.subtitle("Generate a monthly PDF report")
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
export_pdf_row.add_suffix(>k::Image::from_icon_name("document-save-symbolic"));
|
||||||
|
|
||||||
|
let backup_row = adw::ActionRow::builder()
|
||||||
|
.title("Full Backup")
|
||||||
|
.subtitle("Create a backup of all data")
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
backup_row.add_suffix(>k::Image::from_icon_name("document-save-symbolic"));
|
||||||
|
|
||||||
|
let restore_row = adw::ActionRow::builder()
|
||||||
|
.title("Restore from Backup")
|
||||||
|
.subtitle("Restore data from a .outlay backup file")
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
restore_row.add_suffix(>k::Image::from_icon_name("document-open-symbolic"));
|
||||||
|
|
||||||
|
data_group.add(&export_csv_row);
|
||||||
|
data_group.add(&export_json_row);
|
||||||
|
data_group.add(&export_pdf_row);
|
||||||
|
data_group.add(&backup_row);
|
||||||
|
data_group.add(&restore_row);
|
||||||
|
|
||||||
|
// Wire export buttons
|
||||||
|
{
|
||||||
|
let db_ref = db.clone();
|
||||||
|
let toast_ref = toast_overlay.clone();
|
||||||
|
export_csv_row.connect_activated(move |row| {
|
||||||
|
Self::export_csv_action(row, &db_ref, &toast_ref);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let db_ref = db.clone();
|
||||||
|
let toast_ref = toast_overlay.clone();
|
||||||
|
export_json_row.connect_activated(move |row| {
|
||||||
|
Self::export_json_action(row, &db_ref, &toast_ref);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let db_ref = db.clone();
|
||||||
|
let toast_ref = toast_overlay.clone();
|
||||||
|
export_pdf_row.connect_activated(move |row| {
|
||||||
|
Self::export_pdf_action(row, &db_ref, &toast_ref);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let db_ref = db.clone();
|
||||||
|
let toast_ref = toast_overlay.clone();
|
||||||
|
backup_row.connect_activated(move |row| {
|
||||||
|
Self::backup_action(row, &db_ref, &toast_ref);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let toast_ref = toast_overlay.clone();
|
||||||
|
let app_ref = app.clone();
|
||||||
|
restore_row.connect_activated(move |row| {
|
||||||
|
Self::restore_action(row, &toast_ref, &app_ref);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset group
|
||||||
|
let reset_group = adw::PreferencesGroup::builder()
|
||||||
|
.title("Danger Zone")
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let reset_row = adw::ActionRow::builder()
|
||||||
|
.title("Reset All Data")
|
||||||
|
.subtitle("Delete all transactions, budgets, and settings")
|
||||||
|
.activatable(true)
|
||||||
|
.build();
|
||||||
|
reset_row.add_css_class("error");
|
||||||
|
|
||||||
|
{
|
||||||
|
let db_ref = db.clone();
|
||||||
|
let toast_ref = toast_overlay.clone();
|
||||||
|
reset_row.connect_activated(move |row| {
|
||||||
|
Self::reset_action(row, &db_ref, &toast_ref);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
reset_group.add(&reset_row);
|
||||||
|
|
||||||
|
inner.append(¤cy_group);
|
||||||
|
inner.append(&appearance_group);
|
||||||
|
inner.append(&categories_group);
|
||||||
|
inner.append(&add_cat_btn);
|
||||||
|
inner.append(&data_group);
|
||||||
|
inner.append(&reset_group);
|
||||||
|
|
||||||
|
clamp.set_child(Some(&inner));
|
||||||
|
|
||||||
|
let scroll = gtk::ScrolledWindow::builder()
|
||||||
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||||
|
.vexpand(true)
|
||||||
|
.child(&clamp)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
toast_overlay.set_child(Some(&scroll));
|
||||||
|
container.append(&toast_overlay);
|
||||||
|
|
||||||
|
SettingsView { container }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_theme(theme: &str) {
|
||||||
|
let style_manager = adw::StyleManager::default();
|
||||||
|
match theme {
|
||||||
|
"light" => style_manager.set_color_scheme(adw::ColorScheme::ForceLight),
|
||||||
|
"dark" => style_manager.set_color_scheme(adw::ColorScheme::ForceDark),
|
||||||
|
_ => style_manager.set_color_scheme(adw::ColorScheme::Default),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn populate_category_expander(
|
||||||
|
db: &Rc<Database>,
|
||||||
|
expander: &adw::ExpanderRow,
|
||||||
|
txn_type: TransactionType,
|
||||||
|
toast_overlay: &adw::ToastOverlay,
|
||||||
|
) {
|
||||||
|
// Remove existing children
|
||||||
|
while let Some(child) = expander.first_child() {
|
||||||
|
if let Some(child) = child.next_sibling() {
|
||||||
|
if let Some(row) = child.downcast_ref::<adw::ActionRow>() {
|
||||||
|
expander.remove(row);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let cats = db.list_categories(Some(txn_type)).unwrap_or_default();
|
||||||
|
for cat in &cats {
|
||||||
|
let display = match &cat.icon {
|
||||||
|
Some(icon) => format!("{} {}", icon, cat.name),
|
||||||
|
None => cat.name.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let row = adw::ActionRow::builder()
|
||||||
|
.title(&display)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
if !cat.is_default {
|
||||||
|
let delete_btn = gtk::Button::from_icon_name("edit-delete-symbolic");
|
||||||
|
delete_btn.add_css_class("flat");
|
||||||
|
delete_btn.set_valign(gtk::Align::Center);
|
||||||
|
|
||||||
|
let cat_id = cat.id;
|
||||||
|
let db_ref = db.clone();
|
||||||
|
let expander_ref = expander.clone();
|
||||||
|
let toast_ref = toast_overlay.clone();
|
||||||
|
delete_btn.connect_clicked(move |btn| {
|
||||||
|
let alert = adw::AlertDialog::new(
|
||||||
|
Some("Delete this category?"),
|
||||||
|
Some("Transactions using this category will not be deleted."),
|
||||||
|
);
|
||||||
|
alert.add_response("cancel", "Cancel");
|
||||||
|
alert.add_response("delete", "Delete");
|
||||||
|
alert.set_response_appearance("delete", adw::ResponseAppearance::Destructive);
|
||||||
|
alert.set_default_response(Some("cancel"));
|
||||||
|
alert.set_close_response("cancel");
|
||||||
|
|
||||||
|
let db_del = db_ref.clone();
|
||||||
|
let exp_del = expander_ref.clone();
|
||||||
|
let toast_del = toast_ref.clone();
|
||||||
|
alert.connect_response(None, move |_, response| {
|
||||||
|
if response == "delete" {
|
||||||
|
match db_del.delete_category(cat_id) {
|
||||||
|
Ok(()) => {
|
||||||
|
let toast = adw::Toast::new("Category deleted");
|
||||||
|
toast_del.add_toast(toast);
|
||||||
|
Self::populate_category_expander(
|
||||||
|
&db_del, &exp_del, txn_type, &toast_del,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let toast = adw::Toast::new(&format!("Error: {}", e));
|
||||||
|
toast_del.add_toast(toast);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
alert.present(Some(btn));
|
||||||
|
});
|
||||||
|
|
||||||
|
row.add_suffix(&delete_btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
expander.add_row(&row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_add_category_dialog(
|
||||||
|
parent: >k::Button,
|
||||||
|
db: &Rc<Database>,
|
||||||
|
expense_expander: &adw::ExpanderRow,
|
||||||
|
income_expander: &adw::ExpanderRow,
|
||||||
|
toast_overlay: &adw::ToastOverlay,
|
||||||
|
) {
|
||||||
|
let dialog = adw::Dialog::builder()
|
||||||
|
.title("Add Category")
|
||||||
|
.content_width(360)
|
||||||
|
.content_height(350)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let toolbar = adw::ToolbarView::new();
|
||||||
|
toolbar.add_top_bar(&adw::HeaderBar::new());
|
||||||
|
|
||||||
|
let content = gtk::Box::new(gtk::Orientation::Vertical, 16);
|
||||||
|
content.set_margin_top(16);
|
||||||
|
content.set_margin_bottom(16);
|
||||||
|
content.set_margin_start(16);
|
||||||
|
content.set_margin_end(16);
|
||||||
|
|
||||||
|
let form = adw::PreferencesGroup::new();
|
||||||
|
|
||||||
|
let name_row = adw::EntryRow::builder()
|
||||||
|
.title("Name")
|
||||||
|
.build();
|
||||||
|
form.add(&name_row);
|
||||||
|
|
||||||
|
let icon_row = adw::EntryRow::builder()
|
||||||
|
.title("Icon (emoji)")
|
||||||
|
.build();
|
||||||
|
form.add(&icon_row);
|
||||||
|
|
||||||
|
let type_labels = ["Expense", "Income"];
|
||||||
|
let type_model = gtk::StringList::new(&type_labels);
|
||||||
|
let type_row = adw::ComboRow::builder()
|
||||||
|
.title("Type")
|
||||||
|
.model(&type_model)
|
||||||
|
.build();
|
||||||
|
form.add(&type_row);
|
||||||
|
|
||||||
|
let save_btn = gtk::Button::with_label("Save");
|
||||||
|
save_btn.add_css_class("suggested-action");
|
||||||
|
save_btn.add_css_class("pill");
|
||||||
|
save_btn.set_halign(gtk::Align::Center);
|
||||||
|
|
||||||
|
content.append(&form);
|
||||||
|
content.append(&save_btn);
|
||||||
|
toolbar.set_content(Some(&content));
|
||||||
|
dialog.set_child(Some(&toolbar));
|
||||||
|
|
||||||
|
{
|
||||||
|
let db_ref = db.clone();
|
||||||
|
let dialog_ref = dialog.clone();
|
||||||
|
let expense_ref = expense_expander.clone();
|
||||||
|
let income_ref = income_expander.clone();
|
||||||
|
let toast_ref = toast_overlay.clone();
|
||||||
|
save_btn.connect_clicked(move |_| {
|
||||||
|
let name = name_row.text().to_string();
|
||||||
|
if name.trim().is_empty() {
|
||||||
|
let toast = adw::Toast::new("Please enter a category name");
|
||||||
|
toast_ref.add_toast(toast);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let icon_text = icon_row.text().to_string();
|
||||||
|
let icon = if icon_text.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(icon_text.trim().to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
let txn_type = if type_row.selected() == 0 {
|
||||||
|
TransactionType::Expense
|
||||||
|
} else {
|
||||||
|
TransactionType::Income
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_cat = NewCategory {
|
||||||
|
name: name.trim().to_string(),
|
||||||
|
icon,
|
||||||
|
color: None,
|
||||||
|
transaction_type: txn_type,
|
||||||
|
sort_order: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
match db_ref.insert_category(&new_cat) {
|
||||||
|
Ok(_) => {
|
||||||
|
dialog_ref.close();
|
||||||
|
let toast = adw::Toast::new("Category added");
|
||||||
|
toast_ref.add_toast(toast);
|
||||||
|
Self::populate_category_expander(
|
||||||
|
&db_ref, &expense_ref, TransactionType::Expense, &toast_ref,
|
||||||
|
);
|
||||||
|
Self::populate_category_expander(
|
||||||
|
&db_ref, &income_ref, TransactionType::Income, &toast_ref,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let toast = adw::Toast::new(&format!("Error: {}", e));
|
||||||
|
toast_ref.add_toast(toast);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.present(Some(parent));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn export_csv_action(
|
||||||
|
row: &adw::ActionRow,
|
||||||
|
db: &Rc<Database>,
|
||||||
|
toast_overlay: &adw::ToastOverlay,
|
||||||
|
) {
|
||||||
|
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_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(f) => {
|
||||||
|
match export_csv::export_transactions_csv(&db_ref, f, None, None) {
|
||||||
|
Ok(count) => {
|
||||||
|
let toast = adw::Toast::new(&format!(
|
||||||
|
"Exported {} transactions to CSV",
|
||||||
|
count
|
||||||
|
));
|
||||||
|
toast_ref.add_toast(toast);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let toast = adw::Toast::new(&format!("Export error: {}", e));
|
||||||
|
toast_ref.add_toast(toast);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let toast = adw::Toast::new(&format!("File error: {}", e));
|
||||||
|
toast_ref.add_toast(toast);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn export_json_action(
|
||||||
|
row: &adw::ActionRow,
|
||||||
|
db: &Rc<Database>,
|
||||||
|
toast_overlay: &adw::ToastOverlay,
|
||||||
|
) {
|
||||||
|
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_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(f) => match export_json::export_json(&db_ref, f) {
|
||||||
|
Ok(data) => {
|
||||||
|
let toast = adw::Toast::new(&format!(
|
||||||
|
"Exported {} transactions to JSON",
|
||||||
|
data.transactions.len()
|
||||||
|
));
|
||||||
|
toast_ref.add_toast(toast);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let toast = adw::Toast::new(&format!("Export error: {}", e));
|
||||||
|
toast_ref.add_toast(toast);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
let toast = adw::Toast::new(&format!("File error: {}", e));
|
||||||
|
toast_ref.add_toast(toast);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn export_pdf_action(
|
||||||
|
row: &adw::ActionRow,
|
||||||
|
db: &Rc<Database>,
|
||||||
|
toast_overlay: &adw::ToastOverlay,
|
||||||
|
) {
|
||||||
|
let today = Local::now().date_naive();
|
||||||
|
let base_currency = db
|
||||||
|
.get_setting("base_currency")
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.unwrap_or_else(|| "USD".to_string());
|
||||||
|
|
||||||
|
let filter = gtk::FileFilter::new();
|
||||||
|
filter.add_pattern("*.pdf");
|
||||||
|
filter.set_name(Some("PDF files"));
|
||||||
|
|
||||||
|
let filters = gio::ListStore::new::<gtk::FileFilter>();
|
||||||
|
filters.append(&filter);
|
||||||
|
|
||||||
|
let default_name = format!(
|
||||||
|
"outlay-report-{:04}-{:02}.pdf",
|
||||||
|
today.year(),
|
||||||
|
today.month()
|
||||||
|
);
|
||||||
|
let dialog = gtk::FileDialog::builder()
|
||||||
|
.title("Export PDF Report")
|
||||||
|
.initial_name(&default_name)
|
||||||
|
.default_filter(&filter)
|
||||||
|
.filters(&filters)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let db_ref = db.clone();
|
||||||
|
let toast_ref = toast_overlay.clone();
|
||||||
|
let year = today.year();
|
||||||
|
let month = today.month();
|
||||||
|
let window = row.root().and_then(|r| r.downcast::<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 export_pdf::generate_monthly_report(
|
||||||
|
&db_ref,
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
&base_currency,
|
||||||
|
&path,
|
||||||
|
) {
|
||||||
|
Ok(()) => {
|
||||||
|
let toast = adw::Toast::new("PDF report exported");
|
||||||
|
toast_ref.add_toast(toast);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let toast = adw::Toast::new(&format!("PDF error: {}", e));
|
||||||
|
toast_ref.add_toast(toast);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backup_action(
|
||||||
|
row: &adw::ActionRow,
|
||||||
|
db: &Rc<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 toast = adw::Toast::new(&format!(
|
||||||
|
"Backup created ({} transactions)",
|
||||||
|
meta.transaction_count
|
||||||
|
));
|
||||||
|
toast_ref.add_toast(toast);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let toast = adw::Toast::new(&format!("Backup error: {}", e));
|
||||||
|
toast_ref.add_toast(toast);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_action(
|
||||||
|
row: &adw::ActionRow,
|
||||||
|
toast_overlay: &adw::ToastOverlay,
|
||||||
|
app: &adw::Application,
|
||||||
|
) {
|
||||||
|
let filter = gtk::FileFilter::new();
|
||||||
|
filter.add_pattern("*.outlay");
|
||||||
|
filter.set_name(Some("Outlay backup files"));
|
||||||
|
|
||||||
|
let filters = gio::ListStore::new::<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,
|
||||||
|
) {
|
||||||
|
let alert = adw::AlertDialog::new(
|
||||||
|
Some("Reset All Data?"),
|
||||||
|
Some("This will permanently delete all transactions, budgets, recurring transactions, and settings. This cannot be undone."),
|
||||||
|
);
|
||||||
|
alert.add_response("cancel", "Cancel");
|
||||||
|
alert.add_response("reset", "Reset Everything");
|
||||||
|
alert.set_response_appearance("reset", adw::ResponseAppearance::Destructive);
|
||||||
|
alert.set_default_response(Some("cancel"));
|
||||||
|
alert.set_close_response("cancel");
|
||||||
|
|
||||||
|
let db_ref = db.clone();
|
||||||
|
let toast_ref = toast_overlay.clone();
|
||||||
|
alert.connect_response(None, move |_, response| {
|
||||||
|
if response == "reset" {
|
||||||
|
match db_ref.reset_all_data() {
|
||||||
|
Ok(()) => {
|
||||||
|
let toast = adw::Toast::new("All data has been reset");
|
||||||
|
toast_ref.add_toast(toast);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let toast = adw::Toast::new(&format!("Error: {}", e));
|
||||||
|
toast_ref.add_toast(toast);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
alert.present(Some(row));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ use crate::charts_view::ChartsView;
|
|||||||
use crate::history_view::HistoryView;
|
use crate::history_view::HistoryView;
|
||||||
use crate::log_view::LogView;
|
use crate::log_view::LogView;
|
||||||
use crate::recurring_view::RecurringView;
|
use crate::recurring_view::RecurringView;
|
||||||
|
use crate::settings_view::SettingsView;
|
||||||
|
|
||||||
pub struct MainWindow {
|
pub struct MainWindow {
|
||||||
pub window: adw::ApplicationWindow,
|
pub window: adw::ApplicationWindow,
|
||||||
@@ -63,14 +64,9 @@ impl MainWindow {
|
|||||||
let recurring_view = RecurringView::new(db.clone());
|
let recurring_view = RecurringView::new(db.clone());
|
||||||
content_stack.add_named(&recurring_view.container, Some("recurring"));
|
content_stack.add_named(&recurring_view.container, Some("recurring"));
|
||||||
|
|
||||||
// Settings placeholder
|
// Settings view
|
||||||
for item in &SIDEBAR_ITEMS[5..] {
|
let settings_view = SettingsView::new(db.clone(), app);
|
||||||
let page = adw::StatusPage::builder()
|
content_stack.add_named(&settings_view.container, Some("settings"));
|
||||||
.title(item.label)
|
|
||||||
.icon_name(item.icon)
|
|
||||||
.build();
|
|
||||||
content_stack.add_named(&page, Some(item.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
let sidebar_list = gtk::ListBox::new();
|
let sidebar_list = gtk::ListBox::new();
|
||||||
sidebar_list.set_selection_mode(gtk::SelectionMode::Single);
|
sidebar_list.set_selection_mode(gtk::SelectionMode::Single);
|
||||||
|
|||||||
Reference in New Issue
Block a user