Files
outlay/outlay-gtk/src/edit_dialog.rs
lashman 10a76e3003 Add feature batch 2, subscription/recurring sync, smooth charts, and app icon
- Implement subscriptions view with bidirectional recurring transaction sync
- Add cascade delete/pause/resume between subscriptions and recurring
- Fix foreign key constraints when deleting recurring transactions
- Add cross-view instant refresh via callback pattern
- Replace Bezier chart smoothing with Fritsch-Carlson monotone Hermite interpolation
- Smooth budget sparklines using shared monotone_subdivide function
- Add vertical spacing to budget rows
- Add app icon (receipt on GNOME blue) in all sizes for desktop, web, and AppImage
- Add calendar, credit cards, forecast, goals, insights, and wishlist views
- Add date picker, numpad, quick-add, category combo, and edit dialog components
- Add import/export for CSV, JSON, OFX, QIF formats
- Add NLP transaction parsing, OCR receipt scanning, expression evaluator
- Add notification support, Sankey chart, tray icon
- Add demo data seeder with full DB wipe
- Expand database schema with subscriptions, goals, credit cards, and more
2026-03-03 21:18:37 +02:00

699 lines
26 KiB
Rust

use adw::prelude::*;
use chrono::{Datelike, NaiveDate};
use gtk::{gio, glib};
use outlay_core::db::Database;
use outlay_core::models::{Transaction, TransactionType};
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use crate::icon_theme;
use crate::log_view::show_image_preview;
pub fn show_edit_dialog(
parent: &impl IsA<gtk::Widget>,
txn_id: i64,
db: &Rc<Database>,
toast_overlay: &adw::ToastOverlay,
on_changed: impl Fn() + 'static,
) {
let txn = match db.get_transaction(txn_id) {
Ok(t) => t,
Err(_) => return,
};
let dialog = adw::Dialog::builder()
.title("Edit Transaction")
.content_width(400)
.content_height(500)
.build();
let toolbar = adw::ToolbarView::new();
let header = adw::HeaderBar::new();
toolbar.add_top_bar(&header);
let content = gtk::Box::new(gtk::Orientation::Vertical, 16);
content.set_margin_top(16);
content.set_margin_bottom(16);
content.set_margin_start(16);
content.set_margin_end(16);
// Type display (read-only)
let type_label = match txn.transaction_type {
TransactionType::Expense => "Expense",
TransactionType::Income => "Income",
};
let type_row = adw::ActionRow::builder()
.title("Type")
.subtitle(type_label)
.build();
// Amount
let amount_row = adw::EntryRow::builder()
.title("Amount")
.text(&format!("{:.2}", txn.amount))
.build();
amount_row.set_input_purpose(gtk::InputPurpose::Number);
crate::numpad::attach_numpad(&amount_row);
// Category selector
let cat_model = gtk::StringList::new(&[]);
let mut cat_ids: Vec<i64> = Vec::new();
let mut cat_selected: u32 = 0;
if let Ok(cats) = db.list_categories(Some(txn.transaction_type)) {
for (i, cat) in cats.iter().enumerate() {
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(),
};
cat_model.append(&entry);
if cat.id == txn.category_id {
cat_selected = i as u32;
}
cat_ids.push(cat.id);
}
}
let cat_row = adw::ComboRow::builder()
.title("Category")
.model(&cat_model)
.selected(cat_selected)
.build();
cat_row.set_factory(Some(&make_category_factory()));
cat_row.set_list_factory(Some(&make_category_factory()));
let cat_ids = Rc::new(cat_ids);
// Date
let date_fmt = db.get_date_format_string();
let selected_date = Rc::new(Cell::new(txn.date));
let date_label = gtk::Label::new(Some(&txn.date.format(&date_fmt).to_string()));
date_label.set_halign(gtk::Align::End);
date_label.set_hexpand(true);
let calendar = gtk::Calendar::new();
if let Ok(dt) = glib::DateTime::from_local(
txn.date.year(),
txn.date.month() as i32,
txn.date.day() as i32,
0, 0, 0.0,
) {
calendar.set_year(dt.year());
calendar.set_month(dt.month() - 1);
calendar.set_day(dt.day_of_month());
}
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 date_box = gtk::Box::new(gtk::Orientation::Horizontal, 8);
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);
let date_label_ref = date_label.clone();
let popover_ref = popover.clone();
let selected_date_ref = selected_date.clone();
let date_fmt_clone = date_fmt.clone();
calendar.connect_day_selected(move |cal| {
let dt = cal.date();
if let Some(d) = NaiveDate::from_ymd_opt(dt.year(), dt.month() as u32, dt.day_of_month() as u32) {
selected_date_ref.set(d);
date_label_ref.set_label(&d.format(&date_fmt_clone).to_string());
}
popover_ref.popdown();
});
// Payee
let payee_row = adw::EntryRow::builder()
.title("Payee (optional)")
.text(txn.payee.as_deref().unwrap_or(""))
.build();
// Note
let note_row = adw::EntryRow::builder()
.title("Note (optional)")
.text(txn.note.as_deref().unwrap_or(""))
.build();
// Tags
let existing_tags = db.get_transaction_tags(txn_id)
.unwrap_or_default()
.iter()
.map(|t| t.name.clone())
.collect::<Vec<_>>()
.join(", ");
let tags_row = adw::EntryRow::builder()
.title("Tags (comma-separated)")
.text(&existing_tags)
.build();
// Splits display
let has_splits = db.has_splits(txn_id).unwrap_or(false);
let existing_splits = if has_splits {
db.get_splits(txn_id).unwrap_or_default()
} else {
Vec::new()
};
let splits_group = adw::PreferencesGroup::builder()
.title("SPLITS")
.build();
splits_group.set_visible(has_splits);
let split_list = gtk::ListBox::new();
split_list.add_css_class("boxed-list");
split_list.set_selection_mode(gtk::SelectionMode::None);
// Track split entries for saving: (category_ids, DropDown, Entry, ListBoxRow)
type EditSplitRow = (Vec<i64>, gtk::DropDown, gtk::Entry, gtk::ListBoxRow);
let split_entries: Rc<RefCell<Vec<EditSplitRow>>> = Rc::new(RefCell::new(Vec::new()));
let cats_for_splits = db.list_categories(Some(txn.transaction_type)).unwrap_or_default();
let split_cat_ids: Vec<i64> = cats_for_splits.iter().map(|c| c.id).collect();
let split_cat_names: Vec<String> = cats_for_splits.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();
for split in &existing_splits {
let label_refs: Vec<&str> = split_cat_names.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);
if let Some(pos) = split_cat_ids.iter().position(|&id| id == split.category_id) {
dropdown.set_selected(pos as u32);
}
let amt_entry = gtk::Entry::new();
amt_entry.set_text(&format!("{:.2}", split.amount));
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.append(&row);
let row_clone = row.clone();
let entries_ref = split_entries.clone();
let list_ref = split_list.clone();
del_btn.connect_clicked(move |_| {
list_ref.remove(&row_clone);
entries_ref.borrow_mut().retain(|(_, _, _, r)| r != &row_clone);
});
split_entries.borrow_mut().push((split_cat_ids.clone(), dropdown, amt_entry, row));
}
splits_group.add(&split_list);
let form_group = adw::PreferencesGroup::new();
form_group.add(&type_row);
form_group.add(&amount_row);
form_group.add(&cat_row);
form_group.add(&payee_row);
form_group.add(&date_row);
form_group.add(&note_row);
form_group.add(&tags_row);
// Buttons
let btn_box = gtk::Box::new(gtk::Orientation::Horizontal, 12);
btn_box.set_halign(gtk::Align::Center);
btn_box.set_margin_top(8);
let delete_btn = gtk::Button::with_label("Delete");
delete_btn.add_css_class("destructive-action");
delete_btn.add_css_class("pill");
let save_btn = gtk::Button::with_label("Save");
save_btn.add_css_class("suggested-action");
save_btn.add_css_class("pill");
btn_box.append(&delete_btn);
btn_box.append(&save_btn);
// -- Attachment UI --
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 ph = gtk::Box::new(gtk::Orientation::Vertical, 6);
ph.set_margin_top(20);
ph.set_margin_bottom(20);
ph.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");
ph.append(&icon);
ph.append(&label);
attach_placeholder.set_child(Some(&ph));
}
// Thumbnails flow (hidden until populated)
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 when thumbnails 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_box = 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_box.append(&icon);
content_box.append(&label);
attach_more_btn.set_child(Some(&content_box));
}
attach_more_btn.set_visible(false);
attach_box.append(&attach_placeholder);
attach_box.append(&attach_flow);
attach_box.append(&attach_more_btn);
// Load existing attachments from DB into the flow
fn load_attachments(
db: &Rc<Database>,
txn_id: i64,
flow: &gtk::FlowBox,
placeholder: &gtk::Button,
more_btn: &gtk::Button,
toast: &adw::ToastOverlay,
) {
while let Some(child) = flow.first_child() {
flow.remove(&child);
}
let has_any = if let Ok(attachments) = db.list_attachments(txn_id) {
for (att_id, filename, _mime, data) in &attachments {
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 bytes = glib::Bytes::from(data);
let texture = gtk::gdk::Texture::from_bytes(&bytes).ok();
let image = if let Some(tex) = &texture {
let pic = gtk::Picture::for_paintable(tex);
pic.set_content_fit(gtk::ContentFit::Cover);
pic.set_size_request(80, 80);
pic.upcast::<gtk::Widget>()
} else {
let label = gtk::Label::new(Some(&filename));
label.set_size_request(80, 80);
label.upcast::<gtk::Widget>()
};
overlay.set_child(Some(&image));
let del_btn = gtk::Button::from_icon_name("outlay-delete");
del_btn.add_css_class("flat");
del_btn.add_css_class("circular");
del_btn.add_css_class("osd");
del_btn.set_halign(gtk::Align::End);
del_btn.set_valign(gtk::Align::Start);
del_btn.set_tooltip_text(Some("Remove attachment"));
overlay.add_overlay(&del_btn);
// Click thumbnail to view full image
if let Some(tex) = texture {
let click = gtk::GestureClick::new();
let fname = filename.clone();
let data_owned = data.clone();
click.connect_released(move |gesture, _, _, _| {
if let Some(widget) = gesture.widget() {
show_image_preview(&widget, &fname, &tex, &data_owned);
}
});
thumb.add_controller(click);
}
thumb.append(&overlay);
let att_id = *att_id;
let db_ref = db.clone();
let flow_ref = flow.clone();
let ph_ref = placeholder.clone();
let mb_ref = more_btn.clone();
let toast_ref = toast.clone();
del_btn.connect_clicked(move |_| {
let _ = db_ref.delete_attachment(att_id);
load_attachments(&db_ref, txn_id, &flow_ref, &ph_ref, &mb_ref, &toast_ref);
});
flow.insert(&thumb, -1);
}
!attachments.is_empty()
} else {
false
};
flow.set_visible(has_any);
placeholder.set_visible(!has_any);
more_btn.set_visible(has_any);
}
load_attachments(db, txn_id, &attach_flow, &attach_placeholder, &attach_more_btn, toast_overlay);
// Shared file picker for both buttons
let open_picker: Rc<dyn Fn(&gtk::Button)> = {
let db_ref = db.clone();
let toast_ref = toast_overlay.clone();
let flow_ref = attach_flow.clone();
let ph_ref = attach_placeholder.clone();
let mb_ref = attach_more_btn.clone();
let dialog_widget = dialog.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 db_att = db_ref.clone();
let toast_att = toast_ref.clone();
let flow_att = flow_ref.clone();
let ph_att = ph_ref.clone();
let mb_att = mb_ref.clone();
let window = dialog_widget.root()
.or_else(|| btn.root())
.and_then(|r| r.downcast::<gtk::Window>().ok());
file_dialog.open(window.as_ref(), gio::Cancellable::NONE, move |result: Result<gio::File, glib::Error>| {
if let Ok(file) = result {
if let Some(path) = file.path() {
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"
};
match db_att.insert_attachment(txn_id, &filename, mime, &data) {
Ok(_) => {
load_attachments(
&db_att, txn_id, &flow_att,
&ph_att, &mb_att, &toast_att,
);
}
Err(e) => {
let toast = adw::Toast::new(&format!("Error: {}", e));
toast_att.add_toast(toast);
}
}
}
Ok(_) => {
let toast = adw::Toast::new("File too large (max 5MB)");
toast_att.add_toast(toast);
}
Err(e) => {
let toast = adw::Toast::new(&format!("Read error: {}", e));
toast_att.add_toast(toast);
}
}
}
}
});
})
};
{
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));
}
content.append(&form_group);
content.append(&splits_group);
content.append(&attach_box);
content.append(&btn_box);
let scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.child(&content)
.build();
toolbar.set_content(Some(&scroll));
dialog.set_child(Some(&toolbar));
let on_changed = Rc::new(on_changed);
// Wire save
{
let db_ref = db.clone();
let dialog_ref = dialog.clone();
let toast_ref = toast_overlay.clone();
let amount_row_ref = amount_row.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 cat_row_ref = cat_row.clone();
let cat_ids_ref = cat_ids.clone();
let txn_clone = txn.clone();
let on_changed = on_changed.clone();
let split_entries_ref = split_entries.clone();
save_btn.connect_clicked(move |_| {
let amount_text = amount_row_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_ref.add_toast(toast);
return;
}
};
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 cat_idx = cat_row_ref.selected() as usize;
let category_id = cat_ids_ref.get(cat_idx).copied().unwrap_or(txn_clone.category_id);
let payee_text = payee_row_ref.text();
let payee = if payee_text.is_empty() {
None
} else {
Some(payee_text.to_string())
};
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 updated = Transaction {
id: txn_clone.id,
amount,
transaction_type: txn_clone.transaction_type,
category_id,
currency: txn_clone.currency.clone(),
exchange_rate: txn_clone.exchange_rate,
note,
date,
created_at: txn_clone.created_at.clone(),
recurring_id: txn_clone.recurring_id,
payee,
};
match db_ref.update_transaction(&updated) {
Ok(()) => {
// Save tags
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(txn_clone.id, &tag_ids);
// Save splits
let _ = db_ref.delete_splits(txn_clone.id);
let entries = split_entries_ref.borrow();
if !entries.is_empty() {
let splits: Vec<(i64, f64, Option<String>)> = entries.iter().filter_map(|(cat_ids, dropdown, amt_entry, _): &(Vec<i64>, gtk::DropDown, gtk::Entry, gtk::ListBoxRow)| {
let idx = dropdown.selected() as usize;
let cat_id = cat_ids.get(idx).copied()?;
let amt: f64 = outlay_core::expr::eval_expr(&amt_entry.text()).unwrap_or(0.0);
if amt > 0.0 { Some((cat_id, amt, None)) } else { None }
}).collect();
let _ = db_ref.insert_splits(txn_clone.id, &splits);
}
dialog_ref.close();
let toast = adw::Toast::new("Transaction updated");
toast_ref.add_toast(toast);
on_changed();
}
Err(e) => {
let toast = adw::Toast::new(&format!("Error: {}", e));
toast_ref.add_toast(toast);
}
}
});
}
// Wire delete (5.1: undo-based deletion)
{
let db_ref = db.clone();
let dialog_ref = dialog.clone();
let toast_ref = toast_overlay.clone();
let on_changed = on_changed.clone();
let txn_clone = txn.clone();
delete_btn.connect_clicked(move |_| {
// Save attachments before deletion (CASCADE will remove them)
let saved_attachments = db_ref.list_attachments(txn_id).unwrap_or_default();
match db_ref.delete_transaction(txn_id) {
Ok(()) => {
dialog_ref.close();
on_changed();
let toast = adw::Toast::new("Transaction deleted");
toast.set_button_label(Some("Undo"));
toast.set_timeout(5);
let db_undo = db_ref.clone();
let toast_undo = toast_ref.clone();
let txn_restore = txn_clone.clone();
let on_changed_undo = on_changed.clone();
toast.connect_button_clicked(move |_| {
use outlay_core::models::NewTransaction;
let new_txn = NewTransaction {
amount: txn_restore.amount,
transaction_type: txn_restore.transaction_type,
category_id: txn_restore.category_id,
currency: txn_restore.currency.clone(),
exchange_rate: txn_restore.exchange_rate,
note: txn_restore.note.clone(),
date: txn_restore.date,
recurring_id: txn_restore.recurring_id,
payee: txn_restore.payee.clone(),
};
match db_undo.insert_transaction(&new_txn) {
Ok(new_id) => {
// Restore attachments
for (_att_id, filename, mime, data) in &saved_attachments {
let _ = db_undo.insert_attachment(new_id, filename, &mime, data);
}
let t = adw::Toast::new("Transaction restored");
toast_undo.add_toast(t);
on_changed_undo();
}
Err(e) => {
let t = adw::Toast::new(&format!("Restore failed: {}", e));
toast_undo.add_toast(t);
}
}
});
toast_ref.add_toast(toast);
}
Err(e) => {
let toast = adw::Toast::new(&format!("Error: {}", e));
toast_ref.add_toast(toast);
}
}
});
}
dialog.present(Some(parent));
}
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
}