Add edit and delete for transactions in history view
Click a transaction row to open an edit dialog with pre-filled amount, date, and note fields. Save updates the database and refreshes the list. Delete button shows a confirmation dialog before removing the transaction. Toast notifications for all actions.
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
use adw::prelude::*;
|
use adw::prelude::*;
|
||||||
use chrono::{Datelike, Local, NaiveDate};
|
use chrono::{Datelike, Local, NaiveDate};
|
||||||
|
use gtk::glib;
|
||||||
use outlay_core::db::Database;
|
use outlay_core::db::Database;
|
||||||
use outlay_core::models::TransactionType;
|
use outlay_core::models::{Transaction, TransactionType};
|
||||||
use std::cell::Cell;
|
use std::cell::Cell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ pub struct HistoryView {
|
|||||||
impl HistoryView {
|
impl HistoryView {
|
||||||
pub fn new(db: Rc<Database>) -> Self {
|
pub fn new(db: Rc<Database>) -> Self {
|
||||||
let container = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
let container = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||||
|
let toast_overlay = adw::ToastOverlay::new();
|
||||||
|
|
||||||
let clamp = adw::Clamp::new();
|
let clamp = adw::Clamp::new();
|
||||||
clamp.set_maximum_size(700);
|
clamp.set_maximum_size(700);
|
||||||
@@ -58,7 +60,7 @@ impl HistoryView {
|
|||||||
|
|
||||||
// Initial load
|
// Initial load
|
||||||
Self::update_month_label(&month_label, current_year.get(), current_month.get());
|
Self::update_month_label(&month_label, current_year.get(), current_month.get());
|
||||||
Self::load_month(&db, &list_box, current_year.get(), current_month.get());
|
Self::load_month(&db, &list_box, &toast_overlay, current_year.get(), current_month.get());
|
||||||
|
|
||||||
// -- Navigation callbacks --
|
// -- Navigation callbacks --
|
||||||
{
|
{
|
||||||
@@ -67,6 +69,7 @@ impl HistoryView {
|
|||||||
let month_ref = current_month.clone();
|
let month_ref = current_month.clone();
|
||||||
let label_ref = month_label.clone();
|
let label_ref = month_label.clone();
|
||||||
let list_ref = list_box.clone();
|
let list_ref = list_box.clone();
|
||||||
|
let toast_ref = toast_overlay.clone();
|
||||||
prev_btn.connect_clicked(move |_| {
|
prev_btn.connect_clicked(move |_| {
|
||||||
let mut y = year_ref.get();
|
let mut y = year_ref.get();
|
||||||
let mut m = month_ref.get();
|
let mut m = month_ref.get();
|
||||||
@@ -79,7 +82,7 @@ impl HistoryView {
|
|||||||
year_ref.set(y);
|
year_ref.set(y);
|
||||||
month_ref.set(m);
|
month_ref.set(m);
|
||||||
Self::update_month_label(&label_ref, y, m);
|
Self::update_month_label(&label_ref, y, m);
|
||||||
Self::load_month(&db_ref, &list_ref, y, m);
|
Self::load_month(&db_ref, &list_ref, &toast_ref, y, m);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
@@ -88,6 +91,7 @@ impl HistoryView {
|
|||||||
let month_ref = current_month.clone();
|
let month_ref = current_month.clone();
|
||||||
let label_ref = month_label.clone();
|
let label_ref = month_label.clone();
|
||||||
let list_ref = list_box.clone();
|
let list_ref = list_box.clone();
|
||||||
|
let toast_ref = toast_overlay.clone();
|
||||||
next_btn.connect_clicked(move |_| {
|
next_btn.connect_clicked(move |_| {
|
||||||
let mut y = year_ref.get();
|
let mut y = year_ref.get();
|
||||||
let mut m = month_ref.get();
|
let mut m = month_ref.get();
|
||||||
@@ -100,7 +104,7 @@ impl HistoryView {
|
|||||||
year_ref.set(y);
|
year_ref.set(y);
|
||||||
month_ref.set(m);
|
month_ref.set(m);
|
||||||
Self::update_month_label(&label_ref, y, m);
|
Self::update_month_label(&label_ref, y, m);
|
||||||
Self::load_month(&db_ref, &list_ref, y, m);
|
Self::load_month(&db_ref, &list_ref, &toast_ref, y, m);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +113,8 @@ impl HistoryView {
|
|||||||
inner.append(&scroll);
|
inner.append(&scroll);
|
||||||
|
|
||||||
clamp.set_child(Some(&inner));
|
clamp.set_child(Some(&inner));
|
||||||
container.append(&clamp);
|
toast_overlay.set_child(Some(&clamp));
|
||||||
|
container.append(&toast_overlay);
|
||||||
|
|
||||||
HistoryView { container }
|
HistoryView { container }
|
||||||
}
|
}
|
||||||
@@ -125,8 +130,13 @@ impl HistoryView {
|
|||||||
label.set_label(&format!("{} {}", month_name, year));
|
label.set_label(&format!("{} {}", month_name, year));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_month(db: &Database, list_box: >k::ListBox, year: i32, month: u32) {
|
fn load_month(
|
||||||
// Clear existing rows
|
db: &Rc<Database>,
|
||||||
|
list_box: >k::ListBox,
|
||||||
|
toast_overlay: &adw::ToastOverlay,
|
||||||
|
year: i32,
|
||||||
|
month: u32,
|
||||||
|
) {
|
||||||
while let Some(child) = list_box.first_child() {
|
while let Some(child) = list_box.first_child() {
|
||||||
list_box.remove(&child);
|
list_box.remove(&child);
|
||||||
}
|
}
|
||||||
@@ -148,16 +158,13 @@ impl HistoryView {
|
|||||||
let today = Local::now().date_naive();
|
let today = Local::now().date_naive();
|
||||||
let yesterday = today.pred_opt().unwrap_or(today);
|
let yesterday = today.pred_opt().unwrap_or(today);
|
||||||
|
|
||||||
// Group by date
|
|
||||||
let mut current_date: Option<NaiveDate> = None;
|
let mut current_date: Option<NaiveDate> = None;
|
||||||
let mut day_income = 0.0_f64;
|
let mut day_income = 0.0_f64;
|
||||||
let mut day_expense = 0.0_f64;
|
let mut day_expense = 0.0_f64;
|
||||||
|
|
||||||
for txn in &txns {
|
for txn in &txns {
|
||||||
if current_date != Some(txn.date) {
|
if current_date != Some(txn.date) {
|
||||||
// Emit header for new date group
|
|
||||||
if current_date.is_some() {
|
if current_date.is_some() {
|
||||||
// Add net total for previous group
|
|
||||||
Self::add_day_total(list_box, day_income, day_expense);
|
Self::add_day_total(list_box, day_income, day_expense);
|
||||||
}
|
}
|
||||||
current_date = Some(txn.date);
|
current_date = Some(txn.date);
|
||||||
@@ -204,7 +211,9 @@ impl HistoryView {
|
|||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title(&cat_name)
|
.title(&cat_name)
|
||||||
.subtitle(subtitle)
|
.subtitle(subtitle)
|
||||||
|
.activatable(true)
|
||||||
.build();
|
.build();
|
||||||
|
row.add_css_class("property");
|
||||||
|
|
||||||
let amount_label = gtk::Label::new(Some(&amount_str));
|
let amount_label = gtk::Label::new(Some(&amount_str));
|
||||||
match txn.transaction_type {
|
match txn.transaction_type {
|
||||||
@@ -213,10 +222,27 @@ impl HistoryView {
|
|||||||
}
|
}
|
||||||
row.add_suffix(&amount_label);
|
row.add_suffix(&amount_label);
|
||||||
|
|
||||||
|
// Arrow to indicate clickability
|
||||||
|
let arrow = gtk::Image::from_icon_name("go-next-symbolic");
|
||||||
|
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 list_ref = list_box.clone();
|
||||||
|
let toast_ref = toast_overlay.clone();
|
||||||
|
let year_copy = year;
|
||||||
|
let month_copy = month;
|
||||||
|
row.connect_activated(move |row| {
|
||||||
|
Self::show_edit_dialog(
|
||||||
|
row, txn_id, &db_ref, &list_ref, &toast_ref, year_copy, month_copy,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
list_box.append(&row);
|
list_box.append(&row);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final day total
|
|
||||||
if current_date.is_some() {
|
if current_date.is_some() {
|
||||||
Self::add_day_total(list_box, day_income, day_expense);
|
Self::add_day_total(list_box, day_income, day_expense);
|
||||||
}
|
}
|
||||||
@@ -239,4 +265,252 @@ impl HistoryView {
|
|||||||
total_label.add_css_class("caption");
|
total_label.add_css_class("caption");
|
||||||
list_box.append(&total_label);
|
list_box.append(&total_label);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn show_edit_dialog(
|
||||||
|
row: &adw::ActionRow,
|
||||||
|
txn_id: i64,
|
||||||
|
db: &Rc<Database>,
|
||||||
|
list_box: >k::ListBox,
|
||||||
|
toast_overlay: &adw::ToastOverlay,
|
||||||
|
year: i32,
|
||||||
|
month: u32,
|
||||||
|
) {
|
||||||
|
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 for simplicity)
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Category (read-only display for now - changing category type is complex)
|
||||||
|
let cat_display = db
|
||||||
|
.get_category(txn.category_id)
|
||||||
|
.map(|c| match &c.icon {
|
||||||
|
Some(icon) => format!("{} {}", icon, c.name),
|
||||||
|
None => c.name,
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|_| "Unknown".to_string());
|
||||||
|
let cat_row = adw::ActionRow::builder()
|
||||||
|
.title("Category")
|
||||||
|
.subtitle(&cat_display)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Date
|
||||||
|
let date_label = gtk::Label::new(Some(&txn.date.format("%Y-%m-%d").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));
|
||||||
|
date_menu_btn.set_icon_name("x-office-calendar-symbolic");
|
||||||
|
date_menu_btn.add_css_class("flat");
|
||||||
|
|
||||||
|
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();
|
||||||
|
calendar.connect_day_selected(move |cal| {
|
||||||
|
let dt = cal.date();
|
||||||
|
let formatted = dt.format("%Y-%m-%d").unwrap().to_string();
|
||||||
|
date_label_ref.set_label(&formatted);
|
||||||
|
popover_ref.popdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note
|
||||||
|
let note_row = adw::EntryRow::builder()
|
||||||
|
.title("Note (optional)")
|
||||||
|
.text(txn.note.as_deref().unwrap_or(""))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let form_group = adw::PreferencesGroup::new();
|
||||||
|
form_group.add(&type_row);
|
||||||
|
form_group.add(&amount_row);
|
||||||
|
form_group.add(&cat_row);
|
||||||
|
form_group.add(&date_row);
|
||||||
|
form_group.add(¬e_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);
|
||||||
|
|
||||||
|
content.append(&form_group);
|
||||||
|
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));
|
||||||
|
|
||||||
|
// Wire save
|
||||||
|
{
|
||||||
|
let db_ref = db.clone();
|
||||||
|
let dialog_ref = dialog.clone();
|
||||||
|
let list_ref = list_box.clone();
|
||||||
|
let toast_ref = toast_overlay.clone();
|
||||||
|
let amount_row_ref = amount_row.clone();
|
||||||
|
let date_label_ref = date_label.clone();
|
||||||
|
let note_row_ref = note_row.clone();
|
||||||
|
let txn_clone = txn.clone();
|
||||||
|
save_btn.connect_clicked(move |_| {
|
||||||
|
let amount_text = amount_row_ref.text();
|
||||||
|
let amount: f64 = match amount_text.trim().parse() {
|
||||||
|
Ok(v) if v > 0.0 => v,
|
||||||
|
_ => {
|
||||||
|
let toast = adw::Toast::new("Please enter a valid amount");
|
||||||
|
toast_ref.add_toast(toast);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let date_text = date_label_ref.label();
|
||||||
|
let date = NaiveDate::parse_from_str(&date_text, "%Y-%m-%d")
|
||||||
|
.unwrap_or(txn_clone.date);
|
||||||
|
|
||||||
|
let note_text = note_row_ref.text();
|
||||||
|
let note = if note_text.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(note_text.to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
let updated = Transaction {
|
||||||
|
id: txn_clone.id,
|
||||||
|
amount,
|
||||||
|
transaction_type: txn_clone.transaction_type,
|
||||||
|
category_id: txn_clone.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,
|
||||||
|
};
|
||||||
|
|
||||||
|
match db_ref.update_transaction(&updated) {
|
||||||
|
Ok(()) => {
|
||||||
|
dialog_ref.close();
|
||||||
|
let toast = adw::Toast::new("Transaction updated");
|
||||||
|
toast_ref.add_toast(toast);
|
||||||
|
Self::load_month(&db_ref, &list_ref, &toast_ref, year, month);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let toast = adw::Toast::new(&format!("Error: {}", e));
|
||||||
|
toast_ref.add_toast(toast);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire delete
|
||||||
|
{
|
||||||
|
let db_ref = db.clone();
|
||||||
|
let dialog_ref = dialog.clone();
|
||||||
|
let list_ref = list_box.clone();
|
||||||
|
let toast_ref = toast_overlay.clone();
|
||||||
|
delete_btn.connect_clicked(move |btn| {
|
||||||
|
let alert = adw::AlertDialog::new(
|
||||||
|
Some("Delete this transaction?"),
|
||||||
|
Some("This action cannot be undone."),
|
||||||
|
);
|
||||||
|
alert.add_response("cancel", "Cancel");
|
||||||
|
alert.add_response("delete", "Delete");
|
||||||
|
alert.set_response_appearance("delete", adw::ResponseAppearance::Destructive);
|
||||||
|
alert.set_default_response(Some("cancel"));
|
||||||
|
alert.set_close_response("cancel");
|
||||||
|
|
||||||
|
let db_del = db_ref.clone();
|
||||||
|
let dialog_del = dialog_ref.clone();
|
||||||
|
let list_del = list_ref.clone();
|
||||||
|
let toast_del = toast_ref.clone();
|
||||||
|
alert.connect_response(None, move |_, response| {
|
||||||
|
if response == "delete" {
|
||||||
|
match db_del.delete_transaction(txn_id) {
|
||||||
|
Ok(()) => {
|
||||||
|
dialog_del.close();
|
||||||
|
let toast = adw::Toast::new("Transaction deleted");
|
||||||
|
toast_del.add_toast(toast);
|
||||||
|
Self::load_month(&db_del, &list_del, &toast_del, year, month);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let toast = adw::Toast::new(&format!("Error: {}", e));
|
||||||
|
toast_del.add_toast(toast);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
alert.present(Some(btn));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.present(Some(row));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user