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, txn_id: i64, db: &Rc, 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 = 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::>() .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, gtk::DropDown, gtk::Entry, gtk::ListBoxRow); let split_entries: Rc>> = 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 = cats_for_splits.iter().map(|c| c.id).collect(); let split_cat_names: Vec = 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(¬e_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, txn_id: i64, flow: >k::FlowBox, placeholder: >k::Button, more_btn: >k::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::() } else { let label = gtk::Label::new(Some(&filename)); label.set_size_request(80, 80); label.upcast::() }; 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 = { 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: >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 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::().ok()); file_dialog.open(window.as_ref(), gio::Cancellable::NONE, move |result: 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" }; 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 = 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)> = entries.iter().filter_map(|(cat_ids, dropdown, amt_entry, _): &(Vec, 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::().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 }