Add settings view with theme, categories, export, and backup
This commit is contained in:
@@ -734,6 +734,21 @@ impl Database {
|
||||
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<()> {
|
||||
let expense_categories = [
|
||||
("Food & Dining", "\u{1f354}", "#e74c3c"),
|
||||
|
||||
@@ -5,7 +5,7 @@ edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
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"] }
|
||||
chrono = "0.4"
|
||||
gdk = { package = "gdk4", version = "0.11" }
|
||||
|
||||
@@ -3,6 +3,7 @@ mod charts_view;
|
||||
mod history_view;
|
||||
mod log_view;
|
||||
mod recurring_view;
|
||||
mod settings_view;
|
||||
mod window;
|
||||
|
||||
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::log_view::LogView;
|
||||
use crate::recurring_view::RecurringView;
|
||||
use crate::settings_view::SettingsView;
|
||||
|
||||
pub struct MainWindow {
|
||||
pub window: adw::ApplicationWindow,
|
||||
@@ -63,14 +64,9 @@ impl MainWindow {
|
||||
let recurring_view = RecurringView::new(db.clone());
|
||||
content_stack.add_named(&recurring_view.container, Some("recurring"));
|
||||
|
||||
// Settings placeholder
|
||||
for item in &SIDEBAR_ITEMS[5..] {
|
||||
let page = adw::StatusPage::builder()
|
||||
.title(item.label)
|
||||
.icon_name(item.icon)
|
||||
.build();
|
||||
content_stack.add_named(&page, Some(item.id));
|
||||
}
|
||||
// Settings view
|
||||
let settings_view = SettingsView::new(db.clone(), app);
|
||||
content_stack.add_named(&settings_view.container, Some("settings"));
|
||||
|
||||
let sidebar_list = gtk::ListBox::new();
|
||||
sidebar_list.set_selection_mode(gtk::SelectionMode::Single);
|
||||
|
||||
Reference in New Issue
Block a user