use adw::prelude::*; use chrono::{Datelike, Local, NaiveDate}; use outlay_core::db::Database; use outlay_core::models::TransactionType; use std::cell::Cell; use std::rc::Rc; pub struct HistoryView { pub container: gtk::Box, } impl HistoryView { pub fn new(db: Rc) -> Self { let container = gtk::Box::new(gtk::Orientation::Vertical, 0); let clamp = adw::Clamp::new(); clamp.set_maximum_size(700); clamp.set_margin_start(12); clamp.set_margin_end(12); let inner = gtk::Box::new(gtk::Orientation::Vertical, 12); inner.set_margin_top(16); inner.set_margin_bottom(16); // -- Month navigation -- let today = Local::now().date_naive(); let current_year = Rc::new(Cell::new(today.year())); let current_month = Rc::new(Cell::new(today.month())); let nav_box = gtk::Box::new(gtk::Orientation::Horizontal, 8); nav_box.set_halign(gtk::Align::Center); let prev_btn = gtk::Button::from_icon_name("go-previous-symbolic"); prev_btn.add_css_class("flat"); let month_label = gtk::Label::new(None); month_label.add_css_class("title-3"); month_label.set_width_chars(16); month_label.set_xalign(0.5); let next_btn = gtk::Button::from_icon_name("go-next-symbolic"); next_btn.add_css_class("flat"); nav_box.append(&prev_btn); nav_box.append(&month_label); nav_box.append(&next_btn); // -- Transaction list -- let list_box = gtk::ListBox::new(); list_box.set_selection_mode(gtk::SelectionMode::None); list_box.add_css_class("boxed-list"); let scroll = gtk::ScrolledWindow::builder() .hscrollbar_policy(gtk::PolicyType::Never) .vexpand(true) .child(&list_box) .build(); // 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()); // -- Navigation callbacks -- { let db_ref = db.clone(); let year_ref = current_year.clone(); let month_ref = current_month.clone(); let label_ref = month_label.clone(); let list_ref = list_box.clone(); prev_btn.connect_clicked(move |_| { let mut y = year_ref.get(); let mut m = month_ref.get(); if m == 1 { m = 12; y -= 1; } else { m -= 1; } 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); }); } { let db_ref = db.clone(); let year_ref = current_year.clone(); let month_ref = current_month.clone(); let label_ref = month_label.clone(); let list_ref = list_box.clone(); next_btn.connect_clicked(move |_| { let mut y = year_ref.get(); let mut m = month_ref.get(); if m == 12 { m = 1; y += 1; } else { m += 1; } 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); }); } // -- Assemble -- inner.append(&nav_box); inner.append(&scroll); clamp.set_child(Some(&inner)); container.append(&clamp); HistoryView { container } } fn update_month_label(label: >k::Label, year: i32, month: u32) { let month_name = match month { 1 => "January", 2 => "February", 3 => "March", 4 => "April", 5 => "May", 6 => "June", 7 => "July", 8 => "August", 9 => "September", 10 => "October", 11 => "November", 12 => "December", _ => "Unknown", }; label.set_label(&format!("{} {}", month_name, year)); } fn load_month(db: &Database, list_box: >k::ListBox, year: i32, month: u32) { while let Some(child) = list_box.first_child() { list_box.remove(&child); } let txns = match db.list_transactions_by_month(year, month) { Ok(t) => t, Err(_) => return, }; if txns.is_empty() { let row = adw::ActionRow::builder() .title("No transactions this month") .build(); row.add_css_class("dim-label"); list_box.append(&row); return; } 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); day_income = 0.0; day_expense = 0.0; let date_text = if txn.date == today { "Today".to_string() } else if txn.date == yesterday { "Yesterday".to_string() } else { txn.date.format("%A, %B %-d").to_string() }; let header = gtk::Label::new(Some(&date_text)); header.add_css_class("heading"); header.set_halign(gtk::Align::Start); header.set_margin_top(12); header.set_margin_bottom(4); header.set_margin_start(4); list_box.append(&header); } match txn.transaction_type { TransactionType::Income => day_income += txn.amount, TransactionType::Expense => day_expense += txn.amount, } let cat_name = 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 subtitle = txn.note.as_deref().unwrap_or(""); let amount_str = match txn.transaction_type { TransactionType::Expense => format!("-{:.2} {}", txn.amount, txn.currency), TransactionType::Income => format!("+{:.2} {}", txn.amount, txn.currency), }; let row = adw::ActionRow::builder() .title(&cat_name) .subtitle(subtitle) .build(); let amount_label = gtk::Label::new(Some(&amount_str)); match txn.transaction_type { TransactionType::Expense => amount_label.add_css_class("error"), TransactionType::Income => amount_label.add_css_class("success"), } row.add_suffix(&amount_label); list_box.append(&row); } // Final day total if current_date.is_some() { Self::add_day_total(list_box, day_income, day_expense); } } fn add_day_total(list_box: >k::ListBox, income: f64, expense: f64) { let net = income - expense; let net_str = if net >= 0.0 { format!("Net: +{:.2}", net) } else { format!("Net: {:.2}", net) }; let total_label = gtk::Label::new(Some(&net_str)); total_label.set_halign(gtk::Align::End); total_label.set_margin_top(4); total_label.set_margin_bottom(8); total_label.set_margin_end(8); total_label.add_css_class("dim-label"); total_label.add_css_class("caption"); list_box.append(&total_label); } }