Add feature batch 2, subscription/recurring sync, smooth charts, and app icon
- Implement subscriptions view with bidirectional recurring transaction sync - Add cascade delete/pause/resume between subscriptions and recurring - Fix foreign key constraints when deleting recurring transactions - Add cross-view instant refresh via callback pattern - Replace Bezier chart smoothing with Fritsch-Carlson monotone Hermite interpolation - Smooth budget sparklines using shared monotone_subdivide function - Add vertical spacing to budget rows - Add app icon (receipt on GNOME blue) in all sizes for desktop, web, and AppImage - Add calendar, credit cards, forecast, goals, insights, and wishlist views - Add date picker, numpad, quick-add, category combo, and edit dialog components - Add import/export for CSV, JSON, OFX, QIF formats - Add NLP transaction parsing, OCR receipt scanning, expression evaluator - Add notification support, Sankey chart, tray icon - Add demo data seeder with full DB wipe - Expand database schema with subscriptions, goals, credit cards, and more
This commit is contained in:
168
outlay-gtk/src/calendar_view.rs
Normal file
168
outlay-gtk/src/calendar_view.rs
Normal file
@@ -0,0 +1,168 @@
|
||||
use gtk::prelude::*;
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use outlay_core::db::Database;
|
||||
|
||||
/// Create a spending heatmap calendar for a given month.
|
||||
pub fn calendar_heatmap(db: &Database, year: i32, month: u32) -> gtk::Box {
|
||||
let container = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
||||
|
||||
let raw_daily = db.get_daily_totals(year, month).unwrap_or_default();
|
||||
// Extract (day_number, expense_amount)
|
||||
let daily: Vec<(u32, f64)> = raw_daily.iter()
|
||||
.map(|(date, _income, expense)| (date.day(), *expense))
|
||||
.collect();
|
||||
let max_val = daily.iter().map(|(_, v)| *v).fold(0.0_f64, f64::max);
|
||||
|
||||
// Day labels
|
||||
let header = gtk::Box::new(gtk::Orientation::Horizontal, 2);
|
||||
header.set_halign(gtk::Align::Center);
|
||||
for day_name in &["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] {
|
||||
let label = gtk::Label::new(Some(day_name));
|
||||
label.set_width_chars(5);
|
||||
label.add_css_class("caption");
|
||||
label.add_css_class("dim-label");
|
||||
header.append(&label);
|
||||
}
|
||||
container.append(&header);
|
||||
|
||||
// Build daily amount map
|
||||
let mut amounts = std::collections::HashMap::new();
|
||||
for (day, amount) in &daily {
|
||||
amounts.insert(*day, *amount);
|
||||
}
|
||||
|
||||
// Determine first day of month and number of days
|
||||
let first_day = NaiveDate::from_ymd_opt(year, month, 1).unwrap();
|
||||
let days_in_month = if month == 12 {
|
||||
NaiveDate::from_ymd_opt(year + 1, 1, 1)
|
||||
} else {
|
||||
NaiveDate::from_ymd_opt(year, month + 1, 1)
|
||||
}.and_then(|d| d.pred_opt()).map(|d| d.day()).unwrap_or(30);
|
||||
|
||||
// Monday = 0, Sunday = 6
|
||||
let first_weekday = first_day.weekday().num_days_from_monday();
|
||||
|
||||
let grid = gtk::Grid::new();
|
||||
grid.set_row_spacing(2);
|
||||
grid.set_column_spacing(2);
|
||||
grid.set_halign(gtk::Align::Center);
|
||||
|
||||
let mut day = 1_u32;
|
||||
let mut row = 0;
|
||||
let mut col = first_weekday as i32;
|
||||
|
||||
while day <= days_in_month {
|
||||
let amount = amounts.get(&day).copied().unwrap_or(0.0);
|
||||
let cell = make_cell(day, amount, max_val);
|
||||
grid.attach(&cell, col, row, 1, 1);
|
||||
|
||||
col += 1;
|
||||
if col > 6 {
|
||||
col = 0;
|
||||
row += 1;
|
||||
}
|
||||
day += 1;
|
||||
}
|
||||
|
||||
container.append(&grid);
|
||||
|
||||
// Legend
|
||||
let legend = gtk::Box::new(gtk::Orientation::Horizontal, 4);
|
||||
legend.set_halign(gtk::Align::Center);
|
||||
legend.set_margin_top(4);
|
||||
|
||||
let less_label = gtk::Label::new(Some("Less"));
|
||||
less_label.add_css_class("caption");
|
||||
less_label.add_css_class("dim-label");
|
||||
legend.append(&less_label);
|
||||
|
||||
for level in 0..5 {
|
||||
let cell = gtk::DrawingArea::new();
|
||||
cell.set_size_request(16, 16);
|
||||
let intensity = level as f64 / 4.0;
|
||||
cell.set_draw_func(move |_area, ctx, w, h| {
|
||||
let (r, g, b, a) = intensity_color(intensity);
|
||||
ctx.set_source_rgba(r, g, b, a);
|
||||
let radius = 3.0;
|
||||
let w = w as f64;
|
||||
let h = h as f64;
|
||||
ctx.new_sub_path();
|
||||
ctx.arc(w - radius, radius, radius, -std::f64::consts::FRAC_PI_2, 0.0);
|
||||
ctx.arc(w - radius, h - radius, radius, 0.0, std::f64::consts::FRAC_PI_2);
|
||||
ctx.arc(radius, h - radius, radius, std::f64::consts::FRAC_PI_2, std::f64::consts::PI);
|
||||
ctx.arc(radius, radius, radius, std::f64::consts::PI, 3.0 * std::f64::consts::FRAC_PI_2);
|
||||
ctx.close_path();
|
||||
let _ = ctx.fill();
|
||||
});
|
||||
legend.append(&cell);
|
||||
}
|
||||
|
||||
let more_label = gtk::Label::new(Some("More"));
|
||||
more_label.add_css_class("caption");
|
||||
more_label.add_css_class("dim-label");
|
||||
legend.append(&more_label);
|
||||
|
||||
container.append(&legend);
|
||||
container
|
||||
}
|
||||
|
||||
fn make_cell(day: u32, amount: f64, max_val: f64) -> gtk::DrawingArea {
|
||||
let cell = gtk::DrawingArea::builder()
|
||||
.accessible_role(gtk::AccessibleRole::Img)
|
||||
.build();
|
||||
cell.set_size_request(44, 44);
|
||||
|
||||
let intensity = if max_val > 0.0 { amount / max_val } else { 0.0 };
|
||||
let day_str = format!("{}", day);
|
||||
let has_spending = amount > 0.0;
|
||||
let tooltip = if has_spending {
|
||||
format!("Day {}: {:.2}", day, amount)
|
||||
} else {
|
||||
format!("Day {}: no spending", day)
|
||||
};
|
||||
cell.set_tooltip_text(Some(&tooltip));
|
||||
cell.update_property(&[gtk::accessible::Property::Label(&tooltip)]);
|
||||
|
||||
cell.set_draw_func(move |_area, ctx, w, h| {
|
||||
let w = w as f64;
|
||||
let h = h as f64;
|
||||
|
||||
// Background with rounded corners
|
||||
let radius = 4.0;
|
||||
let (r, g, b, a) = intensity_color(intensity);
|
||||
ctx.set_source_rgba(r, g, b, a);
|
||||
ctx.new_sub_path();
|
||||
ctx.arc(w - radius, radius, radius, -std::f64::consts::FRAC_PI_2, 0.0);
|
||||
ctx.arc(w - radius, h - radius, radius, 0.0, std::f64::consts::FRAC_PI_2);
|
||||
ctx.arc(radius, h - radius, radius, std::f64::consts::FRAC_PI_2, std::f64::consts::PI);
|
||||
ctx.arc(radius, radius, radius, std::f64::consts::PI, 3.0 * std::f64::consts::FRAC_PI_2);
|
||||
ctx.close_path();
|
||||
let _ = ctx.fill();
|
||||
|
||||
// Day number
|
||||
ctx.set_source_rgba(1.0, 1.0, 1.0, if has_spending { 0.9 } else { 0.5 });
|
||||
ctx.select_font_face("sans-serif", gtk::cairo::FontSlant::Normal, gtk::cairo::FontWeight::Normal);
|
||||
ctx.set_font_size(12.0);
|
||||
let extents = ctx.text_extents(&day_str).unwrap();
|
||||
let x = (w - extents.width()) / 2.0 - extents.x_bearing();
|
||||
let y = (h - extents.height()) / 2.0 - extents.y_bearing();
|
||||
ctx.move_to(x, y);
|
||||
let _ = ctx.show_text(&day_str);
|
||||
});
|
||||
|
||||
cell
|
||||
}
|
||||
|
||||
fn intensity_color(intensity: f64) -> (f64, f64, f64, f64) {
|
||||
if intensity <= 0.0 {
|
||||
(0.5, 0.5, 0.5, 0.1)
|
||||
} else if intensity < 0.25 {
|
||||
(0.42, 0.75, 0.45, 0.4)
|
||||
} else if intensity < 0.5 {
|
||||
(0.85, 0.75, 0.35, 0.5)
|
||||
} else if intensity < 0.75 {
|
||||
(0.87, 0.55, 0.33, 0.6)
|
||||
} else {
|
||||
(0.87, 0.33, 0.36, 0.7)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user