Files
outlay/outlay-gtk/src/log_view.rs
lashman bdf200211b Change app ID to com.outlay.app, add AppStream metadata, fix toast visibility
- 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
2026-03-03 22:15:59 +02:00

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(&currency_label_refs);
let currency_row = adw::ComboRow::builder()
.title("Currency")
.model(&currency_model)
.selected(base_idx as u32)
.build();
form_group.add(&currency_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(&note_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(&gtk::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: &gtk::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 = &currency_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,
&currency_row, &currency_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,
&currency_row_ref, &currency_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,
&currency,
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: &gtk::Label,
expense_label: &gtk::Label,
net_label: &gtk::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)),
&notification,
);
// 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: &gtk::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: &gtk::Entry,
entries: &Rc<RefCell<Vec<(gtk::ListBoxRow, Vec<i64>, gtk::DropDown, gtk::Entry)>>>,
label: &gtk::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: &gtk::ToggleButton,
income_btn: &gtk::ToggleButton,
amount_entry: &gtk::Entry,
category_row: &adw::ComboRow,
category_ids: &Rc<RefCell<Vec<i64>>>,
category_model: &gtk::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: &gtk::FlowBox,
placeholder: &gtk::Button,
more_btn: &gtk::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: &gtk::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: &gtk::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(&gtk::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: &gtk::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));
}