- Change app ID from io.github.outlay to com.outlay.app across all files - Add AppStream metainfo with full feature description, 16 screenshots, and v0.1.0 release - Update desktop file with expanded metadata (StartupNotify, SingleMainWindow) - Add summary and description fields to GSchema keys - Move toast overlay outside ScrolledWindow so notifications stay visible in viewport - Embed tray icon as ARGB pixmap data for reliable system tray display - Register hicolor icon theme path for taskbar icon on Wayland - Remove unused icon variants (old naming, web favicons, SVG, ICO, shadow) - Add screenshots to data/screenshots/ - Update build script with metainfo and screenshot bundling
2210 lines
94 KiB
Rust
2210 lines
94 KiB
Rust
use adw::prelude::*;
|
|
use chrono::Datelike;
|
|
use gtk::{gio, glib};
|
|
use outlay_core::db::Database;
|
|
use outlay_core::exchange::ExchangeRateService;
|
|
use outlay_core::models::{NewTransaction, TransactionType};
|
|
use outlay_core::ocr;
|
|
use std::cell::{Cell, RefCell};
|
|
use std::rc::Rc;
|
|
|
|
use crate::edit_dialog;
|
|
use crate::icon_theme;
|
|
|
|
type PendingAttachment = (String, String, Vec<u8>);
|
|
|
|
enum SymbolPosition {
|
|
Prefix,
|
|
Suffix,
|
|
}
|
|
|
|
fn currency_info(code: &str) -> (&'static str, SymbolPosition) {
|
|
match code.to_uppercase().as_str() {
|
|
"USD" => ("$", SymbolPosition::Prefix),
|
|
"EUR" => ("\u{20ac}", SymbolPosition::Suffix), // euro sign
|
|
"GBP" => ("\u{00a3}", SymbolPosition::Prefix), // pound sign
|
|
"JPY" => ("\u{00a5}", SymbolPosition::Prefix), // yen sign
|
|
"CAD" => ("C$", SymbolPosition::Prefix),
|
|
"AUD" => ("A$", SymbolPosition::Prefix),
|
|
"CHF" => ("CHF", SymbolPosition::Prefix),
|
|
"CNY" => ("\u{00a5}", SymbolPosition::Prefix), // yuan sign
|
|
"INR" => ("\u{20b9}", SymbolPosition::Prefix), // rupee sign
|
|
"BRL" => ("R$", SymbolPosition::Prefix),
|
|
"MXN" => ("$", SymbolPosition::Prefix),
|
|
"KRW" => ("\u{20a9}", SymbolPosition::Prefix), // won sign
|
|
"SGD" => ("S$", SymbolPosition::Prefix),
|
|
"HKD" => ("HK$", SymbolPosition::Prefix),
|
|
"SEK" => ("kr", SymbolPosition::Suffix),
|
|
"NOK" => ("kr", SymbolPosition::Suffix),
|
|
"DKK" => ("kr", SymbolPosition::Suffix),
|
|
"PLN" => ("zl", SymbolPosition::Suffix),
|
|
"ZAR" => ("R", SymbolPosition::Prefix),
|
|
"TRY" => ("\u{20ba}", SymbolPosition::Prefix), // lira sign
|
|
"RUB" => ("\u{20bd}", SymbolPosition::Suffix), // ruble sign
|
|
"NZD" => ("$", SymbolPosition::Prefix),
|
|
"THB" => ("\u{0e3f}", SymbolPosition::Prefix), // baht sign
|
|
"TWD" => ("NT$", SymbolPosition::Prefix),
|
|
"CZK" => ("Kc", SymbolPosition::Suffix),
|
|
"HUF" => ("Ft", SymbolPosition::Suffix),
|
|
"ILS" => ("\u{20aa}", SymbolPosition::Prefix), // shekel sign
|
|
"PHP" => ("\u{20b1}", SymbolPosition::Prefix), // peso sign
|
|
"MYR" => ("RM", SymbolPosition::Prefix),
|
|
"IDR" => ("Rp", SymbolPosition::Prefix),
|
|
_ => ("$", SymbolPosition::Prefix),
|
|
}
|
|
}
|
|
|
|
pub struct LogView {
|
|
pub container: gtk::Box,
|
|
pub toast_overlay: adw::ToastOverlay,
|
|
db: Rc<Database>,
|
|
category_model: gtk::StringList,
|
|
category_ids: Rc<RefCell<Vec<i64>>>,
|
|
expense_btn: gtk::ToggleButton,
|
|
income_btn: gtk::ToggleButton,
|
|
amount_entry: gtk::Entry,
|
|
category_row: adw::ComboRow,
|
|
currency_row: adw::ComboRow,
|
|
currency_codes: Rc<Vec<String>>,
|
|
}
|
|
|
|
impl LogView {
|
|
pub fn new(db: Rc<Database>, app: &adw::Application) -> Self {
|
|
let toast_overlay = adw::ToastOverlay::new();
|
|
let container = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
|
|
|
let clamp = adw::Clamp::new();
|
|
clamp.set_maximum_size(600);
|
|
clamp.set_margin_top(32);
|
|
clamp.set_margin_bottom(32);
|
|
clamp.set_margin_start(16);
|
|
clamp.set_margin_end(16);
|
|
|
|
let inner = gtk::Box::new(gtk::Orientation::Vertical, 32);
|
|
|
|
let category_ids: Rc<RefCell<Vec<i64>>> = Rc::new(RefCell::new(Vec::new()));
|
|
let exchange_rate: Rc<Cell<f64>> = Rc::new(Cell::new(1.0));
|
|
|
|
// Get base currency from settings
|
|
let base_currency = db
|
|
.get_setting("base_currency")
|
|
.ok()
|
|
.flatten()
|
|
.unwrap_or_else(|| "USD".to_string());
|
|
|
|
// Currency codes list - base currency pinned to top
|
|
let mut currencies = ExchangeRateService::supported_currencies();
|
|
if let Some(pos) = currencies.iter().position(|(c, _)| c.eq_ignore_ascii_case(&base_currency)) {
|
|
let base = currencies.remove(pos);
|
|
currencies.insert(0, base);
|
|
}
|
|
let currency_codes: Vec<&str> = currencies.iter().map(|(code, _)| *code).collect();
|
|
let base_idx = 0usize;
|
|
|
|
// -- Monthly summary cards --
|
|
let today = chrono::Local::now().date_naive();
|
|
let summary_year = Rc::new(Cell::new(today.year()));
|
|
let summary_month = Rc::new(Cell::new(today.month()));
|
|
|
|
let summary_box = gtk::Box::new(gtk::Orientation::Horizontal, 12);
|
|
summary_box.set_halign(gtk::Align::Center);
|
|
|
|
let income_card = Self::make_summary_card("INCOME");
|
|
let expense_card = Self::make_summary_card("EXPENSES");
|
|
let net_card = Self::make_summary_card("NET");
|
|
|
|
summary_box.append(&income_card.0);
|
|
summary_box.append(&expense_card.0);
|
|
summary_box.append(&net_card.0);
|
|
|
|
// Store refs to amount labels for refresh
|
|
let income_amount_label = income_card.1;
|
|
let expense_amount_label = expense_card.1;
|
|
let net_amount_label = net_card.1;
|
|
|
|
Self::refresh_summary(
|
|
&db, &income_amount_label, &expense_amount_label, &net_amount_label,
|
|
&base_currency, summary_year.get(), summary_month.get(),
|
|
);
|
|
|
|
// Summary month navigation
|
|
let summary_nav = {
|
|
let sy = summary_year.clone();
|
|
let sm = summary_month.clone();
|
|
let il = income_amount_label.clone();
|
|
let el = expense_amount_label.clone();
|
|
let nl = net_amount_label.clone();
|
|
let bc = base_currency.clone();
|
|
let db_nav = db.clone();
|
|
crate::month_nav::MonthNav::new(move |year, month| {
|
|
sy.set(year);
|
|
sm.set(month);
|
|
Self::refresh_summary(&db_nav, &il, &el, &nl, &bc, year, month);
|
|
})
|
|
};
|
|
|
|
// -- Transaction type toggle --
|
|
let type_box = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
|
type_box.add_css_class("linked");
|
|
type_box.add_css_class("type-toggle");
|
|
type_box.set_halign(gtk::Align::Center);
|
|
|
|
let expense_btn = gtk::ToggleButton::with_label("Expense");
|
|
expense_btn.set_active(true);
|
|
expense_btn.set_hexpand(true);
|
|
|
|
let income_btn = gtk::ToggleButton::with_label("Income");
|
|
income_btn.set_group(Some(&expense_btn));
|
|
income_btn.set_hexpand(true);
|
|
|
|
type_box.append(&expense_btn);
|
|
type_box.append(&income_btn);
|
|
|
|
// -- Hero amount input with currency symbol --
|
|
let amount_box = gtk::Box::new(gtk::Orientation::Horizontal, 4);
|
|
amount_box.set_halign(gtk::Align::Center);
|
|
amount_box.set_valign(gtk::Align::Center);
|
|
|
|
let prefix_label = gtk::Label::new(None);
|
|
prefix_label.add_css_class("currency-symbol");
|
|
prefix_label.add_css_class("amount-display");
|
|
|
|
let suffix_label = gtk::Label::new(None);
|
|
suffix_label.add_css_class("currency-symbol");
|
|
suffix_label.add_css_class("amount-display");
|
|
|
|
let amount_entry = gtk::Entry::builder()
|
|
.placeholder_text("0.00")
|
|
.input_purpose(gtk::InputPurpose::Number)
|
|
.xalign(0.5)
|
|
.width_chars(4)
|
|
.build();
|
|
amount_entry.add_css_class("amount-hero");
|
|
|
|
// Dynamically resize entry width to hug content
|
|
{
|
|
let entry_ref = amount_entry.clone();
|
|
amount_entry.connect_changed(move |e| {
|
|
let len = e.text().len();
|
|
let chars = if len < 4 { 4 } else { len as i32 + 1 };
|
|
entry_ref.set_width_chars(chars);
|
|
});
|
|
}
|
|
|
|
amount_box.append(&prefix_label);
|
|
amount_box.append(&amount_entry);
|
|
amount_box.append(&suffix_label);
|
|
|
|
// Live expression result label (e.g. "= 20.00")
|
|
let expr_label = gtk::Label::new(None);
|
|
expr_label.add_css_class("dim-label");
|
|
expr_label.add_css_class("caption");
|
|
expr_label.set_visible(false);
|
|
{
|
|
let expr_label_ref = expr_label.clone();
|
|
amount_entry.connect_changed(move |e| {
|
|
let text = e.text();
|
|
let has_operator = text.contains('+') || text.contains('-')
|
|
|| text.contains('*') || text.contains('/');
|
|
if has_operator {
|
|
if let Some(val) = outlay_core::expr::eval_expr(&text) {
|
|
expr_label_ref.set_label(&format!("= {:.2}", val));
|
|
expr_label_ref.set_visible(true);
|
|
} else {
|
|
expr_label_ref.set_visible(false);
|
|
}
|
|
} else {
|
|
expr_label_ref.set_visible(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Set initial currency symbol
|
|
{
|
|
let initial_code = currency_codes.get(base_idx).copied().unwrap_or("USD");
|
|
let (symbol, pos) = currency_info(initial_code);
|
|
match pos {
|
|
SymbolPosition::Prefix => {
|
|
prefix_label.set_label(symbol);
|
|
prefix_label.set_visible(true);
|
|
suffix_label.set_visible(false);
|
|
}
|
|
SymbolPosition::Suffix => {
|
|
suffix_label.set_label(symbol);
|
|
suffix_label.set_visible(true);
|
|
prefix_label.set_visible(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Inline validation: red outline when amount is empty/invalid on focus loss
|
|
{
|
|
let focus_ctl = gtk::EventControllerFocus::new();
|
|
let entry_ref = amount_entry.clone();
|
|
focus_ctl.connect_leave(move |_| {
|
|
let text = entry_ref.text();
|
|
if !text.is_empty() {
|
|
match outlay_core::expr::eval_expr(&text) {
|
|
Some(v) if v > 0.0 => entry_ref.remove_css_class("error"),
|
|
_ => entry_ref.add_css_class("error"),
|
|
}
|
|
}
|
|
});
|
|
let entry_ref2 = amount_entry.clone();
|
|
amount_entry.connect_changed(move |_| {
|
|
entry_ref2.remove_css_class("error");
|
|
});
|
|
amount_entry.add_controller(focus_ctl);
|
|
}
|
|
|
|
// -- Number keypad popover (attached to entry) --
|
|
crate::numpad::attach_numpad(&amount_entry);
|
|
|
|
// -- Natural language quick entry --
|
|
let nl_group = adw::PreferencesGroup::builder()
|
|
.title("QUICK ENTRY")
|
|
.build();
|
|
let nl_entry = adw::EntryRow::builder()
|
|
.title("Quick entry")
|
|
.build();
|
|
nl_entry.set_text("");
|
|
nl_group.add(&nl_entry);
|
|
|
|
let nl_preview = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
|
nl_preview.set_margin_start(12);
|
|
nl_preview.set_margin_end(12);
|
|
nl_preview.set_margin_top(4);
|
|
nl_preview.set_margin_bottom(4);
|
|
nl_preview.set_visible(false);
|
|
|
|
let nl_icon = gtk::Image::from_icon_name("folder-symbolic");
|
|
nl_icon.set_pixel_size(20);
|
|
let nl_cat_label = gtk::Label::new(None);
|
|
nl_cat_label.add_css_class("caption");
|
|
let nl_amount_label = gtk::Label::new(None);
|
|
nl_amount_label.add_css_class("heading");
|
|
let nl_detail_label = gtk::Label::new(None);
|
|
nl_detail_label.add_css_class("dim-label");
|
|
nl_detail_label.set_hexpand(true);
|
|
nl_detail_label.set_halign(gtk::Align::Start);
|
|
let nl_add_btn = gtk::Button::with_label("Add");
|
|
nl_add_btn.add_css_class("suggested-action");
|
|
nl_add_btn.add_css_class("pill");
|
|
|
|
nl_preview.append(&nl_icon);
|
|
nl_preview.append(&nl_cat_label);
|
|
nl_preview.append(&nl_amount_label);
|
|
nl_preview.append(&nl_detail_label);
|
|
nl_preview.append(&nl_add_btn);
|
|
nl_group.add(&nl_preview);
|
|
|
|
// NL parse state
|
|
let nl_parsed: Rc<RefCell<Option<outlay_core::models::ParsedTransaction>>> = Rc::new(RefCell::new(None));
|
|
let nl_debounce: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
|
|
|
// Wire NL entry text change with debounce
|
|
{
|
|
let db_ref = db.clone();
|
|
let preview_ref = nl_preview.clone();
|
|
let cat_label = nl_cat_label.clone();
|
|
let amt_label = nl_amount_label.clone();
|
|
let detail_label = nl_detail_label.clone();
|
|
let icon_ref = nl_icon.clone();
|
|
let parsed_ref = nl_parsed.clone();
|
|
let debounce_ref = nl_debounce.clone();
|
|
nl_entry.connect_changed(move |entry| {
|
|
let text = entry.text().to_string();
|
|
let id = debounce_ref.get().wrapping_add(1);
|
|
debounce_ref.set(id);
|
|
|
|
let db_c = db_ref.clone();
|
|
let preview_c = preview_ref.clone();
|
|
let cat_c = cat_label.clone();
|
|
let amt_c = amt_label.clone();
|
|
let det_c = detail_label.clone();
|
|
let icon_c = icon_ref.clone();
|
|
let parsed_c = parsed_ref.clone();
|
|
let deb_c = debounce_ref.clone();
|
|
glib::timeout_add_local_once(std::time::Duration::from_millis(300), move || {
|
|
if deb_c.get() != id { return; }
|
|
let cats = db_c.list_categories(None).unwrap_or_default();
|
|
let result = outlay_core::nlp::parse_transaction(&text, &cats);
|
|
if let Some(ref parsed) = result {
|
|
let cat_name = parsed.category_name.as_deref().unwrap_or("Uncategorized");
|
|
cat_c.set_label(cat_name);
|
|
amt_c.set_label(&format!("{:.2}", parsed.amount));
|
|
let mut detail_parts = Vec::new();
|
|
if let Some(ref p) = parsed.payee {
|
|
detail_parts.push(format!("at {}", p));
|
|
}
|
|
if let Some(ref n) = parsed.note {
|
|
detail_parts.push(n.clone());
|
|
}
|
|
det_c.set_label(&detail_parts.join(" - "));
|
|
|
|
// Try to find icon for the category
|
|
if let Some(cid) = parsed.category_id {
|
|
if let Ok(cat) = db_c.get_category(cid) {
|
|
let resolved = crate::icon_theme::resolve_category_icon(&cat.icon, &cat.color);
|
|
if let Some(name) = resolved {
|
|
icon_c.set_icon_name(Some(&name));
|
|
}
|
|
}
|
|
}
|
|
preview_c.set_visible(true);
|
|
} else {
|
|
preview_c.set_visible(false);
|
|
}
|
|
*parsed_c.borrow_mut() = result;
|
|
});
|
|
});
|
|
}
|
|
|
|
// Wire NL add button
|
|
{
|
|
let db_ref = db.clone();
|
|
let parsed_ref = nl_parsed.clone();
|
|
let entry_ref = nl_entry.clone();
|
|
let preview_ref = nl_preview.clone();
|
|
let toast_ref = toast_overlay.clone();
|
|
nl_add_btn.connect_clicked(move |_| {
|
|
let parsed = parsed_ref.borrow().clone();
|
|
if let Some(parsed) = parsed {
|
|
let base_currency = db_ref.get_setting("base_currency")
|
|
.ok().flatten()
|
|
.unwrap_or_else(|| "USD".to_string());
|
|
let cat_id = parsed.category_id.unwrap_or_else(|| {
|
|
db_ref.list_categories(Some(outlay_core::models::TransactionType::Expense))
|
|
.ok()
|
|
.and_then(|c| c.first().map(|cat| cat.id))
|
|
.unwrap_or(1)
|
|
});
|
|
let today = chrono::Local::now().date_naive();
|
|
let txn = outlay_core::models::NewTransaction {
|
|
amount: parsed.amount,
|
|
transaction_type: parsed.transaction_type,
|
|
category_id: cat_id,
|
|
currency: base_currency.clone(),
|
|
exchange_rate: 1.0,
|
|
note: parsed.note,
|
|
date: today,
|
|
recurring_id: None,
|
|
payee: parsed.payee,
|
|
};
|
|
if db_ref.insert_transaction(&txn).is_ok() {
|
|
let cat_name = parsed.category_name.as_deref().unwrap_or("Unknown");
|
|
let toast = adw::Toast::new(&format!("Added {:.2} {} to {}", parsed.amount, base_currency, cat_name));
|
|
toast_ref.add_toast(toast);
|
|
entry_ref.set_text("");
|
|
preview_ref.set_visible(false);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Wire Enter key on NL entry
|
|
{
|
|
let add_btn_ref = nl_add_btn.clone();
|
|
nl_entry.connect_apply(move |_| {
|
|
add_btn_ref.emit_clicked();
|
|
});
|
|
}
|
|
|
|
// -- Form group --
|
|
let form_group = adw::PreferencesGroup::builder()
|
|
.title("NEW TRANSACTION")
|
|
.build();
|
|
|
|
// Category (first in form group for tab order)
|
|
let category_model = gtk::StringList::new(&[]);
|
|
let category_row = adw::ComboRow::builder()
|
|
.title("Category")
|
|
.model(&category_model)
|
|
.build();
|
|
category_row.set_factory(Some(&Self::make_category_factory()));
|
|
category_row.set_list_factory(Some(&Self::make_category_factory()));
|
|
|
|
Self::populate_categories_from_db(&db, &category_model, &category_ids, TransactionType::Expense);
|
|
|
|
form_group.add(&category_row);
|
|
|
|
// Currency
|
|
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_row = adw::ComboRow::builder()
|
|
.title("Currency")
|
|
.model(¤cy_model)
|
|
.selected(base_idx as u32)
|
|
.build();
|
|
form_group.add(¤cy_row);
|
|
|
|
// Exchange rate info label
|
|
let rate_label = gtk::Label::new(None);
|
|
rate_label.add_css_class("dim-label");
|
|
rate_label.add_css_class("caption");
|
|
rate_label.set_halign(gtk::Align::Start);
|
|
rate_label.set_margin_start(16);
|
|
rate_label.set_visible(false);
|
|
|
|
// Date picker
|
|
let date_fmt = db.get_date_format_string();
|
|
let today_date = chrono::Local::now().date_naive();
|
|
let selected_date = Rc::new(Cell::new(today_date));
|
|
let date_str = today_date.format(&date_fmt).to_string();
|
|
|
|
let date_label = gtk::Label::new(Some(&date_str));
|
|
date_label.set_halign(gtk::Align::End);
|
|
date_label.set_hexpand(true);
|
|
|
|
let calendar = gtk::Calendar::new();
|
|
let popover = gtk::Popover::new();
|
|
popover.set_child(Some(&calendar));
|
|
|
|
let date_menu_btn = gtk::MenuButton::new();
|
|
date_menu_btn.set_popover(Some(&popover));
|
|
let calendar_icon = gtk::Image::from_icon_name("outlay-calendar");
|
|
calendar_icon.set_pixel_size(28);
|
|
date_menu_btn.set_child(Some(&calendar_icon));
|
|
date_menu_btn.add_css_class("flat");
|
|
date_menu_btn.set_tooltip_text(Some("Pick date"));
|
|
|
|
let today_btn = gtk::Button::with_label("Today");
|
|
today_btn.add_css_class("flat");
|
|
today_btn.add_css_class("caption");
|
|
let yesterday_btn = gtk::Button::with_label("Yesterday");
|
|
yesterday_btn.add_css_class("flat");
|
|
yesterday_btn.add_css_class("caption");
|
|
|
|
{
|
|
let dl = date_label.clone();
|
|
let cal = calendar.clone();
|
|
let sd = selected_date.clone();
|
|
let fmt = date_fmt.clone();
|
|
today_btn.connect_clicked(move |_| {
|
|
let now_date = chrono::Local::now().date_naive();
|
|
sd.set(now_date);
|
|
dl.set_label(&now_date.format(&fmt).to_string());
|
|
let now = glib::DateTime::now_local().unwrap();
|
|
cal.set_year(now.year());
|
|
cal.set_month(now.month() - 1);
|
|
cal.set_day(now.day_of_month());
|
|
});
|
|
}
|
|
{
|
|
let dl = date_label.clone();
|
|
let cal = calendar.clone();
|
|
let sd = selected_date.clone();
|
|
let fmt = date_fmt.clone();
|
|
yesterday_btn.connect_clicked(move |_| {
|
|
let today = chrono::Local::now().date_naive();
|
|
let yday = today - chrono::Duration::days(1);
|
|
sd.set(yday);
|
|
dl.set_label(&yday.format(&fmt).to_string());
|
|
cal.set_year(yday.year());
|
|
cal.set_month(yday.month0() as i32);
|
|
cal.set_day(yday.day() as i32);
|
|
});
|
|
}
|
|
|
|
let date_box = gtk::Box::new(gtk::Orientation::Horizontal, 4);
|
|
date_box.append(&today_btn);
|
|
date_box.append(&yesterday_btn);
|
|
date_box.append(&date_label);
|
|
date_box.append(&date_menu_btn);
|
|
|
|
let date_row = adw::ActionRow::builder()
|
|
.title("Date")
|
|
.build();
|
|
date_row.add_suffix(&date_box);
|
|
date_row.set_activatable_widget(Some(&date_menu_btn));
|
|
|
|
let date_label_ref = date_label.clone();
|
|
let popover_ref = popover.clone();
|
|
let sd = selected_date.clone();
|
|
let fmt = date_fmt.clone();
|
|
calendar.connect_day_selected(move |cal| {
|
|
let dt = cal.date();
|
|
if let Some(d) = chrono::NaiveDate::from_ymd_opt(dt.year(), dt.month() as u32, dt.day_of_month() as u32) {
|
|
sd.set(d);
|
|
date_label_ref.set_label(&d.format(&fmt).to_string());
|
|
}
|
|
popover_ref.popdown();
|
|
});
|
|
|
|
form_group.add(&date_row);
|
|
|
|
// Payee
|
|
let payee_row = adw::EntryRow::builder()
|
|
.title("Payee (optional)")
|
|
.build();
|
|
form_group.add(&payee_row);
|
|
|
|
// Note
|
|
let note_row = adw::EntryRow::builder()
|
|
.title("Note (optional)")
|
|
.build();
|
|
form_group.add(¬e_row);
|
|
|
|
// Tags
|
|
let tags_row = adw::EntryRow::builder()
|
|
.title("Tags (comma-separated)")
|
|
.build();
|
|
form_group.add(&tags_row);
|
|
|
|
// Split toggle
|
|
let split_switch = adw::SwitchRow::builder()
|
|
.title("Split across categories")
|
|
.build();
|
|
form_group.add(&split_switch);
|
|
|
|
// Split entries section (hidden until split mode is on)
|
|
let split_box = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
|
split_box.set_visible(false);
|
|
|
|
let split_list = gtk::ListBox::new();
|
|
split_list.add_css_class("boxed-list");
|
|
split_list.set_selection_mode(gtk::SelectionMode::None);
|
|
|
|
let split_remaining = gtk::Label::new(Some("Remaining: 0.00"));
|
|
split_remaining.add_css_class("caption");
|
|
split_remaining.set_halign(gtk::Align::End);
|
|
split_remaining.set_margin_end(8);
|
|
|
|
let add_split_btn = gtk::Button::with_label("Add Split");
|
|
add_split_btn.add_css_class("flat");
|
|
add_split_btn.set_halign(gtk::Align::Start);
|
|
|
|
split_box.append(&split_list);
|
|
split_box.append(&split_remaining);
|
|
split_box.append(&add_split_btn);
|
|
|
|
// Track split row widgets: (ListBoxRow, category_ids, DropDown, Entry)
|
|
type SplitRow = (gtk::ListBoxRow, Vec<i64>, gtk::DropDown, gtk::Entry);
|
|
let split_entries: Rc<RefCell<Vec<SplitRow>>> = Rc::new(RefCell::new(Vec::new()));
|
|
|
|
// Helper: build one split entry row
|
|
let build_split_entry = {
|
|
let db_se = db.clone();
|
|
let split_list_se = split_list.clone();
|
|
let split_entries_se = split_entries.clone();
|
|
let split_remaining_se = split_remaining.clone();
|
|
let amount_entry_se = amount_entry.clone();
|
|
move |txn_type: TransactionType| {
|
|
let cats = db_se.list_categories(Some(txn_type)).unwrap_or_default();
|
|
let cat_ids: Vec<i64> = cats.iter().map(|c| c.id).collect();
|
|
let labels: Vec<String> = 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> = labels.iter().map(|s| s.as_str()).collect();
|
|
let model = gtk::StringList::new(&label_refs);
|
|
let dropdown = gtk::DropDown::new(Some(model), gtk::Expression::NONE);
|
|
dropdown.set_factory(Some(&crate::category_combo::make_category_factory()));
|
|
dropdown.set_list_factory(Some(&crate::category_combo::make_category_factory()));
|
|
dropdown.set_hexpand(true);
|
|
|
|
let amt_entry = gtk::Entry::new();
|
|
amt_entry.set_placeholder_text(Some("0.00"));
|
|
amt_entry.set_input_purpose(gtk::InputPurpose::Number);
|
|
amt_entry.set_width_chars(8);
|
|
|
|
let del_btn = gtk::Button::from_icon_name("outlay-delete");
|
|
del_btn.add_css_class("flat");
|
|
del_btn.add_css_class("circular");
|
|
del_btn.set_valign(gtk::Align::Center);
|
|
|
|
let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
|
hbox.set_margin_start(12);
|
|
hbox.set_margin_end(8);
|
|
hbox.set_margin_top(6);
|
|
hbox.set_margin_bottom(6);
|
|
hbox.append(&dropdown);
|
|
hbox.append(&amt_entry);
|
|
hbox.append(&del_btn);
|
|
|
|
let row = gtk::ListBoxRow::new();
|
|
row.set_child(Some(&hbox));
|
|
row.set_activatable(false);
|
|
|
|
split_list_se.append(&row);
|
|
|
|
// Update remaining when amount changes
|
|
{
|
|
let entries_ref = split_entries_se.clone();
|
|
let remaining_ref = split_remaining_se.clone();
|
|
let total_ref = amount_entry_se.clone();
|
|
amt_entry.connect_changed(move |_| {
|
|
Self::update_split_remaining(&total_ref, &entries_ref, &remaining_ref);
|
|
});
|
|
}
|
|
|
|
// Delete button removes this split row
|
|
{
|
|
let list_del = split_list_se.clone();
|
|
let entries_del = split_entries_se.clone();
|
|
let row_del = row.clone();
|
|
let remaining_del = split_remaining_se.clone();
|
|
let total_del = amount_entry_se.clone();
|
|
del_btn.connect_clicked(move |_| {
|
|
list_del.remove(&row_del);
|
|
entries_del.borrow_mut().retain(|(r, _, _, _)| r != &row_del);
|
|
Self::update_split_remaining(&total_del, &entries_del, &remaining_del);
|
|
});
|
|
}
|
|
|
|
split_entries_se.borrow_mut().push((row, cat_ids, dropdown, amt_entry));
|
|
}
|
|
};
|
|
|
|
// Wire split switch
|
|
{
|
|
let cat_row_ref = category_row.clone();
|
|
let split_box_ref = split_box.clone();
|
|
let split_list_ref = split_list.clone();
|
|
let entries_ref = split_entries.clone();
|
|
let expense_ref = expense_btn.clone();
|
|
let build_entry = build_split_entry.clone();
|
|
split_switch.connect_active_notify(move |sw| {
|
|
let active = sw.is_active();
|
|
cat_row_ref.set_visible(!active);
|
|
split_box_ref.set_visible(active);
|
|
if active && entries_ref.borrow().is_empty() {
|
|
// Add two initial split rows
|
|
let tt = if expense_ref.is_active() { TransactionType::Expense } else { TransactionType::Income };
|
|
build_entry(tt);
|
|
build_entry(tt);
|
|
}
|
|
if !active {
|
|
// Clear splits
|
|
while let Some(child) = split_list_ref.first_child() {
|
|
split_list_ref.remove(&child.downcast::<gtk::ListBoxRow>().unwrap());
|
|
}
|
|
entries_ref.borrow_mut().clear();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Wire "Add Split" button
|
|
{
|
|
let expense_ref = expense_btn.clone();
|
|
let build_entry = build_split_entry.clone();
|
|
add_split_btn.connect_clicked(move |_| {
|
|
let tt = if expense_ref.is_active() { TransactionType::Expense } else { TransactionType::Income };
|
|
build_entry(tt);
|
|
});
|
|
}
|
|
|
|
// Auto-categorization: when note or payee changes, try to match a rule
|
|
{
|
|
let db_ac = db.clone();
|
|
let cat_row_ac = category_row.clone();
|
|
let ids_ac = category_ids.clone();
|
|
let payee_ac = payee_row.clone();
|
|
let toast_ac = toast_overlay.clone();
|
|
note_row.connect_changed(move |nr| {
|
|
let note_text = nr.text();
|
|
let payee_text = payee_ac.text();
|
|
let note_opt = if note_text.is_empty() { None } else { Some(note_text.as_str()) };
|
|
let payee_opt = if payee_text.is_empty() { None } else { Some(payee_text.as_str()) };
|
|
if let Ok(Some(cat_id)) = db_ac.match_category(note_opt, payee_opt) {
|
|
let ids = ids_ac.borrow();
|
|
if let Some(pos) = ids.iter().position(|&id| id == cat_id) {
|
|
if cat_row_ac.selected() != pos as u32 {
|
|
cat_row_ac.set_selected(pos as u32);
|
|
let toast = adw::Toast::new("Category matched by rule");
|
|
toast.set_timeout(5);
|
|
toast_ac.add_toast(toast);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let db_ac = db.clone();
|
|
let cat_row_ac = category_row.clone();
|
|
let ids_ac = category_ids.clone();
|
|
let note_ac = note_row.clone();
|
|
let toast_ac = toast_overlay.clone();
|
|
payee_row.connect_changed(move |pr| {
|
|
let payee_text = pr.text();
|
|
let note_text = note_ac.text();
|
|
let note_opt = if note_text.is_empty() { None } else { Some(note_text.as_str()) };
|
|
let payee_opt = if payee_text.is_empty() { None } else { Some(payee_text.as_str()) };
|
|
if let Ok(Some(cat_id)) = db_ac.match_category(note_opt, payee_opt) {
|
|
let ids = ids_ac.borrow();
|
|
if let Some(pos) = ids.iter().position(|&id| id == cat_id) {
|
|
if cat_row_ac.selected() != pos as u32 {
|
|
cat_row_ac.set_selected(pos as u32);
|
|
let toast = adw::Toast::new("Category matched by rule");
|
|
toast.set_timeout(5);
|
|
toast_ac.add_toast(toast);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// -- Attachment UI --
|
|
let pending_attachments: Rc<RefCell<Vec<PendingAttachment>>> =
|
|
Rc::new(RefCell::new(Vec::new()));
|
|
|
|
let attach_box = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
|
|
|
// Empty state: dashed drop-zone button
|
|
let attach_placeholder = gtk::Button::new();
|
|
attach_placeholder.add_css_class("flat");
|
|
attach_placeholder.add_css_class("attach-drop-zone");
|
|
{
|
|
let content = gtk::Box::new(gtk::Orientation::Vertical, 6);
|
|
content.set_margin_top(20);
|
|
content.set_margin_bottom(20);
|
|
content.set_halign(gtk::Align::Center);
|
|
let icon = gtk::Image::from_icon_name("mail-attachment-symbolic");
|
|
icon.set_pixel_size(24);
|
|
icon.add_css_class("dim-label");
|
|
let label = gtk::Label::new(Some("Attach receipt"));
|
|
label.add_css_class("dim-label");
|
|
label.add_css_class("caption");
|
|
content.append(&icon);
|
|
content.append(&label);
|
|
attach_placeholder.set_child(Some(&content));
|
|
}
|
|
|
|
// Thumbnails state: flow box (hidden until first attachment)
|
|
let attach_flow = gtk::FlowBox::new();
|
|
attach_flow.set_selection_mode(gtk::SelectionMode::None);
|
|
attach_flow.set_max_children_per_line(4);
|
|
attach_flow.set_min_children_per_line(1);
|
|
attach_flow.set_row_spacing(8);
|
|
attach_flow.set_column_spacing(8);
|
|
attach_flow.set_homogeneous(true);
|
|
attach_flow.set_visible(false);
|
|
|
|
// "Add another" button (visible only when thumbnails are showing)
|
|
let attach_more_btn = gtk::Button::new();
|
|
attach_more_btn.add_css_class("flat");
|
|
attach_more_btn.set_halign(gtk::Align::Start);
|
|
{
|
|
let content = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
|
let icon = gtk::Image::from_icon_name("list-add-symbolic");
|
|
icon.set_pixel_size(16);
|
|
let label = gtk::Label::new(Some("Add another"));
|
|
label.add_css_class("caption");
|
|
content.append(&icon);
|
|
content.append(&label);
|
|
attach_more_btn.set_child(Some(&content));
|
|
}
|
|
attach_more_btn.set_visible(false);
|
|
|
|
attach_box.append(&attach_placeholder);
|
|
attach_box.append(&attach_flow);
|
|
attach_box.append(&attach_more_btn);
|
|
|
|
// Shared file-picker logic for both buttons
|
|
let open_picker: Rc<dyn Fn(>k::Button)> = {
|
|
let pending = pending_attachments.clone();
|
|
let flow = attach_flow.clone();
|
|
let placeholder = attach_placeholder.clone();
|
|
let more_btn = attach_more_btn.clone();
|
|
let amount_ref = amount_entry.clone();
|
|
let toast_ref = toast_overlay.clone();
|
|
Rc::new(move |btn: >k::Button| {
|
|
let filter = gtk::FileFilter::new();
|
|
filter.add_mime_type("image/png");
|
|
filter.add_mime_type("image/jpeg");
|
|
filter.add_mime_type("image/webp");
|
|
filter.set_name(Some("Images"));
|
|
|
|
let filters = gio::ListStore::new::<gtk::FileFilter>();
|
|
filters.append(&filter);
|
|
|
|
let file_dialog = gtk::FileDialog::builder()
|
|
.title("Attach Receipt")
|
|
.default_filter(&filter)
|
|
.filters(&filters)
|
|
.build();
|
|
|
|
let pending = pending.clone();
|
|
let flow = flow.clone();
|
|
let placeholder = placeholder.clone();
|
|
let more_btn = more_btn.clone();
|
|
let amount_ref = amount_ref.clone();
|
|
let toast_ref = toast_ref.clone();
|
|
let window = btn.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() {
|
|
match std::fs::read(&path) {
|
|
Ok(data) if data.len() <= 5 * 1024 * 1024 => {
|
|
let filename = path.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or("receipt")
|
|
.to_string();
|
|
let mime = if filename.ends_with(".png") {
|
|
"image/png"
|
|
} else if filename.ends_with(".webp") {
|
|
"image/webp"
|
|
} else {
|
|
"image/jpeg"
|
|
};
|
|
|
|
let data_for_ocr = data.clone();
|
|
pending.borrow_mut().push((
|
|
filename,
|
|
mime.to_string(),
|
|
data,
|
|
));
|
|
Self::rebuild_attach_flow(
|
|
&pending, &flow, &placeholder, &more_btn,
|
|
);
|
|
|
|
// Run OCR in background (if tesseract available)
|
|
if ocr::is_available() {
|
|
let amount_ocr = amount_ref.clone();
|
|
let toast_ocr = toast_ref.clone();
|
|
glib::spawn_future_local(async move {
|
|
let result = gio::spawn_blocking(move || {
|
|
ocr::extract_amounts_from_image(&data_for_ocr)
|
|
})
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
if let Some(amounts) = result {
|
|
if amounts.len() == 1 {
|
|
amount_ocr.set_text(
|
|
&format!("{:.2}", amounts[0].0),
|
|
);
|
|
let toast = adw::Toast::new(
|
|
"Amount detected from receipt",
|
|
);
|
|
toast_ocr.add_toast(toast);
|
|
} else {
|
|
show_ocr_amount_picker(
|
|
&toast_ocr,
|
|
&amounts,
|
|
&amount_ocr,
|
|
&toast_ocr,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
Ok(_) => {
|
|
let toast = adw::Toast::new("File too large (max 5MB)");
|
|
toast_ref.add_toast(toast);
|
|
}
|
|
Err(e) => {
|
|
let toast = adw::Toast::new(&format!("Read error: {}", e));
|
|
toast_ref.add_toast(toast);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
})
|
|
};
|
|
|
|
// Wire both buttons to the same file picker
|
|
{
|
|
let picker = open_picker.clone();
|
|
attach_placeholder.connect_clicked(move |btn| (picker)(btn));
|
|
}
|
|
{
|
|
let picker = open_picker;
|
|
attach_more_btn.connect_clicked(move |btn| (picker)(btn));
|
|
}
|
|
|
|
// -- Save buttons --
|
|
let save_btn_box = gtk::Box::new(gtk::Orientation::Horizontal, 12);
|
|
save_btn_box.set_halign(gtk::Align::Center);
|
|
save_btn_box.set_margin_top(8);
|
|
|
|
let save_button = gtk::Button::with_label("Save");
|
|
save_button.add_css_class("suggested-action");
|
|
save_button.add_css_class("pill");
|
|
save_button.add_css_class("save-button");
|
|
|
|
let save_next_button = gtk::Button::with_label("Save + Next");
|
|
save_next_button.add_css_class("flat");
|
|
save_next_button.add_css_class("pill");
|
|
|
|
// -- Wire currency change to fetch exchange rate + update symbol --
|
|
{
|
|
let db_ref = db.clone();
|
|
let rate_ref = exchange_rate.clone();
|
|
let rate_label_ref = rate_label.clone();
|
|
let base_currency_clone = base_currency.clone();
|
|
let currency_codes_clone: Vec<String> = currency_codes.iter().map(|s| s.to_string()).collect();
|
|
let prefix_ref = prefix_label.clone();
|
|
let suffix_ref = suffix_label.clone();
|
|
currency_row.connect_selected_notify(move |row| {
|
|
let idx = row.selected() as usize;
|
|
if idx >= currency_codes_clone.len() {
|
|
return;
|
|
}
|
|
let selected = ¤cy_codes_clone[idx];
|
|
|
|
// Update currency symbol
|
|
let (symbol, pos) = currency_info(selected);
|
|
match pos {
|
|
SymbolPosition::Prefix => {
|
|
prefix_ref.set_label(symbol);
|
|
prefix_ref.set_visible(true);
|
|
suffix_ref.set_visible(false);
|
|
}
|
|
SymbolPosition::Suffix => {
|
|
suffix_ref.set_label(symbol);
|
|
suffix_ref.set_visible(true);
|
|
prefix_ref.set_visible(false);
|
|
}
|
|
}
|
|
if selected.eq_ignore_ascii_case(&base_currency_clone) {
|
|
rate_ref.set(1.0);
|
|
rate_label_ref.set_visible(false);
|
|
} else {
|
|
// Fetch rate asynchronously
|
|
let db_async = db_ref.clone();
|
|
let base = base_currency_clone.clone();
|
|
let target = selected.clone();
|
|
let rate_async = rate_ref.clone();
|
|
let label_async = rate_label_ref.clone();
|
|
glib::spawn_future_local(async move {
|
|
let service = ExchangeRateService::new(&db_async);
|
|
match service.get_rate(&base, &target).await {
|
|
Ok(rate) => {
|
|
rate_async.set(rate);
|
|
label_async.set_label(&format!(
|
|
"1 {} = {:.4} {}", base, rate, target
|
|
));
|
|
label_async.set_visible(true);
|
|
}
|
|
Err(_) => {
|
|
rate_async.set(1.0);
|
|
label_async.set_label("Could not fetch exchange rate");
|
|
label_async.set_visible(true);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Session-only memory for last-used category per type (1.2)
|
|
let last_expense_cat: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
|
let last_income_cat: Rc<Cell<u32>> = Rc::new(Cell::new(0));
|
|
let save_next_mode = Rc::new(Cell::new(false));
|
|
|
|
// -- Wire type toggle to filter categories from DB --
|
|
{
|
|
let db_ref = db.clone();
|
|
let model_ref = category_model.clone();
|
|
let ids_ref = category_ids.clone();
|
|
let cat_row_ref = category_row.clone();
|
|
let lec = last_expense_cat.clone();
|
|
expense_btn.connect_toggled(move |btn| {
|
|
if btn.is_active() {
|
|
Self::populate_categories_from_db(
|
|
&db_ref, &model_ref, &ids_ref, TransactionType::Expense,
|
|
);
|
|
cat_row_ref.set_selected(lec.get());
|
|
}
|
|
});
|
|
}
|
|
{
|
|
let db_ref = db.clone();
|
|
let model_ref = category_model.clone();
|
|
let ids_ref = category_ids.clone();
|
|
let cat_row_ref = category_row.clone();
|
|
let lic = last_income_cat.clone();
|
|
income_btn.connect_toggled(move |btn| {
|
|
if btn.is_active() {
|
|
Self::populate_categories_from_db(
|
|
&db_ref, &model_ref, &ids_ref, TransactionType::Income,
|
|
);
|
|
cat_row_ref.set_selected(lic.get());
|
|
}
|
|
});
|
|
}
|
|
|
|
// -- Recent transactions --
|
|
let recent_group = adw::PreferencesGroup::builder()
|
|
.title("RECENT")
|
|
.build();
|
|
|
|
let currency_codes_rc = Rc::new(currency_codes.iter().map(|s| s.to_string()).collect::<Vec<_>>());
|
|
|
|
Self::refresh_recent(
|
|
&db, &recent_group, &toast_overlay,
|
|
&expense_btn, &income_btn, &amount_entry,
|
|
&category_row, &category_ids, &category_model,
|
|
¤cy_row, ¤cy_codes_rc,
|
|
);
|
|
|
|
// -- Wire save button --
|
|
{
|
|
let db_ref = db.clone();
|
|
let expense_btn_ref = expense_btn.clone();
|
|
let income_btn_ref = income_btn.clone();
|
|
let amount_entry_ref = amount_entry.clone();
|
|
let currency_row_ref = currency_row.clone();
|
|
let category_row_ref = category_row.clone();
|
|
let category_model_ref = category_model.clone();
|
|
let ids_ref = category_ids.clone();
|
|
let selected_date_ref = selected_date.clone();
|
|
let note_row_ref = note_row.clone();
|
|
let payee_row_ref = payee_row.clone();
|
|
let tags_row_ref = tags_row.clone();
|
|
let recent_group_ref = recent_group.clone();
|
|
let toast_overlay_ref = toast_overlay.clone();
|
|
let rate_ref = exchange_rate.clone();
|
|
let currency_codes_save: Vec<String> = currency_codes.iter().map(|s| s.to_string()).collect();
|
|
let currency_codes_rc_ref = currency_codes_rc.clone();
|
|
let app_ref = app.clone();
|
|
let income_label_ref = income_amount_label.clone();
|
|
let expense_label_ref = expense_amount_label.clone();
|
|
let net_label_ref = net_amount_label.clone();
|
|
let base_currency_ref = base_currency.clone();
|
|
let sy = summary_year.clone();
|
|
let sm = summary_month.clone();
|
|
let pending_save = pending_attachments.clone();
|
|
let flow_save = attach_flow.clone();
|
|
let placeholder_save = attach_placeholder.clone();
|
|
let more_save = attach_more_btn.clone();
|
|
let save_next_flag = save_next_mode.clone();
|
|
let last_exp_cat = last_expense_cat.clone();
|
|
let last_inc_cat = last_income_cat.clone();
|
|
let split_switch_save = split_switch.clone();
|
|
let split_entries_save = split_entries.clone();
|
|
let split_list_save = split_list.clone();
|
|
|
|
save_button.connect_clicked(move |btn| {
|
|
let is_save_next = save_next_flag.get();
|
|
save_next_flag.set(false);
|
|
let amount_text = amount_entry_ref.text();
|
|
let amount: f64 = match outlay_core::expr::eval_expr(&amount_text) {
|
|
Some(v) if v > 0.0 => v,
|
|
_ => {
|
|
let toast = adw::Toast::new("Please enter a valid amount");
|
|
toast_overlay_ref.add_toast(toast);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let txn_type = if expense_btn_ref.is_active() {
|
|
TransactionType::Expense
|
|
} else {
|
|
TransactionType::Income
|
|
};
|
|
|
|
let cat_idx = category_row_ref.selected() as usize;
|
|
let ids = ids_ref.borrow();
|
|
let category_id = match ids.get(cat_idx) {
|
|
Some(&id) => id,
|
|
None => {
|
|
let toast = adw::Toast::new("Please select a category");
|
|
toast_overlay_ref.add_toast(toast);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let currency_idx = currency_row_ref.selected() as usize;
|
|
let currency = currency_codes_save
|
|
.get(currency_idx)
|
|
.cloned()
|
|
.unwrap_or_else(|| "USD".to_string());
|
|
|
|
let date = selected_date_ref.get();
|
|
|
|
let note_text = note_row_ref.text();
|
|
let note = if note_text.is_empty() {
|
|
None
|
|
} else {
|
|
Some(note_text.to_string())
|
|
};
|
|
|
|
let payee_text = payee_row_ref.text();
|
|
let payee = if payee_text.is_empty() {
|
|
None
|
|
} else {
|
|
Some(payee_text.to_string())
|
|
};
|
|
|
|
// Parse tags from comma-separated input
|
|
let tags_text = tags_row_ref.text();
|
|
let tag_names: Vec<String> = tags_text
|
|
.split(',')
|
|
.map(|s| s.trim().to_string())
|
|
.filter(|s| !s.is_empty())
|
|
.collect();
|
|
|
|
let new_txn = NewTransaction {
|
|
amount,
|
|
transaction_type: txn_type,
|
|
category_id,
|
|
currency,
|
|
exchange_rate: rate_ref.get(),
|
|
note,
|
|
date,
|
|
recurring_id: None,
|
|
payee,
|
|
};
|
|
|
|
// Build the actual save closure (shared between direct save and dialog confirm)
|
|
let do_save = {
|
|
let db_ref = db_ref.clone();
|
|
let toast_overlay_ref = toast_overlay_ref.clone();
|
|
let recent_group_ref = recent_group_ref.clone();
|
|
let expense_btn_ref = expense_btn_ref.clone();
|
|
let income_btn_ref = income_btn_ref.clone();
|
|
let amount_entry_ref = amount_entry_ref.clone();
|
|
let category_row_ref = category_row_ref.clone();
|
|
let ids_ref = ids_ref.clone();
|
|
let category_model_ref = category_model_ref.clone();
|
|
let currency_row_ref = currency_row_ref.clone();
|
|
let currency_codes_rc_ref = currency_codes_rc_ref.clone();
|
|
let app_ref = app_ref.clone();
|
|
let income_label_ref = income_label_ref.clone();
|
|
let expense_label_ref = expense_label_ref.clone();
|
|
let net_label_ref = net_label_ref.clone();
|
|
let base_currency_ref = base_currency_ref.clone();
|
|
let note_row_ref = note_row_ref.clone();
|
|
let payee_row_do = payee_row_ref.clone();
|
|
let tags_row_do = tags_row_ref.clone();
|
|
let sy = sy.clone();
|
|
let sm = sm.clone();
|
|
let pending_do = pending_save.clone();
|
|
let flow_do = flow_save.clone();
|
|
let placeholder_do = placeholder_save.clone();
|
|
let more_do = more_save.clone();
|
|
let last_exp_cat = last_exp_cat.clone();
|
|
let last_inc_cat = last_inc_cat.clone();
|
|
let split_switch_do = split_switch_save.clone();
|
|
let split_list_do = split_list_save.clone();
|
|
let split_entries_do = split_entries_save.clone();
|
|
Rc::new(move |new_txn: NewTransaction, tag_names: Vec<String>, splits: Vec<(i64, f64, Option<String>)>| {
|
|
let txn_type = new_txn.transaction_type;
|
|
let date = new_txn.date;
|
|
let category_id = new_txn.category_id;
|
|
|
|
match db_ref.insert_transaction(&new_txn) {
|
|
Ok(new_id) => {
|
|
// Save pending attachments
|
|
for (fname, mime, data) in pending_do.borrow().iter() {
|
|
let _ = db_ref.insert_attachment(new_id, fname, mime, data);
|
|
}
|
|
pending_do.borrow_mut().clear();
|
|
|
|
// Save tags
|
|
if !tag_names.is_empty() {
|
|
let mut tag_ids = Vec::new();
|
|
for name in &tag_names {
|
|
if let Ok(tid) = db_ref.get_or_create_tag(name) {
|
|
tag_ids.push(tid);
|
|
}
|
|
}
|
|
let _ = db_ref.set_transaction_tags(new_id, &tag_ids);
|
|
}
|
|
|
|
// Save splits
|
|
if !splits.is_empty() {
|
|
let _ = db_ref.insert_splits(new_id, &splits);
|
|
}
|
|
while let Some(child) = flow_do.first_child() {
|
|
flow_do.remove(&child);
|
|
}
|
|
flow_do.set_visible(false);
|
|
placeholder_do.set_visible(true);
|
|
more_do.set_visible(false);
|
|
|
|
let cat_name = db_ref
|
|
.get_category(category_id)
|
|
.map(|c| c.name)
|
|
.unwrap_or_default();
|
|
let msg = format!(
|
|
"Saved: {:.2} {}",
|
|
new_txn.amount, cat_name,
|
|
);
|
|
let toast = adw::Toast::new(&msg);
|
|
toast.set_button_label(Some("Undo"));
|
|
{
|
|
let db_undo = db_ref.clone();
|
|
let recent_undo = recent_group_ref.clone();
|
|
let toast_undo = toast_overlay_ref.clone();
|
|
let eb_undo = expense_btn_ref.clone();
|
|
let ib_undo = income_btn_ref.clone();
|
|
let ae_undo = amount_entry_ref.clone();
|
|
let cr_undo = category_row_ref.clone();
|
|
let ci_undo = ids_ref.clone();
|
|
let cm_undo = category_model_ref.clone();
|
|
let cur_undo = currency_row_ref.clone();
|
|
let cc_undo = currency_codes_rc_ref.clone();
|
|
let il_undo = income_label_ref.clone();
|
|
let el_undo = expense_label_ref.clone();
|
|
let nl_undo = net_label_ref.clone();
|
|
let bc_undo = base_currency_ref.clone();
|
|
let sy_undo = sy.clone();
|
|
let sm_undo = sm.clone();
|
|
toast.connect_button_clicked(move |_| {
|
|
db_undo.delete_transaction(new_id).ok();
|
|
Self::refresh_recent(
|
|
&db_undo, &recent_undo, &toast_undo,
|
|
&eb_undo, &ib_undo, &ae_undo,
|
|
&cr_undo, &ci_undo, &cm_undo,
|
|
&cur_undo, &cc_undo,
|
|
);
|
|
Self::refresh_summary(
|
|
&db_undo, &il_undo, &el_undo, &nl_undo,
|
|
&bc_undo, sy_undo.get(), sm_undo.get(),
|
|
);
|
|
});
|
|
}
|
|
toast_overlay_ref.add_toast(toast);
|
|
|
|
if txn_type == TransactionType::Expense {
|
|
let month_str = format!("{:04}-{:02}", date.year(), date.month());
|
|
if let Ok(thresholds) = db_ref.check_budget_thresholds(category_id, &month_str) {
|
|
let cat_name = db_ref
|
|
.get_category(category_id)
|
|
.map(|c| c.name)
|
|
.unwrap_or_else(|_| "Category".to_string());
|
|
let progress = db_ref
|
|
.get_budget_progress(category_id, &month_str)
|
|
.ok()
|
|
.flatten();
|
|
let pct = progress.map(|(_, _, p)| p).unwrap_or(0.0);
|
|
|
|
for threshold in thresholds {
|
|
Self::send_budget_notification(
|
|
&app_ref, &cat_name, pct, threshold,
|
|
);
|
|
db_ref.record_notification(category_id, &month_str, threshold).ok();
|
|
}
|
|
}
|
|
}
|
|
|
|
amount_entry_ref.set_text("");
|
|
note_row_ref.set_text("");
|
|
payee_row_do.set_text("");
|
|
tags_row_do.set_text("");
|
|
|
|
// Reset split mode
|
|
if split_switch_do.is_active() {
|
|
split_switch_do.set_active(false);
|
|
}
|
|
while let Some(child) = split_list_do.first_child() {
|
|
split_list_do.remove(&child.downcast::<gtk::ListBoxRow>().unwrap());
|
|
}
|
|
split_entries_do.borrow_mut().clear();
|
|
|
|
// Remember last-used category per type (1.2)
|
|
if expense_btn_ref.is_active() {
|
|
last_exp_cat.set(category_row_ref.selected());
|
|
} else {
|
|
last_inc_cat.set(category_row_ref.selected());
|
|
}
|
|
|
|
// Save + Next: re-focus for quick entry (1.1)
|
|
if is_save_next {
|
|
amount_entry_ref.grab_focus();
|
|
}
|
|
|
|
Self::refresh_recent(
|
|
&db_ref, &recent_group_ref, &toast_overlay_ref,
|
|
&expense_btn_ref, &income_btn_ref, &amount_entry_ref,
|
|
&category_row_ref, &ids_ref, &category_model_ref,
|
|
¤cy_row_ref, ¤cy_codes_rc_ref,
|
|
);
|
|
Self::refresh_summary(
|
|
&db_ref, &income_label_ref, &expense_label_ref, &net_label_ref,
|
|
&base_currency_ref, sy.get(), sm.get(),
|
|
);
|
|
}
|
|
Err(e) => {
|
|
let toast = adw::Toast::new(&format!("Error saving: {}", e));
|
|
toast_overlay_ref.add_toast(toast);
|
|
}
|
|
}
|
|
})
|
|
};
|
|
|
|
// Collect splits if split mode is active
|
|
let splits: Vec<(i64, f64, Option<String>)> = if split_switch_save.is_active() {
|
|
let entries = split_entries_save.borrow();
|
|
let mut collected = Vec::new();
|
|
let mut split_total = 0.0_f64;
|
|
for (_row, cat_ids, dropdown, amt_entry) in entries.iter() {
|
|
let idx = dropdown.selected() as usize;
|
|
let cat_id = cat_ids.get(idx).copied().unwrap_or(0);
|
|
let amt_text = amt_entry.text();
|
|
let amt: f64 = outlay_core::expr::eval_expr(&amt_text).unwrap_or(0.0);
|
|
if amt > 0.0 && cat_id > 0 {
|
|
collected.push((cat_id, amt, None));
|
|
split_total += amt;
|
|
}
|
|
}
|
|
if (split_total - amount).abs() > 0.01 {
|
|
let toast = adw::Toast::new(&format!(
|
|
"Split total ({:.2}) does not match amount ({:.2})",
|
|
split_total, amount
|
|
));
|
|
toast_overlay_ref.add_toast(toast);
|
|
return;
|
|
}
|
|
collected
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
// Check for duplicate transaction
|
|
let is_dup = db_ref
|
|
.find_duplicate_transaction(amount, txn_type, category_id, date)
|
|
.unwrap_or(false);
|
|
|
|
if is_dup {
|
|
let alert = adw::AlertDialog::new(
|
|
Some("Possible duplicate"),
|
|
Some("A similar transaction already exists for this date. Save anyway?"),
|
|
);
|
|
alert.add_response("cancel", "Cancel");
|
|
alert.add_response("save", "Save anyway");
|
|
alert.set_response_appearance("save", adw::ResponseAppearance::Suggested);
|
|
alert.set_default_response(Some("cancel"));
|
|
alert.set_close_response("cancel");
|
|
|
|
let do_save_ref = do_save.clone();
|
|
let tag_names_dup = tag_names.clone();
|
|
let splits_dup = splits.clone();
|
|
alert.connect_response(None, move |_, response| {
|
|
if response == "save" {
|
|
do_save_ref(new_txn.clone(), tag_names_dup.clone(), splits_dup.clone());
|
|
}
|
|
});
|
|
alert.present(Some(btn));
|
|
} else {
|
|
do_save(new_txn, tag_names, splits);
|
|
}
|
|
});
|
|
}
|
|
|
|
// -- Wire save+next button: sets flag then activates save --
|
|
{
|
|
let flag = save_next_mode;
|
|
let save_btn_ref = save_button.clone();
|
|
save_next_button.connect_clicked(move |_| {
|
|
flag.set(true);
|
|
save_btn_ref.activate();
|
|
});
|
|
}
|
|
|
|
// -- Templates popover --
|
|
let templates_btn = gtk::MenuButton::new();
|
|
templates_btn.set_icon_name("document-open-symbolic");
|
|
templates_btn.add_css_class("flat");
|
|
templates_btn.set_tooltip_text(Some("Load template"));
|
|
|
|
let templates_popover = gtk::Popover::new();
|
|
let templates_list_box = gtk::ListBox::new();
|
|
templates_list_box.set_selection_mode(gtk::SelectionMode::None);
|
|
templates_list_box.add_css_class("boxed-list");
|
|
|
|
let tpl_scroll = gtk::ScrolledWindow::builder()
|
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
|
.max_content_height(300)
|
|
.propagate_natural_height(true)
|
|
.child(&templates_list_box)
|
|
.build();
|
|
tpl_scroll.set_size_request(280, -1);
|
|
templates_popover.set_child(Some(&tpl_scroll));
|
|
templates_btn.set_popover(Some(&templates_popover));
|
|
|
|
// Populate templates
|
|
{
|
|
let db_tpl = db.clone();
|
|
let list_tpl = templates_list_box.clone();
|
|
let expense_tpl = expense_btn.clone();
|
|
let income_tpl = income_btn.clone();
|
|
let amount_tpl = amount_entry.clone();
|
|
let cat_row_tpl = category_row.clone();
|
|
let ids_tpl = category_ids.clone();
|
|
let model_tpl = category_model.clone();
|
|
let note_tpl = note_row.clone();
|
|
let payee_tpl = payee_row.clone();
|
|
let tags_tpl = tags_row.clone();
|
|
let popover_tpl = templates_popover.clone();
|
|
let toast_tpl = toast_overlay.clone();
|
|
|
|
let populate_templates = Rc::new(move || {
|
|
while let Some(child) = list_tpl.first_child() {
|
|
list_tpl.remove(&child);
|
|
}
|
|
if let Ok(templates) = db_tpl.list_templates() {
|
|
if templates.is_empty() {
|
|
let empty = gtk::Label::new(Some("No templates yet"));
|
|
empty.add_css_class("dim-label");
|
|
empty.set_margin_top(16);
|
|
empty.set_margin_bottom(16);
|
|
list_tpl.append(&empty);
|
|
return;
|
|
}
|
|
for tpl in &templates {
|
|
let cat_name = db_tpl.get_category(tpl.category_id)
|
|
.map(|c| c.name)
|
|
.unwrap_or_else(|_| "Unknown".to_string());
|
|
let subtitle = match tpl.amount {
|
|
Some(a) => format!("{:.2} {} - {}", a, tpl.currency, cat_name),
|
|
None => cat_name.clone(),
|
|
};
|
|
let type_prefix = match tpl.transaction_type {
|
|
TransactionType::Expense => "Expense",
|
|
TransactionType::Income => "Income",
|
|
};
|
|
let row = adw::ActionRow::builder()
|
|
.title(&tpl.name)
|
|
.subtitle(&format!("{} - {}", type_prefix, subtitle))
|
|
.activatable(true)
|
|
.build();
|
|
|
|
let tpl_clone = tpl.clone();
|
|
let exp_ref = expense_tpl.clone();
|
|
let inc_ref = income_tpl.clone();
|
|
let amt_ref = amount_tpl.clone();
|
|
let cr_ref = cat_row_tpl.clone();
|
|
let ids_ref = ids_tpl.clone();
|
|
let model_ref = model_tpl.clone();
|
|
let note_ref = note_tpl.clone();
|
|
let payee_ref = payee_tpl.clone();
|
|
let tags_ref = tags_tpl.clone();
|
|
let pop_ref = popover_tpl.clone();
|
|
let db_pop = db_tpl.clone();
|
|
let toast_ref = toast_tpl.clone();
|
|
row.connect_activated(move |_| {
|
|
// Set type
|
|
match tpl_clone.transaction_type {
|
|
TransactionType::Expense => exp_ref.set_active(true),
|
|
TransactionType::Income => inc_ref.set_active(true),
|
|
}
|
|
// Populate categories for this type
|
|
Self::populate_categories_from_db(
|
|
&db_pop, &model_ref, &ids_ref, tpl_clone.transaction_type,
|
|
);
|
|
// Set amount
|
|
if let Some(a) = tpl_clone.amount {
|
|
amt_ref.set_text(&format!("{:.2}", a));
|
|
}
|
|
// Set category
|
|
if let Some(pos) = ids_ref.borrow().iter().position(|&id| id == tpl_clone.category_id) {
|
|
cr_ref.set_selected(pos as u32);
|
|
}
|
|
// Set note/payee/tags
|
|
note_ref.set_text(tpl_clone.note.as_deref().unwrap_or(""));
|
|
payee_ref.set_text(tpl_clone.payee.as_deref().unwrap_or(""));
|
|
tags_ref.set_text(tpl_clone.tags.as_deref().unwrap_or(""));
|
|
pop_ref.popdown();
|
|
let toast = adw::Toast::new(&format!("Loaded: {}", tpl_clone.name));
|
|
toast.set_timeout(5);
|
|
toast_ref.add_toast(toast);
|
|
});
|
|
|
|
list_tpl.append(&row);
|
|
}
|
|
}
|
|
});
|
|
populate_templates();
|
|
}
|
|
|
|
// -- Save as Template button --
|
|
let save_tpl_btn = gtk::Button::with_label("Save as Template");
|
|
save_tpl_btn.add_css_class("flat");
|
|
save_tpl_btn.add_css_class("pill");
|
|
{
|
|
let db_tpl = db.clone();
|
|
let expense_tpl = expense_btn.clone();
|
|
let amount_tpl = amount_entry.clone();
|
|
let cat_row_tpl = category_row.clone();
|
|
let ids_tpl = category_ids.clone();
|
|
let currency_row_tpl = currency_row.clone();
|
|
let currency_codes_tpl: Vec<String> = currency_codes.iter().map(|s| s.to_string()).collect();
|
|
let note_tpl = note_row.clone();
|
|
let payee_tpl = payee_row.clone();
|
|
let tags_tpl = tags_row.clone();
|
|
let toast_tpl = toast_overlay.clone();
|
|
save_tpl_btn.connect_clicked(move |btn| {
|
|
let amount_text = amount_tpl.text();
|
|
let amount: Option<f64> = outlay_core::expr::eval_expr(&amount_text).filter(|v| *v > 0.0);
|
|
|
|
let txn_type = if expense_tpl.is_active() {
|
|
TransactionType::Expense
|
|
} else {
|
|
TransactionType::Income
|
|
};
|
|
|
|
let cat_idx = cat_row_tpl.selected() as usize;
|
|
let ids = ids_tpl.borrow();
|
|
let category_id = match ids.get(cat_idx) {
|
|
Some(&id) => id,
|
|
None => {
|
|
let toast = adw::Toast::new("Select a category first");
|
|
toast_tpl.add_toast(toast);
|
|
return;
|
|
}
|
|
};
|
|
|
|
let cur_idx = currency_row_tpl.selected() as usize;
|
|
let currency = currency_codes_tpl.get(cur_idx).cloned().unwrap_or_else(|| "USD".to_string());
|
|
|
|
let note_text = note_tpl.text();
|
|
let note = if note_text.is_empty() { None } else { Some(note_text.to_string()) };
|
|
let payee_text = payee_tpl.text();
|
|
let payee = if payee_text.is_empty() { None } else { Some(payee_text.to_string()) };
|
|
let tags_text = tags_tpl.text();
|
|
let tags = if tags_text.is_empty() { None } else { Some(tags_text.to_string()) };
|
|
|
|
let alert = adw::AlertDialog::new(
|
|
Some("Save as Template"),
|
|
Some("Enter a name for this template:"),
|
|
);
|
|
alert.add_response("cancel", "Cancel");
|
|
alert.add_response("save", "Save");
|
|
alert.set_response_appearance("save", adw::ResponseAppearance::Suggested);
|
|
alert.set_default_response(Some("save"));
|
|
alert.set_close_response("cancel");
|
|
|
|
let entry = adw::EntryRow::builder()
|
|
.title("Template name")
|
|
.build();
|
|
alert.set_extra_child(Some(&entry));
|
|
|
|
let db_save = db_tpl.clone();
|
|
let toast_save = toast_tpl.clone();
|
|
alert.connect_response(None, move |_, response| {
|
|
if response == "save" {
|
|
let name = entry.text().to_string();
|
|
if name.trim().is_empty() {
|
|
let toast = adw::Toast::new("Template name cannot be empty");
|
|
toast_save.add_toast(toast);
|
|
return;
|
|
}
|
|
match db_save.insert_template(
|
|
name.trim(),
|
|
amount,
|
|
txn_type,
|
|
category_id,
|
|
¤cy,
|
|
payee.as_deref(),
|
|
note.as_deref(),
|
|
tags.as_deref(),
|
|
) {
|
|
Ok(_) => {
|
|
let toast = adw::Toast::new("Template saved");
|
|
toast_save.add_toast(toast);
|
|
}
|
|
Err(e) => {
|
|
let toast = adw::Toast::new(&format!("Error: {}", e));
|
|
toast_save.add_toast(toast);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
alert.present(Some(btn));
|
|
});
|
|
}
|
|
|
|
// -- Assemble --
|
|
inner.append(&amount_box);
|
|
inner.append(&expr_label);
|
|
inner.append(&type_box);
|
|
inner.append(&nl_group);
|
|
inner.append(&form_group);
|
|
inner.append(&split_box);
|
|
inner.append(&attach_box);
|
|
inner.append(&rate_label);
|
|
save_btn_box.append(&save_button);
|
|
save_btn_box.append(&save_next_button);
|
|
save_btn_box.append(&templates_btn);
|
|
save_btn_box.append(&save_tpl_btn);
|
|
inner.append(&save_btn_box);
|
|
inner.append(&recent_group);
|
|
|
|
clamp.set_child(Some(&inner));
|
|
container.append(&clamp);
|
|
|
|
LogView {
|
|
container,
|
|
toast_overlay,
|
|
db,
|
|
category_model,
|
|
category_ids,
|
|
expense_btn,
|
|
income_btn,
|
|
amount_entry,
|
|
category_row,
|
|
currency_row,
|
|
currency_codes: currency_codes_rc,
|
|
}
|
|
}
|
|
|
|
pub fn set_income_mode(&self, income: bool) {
|
|
if income {
|
|
self.income_btn.set_active(true);
|
|
} else {
|
|
self.expense_btn.set_active(true);
|
|
}
|
|
}
|
|
|
|
pub fn focus_amount(&self) {
|
|
let entry = self.amount_entry.clone();
|
|
glib::timeout_add_local_once(std::time::Duration::from_millis(300), move || {
|
|
entry.grab_focus();
|
|
});
|
|
}
|
|
|
|
pub fn refresh_categories(&self) {
|
|
let txn_type = if self.expense_btn.is_active() {
|
|
TransactionType::Expense
|
|
} else {
|
|
TransactionType::Income
|
|
};
|
|
Self::populate_categories_from_db(&self.db, &self.category_model, &self.category_ids, txn_type);
|
|
}
|
|
|
|
fn make_summary_card(label_text: &str) -> (gtk::Box, gtk::Label) {
|
|
let card = gtk::Box::new(gtk::Orientation::Vertical, 4);
|
|
card.add_css_class("card");
|
|
card.add_css_class("summary-card");
|
|
card.set_hexpand(true);
|
|
|
|
let label = gtk::Label::new(Some(label_text));
|
|
label.add_css_class("summary-card-label");
|
|
label.set_halign(gtk::Align::Start);
|
|
|
|
let amount = gtk::Label::new(Some("0.00"));
|
|
amount.add_css_class("summary-card-amount");
|
|
amount.set_halign(gtk::Align::Start);
|
|
|
|
card.append(&label);
|
|
card.append(&amount);
|
|
|
|
(card, amount)
|
|
}
|
|
|
|
fn refresh_summary(
|
|
db: &Database,
|
|
income_label: >k::Label,
|
|
expense_label: >k::Label,
|
|
net_label: >k::Label,
|
|
base_currency: &str,
|
|
year: i32,
|
|
month: u32,
|
|
) {
|
|
let income = db.get_monthly_total(year, month, TransactionType::Income).unwrap_or(0.0);
|
|
let expense = db.get_monthly_total(year, month, TransactionType::Expense).unwrap_or(0.0);
|
|
let net = income - expense;
|
|
|
|
income_label.set_label(&format!("+{:.2} {}", income, base_currency));
|
|
income_label.remove_css_class("amount-income");
|
|
income_label.remove_css_class("amount-expense");
|
|
income_label.add_css_class("amount-income");
|
|
|
|
expense_label.set_label(&format!("-{:.2} {}", expense, base_currency));
|
|
expense_label.remove_css_class("amount-income");
|
|
expense_label.remove_css_class("amount-expense");
|
|
expense_label.add_css_class("amount-expense");
|
|
|
|
if net >= 0.0 {
|
|
net_label.set_label(&format!("+{:.2} {}", net, base_currency));
|
|
net_label.remove_css_class("amount-expense");
|
|
net_label.add_css_class("amount-income");
|
|
} else {
|
|
net_label.set_label(&format!("{:.2} {}", net, base_currency));
|
|
net_label.remove_css_class("amount-income");
|
|
net_label.add_css_class("amount-expense");
|
|
}
|
|
}
|
|
|
|
fn send_budget_notification(
|
|
app: &adw::Application,
|
|
category: &str,
|
|
percentage: f64,
|
|
threshold: u32,
|
|
) {
|
|
let body = match threshold {
|
|
75 => format!("{} is at {:.0}% of budget", category, percentage),
|
|
90 => format!("{} is at {:.0}% of budget - almost at limit!", category, percentage),
|
|
100 => format!("{} is over budget at {:.0}%!", category, percentage),
|
|
_ => return,
|
|
};
|
|
|
|
// GTK notification
|
|
let notification = gio::Notification::new("Budget Alert");
|
|
notification.set_body(Some(&body));
|
|
app.send_notification(
|
|
Some(&format!("budget-{}-{}", category, threshold)),
|
|
¬ification,
|
|
);
|
|
|
|
// System notification via notify-send
|
|
let urgency = if threshold >= 100 { "critical" } else { "normal" };
|
|
outlay_core::notifications::send_notification("Budget Alert", &body, urgency);
|
|
}
|
|
|
|
fn populate_categories_from_db(
|
|
db: &Database,
|
|
model: >k::StringList,
|
|
ids: &Rc<RefCell<Vec<i64>>>,
|
|
txn_type: TransactionType,
|
|
) {
|
|
while model.n_items() > 0 {
|
|
model.remove(0);
|
|
}
|
|
let mut id_list = ids.borrow_mut();
|
|
id_list.clear();
|
|
|
|
if let Ok(cats) = db.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(),
|
|
};
|
|
model.append(&entry);
|
|
id_list.push(cat.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn update_split_remaining(
|
|
total_entry: >k::Entry,
|
|
entries: &Rc<RefCell<Vec<(gtk::ListBoxRow, Vec<i64>, gtk::DropDown, gtk::Entry)>>>,
|
|
label: >k::Label,
|
|
) {
|
|
let total: f64 = outlay_core::expr::eval_expr(&total_entry.text()).unwrap_or(0.0);
|
|
let split_sum: f64 = entries.borrow().iter().map(|(_, _, _, e)| {
|
|
outlay_core::expr::eval_expr(&e.text()).unwrap_or(0.0)
|
|
}).sum();
|
|
let remaining = total - split_sum;
|
|
if remaining.abs() < 0.01 {
|
|
label.set_label("Splits balanced");
|
|
label.remove_css_class("amount-expense");
|
|
label.add_css_class("amount-income");
|
|
} else {
|
|
label.set_label(&format!("Remaining: {:.2}", remaining));
|
|
label.remove_css_class("amount-income");
|
|
if remaining < 0.0 {
|
|
label.add_css_class("amount-expense");
|
|
} else {
|
|
label.remove_css_class("amount-expense");
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
fn refresh_recent(
|
|
db: &Rc<Database>,
|
|
group: &adw::PreferencesGroup,
|
|
toast_overlay: &adw::ToastOverlay,
|
|
expense_btn: >k::ToggleButton,
|
|
income_btn: >k::ToggleButton,
|
|
amount_entry: >k::Entry,
|
|
category_row: &adw::ComboRow,
|
|
category_ids: &Rc<RefCell<Vec<i64>>>,
|
|
category_model: >k::StringList,
|
|
currency_row: &adw::ComboRow,
|
|
currency_codes: &Rc<Vec<String>>,
|
|
) {
|
|
// Collect all rows first, then remove them
|
|
let mut rows_to_remove = Vec::new();
|
|
let mut child = group.first_child();
|
|
while let Some(widget) = child {
|
|
let next = widget.next_sibling();
|
|
// The PreferencesGroup has an internal structure:
|
|
// a Box containing a ListBox. We need to find all ActionRows
|
|
// inside the internal ListBox and remove them from the group.
|
|
if widget.downcast_ref::<adw::ActionRow>().is_some() {
|
|
rows_to_remove.push(widget.clone());
|
|
} else {
|
|
// Walk into container children to find rows
|
|
Self::collect_action_rows(&widget, &mut rows_to_remove);
|
|
}
|
|
child = next;
|
|
}
|
|
for row in rows_to_remove {
|
|
if let Some(action_row) = row.downcast_ref::<adw::ActionRow>() {
|
|
group.remove(action_row);
|
|
}
|
|
}
|
|
|
|
match db.list_recent_transactions(5) {
|
|
Ok(txns) if !txns.is_empty() => {
|
|
for txn in &txns {
|
|
let cat = db.get_category(txn.category_id).ok();
|
|
let cat_name = cat.as_ref()
|
|
.map(|c| c.name.clone())
|
|
.unwrap_or_else(|| "Unknown".to_string());
|
|
let cat_icon = cat.as_ref().and_then(|c| c.icon.clone());
|
|
let cat_color = cat.as_ref().and_then(|c| c.color.clone());
|
|
|
|
let amount_str = match txn.transaction_type {
|
|
TransactionType::Expense => format!("-{:.2} {}", txn.amount, txn.currency),
|
|
TransactionType::Income => format!("+{:.2} {}", txn.amount, txn.currency),
|
|
};
|
|
|
|
let subtitle = match &txn.note {
|
|
Some(n) if !n.is_empty() => format!("{} - {}", txn.date, n),
|
|
_ => txn.date.to_string(),
|
|
};
|
|
|
|
let row = adw::ActionRow::builder()
|
|
.title(&cat_name)
|
|
.subtitle(&subtitle)
|
|
.activatable(true)
|
|
.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 amount_label = gtk::Label::new(Some(&amount_str));
|
|
amount_label.add_css_class("amount-display");
|
|
match txn.transaction_type {
|
|
TransactionType::Expense => amount_label.add_css_class("amount-expense"),
|
|
TransactionType::Income => amount_label.add_css_class("amount-income"),
|
|
}
|
|
row.add_suffix(&amount_label);
|
|
|
|
// Repeat button to pre-fill form with this transaction
|
|
let repeat_btn = gtk::Button::from_icon_name("tabler-repeat");
|
|
repeat_btn.add_css_class("flat");
|
|
repeat_btn.set_valign(gtk::Align::Center);
|
|
repeat_btn.set_tooltip_text(Some("Repeat this transaction"));
|
|
{
|
|
let txn_type = txn.transaction_type;
|
|
let txn_amount = txn.amount;
|
|
let txn_cat_id = txn.category_id;
|
|
let txn_currency = txn.currency.clone();
|
|
let expense_ref = expense_btn.clone();
|
|
let income_ref = income_btn.clone();
|
|
let amount_ref = amount_entry.clone();
|
|
let cat_row_ref = category_row.clone();
|
|
let cat_ids_ref = category_ids.clone();
|
|
let cur_row_ref = currency_row.clone();
|
|
let cur_codes_ref = currency_codes.clone();
|
|
repeat_btn.connect_clicked(move |_| {
|
|
// Set type toggle
|
|
match txn_type {
|
|
TransactionType::Expense => expense_ref.set_active(true),
|
|
TransactionType::Income => income_ref.set_active(true),
|
|
}
|
|
// Set amount
|
|
amount_ref.set_text(&format!("{:.2}", txn_amount));
|
|
// Select matching category
|
|
let ids = cat_ids_ref.borrow();
|
|
if let Some(pos) = ids.iter().position(|&id| id == txn_cat_id) {
|
|
cat_row_ref.set_selected(pos as u32);
|
|
}
|
|
// Select matching currency
|
|
if let Some(pos) = cur_codes_ref.iter().position(|c| c.eq_ignore_ascii_case(&txn_currency)) {
|
|
cur_row_ref.set_selected(pos as u32);
|
|
}
|
|
});
|
|
}
|
|
row.add_suffix(&repeat_btn);
|
|
|
|
// Arrow to indicate clickability
|
|
let arrow = gtk::Image::from_icon_name("outlay-next");
|
|
arrow.add_css_class("dim-label");
|
|
row.add_suffix(&arrow);
|
|
|
|
// Connect row activation to edit dialog
|
|
let txn_id = txn.id;
|
|
let db_ref = db.clone();
|
|
let group_ref = group.clone();
|
|
let toast_ref = toast_overlay.clone();
|
|
let eb = expense_btn.clone();
|
|
let ib = income_btn.clone();
|
|
let ae = amount_entry.clone();
|
|
let cr = category_row.clone();
|
|
let ci = category_ids.clone();
|
|
let cm = category_model.clone();
|
|
let cur = currency_row.clone();
|
|
let cc = currency_codes.clone();
|
|
row.connect_activated(move |row| {
|
|
let db_c = db_ref.clone();
|
|
let group_c = group_ref.clone();
|
|
let toast_c = toast_ref.clone();
|
|
let eb = eb.clone();
|
|
let ib = ib.clone();
|
|
let ae = ae.clone();
|
|
let cr = cr.clone();
|
|
let ci = ci.clone();
|
|
let cm = cm.clone();
|
|
let cur = cur.clone();
|
|
let cc = cc.clone();
|
|
edit_dialog::show_edit_dialog(row, txn_id, &db_ref, &toast_ref, move || {
|
|
Self::refresh_recent(&db_c, &group_c, &toast_c, &eb, &ib, &ae, &cr, &ci, &cm, &cur, &cc);
|
|
});
|
|
});
|
|
|
|
group.add(&row);
|
|
}
|
|
}
|
|
_ => {
|
|
let placeholder = adw::ActionRow::builder()
|
|
.title("No transactions yet")
|
|
.build();
|
|
placeholder.add_css_class("dim-label");
|
|
group.add(&placeholder);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn rebuild_attach_flow(
|
|
pending: &Rc<RefCell<Vec<PendingAttachment>>>,
|
|
flow: >k::FlowBox,
|
|
placeholder: >k::Button,
|
|
more_btn: >k::Button,
|
|
) {
|
|
while let Some(child) = flow.first_child() {
|
|
flow.remove(&child);
|
|
}
|
|
let items = pending.borrow().clone();
|
|
if items.is_empty() {
|
|
flow.set_visible(false);
|
|
placeholder.set_visible(true);
|
|
more_btn.set_visible(false);
|
|
return;
|
|
}
|
|
flow.set_visible(true);
|
|
placeholder.set_visible(false);
|
|
more_btn.set_visible(true);
|
|
|
|
for (j, (fname, _, fdata)) in items.iter().enumerate() {
|
|
let thumb = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
|
thumb.set_overflow(gtk::Overflow::Hidden);
|
|
thumb.add_css_class("attach-thumbnail");
|
|
|
|
let overlay = gtk::Overlay::new();
|
|
let b = glib::Bytes::from(fdata);
|
|
let tex = gtk::gdk::Texture::from_bytes(&b).ok();
|
|
let img = if let Some(t) = &tex {
|
|
let p = gtk::Picture::for_paintable(t);
|
|
p.set_content_fit(gtk::ContentFit::Cover);
|
|
p.set_size_request(80, 80);
|
|
p.upcast::<gtk::Widget>()
|
|
} else {
|
|
let l = gtk::Label::new(Some(fname));
|
|
l.set_size_request(80, 80);
|
|
l.upcast::<gtk::Widget>()
|
|
};
|
|
overlay.set_child(Some(&img));
|
|
|
|
let del = gtk::Button::from_icon_name("outlay-delete");
|
|
del.add_css_class("flat");
|
|
del.add_css_class("circular");
|
|
del.add_css_class("osd");
|
|
del.set_halign(gtk::Align::End);
|
|
del.set_valign(gtk::Align::Start);
|
|
del.set_tooltip_text(Some("Remove attachment"));
|
|
overlay.add_overlay(&del);
|
|
|
|
// Click thumbnail to view full image
|
|
if let Some(texture) = tex {
|
|
let click = gtk::GestureClick::new();
|
|
let fname_owned = fname.clone();
|
|
let data_owned = fdata.clone();
|
|
click.connect_released(move |gesture, _, _, _| {
|
|
if let Some(widget) = gesture.widget() {
|
|
show_image_preview(&widget, &fname_owned, &texture, &data_owned);
|
|
}
|
|
});
|
|
thumb.add_controller(click);
|
|
}
|
|
|
|
thumb.append(&overlay);
|
|
|
|
let pd = pending.clone();
|
|
let fd = flow.clone();
|
|
let ph = placeholder.clone();
|
|
let mb = more_btn.clone();
|
|
del.connect_clicked(move |_| {
|
|
if j < pd.borrow().len() {
|
|
pd.borrow_mut().remove(j);
|
|
}
|
|
Self::rebuild_attach_flow(&pd, &fd, &ph, &mb);
|
|
});
|
|
|
|
flow.insert(&thumb, -1);
|
|
}
|
|
}
|
|
|
|
fn collect_action_rows(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(&w, rows);
|
|
}
|
|
child = next;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn show_ocr_amount_picker(
|
|
parent: &impl IsA<gtk::Widget>,
|
|
amounts: &[(f64, String)],
|
|
amount_entry: >k::Entry,
|
|
toast_overlay: &adw::ToastOverlay,
|
|
) {
|
|
let dialog = adw::Dialog::builder()
|
|
.title("Detected amounts")
|
|
.content_width(340)
|
|
.build();
|
|
|
|
let toolbar = adw::ToolbarView::new();
|
|
let header = adw::HeaderBar::new();
|
|
toolbar.add_top_bar(&header);
|
|
|
|
let list = gtk::ListBox::builder()
|
|
.selection_mode(gtk::SelectionMode::None)
|
|
.css_classes(["boxed-list"])
|
|
.margin_top(12)
|
|
.margin_bottom(12)
|
|
.margin_start(12)
|
|
.margin_end(12)
|
|
.build();
|
|
|
|
for (amt, line_text) in amounts {
|
|
let row = adw::ActionRow::builder()
|
|
.title(&format!("{:.2}", amt))
|
|
.subtitle(line_text)
|
|
.activatable(true)
|
|
.build();
|
|
row.add_suffix(>k::Image::from_icon_name("go-next-symbolic"));
|
|
|
|
let dialog_ref = dialog.clone();
|
|
let entry_ref = amount_entry.clone();
|
|
let toast_ref = toast_overlay.clone();
|
|
let val = *amt;
|
|
row.connect_activated(move |_| {
|
|
entry_ref.set_text(&format!("{:.2}", val));
|
|
let toast = adw::Toast::new("Amount applied from receipt");
|
|
toast_ref.add_toast(toast);
|
|
dialog_ref.close();
|
|
});
|
|
|
|
list.append(&row);
|
|
}
|
|
|
|
let scroll = gtk::ScrolledWindow::builder()
|
|
.child(&list)
|
|
.propagate_natural_height(true)
|
|
.max_content_height(400)
|
|
.build();
|
|
|
|
toolbar.set_content(Some(&scroll));
|
|
dialog.set_child(Some(&toolbar));
|
|
dialog.present(Some(parent));
|
|
}
|
|
|
|
pub fn show_image_preview(
|
|
parent: &impl IsA<gtk::Widget>,
|
|
filename: &str,
|
|
texture: >k::gdk::Texture,
|
|
image_data: &[u8],
|
|
) {
|
|
let dialog = adw::Dialog::builder()
|
|
.title(filename)
|
|
.content_width(10000)
|
|
.content_height(10000)
|
|
.build();
|
|
|
|
let toolbar = adw::ToolbarView::new();
|
|
let header = adw::HeaderBar::new();
|
|
|
|
// Save button in header
|
|
let save_btn = gtk::Button::from_icon_name("document-save-as-symbolic");
|
|
save_btn.set_tooltip_text(Some("Save image as..."));
|
|
header.pack_end(&save_btn);
|
|
|
|
let fname = filename.to_string();
|
|
let data = image_data.to_vec();
|
|
let dialog_ref = dialog.clone();
|
|
save_btn.connect_clicked(move |btn| {
|
|
let file_dialog = gtk::FileDialog::builder()
|
|
.title("Save receipt image")
|
|
.initial_name(&fname)
|
|
.build();
|
|
|
|
let data_clone = data.clone();
|
|
let window = dialog_ref.root()
|
|
.or_else(|| btn.root())
|
|
.and_then(|r| r.downcast::<gtk::Window>().ok());
|
|
file_dialog.save(window.as_ref(), gio::Cancellable::NONE, move |result| {
|
|
if let Ok(file) = result {
|
|
if let Some(path) = file.path() {
|
|
let _ = std::fs::write(&path, &data_clone);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
toolbar.add_top_bar(&header);
|
|
|
|
let picture = gtk::Picture::for_paintable(texture);
|
|
picture.set_content_fit(gtk::ContentFit::Contain);
|
|
picture.set_margin_top(8);
|
|
picture.set_margin_bottom(8);
|
|
picture.set_margin_start(8);
|
|
picture.set_margin_end(8);
|
|
|
|
let scroll = gtk::ScrolledWindow::builder()
|
|
.child(&picture)
|
|
.build();
|
|
|
|
toolbar.set_content(Some(&scroll));
|
|
dialog.set_child(Some(&toolbar));
|
|
dialog.present(Some(parent));
|
|
}
|