diff --git a/outlay-gtk/src/history_view.rs b/outlay-gtk/src/history_view.rs index 9e9dbed..dc14f79 100644 --- a/outlay-gtk/src/history_view.rs +++ b/outlay-gtk/src/history_view.rs @@ -1,7 +1,8 @@ use adw::prelude::*; use chrono::{Datelike, Local, NaiveDate}; +use gtk::glib; use outlay_core::db::Database; -use outlay_core::models::TransactionType; +use outlay_core::models::{Transaction, TransactionType}; use std::cell::Cell; use std::rc::Rc; @@ -12,6 +13,7 @@ pub struct HistoryView { impl HistoryView { pub fn new(db: Rc) -> Self { let container = gtk::Box::new(gtk::Orientation::Vertical, 0); + let toast_overlay = adw::ToastOverlay::new(); let clamp = adw::Clamp::new(); clamp.set_maximum_size(700); @@ -58,7 +60,7 @@ impl HistoryView { // Initial load 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 -- { @@ -67,6 +69,7 @@ impl HistoryView { let month_ref = current_month.clone(); let label_ref = month_label.clone(); let list_ref = list_box.clone(); + let toast_ref = toast_overlay.clone(); prev_btn.connect_clicked(move |_| { let mut y = year_ref.get(); let mut m = month_ref.get(); @@ -79,7 +82,7 @@ impl HistoryView { year_ref.set(y); month_ref.set(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 label_ref = month_label.clone(); let list_ref = list_box.clone(); + let toast_ref = toast_overlay.clone(); next_btn.connect_clicked(move |_| { let mut y = year_ref.get(); let mut m = month_ref.get(); @@ -100,7 +104,7 @@ impl HistoryView { year_ref.set(y); month_ref.set(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); clamp.set_child(Some(&inner)); - container.append(&clamp); + toast_overlay.set_child(Some(&clamp)); + container.append(&toast_overlay); HistoryView { container } } @@ -125,8 +130,13 @@ impl HistoryView { label.set_label(&format!("{} {}", month_name, year)); } - fn load_month(db: &Database, list_box: >k::ListBox, year: i32, month: u32) { - // Clear existing rows + fn load_month( + db: &Rc, + list_box: >k::ListBox, + toast_overlay: &adw::ToastOverlay, + year: i32, + month: u32, + ) { while let Some(child) = list_box.first_child() { list_box.remove(&child); } @@ -148,16 +158,13 @@ impl HistoryView { let today = Local::now().date_naive(); let yesterday = today.pred_opt().unwrap_or(today); - // Group by date let mut current_date: Option = None; let mut day_income = 0.0_f64; let mut day_expense = 0.0_f64; for txn in &txns { if current_date != Some(txn.date) { - // Emit header for new date group if current_date.is_some() { - // Add net total for previous group Self::add_day_total(list_box, day_income, day_expense); } current_date = Some(txn.date); @@ -204,7 +211,9 @@ impl HistoryView { let row = adw::ActionRow::builder() .title(&cat_name) .subtitle(subtitle) + .activatable(true) .build(); + row.add_css_class("property"); let amount_label = gtk::Label::new(Some(&amount_str)); match txn.transaction_type { @@ -213,10 +222,27 @@ impl HistoryView { } 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); } - // Final day total if current_date.is_some() { Self::add_day_total(list_box, day_income, day_expense); } @@ -239,4 +265,252 @@ impl HistoryView { total_label.add_css_class("caption"); list_box.append(&total_label); } + + fn show_edit_dialog( + row: &adw::ActionRow, + txn_id: i64, + db: &Rc, + 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)); + } }