- 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
699 lines
26 KiB
Rust
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(¬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<Database>,
|
|
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::<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(>k::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: >k::Button| {
|
|
let filter = gtk::FileFilter::new();
|
|
filter.add_mime_type("image/png");
|
|
filter.add_mime_type("image/jpeg");
|
|
filter.add_mime_type("image/webp");
|
|
filter.set_name(Some("Images"));
|
|
|
|
let filters = gio::ListStore::new::<gtk::FileFilter>();
|
|
filters.append(&filter);
|
|
|
|
let file_dialog = gtk::FileDialog::builder()
|
|
.title("Attach Receipt")
|
|
.default_filter(&filter)
|
|
.filters(&filters)
|
|
.build();
|
|
|
|
let 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
|
|
}
|