Add history view with day-grouped transaction list
Month navigation with prev/next buttons, transactions grouped by date with headers (Today/Yesterday/formatted date), category icons, amounts color-coded green for income and red for expenses, and daily net totals.
This commit is contained in:
242
outlay-gtk/src/history_view.rs
Normal file
242
outlay-gtk/src/history_view.rs
Normal file
@@ -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<Database>) -> 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<NaiveDate> = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod history_view;
|
||||||
mod log_view;
|
mod log_view;
|
||||||
mod window;
|
mod window;
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use adw::prelude::*;
|
|||||||
use outlay_core::db::Database;
|
use outlay_core::db::Database;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
use crate::history_view::HistoryView;
|
||||||
use crate::log_view::LogView;
|
use crate::log_view::LogView;
|
||||||
|
|
||||||
pub struct MainWindow {
|
pub struct MainWindow {
|
||||||
@@ -31,16 +32,24 @@ impl MainWindow {
|
|||||||
let content_stack = gtk::Stack::new();
|
let content_stack = gtk::Stack::new();
|
||||||
content_stack.set_transition_type(gtk::StackTransitionType::Crossfade);
|
content_stack.set_transition_type(gtk::StackTransitionType::Crossfade);
|
||||||
|
|
||||||
// Log view - real widget
|
// Log view
|
||||||
let log_view = LogView::new(db);
|
let log_view = LogView::new(db.clone());
|
||||||
let log_scroll = gtk::ScrolledWindow::builder()
|
let log_scroll = gtk::ScrolledWindow::builder()
|
||||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||||
.child(&log_view.container)
|
.child(&log_view.container)
|
||||||
.build();
|
.build();
|
||||||
content_stack.add_named(&log_scroll, Some("log"));
|
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
|
// Remaining pages are placeholders for now
|
||||||
for item in &SIDEBAR_ITEMS[1..] {
|
for item in &SIDEBAR_ITEMS[2..] {
|
||||||
let page = adw::StatusPage::builder()
|
let page = adw::StatusPage::builder()
|
||||||
.title(item.label)
|
.title(item.label)
|
||||||
.icon_name(item.icon)
|
.icon_name(item.icon)
|
||||||
|
|||||||
Reference in New Issue
Block a user