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, } } }