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); 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, category_model: gtk::StringList, category_ids: Rc>>, expense_btn: gtk::ToggleButton, income_btn: gtk::ToggleButton, amount_entry: gtk::Entry, category_row: adw::ComboRow, currency_row: adw::ComboRow, currency_codes: Rc>, } impl LogView { pub fn new(db: Rc, 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>> = Rc::new(RefCell::new(Vec::new())); let exchange_rate: Rc> = 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>> = Rc::new(RefCell::new(None)); let nl_debounce: Rc> = 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 = 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, gtk::DropDown, gtk::Entry); let split_entries: Rc>> = 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 = cats.iter().map(|c| c.id).collect(); let labels: Vec = 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::().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>> = 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 = { 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::(); 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::().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 = 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> = Rc::new(Cell::new(0)); let last_income_cat: Rc> = 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::>()); 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 = 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 = 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, splits: Vec<(i64, f64, Option)>| { 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::().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)> = 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 = 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 = 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>>, 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, 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::().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::().unwrap(); let string_obj = item.item().and_downcast::().unwrap(); let text = string_obj.string(); let hbox = item.child().and_downcast::().unwrap(); let icon = hbox.first_child().and_downcast::().unwrap(); let label = icon.next_sibling().and_downcast::().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, 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>>, category_model: >k::StringList, currency_row: &adw::ComboRow, currency_codes: &Rc>, ) { // 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::().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::() { 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>>, 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::() } else { let l = gtk::Label::new(Some(fname)); l.set_size_request(80, 80); l.upcast::() }; 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) { let mut child = widget.first_child(); while let Some(w) = child { let next = w.next_sibling(); if w.downcast_ref::().is_some() { rows.push(w.clone()); } else { Self::collect_action_rows(&w, rows); } child = next; } } } fn show_ocr_amount_picker( parent: &impl IsA, 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, 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::().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)); }