From 29d86a5241f5c79bf2aff05a4f8bc50119eb3b59 Mon Sep 17 00:00:00 2001 From: lashman Date: Mon, 2 Mar 2026 00:22:19 +0200 Subject: [PATCH] Add charts view with pie, bar, and line charts Pie chart shows expense breakdown by category with color-coded slices. Bar chart shows income vs expense for the last 6 months. Line chart shows cumulative daily net trend. All charts render via plotters BitMapBackend to GDK MemoryTexture for display. Month navigation shared across all three charts. --- Cargo.lock | 1 + outlay-gtk/Cargo.toml | 1 + outlay-gtk/src/charts_view.rs | 401 ++++++++++++++++++++++++++++++++++ outlay-gtk/src/main.rs | 1 + outlay-gtk/src/window.rs | 7 +- 5 files changed, 410 insertions(+), 1 deletion(-) create mode 100644 outlay-gtk/src/charts_view.rs diff --git a/Cargo.lock b/Cargo.lock index e2e59c0..c0087b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1586,6 +1586,7 @@ name = "outlay-gtk" version = "0.1.0" dependencies = [ "chrono", + "gdk4", "gtk4", "libadwaita", "outlay-core", diff --git a/outlay-gtk/Cargo.toml b/outlay-gtk/Cargo.toml index 3aa2aff..fb3cf9d 100644 --- a/outlay-gtk/Cargo.toml +++ b/outlay-gtk/Cargo.toml @@ -8,5 +8,6 @@ outlay-core = { path = "../outlay-core" } gtk = { package = "gtk4", version = "0.11" } adw = { package = "libadwaita", version = "0.9", features = ["v1_8"] } chrono = "0.4" +gdk = { package = "gdk4", version = "0.11" } plotters = { version = "0.3", default-features = false, features = ["bitmap_backend", "bitmap_encoder", "line_series", "area_series"] } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } diff --git a/outlay-gtk/src/charts_view.rs b/outlay-gtk/src/charts_view.rs new file mode 100644 index 0000000..84968ef --- /dev/null +++ b/outlay-gtk/src/charts_view.rs @@ -0,0 +1,401 @@ +use adw::prelude::*; +use chrono::{Datelike, Local}; +use gtk::glib; +use outlay_core::db::Database; +use outlay_core::models::TransactionType; +use plotters::prelude::*; +use std::cell::Cell; +use std::rc::Rc; + +const CHART_WIDTH: u32 = 560; +const CHART_HEIGHT: u32 = 340; + +pub struct ChartsView { + pub container: gtk::Box, +} + +impl ChartsView { + 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, 16); + inner.set_margin_top(16); + inner.set_margin_bottom(16); + + let today = Local::now().date_naive(); + let current_year = Rc::new(Cell::new(today.year())); + let current_month = Rc::new(Cell::new(today.month())); + + // Month navigation + 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); + + // Chart containers + let pie_frame = gtk::Frame::new(Some("Expenses by Category")); + let pie_picture = gtk::Picture::new(); + pie_frame.set_child(Some(&pie_picture)); + + let bar_frame = gtk::Frame::new(Some("Monthly Income vs Expenses")); + let bar_picture = gtk::Picture::new(); + bar_frame.set_child(Some(&bar_picture)); + + let line_frame = gtk::Frame::new(Some("Daily Net Trend")); + let line_picture = gtk::Picture::new(); + line_frame.set_child(Some(&line_picture)); + + // Initial render + Self::update_month_label(&month_label, current_year.get(), current_month.get()); + Self::render_all(&db, &pie_picture, &bar_picture, &line_picture, current_year.get(), current_month.get()); + + // Navigation + { + let db_ref = db.clone(); + let year_ref = current_year.clone(); + let month_ref = current_month.clone(); + let label_ref = month_label.clone(); + let pie_ref = pie_picture.clone(); + let bar_ref = bar_picture.clone(); + let line_ref = line_picture.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::render_all(&db_ref, &pie_ref, &bar_ref, &line_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 pie_ref = pie_picture.clone(); + let bar_ref = bar_picture.clone(); + let line_ref = line_picture.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::render_all(&db_ref, &pie_ref, &bar_ref, &line_ref, y, m); + }); + } + + inner.append(&nav_box); + inner.append(&pie_frame); + inner.append(&bar_frame); + inner.append(&line_frame); + + clamp.set_child(Some(&inner)); + + let scroll = gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .vexpand(true) + .child(&clamp) + .build(); + + container.append(&scroll); + + ChartsView { 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 render_all( + db: &Database, + pie_picture: >k::Picture, + bar_picture: >k::Picture, + line_picture: >k::Picture, + year: i32, + month: u32, + ) { + Self::render_pie_chart(db, pie_picture, year, month); + Self::render_bar_chart(db, bar_picture, year, month); + Self::render_line_chart(db, line_picture, year, month); + } + + fn set_picture_from_rgb(picture: >k::Picture, rgb_data: &[u8], width: u32, height: u32) { + let bytes = glib::Bytes::from(rgb_data); + let stride = (width * 3) as usize; + let texture = gdk::MemoryTexture::new( + width as i32, + height as i32, + gdk::MemoryFormat::R8g8b8, + &bytes, + stride, + ); + picture.set_paintable(Some(&texture)); + } + + fn render_pie_chart(db: &Database, picture: >k::Picture, year: i32, month: u32) { + let data = db + .get_monthly_totals_by_category(year, month, TransactionType::Expense) + .unwrap_or_default(); + + let mut buf = vec![0u8; (CHART_WIDTH * CHART_HEIGHT * 3) as usize]; + { + let root = BitMapBackend::with_buffer(&mut buf, (CHART_WIDTH, CHART_HEIGHT)) + .into_drawing_area(); + root.fill(&WHITE).ok(); + + if data.is_empty() { + root.draw_text( + "No expense data this month", + &("sans-serif", 16).into_text_style(&root), + (CHART_WIDTH as i32 / 2 - 100, CHART_HEIGHT as i32 / 2), + ).ok(); + } else { + let total: f64 = data.iter().map(|(_, v)| *v).sum(); + + // Simple pie chart using plotters primitives + let center = ((CHART_WIDTH / 2) as i32, (CHART_HEIGHT / 2 - 20) as i32); + let radius = 120i32; + + let colors = [ + RGBColor(231, 76, 60), // red + RGBColor(52, 152, 219), // blue + RGBColor(46, 204, 113), // green + RGBColor(155, 89, 182), // purple + RGBColor(241, 196, 15), // yellow + RGBColor(230, 126, 34), // orange + RGBColor(26, 188, 156), // teal + RGBColor(233, 30, 99), // pink + RGBColor(0, 188, 212), // cyan + RGBColor(121, 85, 72), // brown + RGBColor(96, 125, 139), // grey-blue + RGBColor(255, 87, 34), // deep orange + RGBColor(63, 81, 181), // indigo + RGBColor(0, 150, 136), // dark teal + ]; + + let mut start_angle = 0.0_f64; + for (i, (cat, amount)) in data.iter().enumerate() { + let pct = amount / total; + let sweep = pct * 360.0; + let color = &colors[i % colors.len()]; + + // Draw pie slice as filled polygon approximation + let steps = (sweep as usize).max(4); + for step in 0..steps { + let a1 = (start_angle + (step as f64 / steps as f64) * sweep).to_radians(); + let a2 = (start_angle + ((step + 1) as f64 / steps as f64) * sweep).to_radians(); + let p1 = ( + center.0 + (radius as f64 * a1.cos()) as i32, + center.1 - (radius as f64 * a1.sin()) as i32, + ); + let p2 = ( + center.0 + (radius as f64 * a2.cos()) as i32, + center.1 - (radius as f64 * a2.sin()) as i32, + ); + root.draw(&plotters::element::Polygon::new( + vec![center, p1, p2], + color.filled(), + )).ok(); + } + + // Label + if pct > 0.03 { + let mid_angle = (start_angle + sweep / 2.0).to_radians(); + let label_r = radius as f64 + 30.0; + let lx = center.0 + (label_r * mid_angle.cos()) as i32; + let ly = center.1 - (label_r * mid_angle.sin()) as i32; + let text = format!("{} {:.0}%", cat.name, pct * 100.0); + root.draw_text( + &text, + &("sans-serif", 11).into_text_style(&root), + (lx.max(5), ly.max(5)), + ).ok(); + } + + start_angle += sweep; + } + } + + root.present().ok(); + } + + // Encode to PNG + Self::set_picture_from_rgb(picture, &buf, CHART_WIDTH, CHART_HEIGHT); + } + + fn render_bar_chart(db: &Database, picture: >k::Picture, year: i32, month: u32) { + // Get last 6 months of data + let mut months: Vec<(i32, u32, f64, f64)> = Vec::new(); + let mut y = year; + let mut m = month; + for _ in 0..6 { + let income = db.get_monthly_total(y, m, TransactionType::Income).unwrap_or(0.0); + let expense = db.get_monthly_total(y, m, TransactionType::Expense).unwrap_or(0.0); + months.push((y, m, income, expense)); + if m == 1 { m = 12; y -= 1; } else { m -= 1; } + } + months.reverse(); + + let mut buf = vec![0u8; (CHART_WIDTH * CHART_HEIGHT * 3) as usize]; + { + let root = BitMapBackend::with_buffer(&mut buf, (CHART_WIDTH, CHART_HEIGHT)) + .into_drawing_area(); + root.fill(&WHITE).ok(); + + let max_val = months.iter().map(|(_, _, i, e)| i.max(*e)).fold(0.0_f64, f64::max); + let y_max = if max_val > 0.0 { max_val * 1.2 } else { 100.0 }; + + let labels: Vec = months + .iter() + .map(|(_, m, _, _)| { + match m { + 1 => "Jan", 2 => "Feb", 3 => "Mar", + 4 => "Apr", 5 => "May", 6 => "Jun", + 7 => "Jul", 8 => "Aug", 9 => "Sep", + 10 => "Oct", 11 => "Nov", 12 => "Dec", + _ => "?", + }.to_string() + }) + .collect(); + + let mut chart = ChartBuilder::on(&root) + .margin(15) + .x_label_area_size(30) + .y_label_area_size(60) + .build_cartesian_2d(0..6i32, 0.0..y_max) + .unwrap(); + + chart.configure_mesh() + .x_labels(6) + .x_label_formatter(&|x| { + labels.get(*x as usize).cloned().unwrap_or_default() + }) + .y_label_formatter(&|y| format!("{:.0}", y)) + .draw() + .ok(); + + // Income bars (green) + chart.draw_series( + months.iter().enumerate().map(|(i, (_, _, income, _))| { + let x0 = i as i32; + Rectangle::new( + [(x0, 0.0), (x0, *income)], + RGBColor(46, 204, 113).filled(), + ) + }) + ).ok(); + + root.present().ok(); + } + + Self::set_picture_from_rgb(picture, &buf, CHART_WIDTH, CHART_HEIGHT); + } + + fn render_line_chart(db: &Database, picture: >k::Picture, year: i32, month: u32) { + let daily = db.get_daily_totals(year, month).unwrap_or_default(); + + let mut buf = vec![0u8; (CHART_WIDTH * CHART_HEIGHT * 3) as usize]; + { + let root = BitMapBackend::with_buffer(&mut buf, (CHART_WIDTH, CHART_HEIGHT)) + .into_drawing_area(); + root.fill(&WHITE).ok(); + + if daily.is_empty() { + root.draw_text( + "No data this month", + &("sans-serif", 16).into_text_style(&root), + (CHART_WIDTH as i32 / 2 - 60, CHART_HEIGHT as i32 / 2), + ).ok(); + } else { + // Sort by date ascending + let mut sorted = daily.clone(); + sorted.sort_by_key(|(d, _, _)| *d); + + // Build cumulative net + let mut cumulative: Vec<(i32, f64)> = Vec::new(); + let mut running = 0.0_f64; + for (date, income, expense) in &sorted { + running += income - expense; + cumulative.push((date.day() as i32, running)); + } + + let days_in_month = Self::days_in_month(year, month); + let min_val = cumulative.iter().map(|(_, v)| *v).fold(f64::INFINITY, f64::min); + let max_val = cumulative.iter().map(|(_, v)| *v).fold(f64::NEG_INFINITY, f64::max); + let margin = (max_val - min_val).max(10.0) * 0.1; + + let mut chart = ChartBuilder::on(&root) + .margin(15) + .x_label_area_size(30) + .y_label_area_size(60) + .build_cartesian_2d(1..days_in_month as i32 + 1, (min_val - margin)..(max_val + margin)) + .unwrap(); + + chart.configure_mesh() + .x_label_formatter(&|x| format!("{}", x)) + .y_label_formatter(&|y| format!("{:.0}", y)) + .draw() + .ok(); + + chart.draw_series(LineSeries::new( + cumulative.iter().map(|(d, v)| (*d, *v)), + &RGBColor(52, 152, 219), + )).ok(); + + // Zero line + chart.draw_series(LineSeries::new( + vec![(1, 0.0), (days_in_month as i32, 0.0)], + &RGBColor(200, 200, 200), + )).ok(); + } + + root.present().ok(); + } + + Self::set_picture_from_rgb(picture, &buf, CHART_WIDTH, CHART_HEIGHT); + } + + fn days_in_month(year: i32, month: u32) -> u32 { + match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 => { + if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 { + 29 + } else { + 28 + } + } + _ => 30, + } + } + +} diff --git a/outlay-gtk/src/main.rs b/outlay-gtk/src/main.rs index 9e428de..d6b8b1c 100644 --- a/outlay-gtk/src/main.rs +++ b/outlay-gtk/src/main.rs @@ -1,3 +1,4 @@ +mod charts_view; mod history_view; mod log_view; mod window; diff --git a/outlay-gtk/src/window.rs b/outlay-gtk/src/window.rs index 766c57f..4b44d19 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::charts_view::ChartsView; use crate::history_view::HistoryView; use crate::log_view::LogView; @@ -48,8 +49,12 @@ impl MainWindow { .build(); content_stack.add_named(&history_scroll, Some("history")); + // Charts view + let charts_view = ChartsView::new(db.clone()); + content_stack.add_named(&charts_view.container, Some("charts")); + // Remaining pages are placeholders for now - for item in &SIDEBAR_ITEMS[2..] { + for item in &SIDEBAR_ITEMS[3..] { let page = adw::StatusPage::builder() .title(item.label) .icon_name(item.icon)