diff --git a/outlay-gtk/src/history_view.rs b/outlay-gtk/src/history_view.rs new file mode 100644 index 0000000..9e9dbed --- /dev/null +++ b/outlay-gtk/src/history_view.rs @@ -0,0 +1,242 @@ +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) { + // Clear existing rows + 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); + } +} diff --git a/outlay-gtk/src/main.rs b/outlay-gtk/src/main.rs index 3951047..9e428de 100644 --- a/outlay-gtk/src/main.rs +++ b/outlay-gtk/src/main.rs @@ -1,3 +1,4 @@ +mod history_view; mod log_view; mod window; diff --git a/outlay-gtk/src/window.rs b/outlay-gtk/src/window.rs index 6cc429c..766c57f 100644 --- a/outlay-gtk/src/window.rs +++ b/outlay-gtk/src/window.rs @@ -2,6 +2,7 @@ use adw::prelude::*; use outlay_core::db::Database; use std::rc::Rc; +use crate::history_view::HistoryView; use crate::log_view::LogView; pub struct MainWindow { @@ -31,16 +32,24 @@ impl MainWindow { let content_stack = gtk::Stack::new(); content_stack.set_transition_type(gtk::StackTransitionType::Crossfade); - // Log view - real widget - let log_view = LogView::new(db); + // Log view + let log_view = LogView::new(db.clone()); let log_scroll = gtk::ScrolledWindow::builder() .hscrollbar_policy(gtk::PolicyType::Never) .child(&log_view.container) .build(); content_stack.add_named(&log_scroll, Some("log")); + // History view + let history_view = HistoryView::new(db.clone()); + let history_scroll = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .child(&history_view.container) + .build(); + content_stack.add_named(&history_scroll, Some("history")); + // Remaining pages are placeholders for now - for item in &SIDEBAR_ITEMS[1..] { + for item in &SIDEBAR_ITEMS[2..] { let page = adw::StatusPage::builder() .title(item.label) .icon_name(item.icon)