Add feature batch 2, subscription/recurring sync, smooth charts, and app icon
This commit is contained in:
File diff suppressed because it is too large
Load Diff
167
outlay-gtk/src/calendar_view.rs
Normal file
167
outlay-gtk/src/calendar_view.rs
Normal file
@@ -0,0 +1,167 @@
|
||||
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);
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
103
outlay-gtk/src/category_combo.rs
Normal file
103
outlay-gtk/src/category_combo.rs
Normal file
@@ -0,0 +1,103 @@
|
||||
use adw::prelude::*;
|
||||
use crate::icon_theme;
|
||||
use outlay_core::models::TransactionType;
|
||||
use outlay_core::db::Database;
|
||||
use std::rc::Rc;
|
||||
use std::cell::RefCell;
|
||||
|
||||
/// Build a searchable category combo row.
|
||||
///
|
||||
/// Returns (combo_row, category_ids) where category_ids maps combo indices to DB IDs.
|
||||
pub fn make_searchable_category_combo(
|
||||
db: &Rc<Database>,
|
||||
txn_type: Option<TransactionType>,
|
||||
title: &str,
|
||||
) -> (adw::ComboRow, Rc<RefCell<Vec<i64>>>) {
|
||||
let categories = db.list_categories(txn_type).unwrap_or_default();
|
||||
let ids: Vec<i64> = categories.iter().map(|c| c.id).collect();
|
||||
let entries: Vec<String> = categories.iter().map(|c| {
|
||||
let icon = icon_theme::resolve_category_icon(&c.icon, &c.color);
|
||||
match icon {
|
||||
Some(i) => format!("{}\t{}", i, c.name),
|
||||
None => c.name.clone(),
|
||||
}
|
||||
}).collect();
|
||||
|
||||
let refs: Vec<&str> = entries.iter().map(|s| s.as_str()).collect();
|
||||
let model = gtk::StringList::new(&refs);
|
||||
|
||||
let combo = adw::ComboRow::builder()
|
||||
.title(title)
|
||||
.model(&model)
|
||||
.build();
|
||||
|
||||
let factory = make_category_factory();
|
||||
combo.set_factory(Some(&factory));
|
||||
combo.set_list_factory(Some(&make_category_factory()));
|
||||
|
||||
combo.set_enable_search(true);
|
||||
let expression = gtk::PropertyExpression::new(
|
||||
gtk::StringObject::static_type(),
|
||||
gtk::Expression::NONE,
|
||||
"string",
|
||||
);
|
||||
combo.set_expression(Some(&expression));
|
||||
|
||||
let category_ids = Rc::new(RefCell::new(ids));
|
||||
(combo, category_ids)
|
||||
}
|
||||
|
||||
/// Update the combo's model for a different transaction type.
|
||||
pub fn update_category_combo(
|
||||
combo: &adw::ComboRow,
|
||||
ids: &Rc<RefCell<Vec<i64>>>,
|
||||
db: &Rc<Database>,
|
||||
txn_type: Option<TransactionType>,
|
||||
) {
|
||||
let categories = db.list_categories(txn_type).unwrap_or_default();
|
||||
let new_ids: Vec<i64> = categories.iter().map(|c| c.id).collect();
|
||||
let entries: Vec<String> = categories.iter().map(|c| {
|
||||
let icon = icon_theme::resolve_category_icon(&c.icon, &c.color);
|
||||
match icon {
|
||||
Some(i) => format!("{}\t{}", i, c.name),
|
||||
None => c.name.clone(),
|
||||
}
|
||||
}).collect();
|
||||
|
||||
let refs: Vec<&str> = entries.iter().map(|s| s.as_str()).collect();
|
||||
let model = gtk::StringList::new(&refs);
|
||||
combo.set_model(Some(&model));
|
||||
combo.set_selected(0);
|
||||
*ids.borrow_mut() = new_ids;
|
||||
}
|
||||
|
||||
pub fn make_category_factory() -> gtk::SignalListItemFactory {
|
||||
let factory = gtk::SignalListItemFactory::new();
|
||||
factory.connect_setup(|_, item| {
|
||||
let item = item.downcast_ref::<gtk::ListItem>().unwrap();
|
||||
let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
let icon = gtk::Image::new();
|
||||
icon.set_pixel_size(20);
|
||||
let label = gtk::Label::new(None);
|
||||
hbox.append(&icon);
|
||||
hbox.append(&label);
|
||||
item.set_child(Some(&hbox));
|
||||
});
|
||||
factory.connect_bind(|_, item| {
|
||||
let item = item.downcast_ref::<gtk::ListItem>().unwrap();
|
||||
let string_obj = item.item().and_downcast::<gtk::StringObject>().unwrap();
|
||||
let text = string_obj.string();
|
||||
let hbox = item.child().and_downcast::<gtk::Box>().unwrap();
|
||||
let icon = hbox.first_child().and_downcast::<gtk::Image>().unwrap();
|
||||
let label = icon.next_sibling().and_downcast::<gtk::Label>().unwrap();
|
||||
if let Some((icon_name, name)) = text.split_once('\t') {
|
||||
icon.set_icon_name(Some(icon_name));
|
||||
icon.set_visible(true);
|
||||
label.set_label(name);
|
||||
} else {
|
||||
icon.set_visible(false);
|
||||
label.set_label(&text);
|
||||
}
|
||||
});
|
||||
factory
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
558
outlay-gtk/src/credit_cards_view.rs
Normal file
558
outlay-gtk/src/credit_cards_view.rs
Normal file
@@ -0,0 +1,558 @@
|
||||
use adw::prelude::*;
|
||||
use chrono::{Datelike, Local};
|
||||
use outlay_core::db::Database;
|
||||
use outlay_core::exchange::ExchangeRateService;
|
||||
use std::rc::Rc;
|
||||
|
||||
pub struct CreditCardsView {
|
||||
pub container: gtk::Box,
|
||||
}
|
||||
|
||||
impl CreditCardsView {
|
||||
pub fn new(db: Rc<Database>) -> Self {
|
||||
let container = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
let toast_overlay = adw::ToastOverlay::new();
|
||||
|
||||
let clamp = adw::Clamp::new();
|
||||
clamp.set_maximum_size(700);
|
||||
clamp.set_margin_start(16);
|
||||
clamp.set_margin_end(16);
|
||||
|
||||
let inner = gtk::Box::new(gtk::Orientation::Vertical, 20);
|
||||
inner.set_margin_top(20);
|
||||
inner.set_margin_bottom(20);
|
||||
|
||||
let summary_group = adw::PreferencesGroup::builder()
|
||||
.title("SUMMARY")
|
||||
.build();
|
||||
|
||||
let total_balance_row = adw::ActionRow::builder()
|
||||
.title("Total Balance")
|
||||
.build();
|
||||
let balance_label = gtk::Label::new(Some("0.00"));
|
||||
balance_label.add_css_class("amount-display");
|
||||
total_balance_row.add_suffix(&balance_label);
|
||||
|
||||
let total_limit_row = adw::ActionRow::builder()
|
||||
.title("Total Credit Limit")
|
||||
.build();
|
||||
let limit_label = gtk::Label::new(Some("0.00"));
|
||||
limit_label.add_css_class("dim-label");
|
||||
total_limit_row.add_suffix(&limit_label);
|
||||
|
||||
let utilization_row = adw::ActionRow::builder()
|
||||
.title("Overall Utilization")
|
||||
.build();
|
||||
let util_bar = gtk::LevelBar::new();
|
||||
util_bar.set_min_value(0.0);
|
||||
util_bar.set_max_value(1.0);
|
||||
util_bar.set_hexpand(true);
|
||||
util_bar.set_valign(gtk::Align::Center);
|
||||
let util_label = gtk::Label::new(Some("0%"));
|
||||
util_label.add_css_class("caption");
|
||||
util_label.set_margin_start(8);
|
||||
let util_box = gtk::Box::new(gtk::Orientation::Horizontal, 4);
|
||||
util_box.append(&util_bar);
|
||||
util_box.append(&util_label);
|
||||
utilization_row.add_suffix(&util_box);
|
||||
|
||||
summary_group.add(&total_balance_row);
|
||||
summary_group.add(&total_limit_row);
|
||||
summary_group.add(&utilization_row);
|
||||
|
||||
let cards_group = adw::PreferencesGroup::builder()
|
||||
.title("CARDS")
|
||||
.build();
|
||||
|
||||
Self::populate_cards(
|
||||
&db, &cards_group, &toast_overlay,
|
||||
&balance_label, &limit_label, &util_bar, &util_label,
|
||||
);
|
||||
|
||||
let add_btn = gtk::Button::with_label("Add Card");
|
||||
add_btn.add_css_class("pill");
|
||||
add_btn.set_halign(gtk::Align::Center);
|
||||
add_btn.set_margin_top(8);
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let group_ref = cards_group.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
let bl = balance_label.clone();
|
||||
let ll = limit_label.clone();
|
||||
let ub = util_bar.clone();
|
||||
let ul = util_label.clone();
|
||||
add_btn.connect_clicked(move |btn| {
|
||||
Self::show_card_dialog(
|
||||
btn, &db_ref, None, &group_ref, &toast_ref,
|
||||
&bl, &ll, &ub, &ul,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
inner.append(&summary_group);
|
||||
inner.append(&cards_group);
|
||||
inner.append(&add_btn);
|
||||
|
||||
clamp.set_child(Some(&inner));
|
||||
|
||||
let scroll = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
.vexpand(true)
|
||||
.child(&clamp)
|
||||
.build();
|
||||
|
||||
toast_overlay.set_child(Some(&scroll));
|
||||
container.append(&toast_overlay);
|
||||
|
||||
CreditCardsView { container }
|
||||
}
|
||||
|
||||
fn populate_cards(
|
||||
db: &Rc<Database>,
|
||||
group: &adw::PreferencesGroup,
|
||||
toast: &adw::ToastOverlay,
|
||||
balance_label: >k::Label,
|
||||
limit_label: >k::Label,
|
||||
util_bar: >k::LevelBar,
|
||||
util_label: >k::Label,
|
||||
) {
|
||||
while let Some(child) = group.first_child() {
|
||||
if let Some(inner) = child.first_child() {
|
||||
if let Some(listbox) = inner.downcast_ref::<gtk::ListBox>() {
|
||||
while let Some(row) = listbox.row_at_index(0) {
|
||||
listbox.remove(&row);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
let cards = db.list_credit_cards().unwrap_or_default();
|
||||
let today = Local::now().date_naive();
|
||||
|
||||
let mut total_balance = 0.0_f64;
|
||||
let mut total_limit = 0.0_f64;
|
||||
|
||||
for card in &cards {
|
||||
total_balance += card.current_balance;
|
||||
if let Some(lim) = card.credit_limit {
|
||||
total_limit += lim;
|
||||
}
|
||||
|
||||
// Utilization for this card
|
||||
let card_util = if let Some(lim) = card.credit_limit {
|
||||
if lim > 0.0 { card.current_balance / lim } else { 0.0 }
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Days until due
|
||||
let due_day = card.due_day as u32;
|
||||
let current_day = today.day();
|
||||
let days_until_due = if due_day > current_day {
|
||||
due_day - current_day
|
||||
} else if due_day == current_day {
|
||||
0
|
||||
} else {
|
||||
// Next month
|
||||
let days_in_month = {
|
||||
let (y, m) = if today.month() == 12 { (today.year() + 1, 1) } else { (today.year(), today.month() + 1) };
|
||||
chrono::NaiveDate::from_ymd_opt(y, m, 1).unwrap().pred_opt().unwrap().day()
|
||||
};
|
||||
days_in_month - current_day + due_day
|
||||
};
|
||||
|
||||
let subtitle = format!(
|
||||
"{:.2} {} - Due in {} day{}",
|
||||
card.current_balance,
|
||||
card.currency,
|
||||
days_until_due,
|
||||
if days_until_due == 1 { "" } else { "s" },
|
||||
);
|
||||
|
||||
let expander = adw::ExpanderRow::builder()
|
||||
.title(&card.name)
|
||||
.subtitle(&subtitle)
|
||||
.build();
|
||||
|
||||
// Utilization bar in suffix
|
||||
let mini_bar = gtk::LevelBar::new();
|
||||
mini_bar.set_min_value(0.0);
|
||||
mini_bar.set_max_value(1.0);
|
||||
mini_bar.set_value(card_util.min(1.0));
|
||||
mini_bar.set_size_request(60, -1);
|
||||
mini_bar.set_valign(gtk::Align::Center);
|
||||
expander.add_suffix(&mini_bar);
|
||||
|
||||
// Expanded content
|
||||
let close_row = adw::ActionRow::builder()
|
||||
.title("Statement Close Day")
|
||||
.subtitle(&format!("Day {} of each month", card.statement_close_day))
|
||||
.build();
|
||||
expander.add_row(&close_row);
|
||||
|
||||
let due_row = adw::ActionRow::builder()
|
||||
.title("Payment Due Day")
|
||||
.subtitle(&format!("Day {} of each month", card.due_day))
|
||||
.build();
|
||||
expander.add_row(&due_row);
|
||||
|
||||
if let Some(lim) = card.credit_limit {
|
||||
let limit_row = adw::ActionRow::builder()
|
||||
.title("Credit Limit")
|
||||
.subtitle(&format!("{:.2} {}", lim, card.currency))
|
||||
.build();
|
||||
expander.add_row(&limit_row);
|
||||
}
|
||||
|
||||
let min_pmt = card.min_payment_pct * card.current_balance / 100.0;
|
||||
let min_row = adw::ActionRow::builder()
|
||||
.title("Minimum Payment")
|
||||
.subtitle(&format!("{:.2} {} ({:.1}%)", min_pmt, card.currency, card.min_payment_pct))
|
||||
.build();
|
||||
expander.add_row(&min_row);
|
||||
|
||||
// Action buttons
|
||||
let btn_box = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
btn_box.set_halign(gtk::Align::Center);
|
||||
btn_box.set_margin_top(8);
|
||||
btn_box.set_margin_bottom(8);
|
||||
|
||||
let pay_btn = gtk::Button::with_label("Record Payment");
|
||||
pay_btn.add_css_class("suggested-action");
|
||||
pay_btn.add_css_class("pill");
|
||||
|
||||
let edit_btn = gtk::Button::with_label("Edit");
|
||||
edit_btn.add_css_class("pill");
|
||||
|
||||
let del_btn = gtk::Button::with_label("Delete");
|
||||
del_btn.add_css_class("destructive-action");
|
||||
del_btn.add_css_class("pill");
|
||||
|
||||
btn_box.append(&pay_btn);
|
||||
btn_box.append(&edit_btn);
|
||||
btn_box.append(&del_btn);
|
||||
|
||||
let btn_row = adw::ActionRow::new();
|
||||
btn_row.set_child(Some(&btn_box));
|
||||
expander.add_row(&btn_row);
|
||||
|
||||
{
|
||||
let card_id = card.id;
|
||||
let card_name = card.name.clone();
|
||||
let db_ref = db.clone();
|
||||
let group_ref = group.clone();
|
||||
let toast_ref = toast.clone();
|
||||
let bl = balance_label.clone();
|
||||
let ll = limit_label.clone();
|
||||
let ub = util_bar.clone();
|
||||
let ul = util_label.clone();
|
||||
pay_btn.connect_clicked(move |btn| {
|
||||
Self::show_payment_dialog(
|
||||
btn, &db_ref, card_id, &card_name,
|
||||
&group_ref, &toast_ref, &bl, &ll, &ub, &ul,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let card_id = card.id;
|
||||
let db_ref = db.clone();
|
||||
let group_ref = group.clone();
|
||||
let toast_ref = toast.clone();
|
||||
let bl = balance_label.clone();
|
||||
let ll = limit_label.clone();
|
||||
let ub = util_bar.clone();
|
||||
let ul = util_label.clone();
|
||||
edit_btn.connect_clicked(move |btn| {
|
||||
Self::show_card_dialog(
|
||||
btn, &db_ref, Some(card_id), &group_ref, &toast_ref,
|
||||
&bl, &ll, &ub, &ul,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let card_id = card.id;
|
||||
let db_ref = db.clone();
|
||||
let group_ref = group.clone();
|
||||
let toast_ref = toast.clone();
|
||||
let bl = balance_label.clone();
|
||||
let ll = limit_label.clone();
|
||||
let ub = util_bar.clone();
|
||||
let ul = util_label.clone();
|
||||
del_btn.connect_clicked(move |btn| {
|
||||
let alert = adw::AlertDialog::new(
|
||||
Some("Delete this card?"),
|
||||
Some("This cannot be undone."),
|
||||
);
|
||||
alert.add_response("cancel", "Cancel");
|
||||
alert.add_response("delete", "Delete");
|
||||
alert.set_response_appearance("delete", adw::ResponseAppearance::Destructive);
|
||||
alert.set_default_response(Some("cancel"));
|
||||
|
||||
let db_c = db_ref.clone();
|
||||
let g = group_ref.clone();
|
||||
let t = toast_ref.clone();
|
||||
let bl = bl.clone();
|
||||
let ll = ll.clone();
|
||||
let ub = ub.clone();
|
||||
let ul = ul.clone();
|
||||
alert.connect_response(None, move |_, resp| {
|
||||
if resp == "delete" {
|
||||
if db_c.delete_credit_card(card_id).is_ok() {
|
||||
Self::populate_cards(&db_c, &g, &t, &bl, &ll, &ub, &ul);
|
||||
t.add_toast(adw::Toast::new("Card deleted"));
|
||||
}
|
||||
}
|
||||
});
|
||||
alert.present(Some(btn));
|
||||
});
|
||||
}
|
||||
|
||||
group.add(&expander);
|
||||
}
|
||||
|
||||
balance_label.set_label(&format!("{:.2}", total_balance));
|
||||
limit_label.set_label(&format!("{:.2}", total_limit));
|
||||
let util = if total_limit > 0.0 { total_balance / total_limit } else { 0.0 };
|
||||
util_bar.set_value(util.min(1.0));
|
||||
util_label.set_label(&format!("{:.0}%", util * 100.0));
|
||||
|
||||
if cards.is_empty() {
|
||||
let empty = adw::ActionRow::builder()
|
||||
.title("No credit cards")
|
||||
.subtitle("Add a card to track billing cycles and payments")
|
||||
.build();
|
||||
group.add(&empty);
|
||||
}
|
||||
}
|
||||
|
||||
fn show_payment_dialog(
|
||||
parent: &impl IsA<gtk::Widget>,
|
||||
db: &Rc<Database>,
|
||||
card_id: i64,
|
||||
card_name: &str,
|
||||
group: &adw::PreferencesGroup,
|
||||
toast: &adw::ToastOverlay,
|
||||
bl: >k::Label,
|
||||
ll: >k::Label,
|
||||
ub: >k::LevelBar,
|
||||
ul: >k::Label,
|
||||
) {
|
||||
let alert = adw::AlertDialog::new(
|
||||
Some("Record Payment"),
|
||||
Some(&format!("Enter payment amount for {}", card_name)),
|
||||
);
|
||||
alert.add_response("cancel", "Cancel");
|
||||
alert.add_response("pay", "Record");
|
||||
alert.set_response_appearance("pay", adw::ResponseAppearance::Suggested);
|
||||
alert.set_default_response(Some("pay"));
|
||||
|
||||
let entry = adw::EntryRow::builder()
|
||||
.title("Amount")
|
||||
.build();
|
||||
entry.set_input_purpose(gtk::InputPurpose::Number);
|
||||
alert.set_extra_child(Some(&entry));
|
||||
|
||||
let db_ref = db.clone();
|
||||
let group_ref = group.clone();
|
||||
let toast_ref = toast.clone();
|
||||
let card_name = card_name.to_string();
|
||||
let bl = bl.clone();
|
||||
let ll = ll.clone();
|
||||
let ub = ub.clone();
|
||||
let ul = ul.clone();
|
||||
alert.connect_response(None, move |_, resp| {
|
||||
if resp == "pay" {
|
||||
let text = entry.text();
|
||||
if let Some(amount) = outlay_core::expr::eval_expr(&text) {
|
||||
if amount > 0.0 {
|
||||
if db_ref.record_card_payment(card_id, amount).is_ok() {
|
||||
// Create expense transaction for the payment
|
||||
let today = Local::now().date_naive();
|
||||
let base_currency = db_ref.get_setting("base_currency")
|
||||
.ok().flatten()
|
||||
.unwrap_or_else(|| "USD".to_string());
|
||||
// Use first expense category as fallback
|
||||
let cat_id = db_ref.list_categories(Some(outlay_core::models::TransactionType::Expense))
|
||||
.unwrap_or_default()
|
||||
.first()
|
||||
.map(|c| c.id)
|
||||
.unwrap_or(1);
|
||||
let txn = outlay_core::models::NewTransaction {
|
||||
amount,
|
||||
transaction_type: outlay_core::models::TransactionType::Expense,
|
||||
category_id: cat_id,
|
||||
currency: base_currency,
|
||||
exchange_rate: 1.0,
|
||||
note: Some(format!("Credit card payment - {}", card_name)),
|
||||
date: today,
|
||||
recurring_id: None,
|
||||
payee: None,
|
||||
};
|
||||
let _ = db_ref.insert_transaction(&txn);
|
||||
Self::populate_cards(&db_ref, &group_ref, &toast_ref, &bl, &ll, &ub, &ul);
|
||||
toast_ref.add_toast(adw::Toast::new(&format!("Payment of {:.2} recorded", amount)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
alert.present(Some(parent));
|
||||
}
|
||||
|
||||
fn show_card_dialog(
|
||||
parent: &impl IsA<gtk::Widget>,
|
||||
db: &Rc<Database>,
|
||||
card_id: Option<i64>,
|
||||
group: &adw::PreferencesGroup,
|
||||
toast: &adw::ToastOverlay,
|
||||
bl: >k::Label,
|
||||
ll: >k::Label,
|
||||
ub: >k::LevelBar,
|
||||
ul: >k::Label,
|
||||
) {
|
||||
let existing = card_id.and_then(|id| db.get_credit_card(id).ok());
|
||||
let is_edit = existing.is_some();
|
||||
|
||||
let dialog = adw::AlertDialog::new(
|
||||
Some(if is_edit { "Edit Card" } else { "Add Card" }),
|
||||
None,
|
||||
);
|
||||
dialog.add_response("cancel", "Cancel");
|
||||
dialog.add_response("save", if is_edit { "Save" } else { "Add" });
|
||||
dialog.set_response_appearance("save", adw::ResponseAppearance::Suggested);
|
||||
dialog.set_default_response(Some("save"));
|
||||
|
||||
let form = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
||||
|
||||
let name_entry = adw::EntryRow::builder()
|
||||
.title("Card Name")
|
||||
.text(existing.as_ref().map(|c| c.name.as_str()).unwrap_or(""))
|
||||
.build();
|
||||
|
||||
let existing_currency = existing.as_ref().map(|c| c.currency.as_str()).unwrap_or("USD");
|
||||
let limit_entry = adw::EntryRow::builder()
|
||||
.title(&format!("Credit Limit ({})", existing_currency))
|
||||
.text(&existing.as_ref().and_then(|c| c.credit_limit).map(|l| format!("{:.2}", l)).unwrap_or_default())
|
||||
.build();
|
||||
limit_entry.set_input_purpose(gtk::InputPurpose::Number);
|
||||
crate::numpad::attach_numpad(&limit_entry);
|
||||
|
||||
let close_spin = adw::SpinRow::with_range(1.0, 31.0, 1.0);
|
||||
close_spin.set_title("Statement Close Day");
|
||||
close_spin.set_value(existing.as_ref().map(|c| c.statement_close_day as f64).unwrap_or(25.0));
|
||||
|
||||
let due_spin = adw::SpinRow::with_range(1.0, 31.0, 1.0);
|
||||
due_spin.set_title("Payment Due Day");
|
||||
due_spin.set_value(existing.as_ref().map(|c| c.due_day as f64).unwrap_or(15.0));
|
||||
|
||||
let min_spin = adw::SpinRow::with_range(0.0, 100.0, 0.5);
|
||||
min_spin.set_title("Minimum Payment %");
|
||||
min_spin.set_value(existing.as_ref().map(|c| c.min_payment_pct).unwrap_or(2.0));
|
||||
|
||||
let currencies = ExchangeRateService::supported_currencies();
|
||||
let currency_labels: Vec<String> = currencies
|
||||
.iter()
|
||||
.map(|(code, name)| format!("{} - {}", code, name))
|
||||
.collect();
|
||||
let currency_label_refs: Vec<&str> = currency_labels.iter().map(|s| s.as_str()).collect();
|
||||
let currency_model = gtk::StringList::new(¤cy_label_refs);
|
||||
let currency_codes: Vec<String> = currencies.iter().map(|(c, _)| c.to_string()).collect();
|
||||
|
||||
let currency_idx = currency_codes
|
||||
.iter()
|
||||
.position(|c| c.eq_ignore_ascii_case(existing_currency))
|
||||
.unwrap_or(0);
|
||||
|
||||
let currency_combo = adw::ComboRow::builder()
|
||||
.title("Currency")
|
||||
.model(¤cy_model)
|
||||
.selected(currency_idx as u32)
|
||||
.build();
|
||||
|
||||
{
|
||||
let limit_ref = limit_entry.clone();
|
||||
let codes = currency_codes.clone();
|
||||
currency_combo.connect_selected_notify(move |combo| {
|
||||
if let Some(code) = codes.get(combo.selected() as usize) {
|
||||
limit_ref.set_title(&format!("Credit Limit ({})", code));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let list = gtk::ListBox::new();
|
||||
list.add_css_class("boxed-list");
|
||||
list.set_selection_mode(gtk::SelectionMode::None);
|
||||
list.append(&name_entry);
|
||||
list.append(&limit_entry);
|
||||
list.append(&close_spin);
|
||||
list.append(&due_spin);
|
||||
list.append(&min_spin);
|
||||
list.append(¤cy_combo);
|
||||
|
||||
form.append(&list);
|
||||
dialog.set_extra_child(Some(&form));
|
||||
|
||||
let db_ref = db.clone();
|
||||
let group_ref = group.clone();
|
||||
let toast_ref = toast.clone();
|
||||
let bl = bl.clone();
|
||||
let ll = ll.clone();
|
||||
let ub = ub.clone();
|
||||
let ul = ul.clone();
|
||||
dialog.connect_response(None, move |_, resp| {
|
||||
if resp == "save" {
|
||||
let name = name_entry.text().to_string();
|
||||
if name.trim().is_empty() {
|
||||
toast_ref.add_toast(adw::Toast::new("Card name is required"));
|
||||
return;
|
||||
}
|
||||
let limit_text = limit_entry.text().to_string();
|
||||
let credit_limit = if limit_text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
limit_text.parse::<f64>().ok()
|
||||
};
|
||||
let close_day = close_spin.value() as i32;
|
||||
let due_day = due_spin.value() as i32;
|
||||
let min_pct = min_spin.value();
|
||||
let currency = currency_codes
|
||||
.get(currency_combo.selected() as usize)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "USD".to_string());
|
||||
|
||||
if let Some(id) = card_id {
|
||||
let card = outlay_core::models::CreditCard {
|
||||
id,
|
||||
name: name.trim().to_string(),
|
||||
credit_limit,
|
||||
statement_close_day: close_day,
|
||||
due_day,
|
||||
min_payment_pct: min_pct,
|
||||
current_balance: existing.as_ref().map(|c| c.current_balance).unwrap_or(0.0),
|
||||
currency,
|
||||
color: None,
|
||||
active: true,
|
||||
};
|
||||
let _ = db_ref.update_credit_card(&card);
|
||||
} else {
|
||||
let card = outlay_core::models::NewCreditCard {
|
||||
name: name.trim().to_string(),
|
||||
credit_limit,
|
||||
statement_close_day: close_day,
|
||||
due_day,
|
||||
min_payment_pct: min_pct,
|
||||
currency,
|
||||
color: None,
|
||||
};
|
||||
let _ = db_ref.insert_credit_card(&card);
|
||||
}
|
||||
Self::populate_cards(&db_ref, &group_ref, &toast_ref, &bl, &ll, &ub, &ul);
|
||||
toast_ref.add_toast(adw::Toast::new(if is_edit { "Card updated" } else { "Card added" }));
|
||||
}
|
||||
});
|
||||
dialog.present(Some(parent));
|
||||
}
|
||||
}
|
||||
56
outlay-gtk/src/date_picker.rs
Normal file
56
outlay-gtk/src/date_picker.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use adw::prelude::*;
|
||||
use chrono::Datelike;
|
||||
|
||||
/// Create a date picker ActionRow with a calendar popover.
|
||||
/// Returns (ActionRow, Label) where the label holds the selected date string (YYYY-MM-DD).
|
||||
pub fn make_date_row(title: &str, initial_date: &str) -> (adw::ActionRow, gtk::Label) {
|
||||
let date_label = gtk::Label::new(Some(initial_date));
|
||||
date_label.set_halign(gtk::Align::End);
|
||||
date_label.set_hexpand(true);
|
||||
|
||||
let calendar = gtk::Calendar::new();
|
||||
let popover = gtk::Popover::new();
|
||||
popover.set_child(Some(&calendar));
|
||||
|
||||
let date_menu_btn = gtk::MenuButton::new();
|
||||
date_menu_btn.set_popover(Some(&popover));
|
||||
let calendar_icon = gtk::Image::from_icon_name("outlay-calendar");
|
||||
calendar_icon.set_pixel_size(28);
|
||||
date_menu_btn.set_child(Some(&calendar_icon));
|
||||
date_menu_btn.add_css_class("flat");
|
||||
date_menu_btn.set_tooltip_text(Some("Pick date"));
|
||||
|
||||
let date_box = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
date_box.append(&date_label);
|
||||
date_box.append(&date_menu_btn);
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(title)
|
||||
.build();
|
||||
row.add_suffix(&date_box);
|
||||
row.set_activatable_widget(Some(&date_menu_btn));
|
||||
|
||||
let date_label_ref = date_label.clone();
|
||||
let popover_ref = popover.clone();
|
||||
calendar.connect_day_selected(move |cal| {
|
||||
let dt = cal.date();
|
||||
let formatted = dt.format("%Y-%m-%d").unwrap().to_string();
|
||||
date_label_ref.set_label(&formatted);
|
||||
popover_ref.popdown();
|
||||
});
|
||||
|
||||
// Select the initial date on the calendar if provided
|
||||
if let Ok(date) = chrono::NaiveDate::parse_from_str(initial_date, "%Y-%m-%d") {
|
||||
let glib_dt = gtk::glib::DateTime::from_local(
|
||||
date.year(),
|
||||
date.month() as i32,
|
||||
date.day() as i32,
|
||||
0, 0, 0.0,
|
||||
);
|
||||
if let Ok(dt) = glib_dt {
|
||||
calendar.select_day(&dt);
|
||||
}
|
||||
}
|
||||
|
||||
(row, date_label)
|
||||
}
|
||||
688
outlay-gtk/src/edit_dialog.rs
Normal file
688
outlay-gtk/src/edit_dialog.rs
Normal file
@@ -0,0 +1,688 @@
|
||||
use adw::prelude::*;
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use gtk::{gio, glib};
|
||||
use outlay_core::db::Database;
|
||||
use outlay_core::models::{Transaction, TransactionType};
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::icon_theme;
|
||||
use crate::log_view::show_image_preview;
|
||||
|
||||
pub fn show_edit_dialog(
|
||||
parent: &impl IsA<gtk::Widget>,
|
||||
txn_id: i64,
|
||||
db: &Rc<Database>,
|
||||
toast_overlay: &adw::ToastOverlay,
|
||||
on_changed: impl Fn() + 'static,
|
||||
) {
|
||||
let txn = match db.get_transaction(txn_id) {
|
||||
Ok(t) => t,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title("Edit Transaction")
|
||||
.content_width(400)
|
||||
.content_height(500)
|
||||
.build();
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
let header = adw::HeaderBar::new();
|
||||
toolbar.add_top_bar(&header);
|
||||
|
||||
let content = gtk::Box::new(gtk::Orientation::Vertical, 16);
|
||||
content.set_margin_top(16);
|
||||
content.set_margin_bottom(16);
|
||||
content.set_margin_start(16);
|
||||
content.set_margin_end(16);
|
||||
|
||||
// Type display (read-only)
|
||||
let type_label = match txn.transaction_type {
|
||||
TransactionType::Expense => "Expense",
|
||||
TransactionType::Income => "Income",
|
||||
};
|
||||
let type_row = adw::ActionRow::builder()
|
||||
.title("Type")
|
||||
.subtitle(type_label)
|
||||
.build();
|
||||
|
||||
let amount_row = adw::EntryRow::builder()
|
||||
.title("Amount")
|
||||
.text(&format!("{:.2}", txn.amount))
|
||||
.build();
|
||||
amount_row.set_input_purpose(gtk::InputPurpose::Number);
|
||||
crate::numpad::attach_numpad(&amount_row);
|
||||
|
||||
let cat_model = gtk::StringList::new(&[]);
|
||||
let mut cat_ids: Vec<i64> = Vec::new();
|
||||
let mut cat_selected: u32 = 0;
|
||||
if let Ok(cats) = db.list_categories(Some(txn.transaction_type)) {
|
||||
for (i, cat) in cats.iter().enumerate() {
|
||||
let icon_name = icon_theme::resolve_category_icon(&cat.icon, &cat.color);
|
||||
let entry = match &icon_name {
|
||||
Some(icon) => format!("{}\t{}", icon, cat.name),
|
||||
None => cat.name.clone(),
|
||||
};
|
||||
cat_model.append(&entry);
|
||||
if cat.id == txn.category_id {
|
||||
cat_selected = i as u32;
|
||||
}
|
||||
cat_ids.push(cat.id);
|
||||
}
|
||||
}
|
||||
let cat_row = adw::ComboRow::builder()
|
||||
.title("Category")
|
||||
.model(&cat_model)
|
||||
.selected(cat_selected)
|
||||
.build();
|
||||
cat_row.set_factory(Some(&make_category_factory()));
|
||||
cat_row.set_list_factory(Some(&make_category_factory()));
|
||||
let cat_ids = Rc::new(cat_ids);
|
||||
|
||||
let date_fmt = db.get_date_format_string();
|
||||
let selected_date = Rc::new(Cell::new(txn.date));
|
||||
let date_label = gtk::Label::new(Some(&txn.date.format(&date_fmt).to_string()));
|
||||
date_label.set_halign(gtk::Align::End);
|
||||
date_label.set_hexpand(true);
|
||||
|
||||
let calendar = gtk::Calendar::new();
|
||||
if let Ok(dt) = glib::DateTime::from_local(
|
||||
txn.date.year(),
|
||||
txn.date.month() as i32,
|
||||
txn.date.day() as i32,
|
||||
0, 0, 0.0,
|
||||
) {
|
||||
calendar.set_year(dt.year());
|
||||
calendar.set_month(dt.month() - 1);
|
||||
calendar.set_day(dt.day_of_month());
|
||||
}
|
||||
|
||||
let popover = gtk::Popover::new();
|
||||
popover.set_child(Some(&calendar));
|
||||
|
||||
let date_menu_btn = gtk::MenuButton::new();
|
||||
date_menu_btn.set_popover(Some(&popover));
|
||||
let calendar_icon = gtk::Image::from_icon_name("outlay-calendar");
|
||||
calendar_icon.set_pixel_size(28);
|
||||
date_menu_btn.set_child(Some(&calendar_icon));
|
||||
date_menu_btn.add_css_class("flat");
|
||||
date_menu_btn.set_tooltip_text(Some("Pick date"));
|
||||
|
||||
let date_box = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
date_box.append(&date_label);
|
||||
date_box.append(&date_menu_btn);
|
||||
|
||||
let date_row = adw::ActionRow::builder()
|
||||
.title("Date")
|
||||
.build();
|
||||
date_row.add_suffix(&date_box);
|
||||
|
||||
let date_label_ref = date_label.clone();
|
||||
let popover_ref = popover.clone();
|
||||
let selected_date_ref = selected_date.clone();
|
||||
let date_fmt_clone = date_fmt.clone();
|
||||
calendar.connect_day_selected(move |cal| {
|
||||
let dt = cal.date();
|
||||
if let Some(d) = NaiveDate::from_ymd_opt(dt.year(), dt.month() as u32, dt.day_of_month() as u32) {
|
||||
selected_date_ref.set(d);
|
||||
date_label_ref.set_label(&d.format(&date_fmt_clone).to_string());
|
||||
}
|
||||
popover_ref.popdown();
|
||||
});
|
||||
|
||||
let payee_row = adw::EntryRow::builder()
|
||||
.title("Payee (optional)")
|
||||
.text(txn.payee.as_deref().unwrap_or(""))
|
||||
.build();
|
||||
|
||||
let note_row = adw::EntryRow::builder()
|
||||
.title("Note (optional)")
|
||||
.text(txn.note.as_deref().unwrap_or(""))
|
||||
.build();
|
||||
|
||||
let existing_tags = db.get_transaction_tags(txn_id)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|t| t.name.clone())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let tags_row = adw::EntryRow::builder()
|
||||
.title("Tags (comma-separated)")
|
||||
.text(&existing_tags)
|
||||
.build();
|
||||
|
||||
// Splits display
|
||||
let has_splits = db.has_splits(txn_id).unwrap_or(false);
|
||||
let existing_splits = if has_splits {
|
||||
db.get_splits(txn_id).unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let splits_group = adw::PreferencesGroup::builder()
|
||||
.title("SPLITS")
|
||||
.build();
|
||||
splits_group.set_visible(has_splits);
|
||||
|
||||
let split_list = gtk::ListBox::new();
|
||||
split_list.add_css_class("boxed-list");
|
||||
split_list.set_selection_mode(gtk::SelectionMode::None);
|
||||
|
||||
// Track split entries for saving: (category_ids, DropDown, Entry, ListBoxRow)
|
||||
type EditSplitRow = (Vec<i64>, gtk::DropDown, gtk::Entry, gtk::ListBoxRow);
|
||||
let split_entries: Rc<RefCell<Vec<EditSplitRow>>> = Rc::new(RefCell::new(Vec::new()));
|
||||
|
||||
let cats_for_splits = db.list_categories(Some(txn.transaction_type)).unwrap_or_default();
|
||||
let split_cat_ids: Vec<i64> = cats_for_splits.iter().map(|c| c.id).collect();
|
||||
let split_cat_names: Vec<String> = cats_for_splits.iter().map(|c| {
|
||||
let icon = crate::icon_theme::resolve_category_icon(&c.icon, &c.color);
|
||||
match icon {
|
||||
Some(i) => format!("{}\t{}", i, c.name),
|
||||
None => c.name.clone(),
|
||||
}
|
||||
}).collect();
|
||||
|
||||
for split in &existing_splits {
|
||||
let label_refs: Vec<&str> = split_cat_names.iter().map(|s| s.as_str()).collect();
|
||||
let model = gtk::StringList::new(&label_refs);
|
||||
let dropdown = gtk::DropDown::new(Some(model), gtk::Expression::NONE);
|
||||
dropdown.set_factory(Some(&crate::category_combo::make_category_factory()));
|
||||
dropdown.set_list_factory(Some(&crate::category_combo::make_category_factory()));
|
||||
dropdown.set_hexpand(true);
|
||||
if let Some(pos) = split_cat_ids.iter().position(|&id| id == split.category_id) {
|
||||
dropdown.set_selected(pos as u32);
|
||||
}
|
||||
|
||||
let amt_entry = gtk::Entry::new();
|
||||
amt_entry.set_text(&format!("{:.2}", split.amount));
|
||||
amt_entry.set_input_purpose(gtk::InputPurpose::Number);
|
||||
amt_entry.set_width_chars(8);
|
||||
|
||||
let del_btn = gtk::Button::from_icon_name("outlay-delete");
|
||||
del_btn.add_css_class("flat");
|
||||
del_btn.add_css_class("circular");
|
||||
del_btn.set_valign(gtk::Align::Center);
|
||||
|
||||
let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
hbox.set_margin_start(12);
|
||||
hbox.set_margin_end(8);
|
||||
hbox.set_margin_top(6);
|
||||
hbox.set_margin_bottom(6);
|
||||
hbox.append(&dropdown);
|
||||
hbox.append(&amt_entry);
|
||||
hbox.append(&del_btn);
|
||||
|
||||
let row = gtk::ListBoxRow::new();
|
||||
row.set_child(Some(&hbox));
|
||||
row.set_activatable(false);
|
||||
split_list.append(&row);
|
||||
|
||||
let row_clone = row.clone();
|
||||
let entries_ref = split_entries.clone();
|
||||
let list_ref = split_list.clone();
|
||||
del_btn.connect_clicked(move |_| {
|
||||
list_ref.remove(&row_clone);
|
||||
entries_ref.borrow_mut().retain(|(_, _, _, r)| r != &row_clone);
|
||||
});
|
||||
|
||||
split_entries.borrow_mut().push((split_cat_ids.clone(), dropdown, amt_entry, row));
|
||||
}
|
||||
|
||||
splits_group.add(&split_list);
|
||||
|
||||
let form_group = adw::PreferencesGroup::new();
|
||||
form_group.add(&type_row);
|
||||
form_group.add(&amount_row);
|
||||
form_group.add(&cat_row);
|
||||
form_group.add(&payee_row);
|
||||
form_group.add(&date_row);
|
||||
form_group.add(¬e_row);
|
||||
form_group.add(&tags_row);
|
||||
|
||||
let btn_box = gtk::Box::new(gtk::Orientation::Horizontal, 12);
|
||||
btn_box.set_halign(gtk::Align::Center);
|
||||
btn_box.set_margin_top(8);
|
||||
|
||||
let delete_btn = gtk::Button::with_label("Delete");
|
||||
delete_btn.add_css_class("destructive-action");
|
||||
delete_btn.add_css_class("pill");
|
||||
|
||||
let save_btn = gtk::Button::with_label("Save");
|
||||
save_btn.add_css_class("suggested-action");
|
||||
save_btn.add_css_class("pill");
|
||||
|
||||
btn_box.append(&delete_btn);
|
||||
btn_box.append(&save_btn);
|
||||
|
||||
// -- Attachment UI --
|
||||
let attach_box = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
||||
|
||||
// Empty state: dashed drop-zone button
|
||||
let attach_placeholder = gtk::Button::new();
|
||||
attach_placeholder.add_css_class("flat");
|
||||
attach_placeholder.add_css_class("attach-drop-zone");
|
||||
{
|
||||
let ph = gtk::Box::new(gtk::Orientation::Vertical, 6);
|
||||
ph.set_margin_top(20);
|
||||
ph.set_margin_bottom(20);
|
||||
ph.set_halign(gtk::Align::Center);
|
||||
let icon = gtk::Image::from_icon_name("mail-attachment-symbolic");
|
||||
icon.set_pixel_size(24);
|
||||
icon.add_css_class("dim-label");
|
||||
let label = gtk::Label::new(Some("Attach receipt"));
|
||||
label.add_css_class("dim-label");
|
||||
label.add_css_class("caption");
|
||||
ph.append(&icon);
|
||||
ph.append(&label);
|
||||
attach_placeholder.set_child(Some(&ph));
|
||||
}
|
||||
|
||||
// Thumbnails flow (hidden until populated)
|
||||
let attach_flow = gtk::FlowBox::new();
|
||||
attach_flow.set_selection_mode(gtk::SelectionMode::None);
|
||||
attach_flow.set_max_children_per_line(4);
|
||||
attach_flow.set_min_children_per_line(1);
|
||||
attach_flow.set_row_spacing(8);
|
||||
attach_flow.set_column_spacing(8);
|
||||
attach_flow.set_homogeneous(true);
|
||||
attach_flow.set_visible(false);
|
||||
|
||||
// "Add another" button (visible when thumbnails showing)
|
||||
let attach_more_btn = gtk::Button::new();
|
||||
attach_more_btn.add_css_class("flat");
|
||||
attach_more_btn.set_halign(gtk::Align::Start);
|
||||
{
|
||||
let content_box = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
||||
let icon = gtk::Image::from_icon_name("list-add-symbolic");
|
||||
icon.set_pixel_size(16);
|
||||
let label = gtk::Label::new(Some("Add another"));
|
||||
label.add_css_class("caption");
|
||||
content_box.append(&icon);
|
||||
content_box.append(&label);
|
||||
attach_more_btn.set_child(Some(&content_box));
|
||||
}
|
||||
attach_more_btn.set_visible(false);
|
||||
|
||||
attach_box.append(&attach_placeholder);
|
||||
attach_box.append(&attach_flow);
|
||||
attach_box.append(&attach_more_btn);
|
||||
|
||||
// Load existing attachments from DB into the flow
|
||||
fn load_attachments(
|
||||
db: &Rc<Database>,
|
||||
txn_id: i64,
|
||||
flow: >k::FlowBox,
|
||||
placeholder: >k::Button,
|
||||
more_btn: >k::Button,
|
||||
toast: &adw::ToastOverlay,
|
||||
) {
|
||||
while let Some(child) = flow.first_child() {
|
||||
flow.remove(&child);
|
||||
}
|
||||
let has_any = if let Ok(attachments) = db.list_attachments(txn_id) {
|
||||
for (att_id, filename, _mime, data) in &attachments {
|
||||
let thumb = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
thumb.set_overflow(gtk::Overflow::Hidden);
|
||||
thumb.add_css_class("attach-thumbnail");
|
||||
|
||||
let overlay = gtk::Overlay::new();
|
||||
let bytes = glib::Bytes::from(data);
|
||||
let texture = gtk::gdk::Texture::from_bytes(&bytes).ok();
|
||||
let image = if let Some(tex) = &texture {
|
||||
let pic = gtk::Picture::for_paintable(tex);
|
||||
pic.set_content_fit(gtk::ContentFit::Cover);
|
||||
pic.set_size_request(80, 80);
|
||||
pic.upcast::<gtk::Widget>()
|
||||
} else {
|
||||
let label = gtk::Label::new(Some(&filename));
|
||||
label.set_size_request(80, 80);
|
||||
label.upcast::<gtk::Widget>()
|
||||
};
|
||||
overlay.set_child(Some(&image));
|
||||
|
||||
let del_btn = gtk::Button::from_icon_name("outlay-delete");
|
||||
del_btn.add_css_class("flat");
|
||||
del_btn.add_css_class("circular");
|
||||
del_btn.add_css_class("osd");
|
||||
del_btn.set_halign(gtk::Align::End);
|
||||
del_btn.set_valign(gtk::Align::Start);
|
||||
del_btn.set_tooltip_text(Some("Remove attachment"));
|
||||
overlay.add_overlay(&del_btn);
|
||||
|
||||
// Click thumbnail to view full image
|
||||
if let Some(tex) = texture {
|
||||
let click = gtk::GestureClick::new();
|
||||
let fname = filename.clone();
|
||||
let data_owned = data.clone();
|
||||
click.connect_released(move |gesture, _, _, _| {
|
||||
if let Some(widget) = gesture.widget() {
|
||||
show_image_preview(&widget, &fname, &tex, &data_owned);
|
||||
}
|
||||
});
|
||||
thumb.add_controller(click);
|
||||
}
|
||||
|
||||
thumb.append(&overlay);
|
||||
|
||||
let att_id = *att_id;
|
||||
let db_ref = db.clone();
|
||||
let flow_ref = flow.clone();
|
||||
let ph_ref = placeholder.clone();
|
||||
let mb_ref = more_btn.clone();
|
||||
let toast_ref = toast.clone();
|
||||
del_btn.connect_clicked(move |_| {
|
||||
let _ = db_ref.delete_attachment(att_id);
|
||||
load_attachments(&db_ref, txn_id, &flow_ref, &ph_ref, &mb_ref, &toast_ref);
|
||||
});
|
||||
|
||||
flow.insert(&thumb, -1);
|
||||
}
|
||||
!attachments.is_empty()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
flow.set_visible(has_any);
|
||||
placeholder.set_visible(!has_any);
|
||||
more_btn.set_visible(has_any);
|
||||
}
|
||||
load_attachments(db, txn_id, &attach_flow, &attach_placeholder, &attach_more_btn, toast_overlay);
|
||||
|
||||
// Shared file picker for both buttons
|
||||
let open_picker: Rc<dyn Fn(>k::Button)> = {
|
||||
let db_ref = db.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
let flow_ref = attach_flow.clone();
|
||||
let ph_ref = attach_placeholder.clone();
|
||||
let mb_ref = attach_more_btn.clone();
|
||||
let dialog_widget = dialog.clone();
|
||||
Rc::new(move |btn: >k::Button| {
|
||||
let filter = gtk::FileFilter::new();
|
||||
filter.add_mime_type("image/png");
|
||||
filter.add_mime_type("image/jpeg");
|
||||
filter.add_mime_type("image/webp");
|
||||
filter.set_name(Some("Images"));
|
||||
|
||||
let filters = gio::ListStore::new::<gtk::FileFilter>();
|
||||
filters.append(&filter);
|
||||
|
||||
let file_dialog = gtk::FileDialog::builder()
|
||||
.title("Attach Receipt")
|
||||
.default_filter(&filter)
|
||||
.filters(&filters)
|
||||
.build();
|
||||
|
||||
let db_att = db_ref.clone();
|
||||
let toast_att = toast_ref.clone();
|
||||
let flow_att = flow_ref.clone();
|
||||
let ph_att = ph_ref.clone();
|
||||
let mb_att = mb_ref.clone();
|
||||
let window = dialog_widget.root()
|
||||
.or_else(|| btn.root())
|
||||
.and_then(|r| r.downcast::<gtk::Window>().ok());
|
||||
file_dialog.open(window.as_ref(), gio::Cancellable::NONE, move |result: Result<gio::File, glib::Error>| {
|
||||
if let Ok(file) = result {
|
||||
if let Some(path) = file.path() {
|
||||
match std::fs::read(&path) {
|
||||
Ok(data) if data.len() <= 5 * 1024 * 1024 => {
|
||||
let filename = path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("receipt")
|
||||
.to_string();
|
||||
let mime = if filename.ends_with(".png") {
|
||||
"image/png"
|
||||
} else if filename.ends_with(".webp") {
|
||||
"image/webp"
|
||||
} else {
|
||||
"image/jpeg"
|
||||
};
|
||||
match db_att.insert_attachment(txn_id, &filename, mime, &data) {
|
||||
Ok(_) => {
|
||||
load_attachments(
|
||||
&db_att, txn_id, &flow_att,
|
||||
&ph_att, &mb_att, &toast_att,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
let toast = adw::Toast::new(&format!("Error: {}", e));
|
||||
toast_att.add_toast(toast);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(_) => {
|
||||
let toast = adw::Toast::new("File too large (max 5MB)");
|
||||
toast_att.add_toast(toast);
|
||||
}
|
||||
Err(e) => {
|
||||
let toast = adw::Toast::new(&format!("Read error: {}", e));
|
||||
toast_att.add_toast(toast);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
};
|
||||
|
||||
{
|
||||
let picker = open_picker.clone();
|
||||
attach_placeholder.connect_clicked(move |btn| (picker)(btn));
|
||||
}
|
||||
{
|
||||
let picker = open_picker;
|
||||
attach_more_btn.connect_clicked(move |btn| (picker)(btn));
|
||||
}
|
||||
|
||||
content.append(&form_group);
|
||||
content.append(&splits_group);
|
||||
content.append(&attach_box);
|
||||
content.append(&btn_box);
|
||||
|
||||
let scroll = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
.child(&content)
|
||||
.build();
|
||||
|
||||
toolbar.set_content(Some(&scroll));
|
||||
dialog.set_child(Some(&toolbar));
|
||||
|
||||
let on_changed = Rc::new(on_changed);
|
||||
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let dialog_ref = dialog.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
let amount_row_ref = amount_row.clone();
|
||||
let selected_date_ref = selected_date.clone();
|
||||
let note_row_ref = note_row.clone();
|
||||
let payee_row_ref = payee_row.clone();
|
||||
let tags_row_ref = tags_row.clone();
|
||||
let cat_row_ref = cat_row.clone();
|
||||
let cat_ids_ref = cat_ids.clone();
|
||||
let txn_clone = txn.clone();
|
||||
let on_changed = on_changed.clone();
|
||||
let split_entries_ref = split_entries.clone();
|
||||
save_btn.connect_clicked(move |_| {
|
||||
let amount_text = amount_row_ref.text();
|
||||
let amount: f64 = match outlay_core::expr::eval_expr(&amount_text) {
|
||||
Some(v) if v > 0.0 => v,
|
||||
_ => {
|
||||
let toast = adw::Toast::new("Please enter a valid amount");
|
||||
toast_ref.add_toast(toast);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let date = selected_date_ref.get();
|
||||
|
||||
let note_text = note_row_ref.text();
|
||||
let note = if note_text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(note_text.to_string())
|
||||
};
|
||||
|
||||
let cat_idx = cat_row_ref.selected() as usize;
|
||||
let category_id = cat_ids_ref.get(cat_idx).copied().unwrap_or(txn_clone.category_id);
|
||||
|
||||
let payee_text = payee_row_ref.text();
|
||||
let payee = if payee_text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(payee_text.to_string())
|
||||
};
|
||||
|
||||
let tags_text = tags_row_ref.text();
|
||||
let tag_names: Vec<String> = tags_text
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
let updated = Transaction {
|
||||
id: txn_clone.id,
|
||||
amount,
|
||||
transaction_type: txn_clone.transaction_type,
|
||||
category_id,
|
||||
currency: txn_clone.currency.clone(),
|
||||
exchange_rate: txn_clone.exchange_rate,
|
||||
note,
|
||||
date,
|
||||
created_at: txn_clone.created_at.clone(),
|
||||
recurring_id: txn_clone.recurring_id,
|
||||
payee,
|
||||
};
|
||||
|
||||
match db_ref.update_transaction(&updated) {
|
||||
Ok(()) => {
|
||||
let mut tag_ids = Vec::new();
|
||||
for name in &tag_names {
|
||||
if let Ok(tid) = db_ref.get_or_create_tag(name) {
|
||||
tag_ids.push(tid);
|
||||
}
|
||||
}
|
||||
let _ = db_ref.set_transaction_tags(txn_clone.id, &tag_ids);
|
||||
|
||||
let _ = db_ref.delete_splits(txn_clone.id);
|
||||
let entries = split_entries_ref.borrow();
|
||||
if !entries.is_empty() {
|
||||
let splits: Vec<(i64, f64, Option<String>)> = entries.iter().filter_map(|(cat_ids, dropdown, amt_entry, _): &(Vec<i64>, gtk::DropDown, gtk::Entry, gtk::ListBoxRow)| {
|
||||
let idx = dropdown.selected() as usize;
|
||||
let cat_id = cat_ids.get(idx).copied()?;
|
||||
let amt: f64 = outlay_core::expr::eval_expr(&amt_entry.text()).unwrap_or(0.0);
|
||||
if amt > 0.0 { Some((cat_id, amt, None)) } else { None }
|
||||
}).collect();
|
||||
let _ = db_ref.insert_splits(txn_clone.id, &splits);
|
||||
}
|
||||
|
||||
dialog_ref.close();
|
||||
let toast = adw::Toast::new("Transaction updated");
|
||||
toast_ref.add_toast(toast);
|
||||
on_changed();
|
||||
}
|
||||
Err(e) => {
|
||||
let toast = adw::Toast::new(&format!("Error: {}", e));
|
||||
toast_ref.add_toast(toast);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Wire delete (5.1: undo-based deletion)
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let dialog_ref = dialog.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
let on_changed = on_changed.clone();
|
||||
let txn_clone = txn.clone();
|
||||
delete_btn.connect_clicked(move |_| {
|
||||
// Save attachments before deletion (CASCADE will remove them)
|
||||
let saved_attachments = db_ref.list_attachments(txn_id).unwrap_or_default();
|
||||
|
||||
match db_ref.delete_transaction(txn_id) {
|
||||
Ok(()) => {
|
||||
dialog_ref.close();
|
||||
on_changed();
|
||||
|
||||
let toast = adw::Toast::new("Transaction deleted");
|
||||
toast.set_button_label(Some("Undo"));
|
||||
toast.set_timeout(5);
|
||||
|
||||
let db_undo = db_ref.clone();
|
||||
let toast_undo = toast_ref.clone();
|
||||
let txn_restore = txn_clone.clone();
|
||||
let on_changed_undo = on_changed.clone();
|
||||
toast.connect_button_clicked(move |_| {
|
||||
use outlay_core::models::NewTransaction;
|
||||
let new_txn = NewTransaction {
|
||||
amount: txn_restore.amount,
|
||||
transaction_type: txn_restore.transaction_type,
|
||||
category_id: txn_restore.category_id,
|
||||
currency: txn_restore.currency.clone(),
|
||||
exchange_rate: txn_restore.exchange_rate,
|
||||
note: txn_restore.note.clone(),
|
||||
date: txn_restore.date,
|
||||
recurring_id: txn_restore.recurring_id,
|
||||
payee: txn_restore.payee.clone(),
|
||||
};
|
||||
match db_undo.insert_transaction(&new_txn) {
|
||||
Ok(new_id) => {
|
||||
// Restore attachments
|
||||
for (_att_id, filename, mime, data) in &saved_attachments {
|
||||
let _ = db_undo.insert_attachment(new_id, filename, &mime, data);
|
||||
}
|
||||
let t = adw::Toast::new("Transaction restored");
|
||||
toast_undo.add_toast(t);
|
||||
on_changed_undo();
|
||||
}
|
||||
Err(e) => {
|
||||
let t = adw::Toast::new(&format!("Restore failed: {}", e));
|
||||
toast_undo.add_toast(t);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
toast_ref.add_toast(toast);
|
||||
}
|
||||
Err(e) => {
|
||||
let toast = adw::Toast::new(&format!("Error: {}", e));
|
||||
toast_ref.add_toast(toast);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
dialog.present(Some(parent));
|
||||
}
|
||||
|
||||
fn make_category_factory() -> gtk::SignalListItemFactory {
|
||||
let factory = gtk::SignalListItemFactory::new();
|
||||
factory.connect_setup(|_, item| {
|
||||
let item = item.downcast_ref::<gtk::ListItem>().unwrap();
|
||||
let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
let icon = gtk::Image::new();
|
||||
icon.set_pixel_size(20);
|
||||
let label = gtk::Label::new(None);
|
||||
hbox.append(&icon);
|
||||
hbox.append(&label);
|
||||
item.set_child(Some(&hbox));
|
||||
});
|
||||
factory.connect_bind(|_, item| {
|
||||
let item = item.downcast_ref::<gtk::ListItem>().unwrap();
|
||||
let string_obj = item.item().and_downcast::<gtk::StringObject>().unwrap();
|
||||
let text = string_obj.string();
|
||||
let hbox = item.child().and_downcast::<gtk::Box>().unwrap();
|
||||
let icon = hbox.first_child().and_downcast::<gtk::Image>().unwrap();
|
||||
let label = icon.next_sibling().and_downcast::<gtk::Label>().unwrap();
|
||||
if let Some((icon_name, name)) = text.split_once('\t') {
|
||||
icon.set_icon_name(Some(icon_name));
|
||||
icon.set_visible(true);
|
||||
label.set_label(name);
|
||||
} else {
|
||||
icon.set_visible(false);
|
||||
label.set_label(&text);
|
||||
}
|
||||
});
|
||||
factory
|
||||
}
|
||||
154
outlay-gtk/src/forecast_view.rs
Normal file
154
outlay-gtk/src/forecast_view.rs
Normal file
@@ -0,0 +1,154 @@
|
||||
use adw::prelude::*;
|
||||
use outlay_core::db::Database;
|
||||
use std::rc::Rc;
|
||||
|
||||
pub struct ForecastView {
|
||||
pub container: gtk::Box,
|
||||
}
|
||||
|
||||
impl ForecastView {
|
||||
pub fn new(db: Rc<Database>) -> Self {
|
||||
let container = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
let toast_overlay = adw::ToastOverlay::new();
|
||||
|
||||
let clamp = adw::Clamp::new();
|
||||
clamp.set_maximum_size(700);
|
||||
clamp.set_margin_start(16);
|
||||
clamp.set_margin_end(16);
|
||||
|
||||
let inner = gtk::Box::new(gtk::Orientation::Vertical, 20);
|
||||
inner.set_margin_top(20);
|
||||
inner.set_margin_bottom(20);
|
||||
|
||||
let base_currency = db.get_setting("base_currency")
|
||||
.ok().flatten()
|
||||
.unwrap_or_else(|| "USD".to_string());
|
||||
|
||||
let summary_card = gtk::Box::new(gtk::Orientation::Vertical, 4);
|
||||
summary_card.add_css_class("card");
|
||||
summary_card.set_margin_start(4);
|
||||
summary_card.set_margin_end(4);
|
||||
|
||||
let summary_title = gtk::Label::new(Some("CASH FLOW FORECAST"));
|
||||
summary_title.add_css_class("caption");
|
||||
summary_title.add_css_class("dim-label");
|
||||
summary_title.set_halign(gtk::Align::Start);
|
||||
summary_title.set_margin_top(12);
|
||||
summary_title.set_margin_start(12);
|
||||
|
||||
let summary_detail = gtk::Label::new(Some("Based on recurring transactions and 3-month averages"));
|
||||
summary_detail.add_css_class("caption");
|
||||
summary_detail.add_css_class("dim-label");
|
||||
summary_detail.set_halign(gtk::Align::Start);
|
||||
summary_detail.set_margin_start(12);
|
||||
summary_detail.set_margin_bottom(12);
|
||||
|
||||
summary_card.append(&summary_title);
|
||||
summary_card.append(&summary_detail);
|
||||
|
||||
let forecast_group = adw::PreferencesGroup::builder()
|
||||
.title("MONTHLY PROJECTION")
|
||||
.build();
|
||||
|
||||
let forecast_data = db.forecast_cash_flow(6).unwrap_or_default();
|
||||
for (i, (month_key, income, expenses, balance)) in forecast_data.iter().enumerate() {
|
||||
let month_name = Self::month_name(month_key);
|
||||
let is_current = i == 0;
|
||||
let label = if is_current {
|
||||
format!("{} (current)", month_name)
|
||||
} else {
|
||||
format!("{} (projected)", month_name)
|
||||
};
|
||||
|
||||
let net = income - expenses;
|
||||
let balance_str = if *balance >= 0.0 {
|
||||
format!("+{:.0} {}", balance, base_currency)
|
||||
} else {
|
||||
format!("{:.0} {}", balance, base_currency)
|
||||
};
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&label)
|
||||
.subtitle(&format!(
|
||||
"Income: {:.0} - Expenses: {:.0} = Net: {:.0}",
|
||||
income, expenses, net,
|
||||
))
|
||||
.build();
|
||||
|
||||
let balance_label = gtk::Label::new(Some(&balance_str));
|
||||
balance_label.add_css_class("amount-display");
|
||||
if *balance >= 0.0 {
|
||||
balance_label.add_css_class("amount-income");
|
||||
} else {
|
||||
balance_label.add_css_class("amount-expense");
|
||||
}
|
||||
row.add_suffix(&balance_label);
|
||||
|
||||
if !is_current {
|
||||
row.add_css_class("dim-label");
|
||||
}
|
||||
|
||||
forecast_group.add(&row);
|
||||
}
|
||||
|
||||
if forecast_data.is_empty() {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("No data available for forecast")
|
||||
.subtitle("Add some transactions to see projections")
|
||||
.build();
|
||||
row.add_css_class("dim-label");
|
||||
forecast_group.add(&row);
|
||||
}
|
||||
|
||||
// Net cash flow summary
|
||||
if forecast_data.len() >= 2 {
|
||||
let last = &forecast_data[forecast_data.len() - 1];
|
||||
let net_summary = if last.3 >= 0.0 {
|
||||
format!("Projected cumulative balance in 6 months: +{:.0} {}", last.3, base_currency)
|
||||
} else {
|
||||
format!("Projected cumulative balance in 6 months: {:.0} {}", last.3, base_currency)
|
||||
};
|
||||
|
||||
let net_label = gtk::Label::new(Some(&net_summary));
|
||||
net_label.add_css_class("heading");
|
||||
net_label.set_halign(gtk::Align::Start);
|
||||
net_label.set_margin_start(4);
|
||||
net_label.set_margin_top(8);
|
||||
|
||||
inner.append(&summary_card);
|
||||
inner.append(&forecast_group);
|
||||
inner.append(&net_label);
|
||||
} else {
|
||||
inner.append(&summary_card);
|
||||
inner.append(&forecast_group);
|
||||
}
|
||||
|
||||
clamp.set_child(Some(&inner));
|
||||
|
||||
let scroll = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
.vexpand(true)
|
||||
.child(&clamp)
|
||||
.build();
|
||||
|
||||
toast_overlay.set_child(Some(&scroll));
|
||||
container.append(&toast_overlay);
|
||||
|
||||
ForecastView { container }
|
||||
}
|
||||
|
||||
fn month_name(key: &str) -> String {
|
||||
let parts: Vec<&str> = key.split('-').collect();
|
||||
if parts.len() != 2 { return key.to_string(); }
|
||||
let month: u32 = parts[1].parse().unwrap_or(0);
|
||||
let year = parts[0];
|
||||
let 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",
|
||||
};
|
||||
format!("{} {}", name, year)
|
||||
}
|
||||
}
|
||||
600
outlay-gtk/src/goals_view.rs
Normal file
600
outlay-gtk/src/goals_view.rs
Normal file
@@ -0,0 +1,600 @@
|
||||
use adw::prelude::*;
|
||||
use outlay_core::db::Database;
|
||||
use std::rc::Rc;
|
||||
|
||||
pub struct GoalsView {
|
||||
pub container: gtk::Box,
|
||||
}
|
||||
|
||||
impl GoalsView {
|
||||
pub fn new(db: Rc<Database>) -> Self {
|
||||
let container = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
let toast_overlay = adw::ToastOverlay::new();
|
||||
|
||||
let clamp = adw::Clamp::new();
|
||||
clamp.set_maximum_size(700);
|
||||
clamp.set_margin_start(16);
|
||||
clamp.set_margin_end(16);
|
||||
|
||||
let inner = gtk::Box::new(gtk::Orientation::Vertical, 20);
|
||||
inner.set_margin_top(20);
|
||||
inner.set_margin_bottom(20);
|
||||
|
||||
let active_group = adw::PreferencesGroup::builder()
|
||||
.title("SAVINGS GOALS")
|
||||
.build();
|
||||
|
||||
let completed_group = adw::PreferencesGroup::builder()
|
||||
.title("COMPLETED")
|
||||
.build();
|
||||
completed_group.set_visible(false);
|
||||
|
||||
let add_btn = gtk::Button::with_label("Add Goal");
|
||||
add_btn.add_css_class("suggested-action");
|
||||
add_btn.add_css_class("pill");
|
||||
add_btn.set_halign(gtk::Align::Center);
|
||||
add_btn.set_margin_top(8);
|
||||
|
||||
Self::load_goals(&db, &active_group, &completed_group, &toast_overlay);
|
||||
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let active_ref = active_group.clone();
|
||||
let completed_ref = completed_group.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
add_btn.connect_clicked(move |btn| {
|
||||
Self::show_add_dialog(btn, &db_ref, &active_ref, &completed_ref, &toast_ref);
|
||||
});
|
||||
}
|
||||
|
||||
inner.append(&active_group);
|
||||
inner.append(&add_btn);
|
||||
inner.append(&completed_group);
|
||||
|
||||
clamp.set_child(Some(&inner));
|
||||
|
||||
let scroll = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
.vexpand(true)
|
||||
.child(&clamp)
|
||||
.build();
|
||||
|
||||
toast_overlay.set_child(Some(&scroll));
|
||||
container.append(&toast_overlay);
|
||||
|
||||
GoalsView { container }
|
||||
}
|
||||
|
||||
fn load_goals(
|
||||
db: &Rc<Database>,
|
||||
active_group: &adw::PreferencesGroup,
|
||||
completed_group: &adw::PreferencesGroup,
|
||||
toast_overlay: &adw::ToastOverlay,
|
||||
) {
|
||||
Self::clear_group(active_group);
|
||||
Self::clear_group(completed_group);
|
||||
|
||||
let goals = db.list_goals().unwrap_or_default();
|
||||
let mut has_completed = false;
|
||||
|
||||
let base_currency = db
|
||||
.get_setting("base_currency")
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| "USD".to_string());
|
||||
|
||||
for goal in &goals {
|
||||
let pct = if goal.target > 0.0 {
|
||||
(goal.saved / goal.target * 100.0).min(100.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let is_complete = goal.saved >= goal.target;
|
||||
|
||||
let remaining = goal.target - goal.saved;
|
||||
let subtitle = if is_complete {
|
||||
format!("Completed - {:.2} {}", goal.target, goal.currency)
|
||||
} else {
|
||||
let mut parts = vec![
|
||||
format!("{:.2} / {:.2} {} ({:.0}%)", goal.saved, goal.target, goal.currency, pct),
|
||||
];
|
||||
if remaining > 0.0 {
|
||||
parts.push(format!("{:.2} remaining", remaining));
|
||||
}
|
||||
if let Some(deadline) = goal.deadline {
|
||||
let today = chrono::Local::now().date_naive();
|
||||
let days_left = (deadline - today).num_days();
|
||||
if days_left > 0 {
|
||||
parts.push(format!("{} days left", days_left));
|
||||
} else if days_left == 0 {
|
||||
parts.push("Due today".to_string());
|
||||
} else {
|
||||
parts.push(format!("{} days overdue", days_left.abs()));
|
||||
}
|
||||
}
|
||||
// Monthly amount needed
|
||||
if let Ok(Some(monthly)) = db.get_required_monthly(goal.id) {
|
||||
if monthly > 0.0 {
|
||||
parts.push(format!("Need {:.2}/month", monthly));
|
||||
}
|
||||
}
|
||||
// Projection based on average contribution rate
|
||||
if let Ok(avg_rate) = db.get_goal_avg_monthly_contribution(goal.id) {
|
||||
if avg_rate > 0.0 && remaining > 0.0 {
|
||||
let months_remaining = (remaining / avg_rate).ceil() as i32;
|
||||
let today = chrono::Local::now().date_naive();
|
||||
let projected = today + chrono::Months::new(months_remaining as u32);
|
||||
if let Some(deadline) = goal.deadline {
|
||||
let margin_days = (deadline - projected).num_days();
|
||||
if margin_days >= 0 {
|
||||
let margin_months = margin_days / 30;
|
||||
parts.push(format!("On track - ~{} month{} ahead", margin_months, if margin_months == 1 { "" } else { "s" }));
|
||||
} else {
|
||||
let catch_up = remaining / ((deadline - today).num_days().max(1) as f64 / 30.0);
|
||||
parts.push(format!("Behind - need {:.2}/month to catch up", catch_up));
|
||||
}
|
||||
} else {
|
||||
parts.push(format!("Reachable by {}", projected.format("%b %Y")));
|
||||
}
|
||||
} else if remaining > 0.0 {
|
||||
parts.push("Start contributing to see projection".to_string());
|
||||
}
|
||||
}
|
||||
parts.join(" - ")
|
||||
};
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&goal.name)
|
||||
.subtitle(&subtitle)
|
||||
.activatable(true)
|
||||
.build();
|
||||
|
||||
// Progress bar
|
||||
let level = gtk::LevelBar::builder()
|
||||
.min_value(0.0)
|
||||
.max_value(1.0)
|
||||
.value(pct / 100.0)
|
||||
.hexpand(true)
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
level.set_width_request(120);
|
||||
|
||||
if is_complete {
|
||||
let check = gtk::Image::from_icon_name("object-select-symbolic");
|
||||
check.add_css_class("success");
|
||||
row.add_prefix(&check);
|
||||
} else if let Some(ref icon_name) = goal.icon {
|
||||
let icon = gtk::Image::from_icon_name(icon_name);
|
||||
icon.set_pixel_size(24);
|
||||
row.add_prefix(&icon);
|
||||
}
|
||||
|
||||
let suffix_box = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
suffix_box.set_valign(gtk::Align::Center);
|
||||
|
||||
if !is_complete {
|
||||
let contribute_btn = gtk::Button::from_icon_name("list-add-symbolic");
|
||||
contribute_btn.add_css_class("flat");
|
||||
contribute_btn.set_tooltip_text(Some("Add funds"));
|
||||
contribute_btn.set_valign(gtk::Align::Center);
|
||||
|
||||
let goal_id = goal.id;
|
||||
let db_contribute = db.clone();
|
||||
let active_c = active_group.clone();
|
||||
let completed_c = completed_group.clone();
|
||||
let toast_c = toast_overlay.clone();
|
||||
let currency = goal.currency.clone();
|
||||
contribute_btn.connect_clicked(move |btn| {
|
||||
Self::show_contribute_dialog(
|
||||
btn, goal_id, ¤cy,
|
||||
&db_contribute, &active_c, &completed_c, &toast_c,
|
||||
);
|
||||
});
|
||||
suffix_box.append(&contribute_btn);
|
||||
}
|
||||
|
||||
suffix_box.append(&level);
|
||||
row.add_suffix(&suffix_box);
|
||||
|
||||
// Click to edit
|
||||
let goal_id = goal.id;
|
||||
let db_edit = db.clone();
|
||||
let active_e = active_group.clone();
|
||||
let completed_e = completed_group.clone();
|
||||
let toast_e = toast_overlay.clone();
|
||||
row.connect_activated(move |row| {
|
||||
Self::show_edit_dialog(
|
||||
row, goal_id,
|
||||
&db_edit, &active_e, &completed_e, &toast_e,
|
||||
);
|
||||
});
|
||||
|
||||
if is_complete {
|
||||
completed_group.add(&row);
|
||||
has_completed = true;
|
||||
} else {
|
||||
active_group.add(&row);
|
||||
}
|
||||
}
|
||||
|
||||
completed_group.set_visible(has_completed);
|
||||
|
||||
if goals.iter().all(|g| g.saved >= g.target) && !goals.is_empty() {
|
||||
// All goals completed - no empty state needed
|
||||
} else if goals.iter().filter(|g| g.saved < g.target).count() == 0 && goals.is_empty() {
|
||||
let empty_row = adw::ActionRow::builder()
|
||||
.title("No savings goals yet")
|
||||
.subtitle("Set a goal and track your progress")
|
||||
.activatable(false)
|
||||
.build();
|
||||
empty_row.add_css_class("dim-label");
|
||||
active_group.add(&empty_row);
|
||||
}
|
||||
}
|
||||
|
||||
fn show_add_dialog(
|
||||
parent: >k::Button,
|
||||
db: &Rc<Database>,
|
||||
active_group: &adw::PreferencesGroup,
|
||||
completed_group: &adw::PreferencesGroup,
|
||||
toast_overlay: &adw::ToastOverlay,
|
||||
) {
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title("Add Savings Goal")
|
||||
.content_width(360)
|
||||
.content_height(350)
|
||||
.build();
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&adw::HeaderBar::new());
|
||||
|
||||
let content = gtk::Box::new(gtk::Orientation::Vertical, 16);
|
||||
content.set_margin_top(16);
|
||||
content.set_margin_bottom(16);
|
||||
content.set_margin_start(16);
|
||||
content.set_margin_end(16);
|
||||
|
||||
let name_row = adw::EntryRow::builder()
|
||||
.title("Goal Name")
|
||||
.build();
|
||||
|
||||
let target_row = adw::EntryRow::builder()
|
||||
.title("Target Amount")
|
||||
.build();
|
||||
target_row.set_input_purpose(gtk::InputPurpose::Number);
|
||||
crate::numpad::attach_numpad(&target_row);
|
||||
|
||||
let base_currency = db
|
||||
.get_setting("base_currency")
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| "USD".to_string());
|
||||
|
||||
let (deadline_row, deadline_label) = crate::date_picker::make_date_row("Deadline (optional)", "");
|
||||
|
||||
let form = adw::PreferencesGroup::new();
|
||||
form.add(&name_row);
|
||||
form.add(&target_row);
|
||||
form.add(&deadline_row);
|
||||
|
||||
let save_btn = gtk::Button::with_label("Save");
|
||||
save_btn.add_css_class("suggested-action");
|
||||
save_btn.add_css_class("pill");
|
||||
save_btn.set_halign(gtk::Align::Center);
|
||||
|
||||
content.append(&form);
|
||||
content.append(&save_btn);
|
||||
toolbar.set_content(Some(&content));
|
||||
dialog.set_child(Some(&toolbar));
|
||||
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let dialog_ref = dialog.clone();
|
||||
let active_ref = active_group.clone();
|
||||
let completed_ref = completed_group.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
let bc = base_currency.clone();
|
||||
save_btn.connect_clicked(move |_| {
|
||||
let name = name_row.text();
|
||||
if name.is_empty() {
|
||||
let toast = adw::Toast::new("Please enter a goal name");
|
||||
toast_ref.add_toast(toast);
|
||||
return;
|
||||
}
|
||||
let target: f64 = match target_row.text().trim().parse() {
|
||||
Ok(v) if v > 0.0 => v,
|
||||
_ => {
|
||||
let toast = adw::Toast::new("Please enter a valid target amount");
|
||||
toast_ref.add_toast(toast);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let deadline_text = deadline_label.label();
|
||||
let deadline = if deadline_text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
chrono::NaiveDate::parse_from_str(deadline_text.trim(), "%Y-%m-%d").ok()
|
||||
};
|
||||
|
||||
match db_ref.insert_goal(&name, target, &bc, deadline, None, None) {
|
||||
Ok(_) => {
|
||||
dialog_ref.close();
|
||||
let toast = adw::Toast::new("Goal created");
|
||||
toast_ref.add_toast(toast);
|
||||
Self::load_goals(&db_ref, &active_ref, &completed_ref, &toast_ref);
|
||||
}
|
||||
Err(e) => {
|
||||
let toast = adw::Toast::new(&format!("Error: {}", e));
|
||||
toast_ref.add_toast(toast);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
dialog.present(Some(parent));
|
||||
}
|
||||
|
||||
fn show_contribute_dialog(
|
||||
parent: >k::Button,
|
||||
goal_id: i64,
|
||||
currency: &str,
|
||||
db: &Rc<Database>,
|
||||
active_group: &adw::PreferencesGroup,
|
||||
completed_group: &adw::PreferencesGroup,
|
||||
toast_overlay: &adw::ToastOverlay,
|
||||
) {
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title("Add Funds")
|
||||
.content_width(320)
|
||||
.content_height(180)
|
||||
.build();
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&adw::HeaderBar::new());
|
||||
|
||||
let content = gtk::Box::new(gtk::Orientation::Vertical, 16);
|
||||
content.set_margin_top(16);
|
||||
content.set_margin_bottom(16);
|
||||
content.set_margin_start(16);
|
||||
content.set_margin_end(16);
|
||||
|
||||
let amount_row = adw::EntryRow::builder()
|
||||
.title(&format!("Amount ({})", currency))
|
||||
.build();
|
||||
amount_row.set_input_purpose(gtk::InputPurpose::Number);
|
||||
crate::numpad::attach_numpad(&amount_row);
|
||||
|
||||
let form = adw::PreferencesGroup::new();
|
||||
form.add(&amount_row);
|
||||
|
||||
let save_btn = gtk::Button::with_label("Add");
|
||||
save_btn.add_css_class("suggested-action");
|
||||
save_btn.add_css_class("pill");
|
||||
save_btn.set_halign(gtk::Align::Center);
|
||||
|
||||
content.append(&form);
|
||||
content.append(&save_btn);
|
||||
toolbar.set_content(Some(&content));
|
||||
dialog.set_child(Some(&toolbar));
|
||||
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let dialog_ref = dialog.clone();
|
||||
let active_ref = active_group.clone();
|
||||
let completed_ref = completed_group.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
save_btn.connect_clicked(move |_| {
|
||||
let amount: f64 = match amount_row.text().trim().parse() {
|
||||
Ok(v) if v > 0.0 => v,
|
||||
_ => {
|
||||
let toast = adw::Toast::new("Please enter a valid amount");
|
||||
toast_ref.add_toast(toast);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match db_ref.contribute_to_goal(goal_id, amount) {
|
||||
Ok(()) => {
|
||||
dialog_ref.close();
|
||||
let toast = adw::Toast::new(&format!("Added {:.2}", amount));
|
||||
toast_ref.add_toast(toast);
|
||||
Self::load_goals(&db_ref, &active_ref, &completed_ref, &toast_ref);
|
||||
}
|
||||
Err(e) => {
|
||||
let toast = adw::Toast::new(&format!("Error: {}", e));
|
||||
toast_ref.add_toast(toast);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
dialog.present(Some(parent));
|
||||
}
|
||||
|
||||
fn show_edit_dialog(
|
||||
parent: &adw::ActionRow,
|
||||
goal_id: i64,
|
||||
db: &Rc<Database>,
|
||||
active_group: &adw::PreferencesGroup,
|
||||
completed_group: &adw::PreferencesGroup,
|
||||
toast_overlay: &adw::ToastOverlay,
|
||||
) {
|
||||
let goal = match db.get_goal(goal_id) {
|
||||
Ok(g) => g,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title("Edit Goal")
|
||||
.content_width(360)
|
||||
.content_height(350)
|
||||
.build();
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&adw::HeaderBar::new());
|
||||
|
||||
let content = gtk::Box::new(gtk::Orientation::Vertical, 16);
|
||||
content.set_margin_top(16);
|
||||
content.set_margin_bottom(16);
|
||||
content.set_margin_start(16);
|
||||
content.set_margin_end(16);
|
||||
|
||||
let name_row = adw::EntryRow::builder()
|
||||
.title("Goal Name")
|
||||
.text(&goal.name)
|
||||
.build();
|
||||
|
||||
let target_row = adw::EntryRow::builder()
|
||||
.title("Target Amount")
|
||||
.text(&format!("{:.2}", goal.target))
|
||||
.build();
|
||||
target_row.set_input_purpose(gtk::InputPurpose::Number);
|
||||
crate::numpad::attach_numpad(&target_row);
|
||||
|
||||
let initial_deadline = goal.deadline
|
||||
.map(|d| d.format("%Y-%m-%d").to_string())
|
||||
.unwrap_or_default();
|
||||
let (deadline_row, deadline_label) = crate::date_picker::make_date_row("Deadline (optional)", &initial_deadline);
|
||||
|
||||
let form = adw::PreferencesGroup::new();
|
||||
form.add(&name_row);
|
||||
form.add(&target_row);
|
||||
form.add(&deadline_row);
|
||||
|
||||
let btn_box = gtk::Box::new(gtk::Orientation::Horizontal, 12);
|
||||
btn_box.set_halign(gtk::Align::Center);
|
||||
|
||||
let delete_btn = gtk::Button::with_label("Delete");
|
||||
delete_btn.add_css_class("destructive-action");
|
||||
delete_btn.add_css_class("pill");
|
||||
|
||||
let save_btn = gtk::Button::with_label("Save");
|
||||
save_btn.add_css_class("suggested-action");
|
||||
save_btn.add_css_class("pill");
|
||||
|
||||
btn_box.append(&delete_btn);
|
||||
btn_box.append(&save_btn);
|
||||
|
||||
content.append(&form);
|
||||
content.append(&btn_box);
|
||||
toolbar.set_content(Some(&content));
|
||||
dialog.set_child(Some(&toolbar));
|
||||
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let dialog_ref = dialog.clone();
|
||||
let active_ref = active_group.clone();
|
||||
let completed_ref = completed_group.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
let currency = goal.currency.clone();
|
||||
save_btn.connect_clicked(move |_| {
|
||||
let name = name_row.text();
|
||||
if name.is_empty() {
|
||||
let toast = adw::Toast::new("Please enter a goal name");
|
||||
toast_ref.add_toast(toast);
|
||||
return;
|
||||
}
|
||||
let target: f64 = match target_row.text().trim().parse() {
|
||||
Ok(v) if v > 0.0 => v,
|
||||
_ => {
|
||||
let toast = adw::Toast::new("Please enter a valid target amount");
|
||||
toast_ref.add_toast(toast);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let deadline_text = deadline_label.label();
|
||||
let deadline = if deadline_text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
chrono::NaiveDate::parse_from_str(deadline_text.trim(), "%Y-%m-%d").ok()
|
||||
};
|
||||
|
||||
match db_ref.update_goal(goal_id, &name, target, ¤cy, deadline, None, None) {
|
||||
Ok(()) => {
|
||||
dialog_ref.close();
|
||||
let toast = adw::Toast::new("Goal updated");
|
||||
toast_ref.add_toast(toast);
|
||||
Self::load_goals(&db_ref, &active_ref, &completed_ref, &toast_ref);
|
||||
}
|
||||
Err(e) => {
|
||||
let toast = adw::Toast::new(&format!("Error: {}", e));
|
||||
toast_ref.add_toast(toast);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let dialog_ref = dialog.clone();
|
||||
let active_ref = active_group.clone();
|
||||
let completed_ref = completed_group.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
delete_btn.connect_clicked(move |btn| {
|
||||
let alert = adw::AlertDialog::new(
|
||||
Some("Delete this goal?"),
|
||||
Some("This will permanently remove this savings goal."),
|
||||
);
|
||||
alert.add_response("cancel", "Cancel");
|
||||
alert.add_response("delete", "Delete");
|
||||
alert.set_response_appearance("delete", adw::ResponseAppearance::Destructive);
|
||||
alert.set_default_response(Some("cancel"));
|
||||
alert.set_close_response("cancel");
|
||||
|
||||
let db_del = db_ref.clone();
|
||||
let dialog_del = dialog_ref.clone();
|
||||
let active_del = active_ref.clone();
|
||||
let completed_del = completed_ref.clone();
|
||||
let toast_del = toast_ref.clone();
|
||||
alert.connect_response(None, move |_, response| {
|
||||
if response == "delete" {
|
||||
match db_del.delete_goal(goal_id) {
|
||||
Ok(()) => {
|
||||
dialog_del.close();
|
||||
let toast = adw::Toast::new("Goal deleted");
|
||||
toast_del.add_toast(toast);
|
||||
Self::load_goals(&db_del, &active_del, &completed_del, &toast_del);
|
||||
}
|
||||
Err(e) => {
|
||||
let toast = adw::Toast::new(&format!("Error: {}", e));
|
||||
toast_del.add_toast(toast);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
alert.present(Some(btn));
|
||||
});
|
||||
}
|
||||
|
||||
dialog.present(Some(parent));
|
||||
}
|
||||
|
||||
fn clear_group(group: &adw::PreferencesGroup) {
|
||||
let mut rows = Vec::new();
|
||||
let mut child = group.upcast_ref::<gtk::Widget>().first_child();
|
||||
while let Some(c) = child {
|
||||
if let Some(row) = c.downcast_ref::<adw::ActionRow>() {
|
||||
rows.push(row.clone());
|
||||
}
|
||||
let mut inner = c.first_child();
|
||||
while let Some(ic) = inner {
|
||||
if let Some(row) = ic.downcast_ref::<adw::ActionRow>() {
|
||||
rows.push(row.clone());
|
||||
}
|
||||
let mut inner2 = ic.first_child();
|
||||
while let Some(ic2) = inner2 {
|
||||
if let Some(row) = ic2.downcast_ref::<adw::ActionRow>() {
|
||||
rows.push(row.clone());
|
||||
}
|
||||
inner2 = ic2.next_sibling();
|
||||
}
|
||||
inner = ic.next_sibling();
|
||||
}
|
||||
child = c.next_sibling();
|
||||
}
|
||||
for row in &rows {
|
||||
group.remove(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
214
outlay-gtk/src/icon_theme.rs
Normal file
214
outlay-gtk/src/icon_theme.rs
Normal file
@@ -0,0 +1,214 @@
|
||||
use gtk::glib;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn source_icon_dir() -> Option<PathBuf> {
|
||||
let exe_path = std::env::current_exe().unwrap_or_default();
|
||||
let exe_dir = exe_path.parent().unwrap_or(std::path::Path::new("."));
|
||||
|
||||
let candidates = [
|
||||
exe_dir.join("../../outlay-gtk/data/icons/hicolor/scalable/actions"),
|
||||
exe_dir.join("../share/icons/hicolor/scalable/actions"),
|
||||
PathBuf::from("/usr/share/icons/hicolor/scalable/actions"),
|
||||
];
|
||||
|
||||
for candidate in &candidates {
|
||||
if candidate.exists() {
|
||||
return Some(candidate.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn cache_icon_dir() -> PathBuf {
|
||||
glib::user_cache_dir().join("outlay").join("themed-icons")
|
||||
}
|
||||
|
||||
fn generate_themed_icons(is_dark: bool) {
|
||||
let Some(src_dir) = source_icon_dir() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let cache_dir = cache_icon_dir();
|
||||
let actions_dir = cache_dir
|
||||
.join("hicolor")
|
||||
.join("scalable")
|
||||
.join("actions");
|
||||
std::fs::create_dir_all(&actions_dir).ok();
|
||||
|
||||
let stroke_color = if is_dark { "#f7f7f7" } else { "#222222" };
|
||||
|
||||
let entries = match std::fs::read_dir(&src_dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let name = match path.file_name().and_then(|n| n.to_str()) {
|
||||
Some(n) => n.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Only process non-symbolic SVGs (the ones our code references)
|
||||
if name.contains("-symbolic") || !name.ends_with(".svg") {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve symlinks to read the actual content
|
||||
let real_path = match std::fs::canonicalize(&path) {
|
||||
Ok(p) => p,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let content = match std::fs::read_to_string(&real_path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
// Replace any hardcoded stroke color with the themed one
|
||||
let themed = content
|
||||
.replace("stroke=\"#222222\"", &format!("stroke=\"{}\"", stroke_color))
|
||||
.replace("stroke=\"#f7f7f7\"", &format!("stroke=\"{}\"", stroke_color));
|
||||
|
||||
let dest = actions_dir.join(&name);
|
||||
std::fs::write(&dest, themed).ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a tinted variant of a tabler icon with the given hex color.
|
||||
/// Returns the icon name to use with `Image::from_icon_name()`.
|
||||
pub fn get_tinted_icon_name(base_icon: &str, color: &str) -> String {
|
||||
// Strip leading # from color
|
||||
let color_hex = color.trim_start_matches('#');
|
||||
let tinted_name = format!("{}-{}", base_icon, color_hex);
|
||||
|
||||
let cache_dir = cache_icon_dir();
|
||||
let actions_dir = cache_dir
|
||||
.join("hicolor")
|
||||
.join("scalable")
|
||||
.join("actions");
|
||||
let dest = actions_dir.join(format!("{}.svg", tinted_name));
|
||||
|
||||
// Return cached if it exists
|
||||
if dest.exists() {
|
||||
return tinted_name;
|
||||
}
|
||||
|
||||
// Try to find the source SVG
|
||||
let Some(src_dir) = source_icon_dir() else {
|
||||
return base_icon.to_string();
|
||||
};
|
||||
|
||||
let src_path = src_dir.join(format!("{}.svg", base_icon));
|
||||
let real_path = if src_path.exists() {
|
||||
std::fs::canonicalize(&src_path).unwrap_or(src_path)
|
||||
} else {
|
||||
// Try from cache (already themed)
|
||||
let cached = actions_dir.join(format!("{}.svg", base_icon));
|
||||
if cached.exists() {
|
||||
cached
|
||||
} else {
|
||||
return base_icon.to_string();
|
||||
}
|
||||
};
|
||||
|
||||
let content = match std::fs::read_to_string(&real_path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return base_icon.to_string(),
|
||||
};
|
||||
|
||||
// Replace stroke color with the category color
|
||||
let color_with_hash = format!("#{}", color_hex);
|
||||
let tinted = content
|
||||
.replace("stroke=\"#222222\"", &format!("stroke=\"{}\"", color_with_hash))
|
||||
.replace("stroke=\"#f7f7f7\"", &format!("stroke=\"{}\"", color_with_hash));
|
||||
|
||||
std::fs::create_dir_all(&actions_dir).ok();
|
||||
std::fs::write(&dest, tinted).ok();
|
||||
|
||||
// Force icon theme to notice the new file
|
||||
refresh_icon_theme();
|
||||
|
||||
tinted_name
|
||||
}
|
||||
|
||||
/// Force GTK icon theme to rescan its search paths.
|
||||
pub fn refresh_icon_theme() {
|
||||
if let Some(display) = gtk::gdk::Display::default() {
|
||||
let theme = gtk::IconTheme::for_display(&display);
|
||||
let paths = theme.search_path();
|
||||
let path_refs: Vec<&Path> = paths.iter().map(|p| p.as_path()).collect();
|
||||
theme.set_search_path(&path_refs);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a category icon name, applying color tinting if a color is provided.
|
||||
pub fn resolve_category_icon(icon: &Option<String>, color: &Option<String>) -> Option<String> {
|
||||
match (icon, color) {
|
||||
(Some(icon_name), Some(col)) if !col.is_empty() => {
|
||||
Some(get_tinted_icon_name(icon_name, col))
|
||||
}
|
||||
(Some(icon_name), _) => Some(icon_name.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// List all available tabler icon names (without -symbolic variants).
|
||||
pub fn list_tabler_icons() -> Vec<String> {
|
||||
let Some(src_dir) = source_icon_dir() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let entries = match std::fs::read_dir(&src_dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let mut icons: Vec<String> = entries
|
||||
.flatten()
|
||||
.filter_map(|entry| {
|
||||
let name = entry.file_name().to_str()?.to_string();
|
||||
if name.starts_with("tabler-") && name.ends_with(".svg") && !name.contains("-symbolic") {
|
||||
Some(name.trim_end_matches(".svg").to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
icons.sort();
|
||||
icons
|
||||
}
|
||||
|
||||
pub fn setup_themed_icons() {
|
||||
let display = gtk::gdk::Display::default().expect("Could not get default display");
|
||||
let icon_theme = gtk::IconTheme::for_display(&display);
|
||||
|
||||
let style_manager = adw::StyleManager::default();
|
||||
let is_dark = style_manager.is_dark();
|
||||
|
||||
generate_themed_icons(is_dark);
|
||||
|
||||
// Register the themed cache directory FIRST (takes precedence)
|
||||
let cache_dir = cache_icon_dir();
|
||||
icon_theme.add_search_path(&cache_dir);
|
||||
|
||||
// Also register the source directory as fallback (for symbolic variants, etc.)
|
||||
if let Some(src_dir) = source_icon_dir() {
|
||||
if let Some(parent) = src_dir.parent().and_then(|p| p.parent()).and_then(|p| p.parent()) {
|
||||
icon_theme.add_search_path(parent);
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for theme changes and regenerate
|
||||
style_manager.connect_dark_notify(move |sm| {
|
||||
generate_themed_icons(sm.is_dark());
|
||||
|
||||
// Force icon theme to notice the change
|
||||
let display = gtk::gdk::Display::default().unwrap();
|
||||
let theme = gtk::IconTheme::for_display(&display);
|
||||
let paths = theme.search_path();
|
||||
let path_refs: Vec<&Path> = paths.iter().map(|p| p.as_path()).collect();
|
||||
theme.set_search_path(&path_refs);
|
||||
});
|
||||
}
|
||||
491
outlay-gtk/src/insights_view.rs
Normal file
491
outlay-gtk/src/insights_view.rs
Normal file
@@ -0,0 +1,491 @@
|
||||
use adw::prelude::*;
|
||||
use chrono::Datelike;
|
||||
use outlay_core::db::Database;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
pub struct InsightsView {
|
||||
pub container: gtk::Box,
|
||||
db: Rc<Database>,
|
||||
streaks_list: gtk::ListBox,
|
||||
achievements_box: gtk::Box,
|
||||
recap_list: gtk::ListBox,
|
||||
anomalies_list: gtk::ListBox,
|
||||
recap_mode: Rc<Cell<bool>>, // false = monthly, true = yearly
|
||||
year: Rc<Cell<i32>>,
|
||||
month: Rc<Cell<u32>>,
|
||||
on_navigate_category: Rc<RefCell<Option<Rc<dyn Fn(i64)>>>>,
|
||||
}
|
||||
|
||||
impl InsightsView {
|
||||
pub fn new(db: Rc<Database>) -> Self {
|
||||
let today = chrono::Local::now().date_naive();
|
||||
let year = Rc::new(Cell::new(today.year()));
|
||||
let month = Rc::new(Cell::new(today.month()));
|
||||
let recap_mode = Rc::new(Cell::new(false));
|
||||
|
||||
let container = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
container.set_margin_start(16);
|
||||
container.set_margin_end(16);
|
||||
container.set_margin_top(16);
|
||||
container.set_margin_bottom(16);
|
||||
|
||||
let scroll = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
|
||||
let inner = gtk::Box::new(gtk::Orientation::Vertical, 20);
|
||||
inner.set_margin_start(8);
|
||||
inner.set_margin_end(8);
|
||||
inner.set_margin_top(8);
|
||||
inner.set_margin_bottom(8);
|
||||
|
||||
// -- Streaks section --
|
||||
let streaks_group = adw::PreferencesGroup::builder()
|
||||
.title("Streaks")
|
||||
.build();
|
||||
let streaks_list = gtk::ListBox::new();
|
||||
streaks_list.set_selection_mode(gtk::SelectionMode::None);
|
||||
streaks_list.add_css_class("boxed-list");
|
||||
streaks_group.add(&streaks_list);
|
||||
inner.append(&streaks_group);
|
||||
|
||||
// -- Achievements section --
|
||||
let achievements_group = adw::PreferencesGroup::builder()
|
||||
.title("Achievements")
|
||||
.build();
|
||||
let achievements_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
achievements_group.add(&achievements_box);
|
||||
inner.append(&achievements_group);
|
||||
|
||||
// -- Monthly/Yearly Recap section --
|
||||
let recap_group = adw::PreferencesGroup::builder()
|
||||
.title("Recap")
|
||||
.build();
|
||||
|
||||
let toggle_box = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
||||
toggle_box.add_css_class("linked");
|
||||
toggle_box.set_halign(gtk::Align::Center);
|
||||
toggle_box.set_margin_bottom(8);
|
||||
|
||||
let month_btn = gtk::ToggleButton::with_label("This Month");
|
||||
month_btn.set_active(true);
|
||||
let year_btn = gtk::ToggleButton::with_label("This Year");
|
||||
year_btn.set_group(Some(&month_btn));
|
||||
toggle_box.append(&month_btn);
|
||||
toggle_box.append(&year_btn);
|
||||
recap_group.set_header_suffix(Some(&toggle_box));
|
||||
|
||||
let recap_list = gtk::ListBox::new();
|
||||
recap_list.set_selection_mode(gtk::SelectionMode::None);
|
||||
recap_list.add_css_class("boxed-list");
|
||||
recap_group.add(&recap_list);
|
||||
|
||||
inner.append(&recap_group);
|
||||
|
||||
// -- Anomalies section --
|
||||
let anomalies_group = adw::PreferencesGroup::builder()
|
||||
.title("Spending Insights")
|
||||
.build();
|
||||
let anomalies_list = gtk::ListBox::new();
|
||||
anomalies_list.set_selection_mode(gtk::SelectionMode::None);
|
||||
anomalies_list.add_css_class("boxed-list");
|
||||
anomalies_group.add(&anomalies_list);
|
||||
inner.append(&anomalies_group);
|
||||
|
||||
scroll.set_child(Some(&inner));
|
||||
container.append(&scroll);
|
||||
|
||||
let on_navigate_category: Rc<RefCell<Option<Rc<dyn Fn(i64)>>>> = Rc::new(RefCell::new(None));
|
||||
|
||||
let view = InsightsView {
|
||||
container,
|
||||
db,
|
||||
streaks_list,
|
||||
achievements_box,
|
||||
recap_list,
|
||||
anomalies_list,
|
||||
recap_mode,
|
||||
year,
|
||||
month,
|
||||
on_navigate_category,
|
||||
};
|
||||
|
||||
// Wire toggle
|
||||
{
|
||||
let mode = view.recap_mode.clone();
|
||||
let db_ref = view.db.clone();
|
||||
let list_ref = view.recap_list.clone();
|
||||
let y = view.year.clone();
|
||||
let m = view.month.clone();
|
||||
month_btn.connect_toggled(move |btn| {
|
||||
if btn.is_active() {
|
||||
mode.set(false);
|
||||
Self::populate_recap_static(&db_ref, &list_ref, false, y.get(), m.get());
|
||||
}
|
||||
});
|
||||
}
|
||||
{
|
||||
let mode = view.recap_mode.clone();
|
||||
let db_ref = view.db.clone();
|
||||
let list_ref = view.recap_list.clone();
|
||||
let y = view.year.clone();
|
||||
let m = view.month.clone();
|
||||
year_btn.connect_toggled(move |btn| {
|
||||
if btn.is_active() {
|
||||
mode.set(true);
|
||||
Self::populate_recap_static(&db_ref, &list_ref, true, y.get(), m.get());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
view.refresh();
|
||||
view
|
||||
}
|
||||
|
||||
pub fn refresh(&self) {
|
||||
let today = chrono::Local::now().date_naive();
|
||||
self.year.set(today.year());
|
||||
self.month.set(today.month());
|
||||
|
||||
self.populate_streaks();
|
||||
self.populate_achievements();
|
||||
Self::populate_recap_static(&self.db, &self.recap_list, self.recap_mode.get(), today.year(), today.month());
|
||||
self.populate_anomalies();
|
||||
}
|
||||
|
||||
pub fn set_on_navigate_category(&self, cb: Rc<dyn Fn(i64)>) {
|
||||
*self.on_navigate_category.borrow_mut() = Some(cb);
|
||||
}
|
||||
|
||||
fn clear_list(list: >k::ListBox) {
|
||||
while let Some(child) = list.first_child() {
|
||||
list.remove(&child);
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_box(bx: >k::Box) {
|
||||
while let Some(child) = bx.first_child() {
|
||||
bx.remove(&child);
|
||||
}
|
||||
}
|
||||
|
||||
fn populate_streaks(&self) {
|
||||
Self::clear_list(&self.streaks_list);
|
||||
|
||||
let today = chrono::Local::now().date_naive();
|
||||
|
||||
// No-spend streak
|
||||
let streak = self.db.get_no_spend_streak(today).unwrap_or(0);
|
||||
let streak_row = adw::ActionRow::builder()
|
||||
.title(&format!("{} day{}", streak, if streak == 1 { "" } else { "s" }))
|
||||
.subtitle("No-Spend Streak")
|
||||
.build();
|
||||
let icon = gtk::Image::from_icon_name("tabler-flame");
|
||||
icon.set_pixel_size(24);
|
||||
streak_row.add_prefix(&icon);
|
||||
self.streaks_list.append(&streak_row);
|
||||
|
||||
// Under-budget streak (months in a row where total expense < total budget)
|
||||
let budget_streak = self.count_under_budget_months(today);
|
||||
let budget_row = adw::ActionRow::builder()
|
||||
.title(&format!("{} month{}", budget_streak, if budget_streak == 1 { "" } else { "s" }))
|
||||
.subtitle("Under Budget")
|
||||
.build();
|
||||
let icon = gtk::Image::from_icon_name("tabler-shield-check");
|
||||
icon.set_pixel_size(24);
|
||||
budget_row.add_prefix(&icon);
|
||||
self.streaks_list.append(&budget_row);
|
||||
|
||||
// Savings streak (months with positive net)
|
||||
let savings_streak = self.count_positive_net_months(today);
|
||||
let savings_row = adw::ActionRow::builder()
|
||||
.title(&format!("{} month{}", savings_streak, if savings_streak == 1 { "" } else { "s" }))
|
||||
.subtitle("Positive Savings")
|
||||
.build();
|
||||
let icon = gtk::Image::from_icon_name("tabler-trending-up");
|
||||
icon.set_pixel_size(24);
|
||||
savings_row.add_prefix(&icon);
|
||||
self.streaks_list.append(&savings_row);
|
||||
}
|
||||
|
||||
fn count_under_budget_months(&self, today: chrono::NaiveDate) -> i32 {
|
||||
let mut streak = 0;
|
||||
let mut y = today.year();
|
||||
let mut m = today.month();
|
||||
// Check up to 24 months back
|
||||
for _ in 0..24 {
|
||||
let progress = self.db.get_all_budget_progress(y, m).unwrap_or_default();
|
||||
if progress.is_empty() { break; }
|
||||
let all_under = progress.iter().all(|(_, spent, budget, _, _)| *spent <= *budget);
|
||||
if all_under {
|
||||
streak += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
// Previous month
|
||||
if m == 1 { y -= 1; m = 12; } else { m -= 1; }
|
||||
}
|
||||
streak
|
||||
}
|
||||
|
||||
fn count_positive_net_months(&self, today: chrono::NaiveDate) -> i32 {
|
||||
use outlay_core::models::TransactionType;
|
||||
let mut streak = 0;
|
||||
let mut y = today.year();
|
||||
let mut m = today.month();
|
||||
for _ in 0..24 {
|
||||
let income = self.db.get_monthly_total(y, m, TransactionType::Income).unwrap_or(0.0);
|
||||
let expense = self.db.get_monthly_total(y, m, TransactionType::Expense).unwrap_or(0.0);
|
||||
if income <= 0.0 && expense <= 0.0 { break; }
|
||||
if income >= expense {
|
||||
streak += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
if m == 1 { y -= 1; m = 12; } else { m -= 1; }
|
||||
}
|
||||
streak
|
||||
}
|
||||
|
||||
fn populate_achievements(&self) {
|
||||
Self::clear_box(&self.achievements_box);
|
||||
|
||||
let achievements = self.db.list_achievements().unwrap_or_default();
|
||||
if achievements.is_empty() {
|
||||
let label = gtk::Label::new(Some("No achievements yet - keep tracking!"));
|
||||
label.add_css_class("dim-label");
|
||||
label.set_margin_top(8);
|
||||
label.set_margin_bottom(8);
|
||||
self.achievements_box.append(&label);
|
||||
return;
|
||||
}
|
||||
|
||||
let flow = gtk::FlowBox::new();
|
||||
flow.set_selection_mode(gtk::SelectionMode::None);
|
||||
flow.set_max_children_per_line(4);
|
||||
flow.set_min_children_per_line(2);
|
||||
flow.set_column_spacing(8);
|
||||
flow.set_row_spacing(8);
|
||||
|
||||
for ach in &achievements {
|
||||
let card = gtk::Box::new(gtk::Orientation::Vertical, 4);
|
||||
card.set_halign(gtk::Align::Center);
|
||||
card.set_margin_top(8);
|
||||
card.set_margin_bottom(8);
|
||||
card.set_margin_start(4);
|
||||
card.set_margin_end(4);
|
||||
|
||||
let icon_name = Self::achievement_icon(&ach.name);
|
||||
let icon = gtk::Image::from_icon_name(icon_name);
|
||||
icon.set_pixel_size(32);
|
||||
|
||||
if ach.earned_at.is_none() {
|
||||
icon.set_opacity(0.75);
|
||||
}
|
||||
|
||||
let name_label = gtk::Label::new(Some(&ach.name));
|
||||
name_label.set_wrap(true);
|
||||
name_label.set_max_width_chars(12);
|
||||
name_label.set_justify(gtk::Justification::Center);
|
||||
if ach.earned_at.is_some() {
|
||||
name_label.add_css_class("heading");
|
||||
} else {
|
||||
name_label.add_css_class("dim-label");
|
||||
}
|
||||
|
||||
card.append(&icon);
|
||||
card.append(&name_label);
|
||||
|
||||
if !ach.description.is_empty() {
|
||||
let desc_label = gtk::Label::new(Some(&ach.description));
|
||||
desc_label.add_css_class("caption");
|
||||
desc_label.add_css_class("dim-label");
|
||||
desc_label.set_wrap(true);
|
||||
desc_label.set_max_width_chars(14);
|
||||
desc_label.set_justify(gtk::Justification::Center);
|
||||
card.append(&desc_label);
|
||||
}
|
||||
|
||||
if let Some(ref date) = ach.earned_at {
|
||||
let short = date.split(' ').next().unwrap_or(date);
|
||||
let date_label = gtk::Label::new(Some(short));
|
||||
date_label.add_css_class("caption");
|
||||
date_label.add_css_class("success");
|
||||
card.append(&date_label);
|
||||
}
|
||||
|
||||
flow.append(&card);
|
||||
}
|
||||
|
||||
self.achievements_box.append(&flow);
|
||||
}
|
||||
|
||||
fn populate_recap_static(db: &Database, list: >k::ListBox, yearly: bool, year: i32, month: u32) {
|
||||
Self::clear_list(list);
|
||||
|
||||
if yearly {
|
||||
// Yearly view: month-by-month summary
|
||||
let summaries = db.get_yearly_month_summaries(year).unwrap_or_default();
|
||||
|
||||
// Year totals header
|
||||
let total_income: f64 = summaries.iter().map(|(_, i, _)| i).sum();
|
||||
let total_expense: f64 = summaries.iter().map(|(_, _, e)| e).sum();
|
||||
let net = total_income - total_expense;
|
||||
|
||||
let header = adw::ActionRow::builder()
|
||||
.title(&format!("{} Summary", year))
|
||||
.subtitle(&format!(
|
||||
"Income: {:.2} | Expenses: {:.2} | Net: {:.2}",
|
||||
total_income, total_expense, net
|
||||
))
|
||||
.build();
|
||||
header.add_css_class("property");
|
||||
list.append(&header);
|
||||
|
||||
for (label, income, expense) in &summaries {
|
||||
if *income <= 0.0 && *expense <= 0.0 { continue; }
|
||||
let net = income - expense;
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(label)
|
||||
.subtitle(&format!(
|
||||
"In: {:.2} Out: {:.2} Net: {:.2}",
|
||||
income, expense, net
|
||||
))
|
||||
.build();
|
||||
let net_label = gtk::Label::new(Some(&format!("{:+.2}", net)));
|
||||
if net >= 0.0 {
|
||||
net_label.add_css_class("success");
|
||||
} else {
|
||||
net_label.add_css_class("error");
|
||||
}
|
||||
row.add_suffix(&net_label);
|
||||
list.append(&row);
|
||||
}
|
||||
} else {
|
||||
// Monthly recap with category breakdown
|
||||
let recap = match db.get_monthly_recap(year, month) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
// Summary header
|
||||
let header = adw::ActionRow::builder()
|
||||
.title(&format!("{:04}-{:02} Summary", year, month))
|
||||
.subtitle(&format!(
|
||||
"Income: {:.2} | Expenses: {:.2} | Net: {:.2} | {} transactions",
|
||||
recap.total_income, recap.total_expenses, recap.net, recap.transaction_count
|
||||
))
|
||||
.build();
|
||||
header.add_css_class("property");
|
||||
list.append(&header);
|
||||
|
||||
for cat in &recap.categories {
|
||||
let icon_name = cat.category_icon.as_deref().unwrap_or("folder-symbolic");
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&cat.category_name)
|
||||
.subtitle(&format!("{:.2} ({:.1}%)", cat.amount, cat.percentage))
|
||||
.build();
|
||||
|
||||
let icon = gtk::Image::from_icon_name(icon_name);
|
||||
icon.set_pixel_size(20);
|
||||
row.add_prefix(&icon);
|
||||
|
||||
// Change badge
|
||||
if let Some(change) = cat.change_pct {
|
||||
let badge_text = if change >= 0.0 {
|
||||
format!("+{:.0}%", change)
|
||||
} else {
|
||||
format!("{:.0}%", change)
|
||||
};
|
||||
let badge = gtk::Label::new(Some(&badge_text));
|
||||
badge.add_css_class("caption");
|
||||
if change > 10.0 {
|
||||
badge.add_css_class("error");
|
||||
} else if change < -10.0 {
|
||||
badge.add_css_class("success");
|
||||
} else {
|
||||
badge.add_css_class("dim-label");
|
||||
}
|
||||
row.add_suffix(&badge);
|
||||
} else {
|
||||
let badge = gtk::Label::new(Some("new"));
|
||||
badge.add_css_class("caption");
|
||||
badge.add_css_class("dim-label");
|
||||
row.add_suffix(&badge);
|
||||
}
|
||||
|
||||
list.append(&row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn populate_anomalies(&self) {
|
||||
Self::clear_list(&self.anomalies_list);
|
||||
|
||||
let today = chrono::Local::now().date_naive();
|
||||
let anomalies = self.db.detect_anomalies(today.year(), today.month());
|
||||
|
||||
if anomalies.is_empty() {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("No spending anomalies this month")
|
||||
.build();
|
||||
row.add_css_class("dim-label");
|
||||
self.anomalies_list.append(&row);
|
||||
return;
|
||||
}
|
||||
|
||||
for (message, deviation, cat_id) in &anomalies {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(message)
|
||||
.subtitle(&format!("Deviation: {:.2}", deviation))
|
||||
.activatable(cat_id.is_some())
|
||||
.build();
|
||||
|
||||
let icon = if *deviation > 0.0 {
|
||||
let img = gtk::Image::from_icon_name("tabler-alert-triangle");
|
||||
img
|
||||
} else {
|
||||
gtk::Image::from_icon_name("tabler-info-circle")
|
||||
};
|
||||
icon.set_pixel_size(20);
|
||||
row.add_prefix(&icon);
|
||||
|
||||
if let Some(cid) = cat_id {
|
||||
let nav = self.on_navigate_category.clone();
|
||||
let cid = *cid;
|
||||
row.connect_activated(move |_| {
|
||||
if let Some(ref cb) = *nav.borrow() {
|
||||
cb(cid);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
self.anomalies_list.append(&row);
|
||||
}
|
||||
}
|
||||
|
||||
/// Map achievement names to unique tabler icon names.
|
||||
fn achievement_icon(name: &str) -> &'static str {
|
||||
match name {
|
||||
"First Transaction" => "tabler-sparkles",
|
||||
"7-Day No-Spend" => "tabler-calendar-check",
|
||||
"30-Day No-Spend" => "tabler-hourglass-high",
|
||||
"Month Under Budget" => "tabler-shield-check",
|
||||
"3 Months Under Budget" => "tabler-shield-star",
|
||||
"First Goal Completed" => "tabler-target-arrow",
|
||||
"100 Transactions" => "tabler-list-check",
|
||||
"Budget Streak 6mo" => "tabler-crown",
|
||||
"500 Transactions" => "tabler-receipt",
|
||||
"1000 Transactions" => "tabler-diamond",
|
||||
"First Recurring" => "tabler-clock-check",
|
||||
"5 Goals Completed" => "tabler-trophy",
|
||||
"Year Under Budget" => "tabler-rosette-discount-check",
|
||||
"Big Saver" => "tabler-moneybag",
|
||||
"Category Master" => "tabler-chart-arrows-vertical",
|
||||
"Streak Champion" => "tabler-rocket",
|
||||
_ => "tabler-award",
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,31 @@
|
||||
mod budgets_view;
|
||||
mod calendar_view;
|
||||
mod category_combo;
|
||||
mod charts_view;
|
||||
mod credit_cards_view;
|
||||
mod date_picker;
|
||||
mod edit_dialog;
|
||||
mod forecast_view;
|
||||
mod goals_view;
|
||||
mod history_view;
|
||||
mod icon_theme;
|
||||
mod insights_view;
|
||||
mod log_view;
|
||||
mod month_nav;
|
||||
mod numpad;
|
||||
mod quick_add;
|
||||
mod recurring_view;
|
||||
mod settings_view;
|
||||
mod sparkline;
|
||||
mod subscriptions_view;
|
||||
mod tray;
|
||||
mod wishlist_view;
|
||||
mod window;
|
||||
|
||||
use adw::prelude::*;
|
||||
use adw::Application;
|
||||
use gtk::glib;
|
||||
use outlay_core::db::Database;
|
||||
use outlay_core::recurring::generate_missed_transactions;
|
||||
use std::rc::Rc;
|
||||
|
||||
const APP_ID: &str = "io.github.outlay";
|
||||
@@ -24,25 +39,335 @@ fn main() {
|
||||
app.run();
|
||||
}
|
||||
|
||||
mod fontconfig_ffi {
|
||||
use std::os::raw::c_char;
|
||||
|
||||
#[repr(C)]
|
||||
pub struct FcConfig {
|
||||
_private: [u8; 0],
|
||||
}
|
||||
|
||||
unsafe extern "C" {
|
||||
pub fn FcConfigGetCurrent() -> *mut FcConfig;
|
||||
pub fn FcConfigAppFontAddFile(config: *mut FcConfig, file: *const c_char) -> i32;
|
||||
}
|
||||
}
|
||||
|
||||
fn register_fonts() {
|
||||
let exe_path = std::env::current_exe().unwrap_or_default();
|
||||
let exe_dir = exe_path.parent().unwrap_or(std::path::Path::new("."));
|
||||
|
||||
let candidates = [
|
||||
exe_dir.join("../share/fonts"),
|
||||
exe_dir.join("../../outlay-gtk/data/fonts"),
|
||||
];
|
||||
|
||||
for dir in &candidates {
|
||||
if dir.exists() {
|
||||
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().map_or(false, |e| e == "ttf") {
|
||||
let Some(path_str) = path.to_str() else { continue };
|
||||
let Ok(c_path) = std::ffi::CString::new(path_str) else { continue };
|
||||
unsafe {
|
||||
let config = fontconfig_ffi::FcConfigGetCurrent();
|
||||
if !config.is_null() {
|
||||
fontconfig_ffi::FcConfigAppFontAddFile(
|
||||
config,
|
||||
c_path.as_ptr(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn load_css() {
|
||||
let provider = gtk::CssProvider::new();
|
||||
provider.load_from_data(include_str!("style.css"));
|
||||
gtk::style_context_add_provider_for_display(
|
||||
>k::gdk::Display::default().expect("Could not connect to a display."),
|
||||
&provider,
|
||||
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
);
|
||||
}
|
||||
|
||||
fn build_ui(app: &Application) {
|
||||
register_fonts();
|
||||
load_css();
|
||||
icon_theme::setup_themed_icons();
|
||||
|
||||
let data_dir = glib::user_data_dir().join("outlay");
|
||||
std::fs::create_dir_all(&data_dir).expect("Failed to create data directory");
|
||||
let db_path = data_dir.join("outlay.db");
|
||||
|
||||
let db = Database::open(&db_path).expect("Failed to open database");
|
||||
|
||||
// Auto-resume paused recurring transactions that have passed their resume date
|
||||
{
|
||||
let today = chrono::Local::now().date_naive();
|
||||
let resumed = db.auto_resume_recurring(today).unwrap_or(0);
|
||||
if resumed > 0 {
|
||||
// Will show a toast after window is created
|
||||
}
|
||||
}
|
||||
|
||||
// Generate any missed recurring transactions on launch
|
||||
let recurring_count = generate_missed_transactions(&db, chrono::Local::now().date_naive())
|
||||
.unwrap_or(0);
|
||||
let base_currency = db.get_setting("base_currency")
|
||||
.ok().flatten()
|
||||
.unwrap_or_else(|| "USD".to_string());
|
||||
let (recurring_count, recurring_details) = outlay_core::recurring::generate_missed_transactions_detailed(
|
||||
&db,
|
||||
chrono::Local::now().date_naive(),
|
||||
&base_currency,
|
||||
).unwrap_or((0, Vec::new()));
|
||||
|
||||
// Send per-transaction notifications if enabled
|
||||
{
|
||||
let notify_recurring = db.get_setting("notify_recurring")
|
||||
.ok().flatten().unwrap_or_else(|| "1".to_string()) == "1";
|
||||
if notify_recurring {
|
||||
for info in &recurring_details {
|
||||
let body = format!("{}: {:.2} {}", info.description, info.amount, info.currency);
|
||||
outlay_core::notifications::send_notification("Recurring Transaction", &body, "normal");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Weekly spending digest notification
|
||||
{
|
||||
let last_digest = db.get_setting("last_digest_date").ok().flatten();
|
||||
let today = chrono::Local::now().date_naive();
|
||||
let today_str = today.format("%Y-%m-%d").to_string();
|
||||
let should_show = match &last_digest {
|
||||
Some(date_str) => {
|
||||
if let Ok(last) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
|
||||
(today - last).num_days() >= 7
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
None => true,
|
||||
};
|
||||
if should_show {
|
||||
use chrono::Datelike;
|
||||
use outlay_core::models::TransactionType;
|
||||
let expense = db.get_monthly_total(today.year(), today.month(), TransactionType::Expense).unwrap_or(0.0);
|
||||
let income = db.get_monthly_total(today.year(), today.month(), TransactionType::Income).unwrap_or(0.0);
|
||||
let body = format!(
|
||||
"This month so far: spent {:.2} {}, earned {:.2} {}",
|
||||
expense, base_currency, income, base_currency
|
||||
);
|
||||
let notification = gtk::gio::Notification::new("Monthly Summary");
|
||||
notification.set_body(Some(&body));
|
||||
app.send_notification(Some("monthly-digest"), ¬ification);
|
||||
db.set_setting("last_digest_date", &today_str).ok();
|
||||
}
|
||||
}
|
||||
|
||||
// Budget threshold notifications
|
||||
{
|
||||
use chrono::Datelike;
|
||||
let today = chrono::Local::now().date_naive();
|
||||
let all_progress = db.get_all_budget_progress(today.year(), today.month())
|
||||
.unwrap_or_default();
|
||||
for (cat_name, spent, budget_amt, _pct, cat_id) in &all_progress {
|
||||
let crossed = db.check_budget_thresholds(*cat_id, &format!("{:04}-{:02}", today.year(), today.month()))
|
||||
.unwrap_or_default();
|
||||
for threshold in crossed {
|
||||
let days_left = {
|
||||
let days_in_month = if today.month() == 12 {
|
||||
chrono::NaiveDate::from_ymd_opt(today.year() + 1, 1, 1)
|
||||
} else {
|
||||
chrono::NaiveDate::from_ymd_opt(today.year(), today.month() + 1, 1)
|
||||
}.and_then(|d| d.pred_opt()).map(|d| d.day()).unwrap_or(30);
|
||||
days_in_month - today.day()
|
||||
};
|
||||
let body = if threshold >= 100 {
|
||||
format!(
|
||||
"Over budget: {} spending ({:.2} {}) exceeded your {:.2} {} limit",
|
||||
cat_name, spent, base_currency, budget_amt, base_currency
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Budget alert: You've used {}% of your {} budget ({:.2} of {:.2} {}) with {} days left",
|
||||
threshold, cat_name, spent, budget_amt, base_currency, days_left
|
||||
)
|
||||
};
|
||||
let notif = gtk::gio::Notification::new("Budget Alert");
|
||||
notif.set_body(Some(&body));
|
||||
app.send_notification(
|
||||
Some(&format!("budget-{}-{}", cat_id, threshold)),
|
||||
¬if,
|
||||
);
|
||||
let month_str = format!("{:04}-{:02}", today.year(), today.month());
|
||||
db.record_notification(*cat_id, &month_str, threshold).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bill reminder notifications
|
||||
{
|
||||
let upcoming = db.list_upcoming_bills(14).unwrap_or_default();
|
||||
for (rec, next_date) in &upcoming {
|
||||
let today = chrono::Local::now().date_naive();
|
||||
let days_until = (*next_date - today).num_days();
|
||||
if days_until <= rec.reminder_days as i64 {
|
||||
let cat_name = db.get_category(rec.category_id)
|
||||
.ok()
|
||||
.map(|c| c.name)
|
||||
.unwrap_or_else(|| "Bill".to_string());
|
||||
let body = if days_until == 0 {
|
||||
format!("{} ({:.2} {}) is due today", cat_name, rec.amount, rec.currency)
|
||||
} else if days_until == 1 {
|
||||
format!("{} ({:.2} {}) is due tomorrow", cat_name, rec.amount, rec.currency)
|
||||
} else {
|
||||
format!("{} ({:.2} {}) is due in {} days", cat_name, rec.amount, rec.currency, days_until)
|
||||
};
|
||||
let notification = gtk::gio::Notification::new("Bill Reminder");
|
||||
notification.set_body(Some(&body));
|
||||
app.send_notification(Some(&format!("bill-{}", rec.id)), ¬ification);
|
||||
outlay_core::notifications::send_notification("Bill Reminder", &body, "normal");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let db = Rc::new(db);
|
||||
|
||||
let main_window = window::MainWindow::new(app, db);
|
||||
let main_window = window::MainWindow::new(app, db.clone());
|
||||
|
||||
if recurring_count > 0 {
|
||||
let msg = format!("Added {} recurring transaction(s)", recurring_count);
|
||||
let toast = adw::Toast::new(&msg);
|
||||
main_window.log_view.toast_overlay.add_toast(toast);
|
||||
|
||||
let notification = gtk::gio::Notification::new("Recurring Transactions");
|
||||
notification.set_body(Some(&msg));
|
||||
app.send_notification(Some("recurring-added"), ¬ification);
|
||||
}
|
||||
|
||||
// Scheduled automatic backup
|
||||
{
|
||||
if let Some(path) = settings_view::SettingsView::check_and_run_auto_backup(&db) {
|
||||
let toast = adw::Toast::new(&format!("Auto-backup saved to {}", path.display()));
|
||||
main_window.log_view.toast_overlay.add_toast(toast);
|
||||
}
|
||||
}
|
||||
|
||||
// 6.3: Backup reminder
|
||||
{
|
||||
let last_backup = db.get_setting("last_backup_date").ok().flatten();
|
||||
let today = chrono::Local::now().date_naive();
|
||||
let needs_reminder = match &last_backup {
|
||||
Some(date_str) => {
|
||||
if let Ok(last) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
|
||||
(today - last).num_days() >= 30
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
None => true,
|
||||
};
|
||||
let txn_count = db.get_export_stats().map(|(c, _, _, _)| c).unwrap_or(0);
|
||||
if needs_reminder && txn_count > 50 {
|
||||
let toast = adw::Toast::new("It's been a while since your last backup - visit Settings to create one");
|
||||
toast.set_timeout(10);
|
||||
main_window.log_view.toast_overlay.add_toast(toast);
|
||||
}
|
||||
}
|
||||
|
||||
// Achievement check on startup
|
||||
{
|
||||
let today = chrono::Local::now().date_naive();
|
||||
let newly_earned = db.check_and_award_achievements(today).unwrap_or_default();
|
||||
for name in &newly_earned {
|
||||
let toast = adw::Toast::new(&format!("Achievement unlocked: {}", name));
|
||||
main_window.log_view.toast_overlay.add_toast(toast);
|
||||
}
|
||||
}
|
||||
|
||||
// Anomaly toast on startup
|
||||
{
|
||||
use chrono::Datelike;
|
||||
let today = chrono::Local::now().date_naive();
|
||||
let anomalies = db.detect_anomalies(today.year(), today.month());
|
||||
if !anomalies.is_empty() {
|
||||
let toast = adw::Toast::new(&format!("{} spending insight(s) this month", anomalies.len()));
|
||||
toast.set_timeout(5);
|
||||
main_window.log_view.toast_overlay.add_toast(toast);
|
||||
}
|
||||
}
|
||||
|
||||
// Keep the app alive when the window is hidden.
|
||||
// The guard must be moved into the timeout closure so it lives
|
||||
// as long as the GTK main loop runs.
|
||||
let hold_guard = app.hold();
|
||||
|
||||
// Spawn system tray
|
||||
let (tray_tx, tray_rx) = std::sync::mpsc::channel::<tray::TrayCommand>();
|
||||
tray::spawn_tray(tray_tx);
|
||||
|
||||
// Poll tray commands from the GTK main loop
|
||||
let main_window = Rc::new(main_window);
|
||||
|
||||
// Wire donut chart click -> navigate to filtered History
|
||||
{
|
||||
let mw = main_window.clone();
|
||||
main_window.charts_view.set_on_navigate_category(Rc::new(move |cat_id| {
|
||||
mw.switch_to_history_filtered(cat_id);
|
||||
}));
|
||||
}
|
||||
|
||||
// Wire insights anomaly click -> navigate to filtered History
|
||||
{
|
||||
let mw = main_window.clone();
|
||||
main_window.insights_view.set_on_navigate_category(Rc::new(move |cat_id| {
|
||||
mw.switch_to_history_filtered(cat_id);
|
||||
}));
|
||||
}
|
||||
|
||||
// Wire history anomaly banner -> navigate to Insights
|
||||
{
|
||||
let mw = main_window.clone();
|
||||
main_window.history_view.set_on_navigate_insights(Rc::new(move || {
|
||||
mw.switch_to_insights();
|
||||
}));
|
||||
}
|
||||
|
||||
let app_ref = app.clone();
|
||||
{
|
||||
let mw = main_window.clone();
|
||||
let db_ref = db;
|
||||
let _hold = hold_guard;
|
||||
glib::timeout_add_local(std::time::Duration::from_millis(100), move || {
|
||||
let _ = &_hold;
|
||||
while let Ok(cmd) = tray_rx.try_recv() {
|
||||
match cmd {
|
||||
tray::TrayCommand::Show => {
|
||||
mw.show();
|
||||
}
|
||||
tray::TrayCommand::QuickAdd => {
|
||||
quick_add::show_quick_add_popup(&db_ref, &app_ref);
|
||||
}
|
||||
tray::TrayCommand::LogExpense => {
|
||||
mw.switch_to_log(false);
|
||||
mw.show();
|
||||
}
|
||||
tray::TrayCommand::LogIncome => {
|
||||
mw.switch_to_log(true);
|
||||
mw.show();
|
||||
}
|
||||
tray::TrayCommand::Quit => {
|
||||
mw.save_window_state(&db_ref);
|
||||
app_ref.quit();
|
||||
}
|
||||
}
|
||||
}
|
||||
glib::ControlFlow::Continue
|
||||
});
|
||||
}
|
||||
|
||||
main_window.window.present();
|
||||
|
||||
231
outlay-gtk/src/month_nav.rs
Normal file
231
outlay-gtk/src/month_nav.rs
Normal file
@@ -0,0 +1,231 @@
|
||||
use chrono::Datelike;
|
||||
use gtk::prelude::*;
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
const MONTH_NAMES: [&str; 12] = [
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
];
|
||||
|
||||
pub struct MonthNav {
|
||||
pub container: gtk::Box,
|
||||
pub year: Rc<Cell<i32>>,
|
||||
pub month: Rc<Cell<u32>>,
|
||||
label: gtk::Label,
|
||||
}
|
||||
|
||||
impl MonthNav {
|
||||
pub fn new(on_change: impl Fn(i32, u32) + 'static) -> Self {
|
||||
let today = chrono::Local::now().date_naive();
|
||||
let year = Rc::new(Cell::new(today.year()));
|
||||
let month = Rc::new(Cell::new(today.month()));
|
||||
|
||||
let container = gtk::Box::new(gtk::Orientation::Horizontal, 12);
|
||||
container.set_halign(gtk::Align::Center);
|
||||
container.set_margin_top(12);
|
||||
container.set_margin_bottom(12);
|
||||
|
||||
let prev_btn = gtk::Button::from_icon_name("go-previous-symbolic");
|
||||
prev_btn.add_css_class("flat");
|
||||
prev_btn.add_css_class("circular");
|
||||
prev_btn.set_tooltip_text(Some("Previous month"));
|
||||
|
||||
// Month label as a button that opens a picker popover
|
||||
let label = gtk::Label::new(None);
|
||||
label.add_css_class("month-nav-label");
|
||||
label.set_width_chars(18);
|
||||
|
||||
let label_btn = gtk::Button::new();
|
||||
label_btn.set_child(Some(&label));
|
||||
label_btn.add_css_class("flat");
|
||||
label_btn.set_tooltip_text(Some("Pick a month"));
|
||||
|
||||
let next_btn = gtk::Button::from_icon_name("go-next-symbolic");
|
||||
next_btn.add_css_class("flat");
|
||||
next_btn.add_css_class("circular");
|
||||
next_btn.set_tooltip_text(Some("Next month"));
|
||||
|
||||
container.append(&prev_btn);
|
||||
container.append(&label_btn);
|
||||
container.append(&next_btn);
|
||||
|
||||
let on_change = Rc::new(on_change);
|
||||
|
||||
Self::update_label(&label, year.get(), month.get());
|
||||
|
||||
// Month picker popover
|
||||
let popover = gtk::Popover::new();
|
||||
popover.set_parent(&label_btn);
|
||||
|
||||
let picker_box = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
||||
picker_box.set_margin_start(12);
|
||||
picker_box.set_margin_end(12);
|
||||
picker_box.set_margin_top(12);
|
||||
picker_box.set_margin_bottom(12);
|
||||
|
||||
// Year row: prev year | year label | next year
|
||||
let year_row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
year_row.set_halign(gtk::Align::Center);
|
||||
|
||||
let picker_year = Rc::new(Cell::new(year.get()));
|
||||
|
||||
let year_label = gtk::Label::new(Some(&year.get().to_string()));
|
||||
year_label.add_css_class("heading");
|
||||
year_label.set_width_chars(6);
|
||||
|
||||
let py_btn = gtk::Button::from_icon_name("go-previous-symbolic");
|
||||
py_btn.add_css_class("flat");
|
||||
py_btn.add_css_class("circular");
|
||||
let ny_btn = gtk::Button::from_icon_name("go-next-symbolic");
|
||||
ny_btn.add_css_class("flat");
|
||||
ny_btn.add_css_class("circular");
|
||||
|
||||
year_row.append(&py_btn);
|
||||
year_row.append(&year_label);
|
||||
year_row.append(&ny_btn);
|
||||
picker_box.append(&year_row);
|
||||
|
||||
{
|
||||
let py = picker_year.clone();
|
||||
let yl = year_label.clone();
|
||||
py_btn.connect_clicked(move |_| {
|
||||
py.set(py.get() - 1);
|
||||
yl.set_label(&py.get().to_string());
|
||||
});
|
||||
}
|
||||
{
|
||||
let py = picker_year.clone();
|
||||
let yl = year_label.clone();
|
||||
ny_btn.connect_clicked(move |_| {
|
||||
py.set(py.get() + 1);
|
||||
yl.set_label(&py.get().to_string());
|
||||
});
|
||||
}
|
||||
|
||||
// 4x3 grid of month buttons
|
||||
let month_grid = gtk::Grid::new();
|
||||
month_grid.set_row_spacing(4);
|
||||
month_grid.set_column_spacing(4);
|
||||
|
||||
for i in 0..12u32 {
|
||||
let btn = gtk::Button::with_label(MONTH_NAMES[i as usize]);
|
||||
btn.add_css_class("flat");
|
||||
btn.set_hexpand(true);
|
||||
|
||||
let y = year.clone();
|
||||
let m = month.clone();
|
||||
let py = picker_year.clone();
|
||||
let lbl = label.clone();
|
||||
let cb = on_change.clone();
|
||||
let pop = popover.clone();
|
||||
btn.connect_clicked(move |_| {
|
||||
let new_month = i + 1;
|
||||
let new_year = py.get();
|
||||
y.set(new_year);
|
||||
m.set(new_month);
|
||||
Self::update_label(&lbl, new_year, new_month);
|
||||
cb(new_year, new_month);
|
||||
pop.popdown();
|
||||
});
|
||||
|
||||
month_grid.attach(&btn, (i % 4) as i32, (i / 4) as i32, 1, 1);
|
||||
}
|
||||
|
||||
picker_box.append(&month_grid);
|
||||
|
||||
// "Today" button to quickly jump to current month
|
||||
let today_btn = gtk::Button::with_label("Today");
|
||||
today_btn.add_css_class("flat");
|
||||
{
|
||||
let y = year.clone();
|
||||
let m = month.clone();
|
||||
let lbl = label.clone();
|
||||
let cb = on_change.clone();
|
||||
let pop = popover.clone();
|
||||
today_btn.connect_clicked(move |_| {
|
||||
let now = chrono::Local::now().date_naive();
|
||||
y.set(now.year());
|
||||
m.set(now.month());
|
||||
Self::update_label(&lbl, now.year(), now.month());
|
||||
cb(now.year(), now.month());
|
||||
pop.popdown();
|
||||
});
|
||||
}
|
||||
picker_box.append(&today_btn);
|
||||
|
||||
popover.set_child(Some(&picker_box));
|
||||
|
||||
// Sync picker year when popover opens
|
||||
{
|
||||
let py = picker_year.clone();
|
||||
let y = year.clone();
|
||||
let yl = year_label.clone();
|
||||
popover.connect_show(move |_| {
|
||||
py.set(y.get());
|
||||
yl.set_label(&y.get().to_string());
|
||||
});
|
||||
}
|
||||
|
||||
label_btn.connect_clicked(move |_| {
|
||||
popover.popup();
|
||||
});
|
||||
|
||||
// Prev/Next buttons
|
||||
{
|
||||
let y = year.clone();
|
||||
let m = month.clone();
|
||||
let lbl = label.clone();
|
||||
let cb = on_change.clone();
|
||||
prev_btn.connect_clicked(move |_| {
|
||||
let mut yr = y.get();
|
||||
let mut mo = m.get();
|
||||
if mo == 1 {
|
||||
mo = 12;
|
||||
yr -= 1;
|
||||
} else {
|
||||
mo -= 1;
|
||||
}
|
||||
y.set(yr);
|
||||
m.set(mo);
|
||||
Self::update_label(&lbl, yr, mo);
|
||||
cb(yr, mo);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let y = year.clone();
|
||||
let m = month.clone();
|
||||
let lbl = label.clone();
|
||||
let cb = on_change;
|
||||
next_btn.connect_clicked(move |_| {
|
||||
let mut yr = y.get();
|
||||
let mut mo = m.get();
|
||||
if mo == 12 {
|
||||
mo = 1;
|
||||
yr += 1;
|
||||
} else {
|
||||
mo += 1;
|
||||
}
|
||||
y.set(yr);
|
||||
m.set(mo);
|
||||
Self::update_label(&lbl, yr, mo);
|
||||
cb(yr, mo);
|
||||
});
|
||||
}
|
||||
|
||||
MonthNav { container, year, month, label }
|
||||
}
|
||||
|
||||
pub fn set_month(&self, year: i32, month: u32) {
|
||||
self.year.set(year);
|
||||
self.month.set(month);
|
||||
Self::update_label(&self.label, year, month);
|
||||
}
|
||||
|
||||
fn update_label(label: >k::Label, year: i32, month: u32) {
|
||||
let date = chrono::NaiveDate::from_ymd_opt(year, month, 1).unwrap();
|
||||
let month_name = date.format("%B %Y").to_string();
|
||||
label.set_label(&month_name);
|
||||
}
|
||||
}
|
||||
173
outlay-gtk/src/numpad.rs
Normal file
173
outlay-gtk/src/numpad.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
use adw::prelude::*;
|
||||
|
||||
/// Attach a numpad popover to any editable widget (gtk::Entry, adw::EntryRow, etc.).
|
||||
/// The popover opens on click and provides digit entry, operators, and expression evaluation.
|
||||
pub fn attach_numpad<W>(entry: &W)
|
||||
where
|
||||
W: IsA<gtk::Widget> + IsA<gtk::Editable> + Clone + 'static,
|
||||
{
|
||||
let numpad_popover = gtk::Popover::new();
|
||||
numpad_popover.set_parent(entry);
|
||||
numpad_popover.set_autohide(true);
|
||||
|
||||
let numpad_outer = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
||||
numpad_outer.set_margin_top(8);
|
||||
numpad_outer.set_margin_bottom(8);
|
||||
numpad_outer.set_margin_start(8);
|
||||
numpad_outer.set_margin_end(8);
|
||||
|
||||
let numpad_grid = gtk::Grid::new();
|
||||
numpad_grid.set_row_spacing(6);
|
||||
numpad_grid.set_column_spacing(6);
|
||||
numpad_grid.add_css_class("numpad-grid");
|
||||
|
||||
let keys = [
|
||||
("7", 0, 0), ("8", 0, 1), ("9", 0, 2),
|
||||
("4", 1, 0), ("5", 1, 1), ("6", 1, 2),
|
||||
("1", 2, 0), ("2", 2, 1), ("3", 2, 2),
|
||||
(".", 3, 0), ("0", 3, 1),
|
||||
];
|
||||
|
||||
for (label, row, col) in &keys {
|
||||
let btn = gtk::Button::with_label(label);
|
||||
btn.set_hexpand(true);
|
||||
|
||||
let entry_ref = entry.clone();
|
||||
let l = label.to_string();
|
||||
btn.connect_clicked(move |_| {
|
||||
let current = entry_ref.text().to_string();
|
||||
// Only restrict decimal within the current number segment (after last operator)
|
||||
if l == "." {
|
||||
let last_segment = current.rsplit(|c: char| "+-*/".contains(c)).next().unwrap_or("");
|
||||
if last_segment.contains('.') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if l != "." {
|
||||
let last_segment = current.rsplit(|c: char| "+-*/".contains(c)).next().unwrap_or("");
|
||||
if let Some(dot_pos) = last_segment.find('.') {
|
||||
if last_segment.len() - dot_pos > 2 {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
entry_ref.set_text(&format!("{}{}", current, l));
|
||||
entry_ref.set_position(-1);
|
||||
});
|
||||
numpad_grid.attach(&btn, *col, *row, 1, 1);
|
||||
}
|
||||
|
||||
let backspace_btn = gtk::Button::with_label("\u{232b}");
|
||||
backspace_btn.set_hexpand(true);
|
||||
|
||||
backspace_btn.add_css_class("destructive-action");
|
||||
{
|
||||
let entry_ref = entry.clone();
|
||||
backspace_btn.connect_clicked(move |_| {
|
||||
let current = entry_ref.text().to_string();
|
||||
if !current.is_empty() {
|
||||
entry_ref.set_text(¤t[..current.len() - 1]);
|
||||
entry_ref.set_position(-1);
|
||||
}
|
||||
});
|
||||
}
|
||||
numpad_grid.attach(&backspace_btn, 2, 3, 1, 1);
|
||||
|
||||
// Operator buttons in the 4th column
|
||||
let ops = [
|
||||
("/", 0, 3),
|
||||
("*", 1, 3),
|
||||
("-", 2, 3),
|
||||
("+", 3, 3),
|
||||
];
|
||||
|
||||
for (label, row, col) in &ops {
|
||||
let btn = gtk::Button::with_label(label);
|
||||
btn.set_hexpand(true);
|
||||
|
||||
btn.add_css_class("numpad-op");
|
||||
let entry_ref = entry.clone();
|
||||
let l = label.to_string();
|
||||
btn.connect_clicked(move |_| {
|
||||
let current = entry_ref.text().to_string();
|
||||
if current.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Don't allow consecutive operators - replace the last one
|
||||
if let Some(last) = current.chars().last() {
|
||||
if "+-*/".contains(last) {
|
||||
entry_ref.set_text(&format!("{}{}", ¤t[..current.len() - 1], l));
|
||||
entry_ref.set_position(-1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
entry_ref.set_text(&format!("{}{}", current, l));
|
||||
entry_ref.set_position(-1);
|
||||
});
|
||||
numpad_grid.attach(&btn, *col, *row, 1, 1);
|
||||
}
|
||||
|
||||
// Equals button spanning full width below the grid
|
||||
let equals_btn = gtk::Button::with_label("=");
|
||||
equals_btn.set_hexpand(true);
|
||||
|
||||
equals_btn.add_css_class("suggested-action");
|
||||
{
|
||||
let entry_ref = entry.clone();
|
||||
equals_btn.connect_clicked(move |_| {
|
||||
let current = entry_ref.text().to_string();
|
||||
if let Some(result) = outlay_core::expr::eval_expr(¤t) {
|
||||
// Format: strip trailing zeros but keep up to 2 decimal places
|
||||
let formatted = format!("{:.2}", result);
|
||||
let formatted = formatted.trim_end_matches('0').trim_end_matches('.');
|
||||
entry_ref.set_text(formatted);
|
||||
entry_ref.set_position(-1);
|
||||
}
|
||||
});
|
||||
}
|
||||
numpad_grid.attach(&equals_btn, 0, 4, 4, 1);
|
||||
|
||||
numpad_outer.append(&numpad_grid);
|
||||
|
||||
let action_box = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
action_box.set_margin_top(4);
|
||||
action_box.set_homogeneous(true);
|
||||
|
||||
let cancel_btn = gtk::Button::with_label("Cancel");
|
||||
|
||||
cancel_btn.add_css_class("flat");
|
||||
{
|
||||
let popover_ref = numpad_popover.clone();
|
||||
let entry_ref = entry.clone();
|
||||
cancel_btn.connect_clicked(move |_| {
|
||||
entry_ref.set_text("");
|
||||
popover_ref.popdown();
|
||||
});
|
||||
}
|
||||
|
||||
let confirm_btn = gtk::Button::with_label("Done");
|
||||
|
||||
confirm_btn.add_css_class("suggested-action");
|
||||
{
|
||||
let popover_ref = numpad_popover.clone();
|
||||
confirm_btn.connect_clicked(move |_| {
|
||||
popover_ref.popdown();
|
||||
});
|
||||
}
|
||||
|
||||
action_box.append(&cancel_btn);
|
||||
action_box.append(&confirm_btn);
|
||||
numpad_outer.append(&action_box);
|
||||
|
||||
numpad_popover.set_child(Some(&numpad_outer));
|
||||
|
||||
let click_ctrl = gtk::GestureClick::new();
|
||||
click_ctrl.set_propagation_phase(gtk::PropagationPhase::Capture);
|
||||
let popover_ref = numpad_popover.clone();
|
||||
click_ctrl.connect_released(move |_, _, _, _| {
|
||||
if !popover_ref.is_visible() {
|
||||
popover_ref.popup();
|
||||
}
|
||||
});
|
||||
entry.add_controller(click_ctrl);
|
||||
}
|
||||
467
outlay-gtk/src/quick_add.rs
Normal file
467
outlay-gtk/src/quick_add.rs
Normal file
@@ -0,0 +1,467 @@
|
||||
use adw::prelude::*;
|
||||
use gtk::glib;
|
||||
use outlay_core::db::Database;
|
||||
use outlay_core::models::{NewTransaction, TransactionType};
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::icon_theme;
|
||||
|
||||
pub fn show_quick_add(db: &Rc<Database>, parent_window: Option<&impl IsA<gtk::Widget>>) {
|
||||
let base_currency = db
|
||||
.get_setting("base_currency")
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| "USD".to_string());
|
||||
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title("Quick Add")
|
||||
.content_width(380)
|
||||
.content_height(420)
|
||||
.build();
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
let header = adw::HeaderBar::new();
|
||||
toolbar.add_top_bar(&header);
|
||||
|
||||
let content = gtk::Box::new(gtk::Orientation::Vertical, 12);
|
||||
content.set_margin_start(16);
|
||||
content.set_margin_end(16);
|
||||
content.set_margin_top(12);
|
||||
content.set_margin_bottom(16);
|
||||
|
||||
let is_income = Rc::new(Cell::new(false));
|
||||
let type_box = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
||||
type_box.set_halign(gtk::Align::Center);
|
||||
type_box.add_css_class("linked");
|
||||
|
||||
let expense_btn = gtk::ToggleButton::with_label("Expense");
|
||||
expense_btn.set_active(true);
|
||||
let income_btn = gtk::ToggleButton::with_label("Income");
|
||||
income_btn.set_group(Some(&expense_btn));
|
||||
|
||||
type_box.append(&expense_btn);
|
||||
type_box.append(&income_btn);
|
||||
content.append(&type_box);
|
||||
|
||||
{
|
||||
let is_income_ref = is_income.clone();
|
||||
income_btn.connect_toggled(move |btn| {
|
||||
is_income_ref.set(btn.is_active());
|
||||
});
|
||||
}
|
||||
|
||||
let amount_group = adw::PreferencesGroup::new();
|
||||
let amount_row = adw::EntryRow::builder()
|
||||
.title("Amount")
|
||||
.build();
|
||||
crate::numpad::attach_numpad(&amount_row);
|
||||
amount_group.add(&amount_row);
|
||||
content.append(&amount_group);
|
||||
|
||||
let cat_group = adw::PreferencesGroup::new();
|
||||
let expense_cats = db
|
||||
.list_categories(Some(TransactionType::Expense))
|
||||
.unwrap_or_default();
|
||||
let income_cats = db
|
||||
.list_categories(Some(TransactionType::Income))
|
||||
.unwrap_or_default();
|
||||
|
||||
let all_expense_entries: Vec<String> = expense_cats
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let icon = icon_theme::resolve_category_icon(&c.icon, &c.color);
|
||||
match icon {
|
||||
Some(i) => format!("{}\t{}", i, c.name),
|
||||
None => c.name.clone(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let all_income_entries: Vec<String> = income_cats
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let icon = icon_theme::resolve_category_icon(&c.icon, &c.color);
|
||||
match icon {
|
||||
Some(i) => format!("{}\t{}", i, c.name),
|
||||
None => c.name.clone(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let all_expense_ids: Vec<i64> = expense_cats.iter().map(|c| c.id).collect();
|
||||
let all_income_ids: Vec<i64> = income_cats.iter().map(|c| c.id).collect();
|
||||
|
||||
let expense_label_refs: Vec<&str> = all_expense_entries.iter().map(|s| s.as_str()).collect();
|
||||
let expense_model = gtk::StringList::new(&expense_label_refs);
|
||||
|
||||
let category_row = adw::ComboRow::builder()
|
||||
.title("Category")
|
||||
.model(&expense_model)
|
||||
.build();
|
||||
category_row.set_factory(Some(&make_category_factory()));
|
||||
category_row.set_list_factory(Some(&make_category_factory()));
|
||||
cat_group.add(&category_row);
|
||||
content.append(&cat_group);
|
||||
|
||||
// Update category list on type toggle
|
||||
{
|
||||
let cat_row = category_row.clone();
|
||||
let exp_entries = all_expense_entries.clone();
|
||||
let inc_entries = all_income_entries.clone();
|
||||
income_btn.connect_toggled(move |btn| {
|
||||
let entries = if btn.is_active() {
|
||||
&inc_entries
|
||||
} else {
|
||||
&exp_entries
|
||||
};
|
||||
let refs: Vec<&str> = entries.iter().map(|s| s.as_str()).collect();
|
||||
let model = gtk::StringList::new(&refs);
|
||||
cat_row.set_model(Some(&model));
|
||||
cat_row.set_selected(0);
|
||||
});
|
||||
}
|
||||
|
||||
let note_group = adw::PreferencesGroup::new();
|
||||
let note_row = adw::EntryRow::builder()
|
||||
.title("Note (optional)")
|
||||
.build();
|
||||
note_group.add(¬e_row);
|
||||
content.append(¬e_group);
|
||||
|
||||
let save_btn = gtk::Button::with_label("Save");
|
||||
save_btn.add_css_class("suggested-action");
|
||||
save_btn.add_css_class("pill");
|
||||
save_btn.set_halign(gtk::Align::Center);
|
||||
save_btn.set_margin_top(8);
|
||||
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let dialog_ref = dialog.clone();
|
||||
let amount_ref = amount_row.clone();
|
||||
let note_ref = note_row.clone();
|
||||
let category_ref = category_row.clone();
|
||||
let is_income_ref = is_income.clone();
|
||||
let base_curr = base_currency.clone();
|
||||
let exp_ids = all_expense_ids;
|
||||
let inc_ids = all_income_ids;
|
||||
|
||||
save_btn.connect_clicked(move |_| {
|
||||
let amount_text = amount_ref.text().to_string();
|
||||
let amount = outlay_core::expr::eval_expr(&amount_text).unwrap_or(0.0);
|
||||
|
||||
if amount <= 0.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let is_inc = is_income_ref.get();
|
||||
let txn_type = if is_inc {
|
||||
TransactionType::Income
|
||||
} else {
|
||||
TransactionType::Expense
|
||||
};
|
||||
|
||||
let cat_idx = category_ref.selected() as usize;
|
||||
let cat_id = if is_inc {
|
||||
inc_ids.get(cat_idx).copied().unwrap_or(1)
|
||||
} else {
|
||||
exp_ids.get(cat_idx).copied().unwrap_or(1)
|
||||
};
|
||||
|
||||
let note_text = note_ref.text().to_string();
|
||||
let note = if note_text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(note_text)
|
||||
};
|
||||
|
||||
let today = chrono::Local::now().date_naive();
|
||||
|
||||
let txn = NewTransaction {
|
||||
amount,
|
||||
transaction_type: txn_type,
|
||||
category_id: cat_id,
|
||||
currency: base_curr.clone(),
|
||||
exchange_rate: 1.0,
|
||||
note,
|
||||
date: today,
|
||||
recurring_id: None,
|
||||
payee: None,
|
||||
};
|
||||
|
||||
match db_ref.insert_transaction(&txn) {
|
||||
Ok(_) => {
|
||||
let type_str = if is_inc { "Income" } else { "Expense" };
|
||||
let body = format!("{}: {:.2} {}", type_str, amount, base_curr);
|
||||
outlay_core::notifications::send_notification(
|
||||
"Transaction Saved",
|
||||
&body,
|
||||
"normal",
|
||||
);
|
||||
dialog_ref.close();
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Quick-add save error: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
content.append(&save_btn);
|
||||
toolbar.set_content(Some(&content));
|
||||
dialog.set_child(Some(&toolbar));
|
||||
|
||||
// Focus amount on show
|
||||
let amount_focus = amount_row.clone();
|
||||
glib::idle_add_local_once(move || {
|
||||
amount_focus.grab_focus();
|
||||
});
|
||||
|
||||
if let Some(win) = parent_window {
|
||||
dialog.present(Some(win.as_ref()));
|
||||
} else {
|
||||
dialog.present(gtk::Widget::NONE);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show_quick_add_popup(db: &Rc<Database>, app: &adw::Application) {
|
||||
let base_currency = db
|
||||
.get_setting("base_currency")
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| "USD".to_string());
|
||||
|
||||
let win = adw::Window::builder()
|
||||
.title("Quick Add")
|
||||
.default_width(380)
|
||||
.default_height(460)
|
||||
.resizable(false)
|
||||
.application(app)
|
||||
.build();
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
let header = adw::HeaderBar::new();
|
||||
toolbar.add_top_bar(&header);
|
||||
|
||||
let content = gtk::Box::new(gtk::Orientation::Vertical, 12);
|
||||
content.set_margin_start(16);
|
||||
content.set_margin_end(16);
|
||||
content.set_margin_top(12);
|
||||
content.set_margin_bottom(16);
|
||||
|
||||
let is_income = Rc::new(Cell::new(false));
|
||||
let type_box = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
||||
type_box.set_halign(gtk::Align::Center);
|
||||
type_box.add_css_class("linked");
|
||||
|
||||
let expense_btn = gtk::ToggleButton::with_label("Expense");
|
||||
expense_btn.set_active(true);
|
||||
let income_btn = gtk::ToggleButton::with_label("Income");
|
||||
income_btn.set_group(Some(&expense_btn));
|
||||
|
||||
type_box.append(&expense_btn);
|
||||
type_box.append(&income_btn);
|
||||
content.append(&type_box);
|
||||
|
||||
{
|
||||
let is_income_ref = is_income.clone();
|
||||
income_btn.connect_toggled(move |btn| {
|
||||
is_income_ref.set(btn.is_active());
|
||||
});
|
||||
}
|
||||
|
||||
let amount_group = adw::PreferencesGroup::new();
|
||||
let amount_row = adw::EntryRow::builder()
|
||||
.title("Amount")
|
||||
.build();
|
||||
crate::numpad::attach_numpad(&amount_row);
|
||||
amount_group.add(&amount_row);
|
||||
content.append(&amount_group);
|
||||
|
||||
let cat_group = adw::PreferencesGroup::new();
|
||||
let expense_cats = db
|
||||
.list_categories(Some(TransactionType::Expense))
|
||||
.unwrap_or_default();
|
||||
let income_cats = db
|
||||
.list_categories(Some(TransactionType::Income))
|
||||
.unwrap_or_default();
|
||||
|
||||
let all_expense_entries: Vec<String> = expense_cats
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let icon = icon_theme::resolve_category_icon(&c.icon, &c.color);
|
||||
match icon {
|
||||
Some(i) => format!("{}\t{}", i, c.name),
|
||||
None => c.name.clone(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let all_income_entries: Vec<String> = income_cats
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let icon = icon_theme::resolve_category_icon(&c.icon, &c.color);
|
||||
match icon {
|
||||
Some(i) => format!("{}\t{}", i, c.name),
|
||||
None => c.name.clone(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let all_expense_ids: Vec<i64> = expense_cats.iter().map(|c| c.id).collect();
|
||||
let all_income_ids: Vec<i64> = income_cats.iter().map(|c| c.id).collect();
|
||||
|
||||
let expense_label_refs: Vec<&str> = all_expense_entries.iter().map(|s| s.as_str()).collect();
|
||||
let expense_model = gtk::StringList::new(&expense_label_refs);
|
||||
|
||||
let category_row = adw::ComboRow::builder()
|
||||
.title("Category")
|
||||
.model(&expense_model)
|
||||
.build();
|
||||
category_row.set_factory(Some(&make_category_factory()));
|
||||
category_row.set_list_factory(Some(&make_category_factory()));
|
||||
cat_group.add(&category_row);
|
||||
content.append(&cat_group);
|
||||
|
||||
// Update category list on type toggle
|
||||
{
|
||||
let cat_row = category_row.clone();
|
||||
let exp_entries = all_expense_entries.clone();
|
||||
let inc_entries = all_income_entries.clone();
|
||||
income_btn.connect_toggled(move |btn| {
|
||||
let entries = if btn.is_active() {
|
||||
&inc_entries
|
||||
} else {
|
||||
&exp_entries
|
||||
};
|
||||
let refs: Vec<&str> = entries.iter().map(|s| s.as_str()).collect();
|
||||
let model = gtk::StringList::new(&refs);
|
||||
cat_row.set_model(Some(&model));
|
||||
cat_row.set_selected(0);
|
||||
});
|
||||
}
|
||||
|
||||
let note_group = adw::PreferencesGroup::new();
|
||||
let note_row = adw::EntryRow::builder()
|
||||
.title("Note (optional)")
|
||||
.build();
|
||||
note_group.add(¬e_row);
|
||||
content.append(¬e_group);
|
||||
|
||||
let save_btn = gtk::Button::with_label("Save");
|
||||
save_btn.add_css_class("suggested-action");
|
||||
save_btn.add_css_class("pill");
|
||||
save_btn.set_halign(gtk::Align::Center);
|
||||
save_btn.set_margin_top(8);
|
||||
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let win_ref = win.clone();
|
||||
let amount_ref = amount_row.clone();
|
||||
let note_ref = note_row.clone();
|
||||
let category_ref = category_row.clone();
|
||||
let is_income_ref = is_income.clone();
|
||||
let base_curr = base_currency.clone();
|
||||
let exp_ids = all_expense_ids;
|
||||
let inc_ids = all_income_ids;
|
||||
|
||||
save_btn.connect_clicked(move |_| {
|
||||
let amount_text = amount_ref.text().to_string();
|
||||
let amount = outlay_core::expr::eval_expr(&amount_text).unwrap_or(0.0);
|
||||
|
||||
if amount <= 0.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let is_inc = is_income_ref.get();
|
||||
let txn_type = if is_inc {
|
||||
TransactionType::Income
|
||||
} else {
|
||||
TransactionType::Expense
|
||||
};
|
||||
|
||||
let cat_idx = category_ref.selected() as usize;
|
||||
let cat_id = if is_inc {
|
||||
inc_ids.get(cat_idx).copied().unwrap_or(1)
|
||||
} else {
|
||||
exp_ids.get(cat_idx).copied().unwrap_or(1)
|
||||
};
|
||||
|
||||
let note_text = note_ref.text().to_string();
|
||||
let note = if note_text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(note_text)
|
||||
};
|
||||
|
||||
let today = chrono::Local::now().date_naive();
|
||||
|
||||
let txn = NewTransaction {
|
||||
amount,
|
||||
transaction_type: txn_type,
|
||||
category_id: cat_id,
|
||||
currency: base_curr.clone(),
|
||||
exchange_rate: 1.0,
|
||||
note,
|
||||
date: today,
|
||||
recurring_id: None,
|
||||
payee: None,
|
||||
};
|
||||
|
||||
match db_ref.insert_transaction(&txn) {
|
||||
Ok(_) => {
|
||||
let type_str = if is_inc { "Income" } else { "Expense" };
|
||||
let body = format!("{}: {:.2} {}", type_str, amount, base_curr);
|
||||
outlay_core::notifications::send_notification(
|
||||
"Transaction Saved",
|
||||
&body,
|
||||
"normal",
|
||||
);
|
||||
win_ref.close();
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Quick-add save error: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
content.append(&save_btn);
|
||||
toolbar.set_content(Some(&content));
|
||||
win.set_content(Some(&toolbar));
|
||||
|
||||
// Focus amount on show
|
||||
let amount_focus = amount_row.clone();
|
||||
glib::idle_add_local_once(move || {
|
||||
amount_focus.grab_focus();
|
||||
});
|
||||
|
||||
win.present();
|
||||
}
|
||||
|
||||
fn make_category_factory() -> gtk::SignalListItemFactory {
|
||||
let factory = gtk::SignalListItemFactory::new();
|
||||
factory.connect_setup(|_, item| {
|
||||
let item = item.downcast_ref::<gtk::ListItem>().unwrap();
|
||||
let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
let icon = gtk::Image::new();
|
||||
icon.set_pixel_size(20);
|
||||
let label = gtk::Label::new(None);
|
||||
hbox.append(&icon);
|
||||
hbox.append(&label);
|
||||
item.set_child(Some(&hbox));
|
||||
});
|
||||
factory.connect_bind(|_, item| {
|
||||
let item = item.downcast_ref::<gtk::ListItem>().unwrap();
|
||||
let string_obj = item.item().and_downcast::<gtk::StringObject>().unwrap();
|
||||
let text = string_obj.string();
|
||||
let hbox = item.child().and_downcast::<gtk::Box>().unwrap();
|
||||
let icon = hbox.first_child().and_downcast::<gtk::Image>().unwrap();
|
||||
let label = icon.next_sibling().and_downcast::<gtk::Label>().unwrap();
|
||||
if let Some((icon_name, name)) = text.split_once('\t') {
|
||||
icon.set_icon_name(Some(icon_name));
|
||||
icon.set_visible(true);
|
||||
label.set_label(name);
|
||||
} else {
|
||||
icon.set_visible(false);
|
||||
label.set_label(&text);
|
||||
}
|
||||
});
|
||||
factory
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
71
outlay-gtk/src/sparkline.rs
Normal file
71
outlay-gtk/src/sparkline.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use gtk::prelude::*;
|
||||
|
||||
/// Create a sparkline widget showing a mini trend chart.
|
||||
pub fn sparkline_widget(data: &[f64], width: i32, height: i32) -> gtk::DrawingArea {
|
||||
let area = gtk::DrawingArea::builder()
|
||||
.accessible_role(gtk::AccessibleRole::Img)
|
||||
.build();
|
||||
area.update_property(&[gtk::accessible::Property::Label("Spending trend sparkline")]);
|
||||
area.set_size_request(width, height);
|
||||
|
||||
if data.is_empty() {
|
||||
return area;
|
||||
}
|
||||
|
||||
let data_owned: Vec<f64> = data.to_vec();
|
||||
area.set_draw_func(move |_area, ctx, w, h| {
|
||||
let w = w as f64;
|
||||
let h = h as f64;
|
||||
let data = &data_owned;
|
||||
|
||||
if data.is_empty() || w <= 0.0 || h <= 0.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let min = data.iter().cloned().fold(f64::INFINITY, f64::min);
|
||||
let max = data.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
|
||||
let range = if (max - min).abs() < 0.01 { 1.0 } else { max - min };
|
||||
|
||||
let padding = 2.0;
|
||||
let chart_w = w - padding * 2.0;
|
||||
let chart_h = h - padding * 2.0;
|
||||
|
||||
// Determine trend direction
|
||||
let first = data.first().copied().unwrap_or(0.0);
|
||||
let last = data.last().copied().unwrap_or(0.0);
|
||||
let trending_up = last > first;
|
||||
|
||||
// Color: red for trending up (more expenses), green for trending down
|
||||
let (r, g, b) = if trending_up {
|
||||
(0.87, 0.33, 0.36) // Red-ish for expenses going up
|
||||
} else {
|
||||
(0.42, 0.75, 0.45) // Green-ish for expenses going down
|
||||
};
|
||||
|
||||
let step = if data.len() > 1 { chart_w / (data.len() - 1) as f64 } else { chart_w };
|
||||
let points: Vec<(f64, f64)> = data.iter().enumerate().map(|(i, &val)| {
|
||||
let x = padding + i as f64 * step;
|
||||
let y = padding + chart_h - ((val - min) / range * chart_h);
|
||||
(x, y)
|
||||
}).collect();
|
||||
|
||||
ctx.move_to(points[0].0, h - padding);
|
||||
for &(x, y) in &points {
|
||||
ctx.line_to(x, y);
|
||||
}
|
||||
ctx.line_to(points.last().unwrap().0, h - padding);
|
||||
ctx.close_path();
|
||||
ctx.set_source_rgba(r, g, b, 0.15);
|
||||
let _ = ctx.fill();
|
||||
|
||||
ctx.move_to(points[0].0, points[0].1);
|
||||
for &(x, y) in points.iter().skip(1) {
|
||||
ctx.line_to(x, y);
|
||||
}
|
||||
ctx.set_source_rgba(r, g, b, 0.8);
|
||||
ctx.set_line_width(1.5);
|
||||
let _ = ctx.stroke();
|
||||
});
|
||||
|
||||
area
|
||||
}
|
||||
358
outlay-gtk/src/style.css
Normal file
358
outlay-gtk/src/style.css
Normal file
@@ -0,0 +1,358 @@
|
||||
/* === Accent color override === */
|
||||
:root {
|
||||
--accent-bg-color: #3a944a;
|
||||
--accent-color: #3a944a;
|
||||
--income-color: #2ec27e;
|
||||
--expense-color: #d4434e;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--income-color: #57e389;
|
||||
--expense-color: #f25d64;
|
||||
}
|
||||
}
|
||||
|
||||
/* === Heading typography (Space Grotesk) === */
|
||||
headerbar .title {
|
||||
font-family: "Space Grotesk", system-ui, sans-serif;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sidebar-label {
|
||||
font-family: "Space Grotesk", system-ui, sans-serif;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* === Section group titles (uppercase overline) === */
|
||||
.section-overline,
|
||||
preferencesgroup > box > clamp > box > box > label.title {
|
||||
font-family: "Space Grotesk", system-ui, sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 3px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* === Month navigation (split treatment) === */
|
||||
.month-nav {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.month-nav-label {
|
||||
font-family: "Space Grotesk", system-ui, sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* === Chart frame titles === */
|
||||
.chart-title {
|
||||
font-family: "Space Grotesk", system-ui, sans-serif;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* === Amount typography === */
|
||||
.amount-display {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-feature-settings: "tnum" 1;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
.amount-hero {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 76px;
|
||||
font-weight: 800;
|
||||
font-feature-settings: "tnum" 1;
|
||||
letter-spacing: -1px;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.amount-hero:focus {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* === Currency symbol next to hero amount === */
|
||||
.currency-symbol {
|
||||
font-family: "Space Grotesk", system-ui, sans-serif;
|
||||
font-size: 44px;
|
||||
font-weight: 300;
|
||||
opacity: 0.60;
|
||||
}
|
||||
|
||||
/* === Number keypad === */
|
||||
.numpad-grid button {
|
||||
min-width: 72px;
|
||||
min-height: 56px;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
border-radius: 12px;
|
||||
transition: background-color 100ms ease;
|
||||
}
|
||||
|
||||
.numpad-grid button.destructive-action {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.numpad-grid button.numpad-op {
|
||||
font-size: 20px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.numpad-grid button.suggested-action {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.amount-income {
|
||||
color: var(--income-color);
|
||||
}
|
||||
|
||||
.amount-expense {
|
||||
color: var(--expense-color);
|
||||
}
|
||||
|
||||
/* === Summary cards === */
|
||||
.summary-card {
|
||||
padding: 14px 18px;
|
||||
border-radius: 12px;
|
||||
background-color: var(--card-bg-color);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.summary-card {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.summary-card-label {
|
||||
font-family: "Space Grotesk", system-ui, sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.summary-card-amount {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
font-feature-settings: "tnum" 1;
|
||||
}
|
||||
|
||||
/* === Sidebar refinements === */
|
||||
list.navigation-sidebar > row {
|
||||
margin: 3px 8px;
|
||||
border-radius: 10px;
|
||||
padding: 4px 0;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
/* === Transaction rows in history === */
|
||||
.transaction-card {
|
||||
border-radius: 12px;
|
||||
margin: 4px 0;
|
||||
padding: 4px 0;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
/* === Date headers (split day + date) === */
|
||||
.date-header {
|
||||
font-family: "Space Grotesk", system-ui, sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
letter-spacing: 2px;
|
||||
opacity: 0.75;
|
||||
padding: 24px 4px 2px 4px;
|
||||
}
|
||||
|
||||
.date-header-detail {
|
||||
font-family: "Space Grotesk", system-ui, sans-serif;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
letter-spacing: 0.2px;
|
||||
padding: 0px 4px 10px 4px;
|
||||
}
|
||||
|
||||
/* === Day net totals === */
|
||||
.day-net {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.2px;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* === Budget bars === */
|
||||
levelbar > trough {
|
||||
border-radius: 6px;
|
||||
min-height: 10px;
|
||||
}
|
||||
|
||||
levelbar > trough > block.filled {
|
||||
border-radius: 6px;
|
||||
background-color: var(--accent-bg-color);
|
||||
}
|
||||
|
||||
levelbar.warning > trough > block.filled {
|
||||
background-color: var(--warning-bg-color);
|
||||
}
|
||||
|
||||
levelbar.error > trough > block.filled {
|
||||
background-color: var(--error-bg-color);
|
||||
}
|
||||
|
||||
/* === Buttons === */
|
||||
button.suggested-action,
|
||||
button.destructive-action {
|
||||
font-family: "Space Grotesk", system-ui, sans-serif;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
transition: background-color 150ms ease, box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
button.pill {
|
||||
padding: 8px 24px;
|
||||
}
|
||||
|
||||
/* === Interactive row hover === */
|
||||
list > row.activatable {
|
||||
transition: background-color 100ms ease;
|
||||
}
|
||||
|
||||
/* === Cards (general) === */
|
||||
.card {
|
||||
transition: box-shadow 150ms ease;
|
||||
}
|
||||
|
||||
/* === Icon picker === */
|
||||
.icon-picker-grid > flowboxchild {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.icon-picker-btn {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 6px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* === Save button emphasis === */
|
||||
.save-button {
|
||||
padding: 12px 36px;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* === Budget row typography === */
|
||||
.budget-spent {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
font-feature-settings: "tnum" 1;
|
||||
}
|
||||
|
||||
.budget-remaining {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
font-feature-settings: "tnum" 1;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.budget-total {
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
font-feature-settings: "tnum" 1;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* === Sidebar section labels === */
|
||||
.sidebar-section-label {
|
||||
font-family: "Space Grotesk", system-ui, sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2.5px;
|
||||
opacity: 0.75;
|
||||
padding: 12px 16px 4px 16px;
|
||||
}
|
||||
|
||||
/* === General spacing helpers === */
|
||||
.spacious-group {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
/* === View heading (for section titles inside views) === */
|
||||
.view-heading {
|
||||
font-family: "Space Grotesk", system-ui, sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* === Receipt attachment drop zone === */
|
||||
.attach-drop-zone {
|
||||
min-height: 80px;
|
||||
border: 1px dashed alpha(currentColor, 0.15);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.attach-drop-zone:hover {
|
||||
border-color: alpha(currentColor, 0.3);
|
||||
background: alpha(currentColor, 0.03);
|
||||
}
|
||||
|
||||
.attach-thumbnail {
|
||||
border-radius: 12px;
|
||||
background: alpha(currentColor, 0.05);
|
||||
}
|
||||
|
||||
/* === Type toggle buttons === */
|
||||
.type-toggle button {
|
||||
padding: 8px 24px;
|
||||
font-family: "Space Grotesk", system-ui, sans-serif;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
transition: background-color 150ms ease;
|
||||
}
|
||||
|
||||
/* === 7.3 High contrast mode compatibility === */
|
||||
@media (prefers-contrast: more) {
|
||||
.date-header,
|
||||
.summary-card-label,
|
||||
.day-net,
|
||||
.section-overline,
|
||||
preferencesgroup > box > clamp > box > box > label.title,
|
||||
.sidebar-section-label,
|
||||
.budget-remaining,
|
||||
.budget-total {
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
.currency-symbol {
|
||||
opacity: 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
/* === 7.4 Reduced motion support === */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
transition-duration: 0.001ms !important;
|
||||
animation-duration: 0.001ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* === 7.5 Focus ring visibility === */
|
||||
button:focus-visible,
|
||||
row:focus-visible,
|
||||
flowboxchild:focus-visible {
|
||||
outline: 2px solid var(--accent-color);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
946
outlay-gtk/src/subscriptions_view.rs
Normal file
946
outlay-gtk/src/subscriptions_view.rs
Normal file
@@ -0,0 +1,946 @@
|
||||
use adw::prelude::*;
|
||||
use chrono::NaiveDate;
|
||||
use outlay_core::db::Database;
|
||||
use outlay_core::exchange::ExchangeRateService;
|
||||
use outlay_core::models::{Frequency, NewSubscription, Subscription};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::icon_theme;
|
||||
|
||||
type ChangeCb = Rc<RefCell<Option<Box<dyn Fn()>>>>;
|
||||
|
||||
pub struct SubscriptionsView {
|
||||
pub container: gtk::Box,
|
||||
db: Rc<Database>,
|
||||
active_group: adw::PreferencesGroup,
|
||||
paused_group: adw::PreferencesGroup,
|
||||
monthly_label: gtk::Label,
|
||||
yearly_label: gtk::Label,
|
||||
base_currency: String,
|
||||
toast_overlay: adw::ToastOverlay,
|
||||
on_change: ChangeCb,
|
||||
}
|
||||
|
||||
impl SubscriptionsView {
|
||||
pub fn new(db: Rc<Database>) -> Self {
|
||||
let container = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
let toast_overlay = adw::ToastOverlay::new();
|
||||
|
||||
let clamp = adw::Clamp::new();
|
||||
clamp.set_maximum_size(700);
|
||||
clamp.set_margin_start(16);
|
||||
clamp.set_margin_end(16);
|
||||
|
||||
let inner = gtk::Box::new(gtk::Orientation::Vertical, 20);
|
||||
inner.set_margin_top(20);
|
||||
inner.set_margin_bottom(20);
|
||||
|
||||
let base_currency = db.get_setting("base_currency")
|
||||
.ok().flatten()
|
||||
.unwrap_or_else(|| "USD".to_string());
|
||||
|
||||
// Cost summary card
|
||||
let cost_card = gtk::Box::new(gtk::Orientation::Vertical, 4);
|
||||
cost_card.add_css_class("card");
|
||||
cost_card.set_margin_start(4);
|
||||
cost_card.set_margin_end(4);
|
||||
|
||||
let cost_title = gtk::Label::new(Some("SUBSCRIPTION COST"));
|
||||
cost_title.add_css_class("caption");
|
||||
cost_title.add_css_class("dim-label");
|
||||
cost_title.set_halign(gtk::Align::Start);
|
||||
cost_title.set_margin_top(12);
|
||||
cost_title.set_margin_start(12);
|
||||
|
||||
let monthly = db.get_subscription_monthly_total().unwrap_or(0.0);
|
||||
let yearly = db.get_subscription_yearly_total().unwrap_or(0.0);
|
||||
|
||||
let monthly_label = gtk::Label::new(Some(&format!("{:.2} {}/month", monthly, base_currency)));
|
||||
monthly_label.add_css_class("title-1");
|
||||
monthly_label.set_halign(gtk::Align::Start);
|
||||
monthly_label.set_margin_start(12);
|
||||
|
||||
let yearly_label = gtk::Label::new(Some(&format!("{:.2} {}/year", yearly, base_currency)));
|
||||
yearly_label.add_css_class("caption");
|
||||
yearly_label.add_css_class("dim-label");
|
||||
yearly_label.set_halign(gtk::Align::Start);
|
||||
yearly_label.set_margin_start(12);
|
||||
yearly_label.set_margin_bottom(12);
|
||||
|
||||
cost_card.append(&cost_title);
|
||||
cost_card.append(&monthly_label);
|
||||
cost_card.append(&yearly_label);
|
||||
|
||||
let active_group = adw::PreferencesGroup::builder()
|
||||
.title("ACTIVE SUBSCRIPTIONS")
|
||||
.build();
|
||||
|
||||
let paused_group = adw::PreferencesGroup::builder()
|
||||
.title("PAUSED SUBSCRIPTIONS")
|
||||
.build();
|
||||
|
||||
let on_change: ChangeCb = Rc::new(RefCell::new(None));
|
||||
|
||||
Self::load_subscriptions(
|
||||
&db, &active_group, &paused_group,
|
||||
&monthly_label, &yearly_label, &base_currency,
|
||||
&toast_overlay, &on_change,
|
||||
);
|
||||
|
||||
let add_btn = gtk::Button::with_label("Add Subscription");
|
||||
add_btn.add_css_class("suggested-action");
|
||||
add_btn.add_css_class("pill");
|
||||
add_btn.set_halign(gtk::Align::Center);
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let active_ref = active_group.clone();
|
||||
let paused_ref = paused_group.clone();
|
||||
let monthly_ref = monthly_label.clone();
|
||||
let yearly_ref = yearly_label.clone();
|
||||
let base_ref = base_currency.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
let change_ref = on_change.clone();
|
||||
add_btn.connect_clicked(move |btn| {
|
||||
Self::show_add_dialog(
|
||||
btn, &db_ref, &active_ref, &paused_ref,
|
||||
&monthly_ref, &yearly_ref, &base_ref, &toast_ref,
|
||||
&change_ref,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
inner.append(&cost_card);
|
||||
inner.append(&active_group);
|
||||
inner.append(&paused_group);
|
||||
inner.append(&add_btn);
|
||||
|
||||
clamp.set_child(Some(&inner));
|
||||
|
||||
let scroll = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
.vexpand(true)
|
||||
.child(&clamp)
|
||||
.build();
|
||||
|
||||
toast_overlay.set_child(Some(&scroll));
|
||||
container.append(&toast_overlay);
|
||||
|
||||
SubscriptionsView {
|
||||
container,
|
||||
db,
|
||||
active_group,
|
||||
paused_group,
|
||||
monthly_label,
|
||||
yearly_label,
|
||||
base_currency,
|
||||
toast_overlay,
|
||||
on_change,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn refresh(&self) {
|
||||
Self::load_subscriptions(
|
||||
&self.db, &self.active_group, &self.paused_group,
|
||||
&self.monthly_label, &self.yearly_label, &self.base_currency,
|
||||
&self.toast_overlay, &self.on_change,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn set_on_change(&self, cb: impl Fn() + 'static) {
|
||||
*self.on_change.borrow_mut() = Some(Box::new(cb));
|
||||
}
|
||||
|
||||
fn notify_change(on_change: &ChangeCb) {
|
||||
if let Some(cb) = on_change.borrow().as_ref() {
|
||||
cb();
|
||||
}
|
||||
}
|
||||
|
||||
fn load_subscriptions(
|
||||
db: &Rc<Database>,
|
||||
active_group: &adw::PreferencesGroup,
|
||||
paused_group: &adw::PreferencesGroup,
|
||||
monthly_label: >k::Label,
|
||||
yearly_label: >k::Label,
|
||||
base_currency: &str,
|
||||
toast_overlay: &adw::ToastOverlay,
|
||||
on_change: &ChangeCb,
|
||||
) {
|
||||
Self::clear_group(active_group);
|
||||
Self::clear_group(paused_group);
|
||||
|
||||
let monthly = db.get_subscription_monthly_total().unwrap_or(0.0);
|
||||
let yearly = db.get_subscription_yearly_total().unwrap_or(0.0);
|
||||
monthly_label.set_label(&format!("{:.2} {}/month", monthly, base_currency));
|
||||
yearly_label.set_label(&format!("{:.2} {}/year", yearly, base_currency));
|
||||
|
||||
let subs = db.list_subscriptions_v2().unwrap_or_default();
|
||||
let active_subs: Vec<&Subscription> = subs.iter().filter(|s| s.active).collect();
|
||||
let paused_subs: Vec<&Subscription> = subs.iter().filter(|s| !s.active).collect();
|
||||
|
||||
if active_subs.is_empty() {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("No active subscriptions")
|
||||
.subtitle("Add your first subscription")
|
||||
.build();
|
||||
row.add_css_class("dim-label");
|
||||
active_group.add(&row);
|
||||
} else {
|
||||
for sub in &active_subs {
|
||||
let row = Self::make_subscription_row(
|
||||
db, sub, active_group, paused_group,
|
||||
monthly_label, yearly_label, base_currency, toast_overlay,
|
||||
on_change,
|
||||
);
|
||||
active_group.add(&row);
|
||||
}
|
||||
}
|
||||
|
||||
if paused_subs.is_empty() {
|
||||
paused_group.set_visible(false);
|
||||
} else {
|
||||
paused_group.set_visible(true);
|
||||
for sub in &paused_subs {
|
||||
let row = Self::make_subscription_row(
|
||||
db, sub, active_group, paused_group,
|
||||
monthly_label, yearly_label, base_currency, toast_overlay,
|
||||
on_change,
|
||||
);
|
||||
paused_group.add(&row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn make_subscription_row(
|
||||
db: &Rc<Database>,
|
||||
sub: &Subscription,
|
||||
active_group: &adw::PreferencesGroup,
|
||||
paused_group: &adw::PreferencesGroup,
|
||||
monthly_label: >k::Label,
|
||||
yearly_label: >k::Label,
|
||||
base_currency: &str,
|
||||
toast_overlay: &adw::ToastOverlay,
|
||||
on_change: &ChangeCb,
|
||||
) -> adw::ActionRow {
|
||||
let cat = db.get_subscription_category(sub.category_id).ok();
|
||||
let cat_icon = cat.as_ref().and_then(|c| c.icon.clone());
|
||||
let cat_color = cat.as_ref().and_then(|c| c.color.clone());
|
||||
|
||||
let freq_label = match sub.frequency {
|
||||
Frequency::Daily => "Daily",
|
||||
Frequency::Weekly => "Weekly",
|
||||
Frequency::Biweekly => "Biweekly",
|
||||
Frequency::Monthly => "Monthly",
|
||||
Frequency::Yearly => "Yearly",
|
||||
};
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&sub.name)
|
||||
.subtitle(freq_label)
|
||||
.build();
|
||||
|
||||
let icon_name = icon_theme::resolve_category_icon(&cat_icon, &cat_color);
|
||||
if let Some(name) = &icon_name {
|
||||
let icon = gtk::Image::from_icon_name(name);
|
||||
icon.set_pixel_size(24);
|
||||
row.add_prefix(&icon);
|
||||
}
|
||||
|
||||
let suffix_box = gtk::Box::new(gtk::Orientation::Horizontal, 4);
|
||||
suffix_box.set_valign(gtk::Align::Center);
|
||||
|
||||
let monthly_equiv = match sub.frequency {
|
||||
Frequency::Daily => sub.amount * 30.0,
|
||||
Frequency::Weekly => sub.amount * 4.33,
|
||||
Frequency::Biweekly => sub.amount * 2.17,
|
||||
Frequency::Monthly => sub.amount,
|
||||
Frequency::Yearly => sub.amount / 12.0,
|
||||
};
|
||||
let equiv_label = gtk::Label::new(Some(&format!("{:.2} {}/mo", monthly_equiv, sub.currency)));
|
||||
equiv_label.add_css_class("caption");
|
||||
equiv_label.add_css_class("dim-label");
|
||||
suffix_box.append(&equiv_label);
|
||||
|
||||
let pause_btn = if sub.active {
|
||||
let btn = gtk::Button::from_icon_name("tabler-player-pause");
|
||||
btn.set_tooltip_text(Some("Pause"));
|
||||
btn
|
||||
} else {
|
||||
let btn = gtk::Button::from_icon_name("tabler-player-play");
|
||||
btn.set_tooltip_text(Some("Resume"));
|
||||
btn
|
||||
};
|
||||
pause_btn.add_css_class("flat");
|
||||
{
|
||||
let sub_id = sub.id;
|
||||
let db_ref = db.clone();
|
||||
let active_ref = active_group.clone();
|
||||
let paused_ref = paused_group.clone();
|
||||
let monthly_ref = monthly_label.clone();
|
||||
let yearly_ref = yearly_label.clone();
|
||||
let base_ref = base_currency.to_string();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
let change_ref = on_change.clone();
|
||||
pause_btn.connect_clicked(move |_| {
|
||||
let _ = db_ref.toggle_subscription_active(sub_id);
|
||||
Self::load_subscriptions(
|
||||
&db_ref, &active_ref, &paused_ref,
|
||||
&monthly_ref, &yearly_ref, &base_ref, &toast_ref,
|
||||
&change_ref,
|
||||
);
|
||||
Self::notify_change(&change_ref);
|
||||
});
|
||||
}
|
||||
suffix_box.append(&pause_btn);
|
||||
|
||||
let edit_btn = gtk::Button::from_icon_name("tabler-edit");
|
||||
edit_btn.add_css_class("flat");
|
||||
edit_btn.set_tooltip_text(Some("Edit"));
|
||||
{
|
||||
let sub_clone = sub.clone();
|
||||
let db_ref = db.clone();
|
||||
let active_ref = active_group.clone();
|
||||
let paused_ref = paused_group.clone();
|
||||
let monthly_ref = monthly_label.clone();
|
||||
let yearly_ref = yearly_label.clone();
|
||||
let base_ref = base_currency.to_string();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
let change_ref = on_change.clone();
|
||||
edit_btn.connect_clicked(move |btn| {
|
||||
Self::show_edit_dialog(
|
||||
btn, &db_ref, &sub_clone, &active_ref, &paused_ref,
|
||||
&monthly_ref, &yearly_ref, &base_ref, &toast_ref,
|
||||
&change_ref,
|
||||
);
|
||||
});
|
||||
}
|
||||
suffix_box.append(&edit_btn);
|
||||
|
||||
let del_btn = gtk::Button::from_icon_name("tabler-trash");
|
||||
del_btn.add_css_class("flat");
|
||||
del_btn.set_tooltip_text(Some("Delete"));
|
||||
{
|
||||
let sub_id = sub.id;
|
||||
let db_ref = db.clone();
|
||||
let active_ref = active_group.clone();
|
||||
let paused_ref = paused_group.clone();
|
||||
let monthly_ref = monthly_label.clone();
|
||||
let yearly_ref = yearly_label.clone();
|
||||
let base_ref = base_currency.to_string();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
let change_ref = on_change.clone();
|
||||
del_btn.connect_clicked(move |_| {
|
||||
let _ = db_ref.delete_subscription_with_cascade(sub_id);
|
||||
Self::load_subscriptions(
|
||||
&db_ref, &active_ref, &paused_ref,
|
||||
&monthly_ref, &yearly_ref, &base_ref, &toast_ref,
|
||||
&change_ref,
|
||||
);
|
||||
Self::notify_change(&change_ref);
|
||||
toast_ref.add_toast(adw::Toast::new("Subscription deleted"));
|
||||
});
|
||||
}
|
||||
suffix_box.append(&del_btn);
|
||||
|
||||
row.add_suffix(&suffix_box);
|
||||
row
|
||||
}
|
||||
|
||||
fn show_add_dialog(
|
||||
parent: >k::Button,
|
||||
db: &Rc<Database>,
|
||||
active_group: &adw::PreferencesGroup,
|
||||
paused_group: &adw::PreferencesGroup,
|
||||
monthly_label: >k::Label,
|
||||
yearly_label: >k::Label,
|
||||
base_currency: &str,
|
||||
toast_overlay: &adw::ToastOverlay,
|
||||
on_change: &ChangeCb,
|
||||
) {
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title("Add Subscription")
|
||||
.content_width(400)
|
||||
.content_height(500)
|
||||
.build();
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&adw::HeaderBar::new());
|
||||
|
||||
// Wizard stack with two steps
|
||||
let wizard = gtk::Stack::new();
|
||||
wizard.set_transition_type(gtk::StackTransitionType::SlideLeftRight);
|
||||
|
||||
// --- Step 1: Essentials ---
|
||||
let step1 = gtk::Box::new(gtk::Orientation::Vertical, 16);
|
||||
step1.set_margin_top(16);
|
||||
step1.set_margin_bottom(16);
|
||||
step1.set_margin_start(16);
|
||||
step1.set_margin_end(16);
|
||||
|
||||
let form1 = adw::PreferencesGroup::new();
|
||||
|
||||
let name_row = adw::EntryRow::builder()
|
||||
.title("Name")
|
||||
.build();
|
||||
form1.add(&name_row);
|
||||
|
||||
let amount_row = adw::EntryRow::builder()
|
||||
.title("Amount")
|
||||
.build();
|
||||
amount_row.set_input_purpose(gtk::InputPurpose::Number);
|
||||
crate::numpad::attach_numpad(&amount_row);
|
||||
form1.add(&amount_row);
|
||||
|
||||
let cat_model = gtk::StringList::new(&[]);
|
||||
let cat_ids: Rc<RefCell<Vec<i64>>> = Rc::new(RefCell::new(Vec::new()));
|
||||
if let Ok(cats) = db.list_subscription_categories() {
|
||||
for cat in &cats {
|
||||
let icon_name = icon_theme::resolve_category_icon(&cat.icon, &cat.color);
|
||||
let entry = match &icon_name {
|
||||
Some(icon) => format!("{}\t{}", icon, cat.name),
|
||||
None => cat.name.clone(),
|
||||
};
|
||||
cat_model.append(&entry);
|
||||
cat_ids.borrow_mut().push(cat.id);
|
||||
}
|
||||
}
|
||||
let cat_row = adw::ComboRow::builder()
|
||||
.title("Category")
|
||||
.model(&cat_model)
|
||||
.build();
|
||||
cat_row.set_factory(Some(&Self::make_category_factory()));
|
||||
cat_row.set_list_factory(Some(&Self::make_category_factory()));
|
||||
form1.add(&cat_row);
|
||||
|
||||
let freq_labels = ["Daily", "Weekly", "Biweekly", "Monthly", "Yearly"];
|
||||
let freq_model = gtk::StringList::new(&freq_labels);
|
||||
let freq_row = adw::ComboRow::builder()
|
||||
.title("Frequency")
|
||||
.model(&freq_model)
|
||||
.selected(3u32) // Monthly default
|
||||
.build();
|
||||
form1.add(&freq_row);
|
||||
|
||||
let step_label = gtk::Label::new(Some("Step 1 of 2"));
|
||||
step_label.add_css_class("caption");
|
||||
step_label.add_css_class("dim-label");
|
||||
|
||||
let next_btn = gtk::Button::with_label("Next");
|
||||
next_btn.add_css_class("suggested-action");
|
||||
next_btn.add_css_class("pill");
|
||||
next_btn.set_halign(gtk::Align::Center);
|
||||
|
||||
step1.append(&form1);
|
||||
step1.append(&step_label);
|
||||
step1.append(&next_btn);
|
||||
|
||||
// --- Step 2: Details ---
|
||||
let step2 = gtk::Box::new(gtk::Orientation::Vertical, 16);
|
||||
step2.set_margin_top(16);
|
||||
step2.set_margin_bottom(16);
|
||||
step2.set_margin_start(16);
|
||||
step2.set_margin_end(16);
|
||||
|
||||
let form2 = adw::PreferencesGroup::new();
|
||||
|
||||
let base_cur = db.get_setting("base_currency")
|
||||
.ok().flatten()
|
||||
.unwrap_or_else(|| "USD".to_string());
|
||||
let mut currencies = ExchangeRateService::supported_currencies();
|
||||
if let Some(pos) = currencies.iter().position(|(c, _)| c.eq_ignore_ascii_case(&base_cur)) {
|
||||
let base = currencies.remove(pos);
|
||||
currencies.insert(0, base);
|
||||
}
|
||||
let currency_labels: Vec<String> = currencies
|
||||
.iter()
|
||||
.map(|(code, name)| format!("{} - {}", code, name))
|
||||
.collect();
|
||||
let currency_label_refs: Vec<&str> = currency_labels.iter().map(|s| s.as_str()).collect();
|
||||
let currency_model = gtk::StringList::new(¤cy_label_refs);
|
||||
let currency_codes: Vec<String> = currencies.iter().map(|(c, _)| c.to_string()).collect();
|
||||
let currency_row = adw::ComboRow::builder()
|
||||
.title("Currency")
|
||||
.model(¤cy_model)
|
||||
.selected(0u32)
|
||||
.build();
|
||||
form2.add(¤cy_row);
|
||||
|
||||
let today_str = chrono::Local::now().date_naive().format("%Y-%m-%d").to_string();
|
||||
let (start_row, start_date_label) = crate::date_picker::make_date_row("Start date", &today_str);
|
||||
form2.add(&start_row);
|
||||
|
||||
let note_row = adw::EntryRow::builder()
|
||||
.title("Note (optional)")
|
||||
.build();
|
||||
form2.add(¬e_row);
|
||||
|
||||
let url_row = adw::EntryRow::builder()
|
||||
.title("URL (optional)")
|
||||
.build();
|
||||
form2.add(&url_row);
|
||||
|
||||
let step2_label = gtk::Label::new(Some("Step 2 of 2"));
|
||||
step2_label.add_css_class("caption");
|
||||
step2_label.add_css_class("dim-label");
|
||||
|
||||
let btn_box = gtk::Box::new(gtk::Orientation::Horizontal, 12);
|
||||
btn_box.set_halign(gtk::Align::Center);
|
||||
|
||||
let back_btn = gtk::Button::with_label("Back");
|
||||
back_btn.add_css_class("pill");
|
||||
|
||||
let save_btn = gtk::Button::with_label("Save");
|
||||
save_btn.add_css_class("suggested-action");
|
||||
save_btn.add_css_class("pill");
|
||||
|
||||
btn_box.append(&back_btn);
|
||||
btn_box.append(&save_btn);
|
||||
|
||||
step2.append(&form2);
|
||||
step2.append(&step2_label);
|
||||
step2.append(&btn_box);
|
||||
|
||||
// Wire wizard navigation
|
||||
{
|
||||
let w = wizard.clone();
|
||||
let name_ref = name_row.clone();
|
||||
let amount_ref = amount_row.clone();
|
||||
let ids_ref = cat_ids.clone();
|
||||
let cat_ref = cat_row.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
next_btn.connect_clicked(move |_| {
|
||||
if name_ref.text().trim().is_empty() {
|
||||
toast_ref.add_toast(adw::Toast::new("Please enter a name"));
|
||||
return;
|
||||
}
|
||||
let _: f64 = match amount_ref.text().trim().parse() {
|
||||
Ok(v) if v > 0.0 => v,
|
||||
_ => {
|
||||
toast_ref.add_toast(adw::Toast::new("Please enter a valid amount"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let cat_idx = cat_ref.selected() as usize;
|
||||
if ids_ref.borrow().get(cat_idx).is_none() {
|
||||
toast_ref.add_toast(adw::Toast::new("Please select a category"));
|
||||
return;
|
||||
}
|
||||
w.set_visible_child_name("step2");
|
||||
});
|
||||
}
|
||||
{
|
||||
let w = wizard.clone();
|
||||
back_btn.connect_clicked(move |_| {
|
||||
w.set_visible_child_name("step1");
|
||||
});
|
||||
}
|
||||
|
||||
wizard.add_named(&step1, Some("step1"));
|
||||
wizard.add_named(&step2, Some("step2"));
|
||||
|
||||
let scroll = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
.vexpand(true)
|
||||
.child(&wizard)
|
||||
.build();
|
||||
|
||||
toolbar.set_content(Some(&scroll));
|
||||
dialog.set_child(Some(&toolbar));
|
||||
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let dialog_ref = dialog.clone();
|
||||
let active_ref = active_group.clone();
|
||||
let paused_ref = paused_group.clone();
|
||||
let monthly_ref = monthly_label.clone();
|
||||
let yearly_ref = yearly_label.clone();
|
||||
let base_ref = base_currency.to_string();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
let cat_ids = cat_ids.clone();
|
||||
let currency_codes = currency_codes.clone();
|
||||
let change_ref = on_change.clone();
|
||||
save_btn.connect_clicked(move |_| {
|
||||
let name = name_row.text().to_string();
|
||||
if name.trim().is_empty() {
|
||||
toast_ref.add_toast(adw::Toast::new("Please enter a name"));
|
||||
return;
|
||||
}
|
||||
|
||||
let amount_text = amount_row.text().to_string();
|
||||
let amount: f64 = match amount_text.parse() {
|
||||
Ok(v) if v > 0.0 => v,
|
||||
_ => {
|
||||
toast_ref.add_toast(adw::Toast::new("Please enter a valid amount"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let currency = currency_codes
|
||||
.get(currency_row.selected() as usize)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "USD".to_string());
|
||||
|
||||
let frequency = match freq_row.selected() {
|
||||
0 => Frequency::Daily,
|
||||
1 => Frequency::Weekly,
|
||||
2 => Frequency::Biweekly,
|
||||
3 => Frequency::Monthly,
|
||||
_ => Frequency::Yearly,
|
||||
};
|
||||
|
||||
let cat_idx = cat_row.selected() as usize;
|
||||
let ids = cat_ids.borrow();
|
||||
let category_id = match ids.get(cat_idx) {
|
||||
Some(&id) => id,
|
||||
None => {
|
||||
toast_ref.add_toast(adw::Toast::new("Please select a category"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let start_str = start_date_label.text().to_string();
|
||||
let start_date = NaiveDate::parse_from_str(&start_str, "%Y-%m-%d")
|
||||
.unwrap_or_else(|_| chrono::Local::now().date_naive());
|
||||
|
||||
let note_text = note_row.text();
|
||||
let note = if note_text.is_empty() { None } else { Some(note_text.to_string()) };
|
||||
let url_text = url_row.text();
|
||||
let url = if url_text.is_empty() { None } else { Some(url_text.to_string()) };
|
||||
|
||||
let new_sub = NewSubscription {
|
||||
name: name.trim().to_string(),
|
||||
amount,
|
||||
currency,
|
||||
frequency,
|
||||
category_id,
|
||||
start_date,
|
||||
note,
|
||||
url,
|
||||
recurring_id: None,
|
||||
};
|
||||
|
||||
let sub_cat_id = db_ref.find_subscriptions_category_id()
|
||||
.ok().flatten()
|
||||
.unwrap_or(category_id);
|
||||
|
||||
match db_ref.insert_linked_subscription_and_recurring(&new_sub, sub_cat_id) {
|
||||
Ok(_) => {
|
||||
dialog_ref.close();
|
||||
Self::load_subscriptions(
|
||||
&db_ref, &active_ref, &paused_ref,
|
||||
&monthly_ref, &yearly_ref, &base_ref, &toast_ref,
|
||||
&change_ref,
|
||||
);
|
||||
Self::notify_change(&change_ref);
|
||||
toast_ref.add_toast(adw::Toast::new("Subscription added"));
|
||||
}
|
||||
Err(e) => {
|
||||
toast_ref.add_toast(adw::Toast::new(&format!("Error: {}", e)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
dialog.present(Some(parent));
|
||||
}
|
||||
|
||||
fn show_edit_dialog(
|
||||
parent: >k::Button,
|
||||
db: &Rc<Database>,
|
||||
sub: &Subscription,
|
||||
active_group: &adw::PreferencesGroup,
|
||||
paused_group: &adw::PreferencesGroup,
|
||||
monthly_label: >k::Label,
|
||||
yearly_label: >k::Label,
|
||||
base_currency: &str,
|
||||
toast_overlay: &adw::ToastOverlay,
|
||||
on_change: &ChangeCb,
|
||||
) {
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title("Edit Subscription")
|
||||
.content_width(400)
|
||||
.content_height(500)
|
||||
.build();
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&adw::HeaderBar::new());
|
||||
|
||||
let content = gtk::Box::new(gtk::Orientation::Vertical, 16);
|
||||
content.set_margin_top(16);
|
||||
content.set_margin_bottom(16);
|
||||
content.set_margin_start(16);
|
||||
content.set_margin_end(16);
|
||||
|
||||
let form = adw::PreferencesGroup::new();
|
||||
|
||||
let name_row = adw::EntryRow::builder()
|
||||
.title("Name")
|
||||
.text(&sub.name)
|
||||
.build();
|
||||
form.add(&name_row);
|
||||
|
||||
let amount_row = adw::EntryRow::builder()
|
||||
.title("Amount")
|
||||
.text(&format!("{:.2}", sub.amount))
|
||||
.build();
|
||||
amount_row.set_input_purpose(gtk::InputPurpose::Number);
|
||||
crate::numpad::attach_numpad(&amount_row);
|
||||
form.add(&amount_row);
|
||||
|
||||
let mut currencies = ExchangeRateService::supported_currencies();
|
||||
let base_cur = db.get_setting("base_currency")
|
||||
.ok().flatten()
|
||||
.unwrap_or_else(|| "USD".to_string());
|
||||
if let Some(pos) = currencies.iter().position(|(c, _)| c.eq_ignore_ascii_case(&base_cur)) {
|
||||
let base = currencies.remove(pos);
|
||||
currencies.insert(0, base);
|
||||
}
|
||||
let currency_labels: Vec<String> = currencies
|
||||
.iter()
|
||||
.map(|(code, name)| format!("{} - {}", code, name))
|
||||
.collect();
|
||||
let currency_label_refs: Vec<&str> = currency_labels.iter().map(|s| s.as_str()).collect();
|
||||
let currency_model = gtk::StringList::new(¤cy_label_refs);
|
||||
let currency_codes: Vec<String> = currencies.iter().map(|(c, _)| c.to_string()).collect();
|
||||
let selected_currency = currency_codes.iter().position(|c| c == &sub.currency).unwrap_or(0) as u32;
|
||||
let currency_row = adw::ComboRow::builder()
|
||||
.title("Currency")
|
||||
.model(¤cy_model)
|
||||
.selected(selected_currency)
|
||||
.build();
|
||||
form.add(¤cy_row);
|
||||
|
||||
let freq_labels = ["Daily", "Weekly", "Biweekly", "Monthly", "Yearly"];
|
||||
let freq_model = gtk::StringList::new(&freq_labels);
|
||||
let selected_freq = match sub.frequency {
|
||||
Frequency::Daily => 0u32,
|
||||
Frequency::Weekly => 1,
|
||||
Frequency::Biweekly => 2,
|
||||
Frequency::Monthly => 3,
|
||||
Frequency::Yearly => 4,
|
||||
};
|
||||
let freq_row = adw::ComboRow::builder()
|
||||
.title("Frequency")
|
||||
.model(&freq_model)
|
||||
.selected(selected_freq)
|
||||
.build();
|
||||
form.add(&freq_row);
|
||||
|
||||
let cat_model = gtk::StringList::new(&[]);
|
||||
let cat_ids: Rc<RefCell<Vec<i64>>> = Rc::new(RefCell::new(Vec::new()));
|
||||
let mut selected_cat = 0u32;
|
||||
if let Ok(cats) = db.list_subscription_categories() {
|
||||
for (i, cat) in cats.iter().enumerate() {
|
||||
let icon_name = icon_theme::resolve_category_icon(&cat.icon, &cat.color);
|
||||
let entry = match &icon_name {
|
||||
Some(icon) => format!("{}\t{}", icon, cat.name),
|
||||
None => cat.name.clone(),
|
||||
};
|
||||
cat_model.append(&entry);
|
||||
cat_ids.borrow_mut().push(cat.id);
|
||||
if cat.id == sub.category_id {
|
||||
selected_cat = i as u32;
|
||||
}
|
||||
}
|
||||
}
|
||||
let cat_row = adw::ComboRow::builder()
|
||||
.title("Category")
|
||||
.model(&cat_model)
|
||||
.selected(selected_cat)
|
||||
.build();
|
||||
cat_row.set_factory(Some(&Self::make_category_factory()));
|
||||
cat_row.set_list_factory(Some(&Self::make_category_factory()));
|
||||
form.add(&cat_row);
|
||||
|
||||
let start_str = sub.start_date.format("%Y-%m-%d").to_string();
|
||||
let (start_row, start_date_label) = crate::date_picker::make_date_row("Start date", &start_str);
|
||||
form.add(&start_row);
|
||||
|
||||
let note_row = adw::EntryRow::builder()
|
||||
.title("Note (optional)")
|
||||
.text(sub.note.as_deref().unwrap_or(""))
|
||||
.build();
|
||||
form.add(¬e_row);
|
||||
|
||||
let url_row = adw::EntryRow::builder()
|
||||
.title("URL (optional)")
|
||||
.text(sub.url.as_deref().unwrap_or(""))
|
||||
.build();
|
||||
form.add(&url_row);
|
||||
|
||||
let save_btn = gtk::Button::with_label("Save");
|
||||
save_btn.add_css_class("suggested-action");
|
||||
save_btn.add_css_class("pill");
|
||||
save_btn.set_halign(gtk::Align::Center);
|
||||
|
||||
content.append(&form);
|
||||
content.append(&save_btn);
|
||||
|
||||
let scroll = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
.child(&content)
|
||||
.build();
|
||||
|
||||
toolbar.set_content(Some(&scroll));
|
||||
dialog.set_child(Some(&toolbar));
|
||||
|
||||
{
|
||||
let sub_id = sub.id;
|
||||
let sub_active = sub.active;
|
||||
let sub_recurring_id = sub.recurring_id;
|
||||
let db_ref = db.clone();
|
||||
let dialog_ref = dialog.clone();
|
||||
let active_ref = active_group.clone();
|
||||
let paused_ref = paused_group.clone();
|
||||
let monthly_ref = monthly_label.clone();
|
||||
let yearly_ref = yearly_label.clone();
|
||||
let base_ref = base_currency.to_string();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
let cat_ids = cat_ids.clone();
|
||||
let currency_codes = currency_codes.clone();
|
||||
let change_ref = on_change.clone();
|
||||
save_btn.connect_clicked(move |_| {
|
||||
let name = name_row.text().to_string();
|
||||
if name.trim().is_empty() {
|
||||
toast_ref.add_toast(adw::Toast::new("Please enter a name"));
|
||||
return;
|
||||
}
|
||||
|
||||
let amount_text = amount_row.text().to_string();
|
||||
let amount: f64 = match amount_text.parse() {
|
||||
Ok(v) if v > 0.0 => v,
|
||||
_ => {
|
||||
toast_ref.add_toast(adw::Toast::new("Please enter a valid amount"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let currency = currency_codes
|
||||
.get(currency_row.selected() as usize)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "USD".to_string());
|
||||
|
||||
let frequency = match freq_row.selected() {
|
||||
0 => Frequency::Daily,
|
||||
1 => Frequency::Weekly,
|
||||
2 => Frequency::Biweekly,
|
||||
3 => Frequency::Monthly,
|
||||
_ => Frequency::Yearly,
|
||||
};
|
||||
|
||||
let cat_idx = cat_row.selected() as usize;
|
||||
let ids = cat_ids.borrow();
|
||||
let category_id = match ids.get(cat_idx) {
|
||||
Some(&id) => id,
|
||||
None => {
|
||||
toast_ref.add_toast(adw::Toast::new("Please select a category"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let start_str = start_date_label.text().to_string();
|
||||
let start_date = NaiveDate::parse_from_str(&start_str, "%Y-%m-%d")
|
||||
.unwrap_or_else(|_| chrono::Local::now().date_naive());
|
||||
|
||||
let note_text = note_row.text();
|
||||
let note = if note_text.is_empty() { None } else { Some(note_text.to_string()) };
|
||||
let url_text = url_row.text();
|
||||
let url = if url_text.is_empty() { None } else { Some(url_text.to_string()) };
|
||||
|
||||
let updated = Subscription {
|
||||
id: sub_id,
|
||||
name: name.trim().to_string(),
|
||||
amount,
|
||||
currency: currency.clone(),
|
||||
frequency,
|
||||
category_id,
|
||||
start_date,
|
||||
next_due: start_date,
|
||||
active: sub_active,
|
||||
note: note.clone(),
|
||||
url,
|
||||
recurring_id: sub_recurring_id,
|
||||
};
|
||||
|
||||
match db_ref.update_subscription(&updated) {
|
||||
Ok(_) => {
|
||||
if let Some(rec_id) = sub_recurring_id {
|
||||
if let Ok(mut rec) = db_ref.get_recurring(rec_id) {
|
||||
rec.amount = amount;
|
||||
rec.frequency = frequency;
|
||||
rec.currency = currency;
|
||||
rec.note = note;
|
||||
let _ = db_ref.update_recurring(&rec);
|
||||
}
|
||||
}
|
||||
dialog_ref.close();
|
||||
Self::load_subscriptions(
|
||||
&db_ref, &active_ref, &paused_ref,
|
||||
&monthly_ref, &yearly_ref, &base_ref, &toast_ref,
|
||||
&change_ref,
|
||||
);
|
||||
Self::notify_change(&change_ref);
|
||||
toast_ref.add_toast(adw::Toast::new("Subscription updated"));
|
||||
}
|
||||
Err(e) => {
|
||||
toast_ref.add_toast(adw::Toast::new(&format!("Error: {}", e)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
dialog.present(Some(parent));
|
||||
}
|
||||
|
||||
fn make_category_factory() -> gtk::SignalListItemFactory {
|
||||
let factory = gtk::SignalListItemFactory::new();
|
||||
factory.connect_setup(|_, item| {
|
||||
let item = item.downcast_ref::<gtk::ListItem>().unwrap();
|
||||
let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
let icon = gtk::Image::new();
|
||||
icon.set_pixel_size(20);
|
||||
let label = gtk::Label::new(None);
|
||||
hbox.append(&icon);
|
||||
hbox.append(&label);
|
||||
item.set_child(Some(&hbox));
|
||||
});
|
||||
factory.connect_bind(|_, item| {
|
||||
let item = item.downcast_ref::<gtk::ListItem>().unwrap();
|
||||
let string_obj = item.item().and_downcast::<gtk::StringObject>().unwrap();
|
||||
let text = string_obj.string();
|
||||
let hbox = item.child().and_downcast::<gtk::Box>().unwrap();
|
||||
let icon = hbox.first_child().and_downcast::<gtk::Image>().unwrap();
|
||||
let label = icon.next_sibling().and_downcast::<gtk::Label>().unwrap();
|
||||
if let Some((icon_name, name)) = text.split_once('\t') {
|
||||
icon.set_icon_name(Some(icon_name));
|
||||
icon.set_visible(true);
|
||||
label.set_label(name);
|
||||
} else {
|
||||
icon.set_visible(false);
|
||||
label.set_label(&text);
|
||||
}
|
||||
});
|
||||
factory
|
||||
}
|
||||
|
||||
fn clear_group(group: &adw::PreferencesGroup) {
|
||||
let mut rows = Vec::new();
|
||||
Self::collect_action_rows(group.upcast_ref(), &mut rows);
|
||||
for row in &rows {
|
||||
group.remove(row);
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_action_rows(widget: >k::Widget, rows: &mut Vec<adw::ActionRow>) {
|
||||
if let Some(row) = widget.downcast_ref::<adw::ActionRow>() {
|
||||
rows.push(row.clone());
|
||||
return;
|
||||
}
|
||||
let mut child = widget.first_child();
|
||||
while let Some(c) = child {
|
||||
Self::collect_action_rows(&c, rows);
|
||||
child = c.next_sibling();
|
||||
}
|
||||
}
|
||||
}
|
||||
147
outlay-gtk/src/tray.rs
Normal file
147
outlay-gtk/src/tray.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
use ksni::menu::StandardItem;
|
||||
use ksni::{Category, ToolTip, Tray, TrayMethods};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc;
|
||||
|
||||
pub enum TrayCommand {
|
||||
Show,
|
||||
QuickAdd,
|
||||
LogExpense,
|
||||
LogIncome,
|
||||
Quit,
|
||||
}
|
||||
|
||||
struct OutlayTray {
|
||||
sender: mpsc::Sender<TrayCommand>,
|
||||
icon_theme_path: String,
|
||||
}
|
||||
|
||||
impl Tray for OutlayTray {
|
||||
fn id(&self) -> String {
|
||||
"outlay".into()
|
||||
}
|
||||
|
||||
fn title(&self) -> String {
|
||||
"Outlay".into()
|
||||
}
|
||||
|
||||
fn icon_name(&self) -> String {
|
||||
"io.github.outlay".into()
|
||||
}
|
||||
|
||||
fn icon_theme_path(&self) -> String {
|
||||
self.icon_theme_path.clone()
|
||||
}
|
||||
|
||||
fn category(&self) -> Category {
|
||||
Category::ApplicationStatus
|
||||
}
|
||||
|
||||
fn tool_tip(&self) -> ToolTip {
|
||||
ToolTip {
|
||||
title: "Outlay - Personal Finance".into(),
|
||||
description: String::new(),
|
||||
icon_name: String::new(),
|
||||
icon_pixmap: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn activate(&mut self, _x: i32, _y: i32) {
|
||||
self.sender.send(TrayCommand::Show).ok();
|
||||
}
|
||||
|
||||
fn menu(&self) -> Vec<ksni::MenuItem<Self>> {
|
||||
vec![
|
||||
StandardItem {
|
||||
label: "Show Outlay".into(),
|
||||
activate: Box::new(|tray: &mut Self| {
|
||||
tray.sender.send(TrayCommand::Show).ok();
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
ksni::MenuItem::Separator,
|
||||
StandardItem {
|
||||
label: "Quick Add".into(),
|
||||
activate: Box::new(|tray: &mut Self| {
|
||||
tray.sender.send(TrayCommand::QuickAdd).ok();
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
StandardItem {
|
||||
label: "Log Expense".into(),
|
||||
activate: Box::new(|tray: &mut Self| {
|
||||
tray.sender.send(TrayCommand::LogExpense).ok();
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
StandardItem {
|
||||
label: "Log Income".into(),
|
||||
activate: Box::new(|tray: &mut Self| {
|
||||
tray.sender.send(TrayCommand::LogIncome).ok();
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
ksni::MenuItem::Separator,
|
||||
StandardItem {
|
||||
label: "Quit".into(),
|
||||
activate: Box::new(|tray: &mut Self| {
|
||||
tray.sender.send(TrayCommand::Quit).ok();
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
fn find_icon_theme_path() -> String {
|
||||
let exe_path = std::env::current_exe().unwrap_or_default();
|
||||
let exe_dir = exe_path.parent().unwrap_or(std::path::Path::new("."));
|
||||
|
||||
let candidates = [
|
||||
exe_dir.join("../../outlay-gtk/data/icons"),
|
||||
exe_dir.join("../share/icons"),
|
||||
PathBuf::from("/usr/share/icons"),
|
||||
];
|
||||
|
||||
for candidate in &candidates {
|
||||
if candidate.exists() {
|
||||
if let Ok(resolved) = candidate.canonicalize() {
|
||||
return resolved.to_string_lossy().into_owned();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String::new()
|
||||
}
|
||||
|
||||
pub fn spawn_tray(sender: mpsc::Sender<TrayCommand>) {
|
||||
let icon_theme_path = find_icon_theme_path();
|
||||
|
||||
let tray = OutlayTray {
|
||||
sender,
|
||||
icon_theme_path,
|
||||
};
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("Failed to create tokio runtime for tray");
|
||||
|
||||
rt.block_on(async {
|
||||
match tray.spawn().await {
|
||||
Ok(_handle) => {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[tray] Failed to register: {:?}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,43 +1,89 @@
|
||||
use adw::prelude::*;
|
||||
use gtk::glib;
|
||||
use chrono::Datelike;
|
||||
use gtk::{gdk, glib};
|
||||
use outlay_core::db::Database;
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::budgets_view::BudgetsView;
|
||||
use crate::icon_theme;
|
||||
use crate::charts_view::ChartsView;
|
||||
use crate::credit_cards_view::CreditCardsView;
|
||||
use crate::forecast_view::ForecastView;
|
||||
use crate::insights_view::InsightsView;
|
||||
use crate::goals_view::GoalsView;
|
||||
use crate::history_view::HistoryView;
|
||||
use crate::log_view::LogView;
|
||||
use crate::recurring_view::RecurringView;
|
||||
use crate::settings_view::SettingsView;
|
||||
use crate::subscriptions_view::SubscriptionsView;
|
||||
use crate::wishlist_view::WishlistView;
|
||||
|
||||
pub struct MainWindow {
|
||||
pub window: adw::ApplicationWindow,
|
||||
pub split_view: adw::NavigationSplitView,
|
||||
pub content_stack: gtk::Stack,
|
||||
pub log_view: LogView,
|
||||
pub log_view: Rc<LogView>,
|
||||
pub history_view: Rc<HistoryView>,
|
||||
pub charts_view: Rc<ChartsView>,
|
||||
pub budgets_view: Rc<BudgetsView>,
|
||||
pub insights_view: Rc<InsightsView>,
|
||||
sidebar_list: gtk::ListBox,
|
||||
content_stack: gtk::Stack,
|
||||
content_page: adw::NavigationPage,
|
||||
}
|
||||
|
||||
struct SidebarItem {
|
||||
id: &'static str,
|
||||
label: &'static str,
|
||||
icon: &'static str,
|
||||
color: &'static str, // CSS color visible in both light and dark modes
|
||||
}
|
||||
|
||||
const SIDEBAR_ITEMS: &[SidebarItem] = &[
|
||||
SidebarItem { id: "log", label: "Log", icon: "list-add-symbolic" },
|
||||
SidebarItem { id: "history", label: "History", icon: "document-open-recent-symbolic" },
|
||||
SidebarItem { id: "charts", label: "Charts", icon: "utilities-system-monitor-symbolic" },
|
||||
SidebarItem { id: "budgets", label: "Budgets", icon: "wallet2-symbolic" },
|
||||
SidebarItem { id: "recurring", label: "Recurring", icon: "view-refresh-symbolic" },
|
||||
SidebarItem { id: "settings", label: "Settings", icon: "emblem-system-symbolic" },
|
||||
struct SidebarSection {
|
||||
label: &'static str,
|
||||
items: &'static [SidebarItem],
|
||||
}
|
||||
|
||||
const SIDEBAR_SECTIONS: &[SidebarSection] = &[
|
||||
SidebarSection {
|
||||
label: "TRACKING",
|
||||
items: &[
|
||||
SidebarItem { id: "log", label: "Log", icon: "outlay-log", color: "#4dabf7" },
|
||||
SidebarItem { id: "history", label: "History", icon: "outlay-history", color: "#9775fa" },
|
||||
SidebarItem { id: "charts", label: "Charts", icon: "outlay-charts", color: "#ff8787" },
|
||||
],
|
||||
},
|
||||
SidebarSection {
|
||||
label: "PLANNING",
|
||||
items: &[
|
||||
SidebarItem { id: "budgets", label: "Budgets", icon: "outlay-budgets", color: "#69db7c" },
|
||||
SidebarItem { id: "goals", label: "Goals", icon: "outlay-goals", color: "#ffd43b" },
|
||||
SidebarItem { id: "forecast", label: "Forecast", icon: "outlay-forecast", color: "#74c0fc" },
|
||||
],
|
||||
},
|
||||
SidebarSection {
|
||||
label: "MANAGEMENT",
|
||||
items: &[
|
||||
SidebarItem { id: "recurring", label: "Recurring", icon: "outlay-recurring", color: "#38d9a9" },
|
||||
SidebarItem { id: "subscriptions", label: "Subscriptions", icon: "outlay-subscriptions", color: "#e599f7" },
|
||||
SidebarItem { id: "wishlist", label: "Wishlist", icon: "outlay-wishlist", color: "#ffa94d" },
|
||||
SidebarItem { id: "creditcards", label: "Credit Cards", icon: "outlay-creditcards", color: "#a9e34b" },
|
||||
SidebarItem { id: "insights", label: "Insights", icon: "outlay-insights", color: "#f783ac" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const SETTINGS_ITEM: SidebarItem = SidebarItem { id: "settings", label: "Settings", icon: "outlay-settings", color: "#adb5bd" };
|
||||
|
||||
fn all_sidebar_items() -> Vec<&'static SidebarItem> {
|
||||
SIDEBAR_SECTIONS.iter().flat_map(|s| s.items.iter()).collect()
|
||||
}
|
||||
|
||||
impl MainWindow {
|
||||
pub fn new(app: &adw::Application, db: Rc<Database>) -> Self {
|
||||
let content_stack = gtk::Stack::new();
|
||||
content_stack.set_transition_type(gtk::StackTransitionType::Crossfade);
|
||||
|
||||
let log_view = LogView::new(db.clone(), app);
|
||||
let log_view = Rc::new(LogView::new(db.clone(), app));
|
||||
let log_scroll = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
.child(&log_view.container)
|
||||
@@ -57,45 +103,107 @@ impl MainWindow {
|
||||
let budgets_view = BudgetsView::new(db.clone());
|
||||
content_stack.add_named(&budgets_view.container, Some("budgets"));
|
||||
|
||||
let recurring_view = RecurringView::new(db.clone());
|
||||
let goals_view = GoalsView::new(db.clone());
|
||||
content_stack.add_named(&goals_view.container, Some("goals"));
|
||||
|
||||
let recurring_view = Rc::new(RecurringView::new(db.clone()));
|
||||
content_stack.add_named(&recurring_view.container, Some("recurring"));
|
||||
|
||||
let subscriptions_view = Rc::new(SubscriptionsView::new(db.clone()));
|
||||
content_stack.add_named(&subscriptions_view.container, Some("subscriptions"));
|
||||
|
||||
// Cross-view refresh: changes in subscriptions refresh recurring and vice versa
|
||||
{
|
||||
let rec_ref = recurring_view.clone();
|
||||
subscriptions_view.set_on_change(move || {
|
||||
rec_ref.refresh();
|
||||
});
|
||||
}
|
||||
{
|
||||
let sub_ref = subscriptions_view.clone();
|
||||
recurring_view.set_on_change(move || {
|
||||
sub_ref.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
let wishlist_view = WishlistView::new(db.clone());
|
||||
content_stack.add_named(&wishlist_view.container, Some("wishlist"));
|
||||
|
||||
let forecast_view = ForecastView::new(db.clone());
|
||||
content_stack.add_named(&forecast_view.container, Some("forecast"));
|
||||
|
||||
let insights_view = InsightsView::new(db.clone());
|
||||
content_stack.add_named(&insights_view.container, Some("insights"));
|
||||
|
||||
let credit_cards_view = CreditCardsView::new(db.clone());
|
||||
content_stack.add_named(&credit_cards_view.container, Some("creditcards"));
|
||||
|
||||
let settings_view = SettingsView::new(db.clone(), app);
|
||||
content_stack.add_named(&settings_view.container, Some("settings"));
|
||||
|
||||
// Main sidebar items (top) - grouped by section with headers
|
||||
let sidebar_list = gtk::ListBox::new();
|
||||
sidebar_list.set_selection_mode(gtk::SelectionMode::Single);
|
||||
sidebar_list.add_css_class("navigation-sidebar");
|
||||
|
||||
for item in SIDEBAR_ITEMS {
|
||||
let all_items = all_sidebar_items();
|
||||
for item in &all_items {
|
||||
let row = Self::make_sidebar_row(item);
|
||||
sidebar_list.append(&row);
|
||||
}
|
||||
|
||||
let content_stack_ref = content_stack.clone();
|
||||
sidebar_list.connect_row_selected(move |_, row| {
|
||||
if let Some(row) = row {
|
||||
let idx = row.index() as usize;
|
||||
if idx < SIDEBAR_ITEMS.len() {
|
||||
content_stack_ref.set_visible_child_name(SIDEBAR_ITEMS[idx].id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Select the first row by default
|
||||
if let Some(first_row) = sidebar_list.row_at_index(0) {
|
||||
sidebar_list.select_row(Some(&first_row));
|
||||
// Compute section boundary indices: the row index where each section starts
|
||||
// TRACKING: 0, PLANNING: 3, MANAGEMENT: 6
|
||||
let mut section_starts: Vec<(i32, &'static str)> = Vec::new();
|
||||
let mut idx = 0i32;
|
||||
for section in SIDEBAR_SECTIONS {
|
||||
section_starts.push((idx, section.label));
|
||||
idx += section.items.len() as i32;
|
||||
}
|
||||
|
||||
let sidebar_scroll = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
.vexpand(true)
|
||||
.child(&sidebar_list)
|
||||
.build();
|
||||
sidebar_list.set_header_func(move |row, _before| {
|
||||
let ri = row.index();
|
||||
for &(start, label) in §ion_starts {
|
||||
if ri == start {
|
||||
let header_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
|
||||
// Add separator before all sections except the first
|
||||
if start > 0 {
|
||||
let sep = gtk::Separator::new(gtk::Orientation::Horizontal);
|
||||
sep.set_margin_top(6);
|
||||
sep.set_margin_bottom(2);
|
||||
sep.set_margin_start(12);
|
||||
sep.set_margin_end(12);
|
||||
header_box.append(&sep);
|
||||
}
|
||||
|
||||
let section_label = gtk::Label::new(Some(label));
|
||||
section_label.add_css_class("sidebar-section-label");
|
||||
section_label.set_halign(gtk::Align::Start);
|
||||
header_box.append(§ion_label);
|
||||
|
||||
row.set_header(Some(&header_box));
|
||||
return;
|
||||
}
|
||||
}
|
||||
row.set_header(gtk::Widget::NONE);
|
||||
});
|
||||
|
||||
// Settings item (bottom)
|
||||
let settings_list = gtk::ListBox::new();
|
||||
settings_list.set_selection_mode(gtk::SelectionMode::Single);
|
||||
settings_list.add_css_class("navigation-sidebar");
|
||||
settings_list.append(&Self::make_sidebar_row(&SETTINGS_ITEM));
|
||||
|
||||
let sidebar_box = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
sidebar_list.set_vexpand(true);
|
||||
sidebar_box.append(&sidebar_list);
|
||||
sidebar_box.append(>k::Separator::new(gtk::Orientation::Horizontal));
|
||||
sidebar_box.append(&settings_list);
|
||||
|
||||
let sidebar_toolbar = adw::ToolbarView::new();
|
||||
sidebar_toolbar.add_top_bar(&adw::HeaderBar::new());
|
||||
sidebar_toolbar.set_content(Some(&sidebar_scroll));
|
||||
sidebar_toolbar.set_content(Some(&sidebar_box));
|
||||
|
||||
let sidebar_page = adw::NavigationPage::builder()
|
||||
.title("Outlay")
|
||||
@@ -107,10 +215,114 @@ impl MainWindow {
|
||||
content_toolbar.set_content(Some(&content_stack));
|
||||
|
||||
let content_page = adw::NavigationPage::builder()
|
||||
.title("Outlay")
|
||||
.title("Log")
|
||||
.child(&content_toolbar)
|
||||
.build();
|
||||
|
||||
// Wire sidebar selection to switch content, update title, and refresh views
|
||||
let history_view_ref = Rc::new(history_view);
|
||||
let charts_view_ref = Rc::new(charts_view);
|
||||
let budgets_view_ref = Rc::new(budgets_view);
|
||||
let insights_view_ref = Rc::new(insights_view);
|
||||
|
||||
// Wire up data reset callback to refresh all views
|
||||
{
|
||||
let log_ref = log_view.clone();
|
||||
let hist_ref = history_view_ref.clone();
|
||||
let chart_ref = charts_view_ref.clone();
|
||||
let budget_ref = budgets_view_ref.clone();
|
||||
let insights_ref = insights_view_ref.clone();
|
||||
settings_view.set_on_data_reset(move || {
|
||||
log_ref.refresh_categories();
|
||||
hist_ref.refresh();
|
||||
chart_ref.refresh();
|
||||
let today = chrono::Local::now().date_naive();
|
||||
budget_ref.set_month(today.year(), today.month());
|
||||
insights_ref.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
// Shared month state for syncing across views
|
||||
let shared_month: Rc<Cell<(i32, u32)>> = {
|
||||
let today = chrono::Local::now().date_naive();
|
||||
Rc::new(Cell::new((today.year(), today.month())))
|
||||
};
|
||||
|
||||
// Main list selection - deselect settings
|
||||
{
|
||||
let content_stack_ref = content_stack.clone();
|
||||
let content_page_ref = content_page.clone();
|
||||
let history_ref = history_view_ref.clone();
|
||||
let charts_ref = charts_view_ref.clone();
|
||||
let budgets_ref = budgets_view_ref.clone();
|
||||
let log_ref = log_view.clone();
|
||||
let insights_ref = insights_view_ref.clone();
|
||||
let settings_list_ref = settings_list.clone();
|
||||
let shared = shared_month.clone();
|
||||
sidebar_list.connect_row_selected(move |_, row| {
|
||||
if let Some(row) = row {
|
||||
settings_list_ref.unselect_all();
|
||||
let idx = row.index() as usize;
|
||||
if idx < all_sidebar_items().len() {
|
||||
// Save month from the view we're leaving
|
||||
let current_view = content_stack_ref.visible_child_name();
|
||||
if let Some(ref name) = current_view {
|
||||
let month = match name.as_str() {
|
||||
"history" => Some(history_ref.get_month()),
|
||||
"charts" => Some(charts_ref.get_month()),
|
||||
"budgets" => Some(budgets_ref.get_month()),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(m) = month {
|
||||
shared.set(m);
|
||||
}
|
||||
}
|
||||
|
||||
content_stack_ref.set_visible_child_name(all_sidebar_items()[idx].id);
|
||||
content_page_ref.set_title(all_sidebar_items()[idx].label);
|
||||
|
||||
// Sync month to the view we're entering and refresh
|
||||
let (sy, sm) = shared.get();
|
||||
match all_sidebar_items()[idx].id {
|
||||
"log" => log_ref.refresh_categories(),
|
||||
"history" => {
|
||||
history_ref.set_month(sy, sm);
|
||||
}
|
||||
"charts" => {
|
||||
charts_ref.set_month(sy, sm);
|
||||
}
|
||||
"budgets" => {
|
||||
budgets_ref.set_month(sy, sm);
|
||||
}
|
||||
"insights" => {
|
||||
insights_ref.refresh();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Settings list selection - deselect main list
|
||||
{
|
||||
let content_stack_ref = content_stack.clone();
|
||||
let content_page_ref = content_page.clone();
|
||||
let sidebar_list_ref = sidebar_list.clone();
|
||||
settings_list.connect_row_selected(move |_, row| {
|
||||
if row.is_some() {
|
||||
sidebar_list_ref.unselect_all();
|
||||
content_stack_ref.set_visible_child_name(SETTINGS_ITEM.id);
|
||||
content_page_ref.set_title(SETTINGS_ITEM.label);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Select the first row by default
|
||||
if let Some(first_row) = sidebar_list.row_at_index(0) {
|
||||
sidebar_list.select_row(Some(&first_row));
|
||||
}
|
||||
|
||||
let split_view = adw::NavigationSplitView::new();
|
||||
split_view.set_sidebar(Some(&sidebar_page));
|
||||
split_view.set_content(Some(&content_page));
|
||||
@@ -147,7 +359,7 @@ impl MainWindow {
|
||||
window.maximize();
|
||||
}
|
||||
|
||||
// Save window size on close
|
||||
// Hide window on close instead of quitting
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
window.connect_close_request(move |win| {
|
||||
@@ -157,28 +369,260 @@ impl MainWindow {
|
||||
db_ref
|
||||
.set_setting("window_maximized", if win.is_maximized() { "true" } else { "false" })
|
||||
.ok();
|
||||
glib::Propagation::Proceed
|
||||
win.set_visible(false);
|
||||
glib::Propagation::Stop
|
||||
});
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
{
|
||||
let key_ctrl = gtk::EventControllerKey::new();
|
||||
let sidebar_ref = sidebar_list.clone();
|
||||
let settings_list_ref = settings_list.clone();
|
||||
let content_ref = content_stack.clone();
|
||||
let page_ref = content_page.clone();
|
||||
let log_ref = log_view.clone();
|
||||
let hist_ref = history_view_ref.clone();
|
||||
let chart_ref = charts_view_ref.clone();
|
||||
let window_ref = window.clone();
|
||||
key_ctrl.connect_key_pressed(move |_, key, _, modifier| {
|
||||
if !modifier.contains(gdk::ModifierType::CONTROL_MASK) {
|
||||
return glib::Propagation::Proceed;
|
||||
}
|
||||
match key {
|
||||
gdk::Key::_1 | gdk::Key::_2 | gdk::Key::_3 | gdk::Key::_4 | gdk::Key::_5 | gdk::Key::_6 | gdk::Key::_7 | gdk::Key::_8 | gdk::Key::_9 | gdk::Key::_0 => {
|
||||
let idx = match key {
|
||||
gdk::Key::_1 => 0,
|
||||
gdk::Key::_2 => 1,
|
||||
gdk::Key::_3 => 2,
|
||||
gdk::Key::_4 => 3,
|
||||
gdk::Key::_5 => 4,
|
||||
gdk::Key::_6 => 5,
|
||||
gdk::Key::_7 => 6,
|
||||
gdk::Key::_8 => 7,
|
||||
gdk::Key::_9 => 8,
|
||||
gdk::Key::_0 => 9,
|
||||
_ => 0,
|
||||
};
|
||||
if let Some(row) = sidebar_ref.row_at_index(idx) {
|
||||
settings_list_ref.unselect_all();
|
||||
sidebar_ref.select_row(Some(&row));
|
||||
content_ref.set_visible_child_name(all_sidebar_items()[idx as usize].id);
|
||||
page_ref.set_title(all_sidebar_items()[idx as usize].label);
|
||||
if idx == 0 { log_ref.refresh_categories(); }
|
||||
if idx == 1 { hist_ref.refresh(); }
|
||||
if idx == 2 { chart_ref.refresh(); }
|
||||
}
|
||||
}
|
||||
gdk::Key::comma => {
|
||||
sidebar_ref.unselect_all();
|
||||
settings_list_ref.select_row(settings_list_ref.row_at_index(0).as_ref());
|
||||
content_ref.set_visible_child_name(SETTINGS_ITEM.id);
|
||||
page_ref.set_title(SETTINGS_ITEM.label);
|
||||
}
|
||||
gdk::Key::e => {
|
||||
if let Some(row) = sidebar_ref.row_at_index(0) {
|
||||
settings_list_ref.unselect_all();
|
||||
sidebar_ref.select_row(Some(&row));
|
||||
}
|
||||
content_ref.set_visible_child_name("log");
|
||||
page_ref.set_title("Log");
|
||||
log_ref.refresh_categories();
|
||||
log_ref.set_income_mode(false);
|
||||
log_ref.focus_amount();
|
||||
}
|
||||
gdk::Key::i => {
|
||||
if let Some(row) = sidebar_ref.row_at_index(0) {
|
||||
settings_list_ref.unselect_all();
|
||||
sidebar_ref.select_row(Some(&row));
|
||||
}
|
||||
content_ref.set_visible_child_name("log");
|
||||
page_ref.set_title("Log");
|
||||
log_ref.refresh_categories();
|
||||
log_ref.set_income_mode(true);
|
||||
log_ref.focus_amount();
|
||||
}
|
||||
gdk::Key::question => {
|
||||
Self::show_shortcuts_window(&window_ref);
|
||||
}
|
||||
_ => return glib::Propagation::Proceed,
|
||||
}
|
||||
glib::Propagation::Stop
|
||||
});
|
||||
window.add_controller(key_ctrl);
|
||||
}
|
||||
|
||||
MainWindow {
|
||||
window,
|
||||
split_view,
|
||||
content_stack,
|
||||
log_view,
|
||||
history_view: history_view_ref,
|
||||
charts_view: charts_view_ref,
|
||||
budgets_view: budgets_view_ref,
|
||||
insights_view: insights_view_ref,
|
||||
sidebar_list,
|
||||
content_stack,
|
||||
content_page,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(&self) {
|
||||
self.window.set_visible(true);
|
||||
self.window.present();
|
||||
}
|
||||
|
||||
pub fn switch_to_log(&self, income: bool) {
|
||||
if let Some(row) = self.sidebar_list.row_at_index(0) {
|
||||
self.sidebar_list.select_row(Some(&row));
|
||||
}
|
||||
self.content_stack.set_visible_child_name("log");
|
||||
self.content_page.set_title("Log");
|
||||
self.log_view.refresh_categories();
|
||||
self.log_view.set_income_mode(income);
|
||||
self.log_view.focus_amount();
|
||||
}
|
||||
|
||||
pub fn switch_to_history_filtered(&self, category_id: i64) {
|
||||
if let Some(row) = self.sidebar_list.row_at_index(1) {
|
||||
self.sidebar_list.select_row(Some(&row));
|
||||
}
|
||||
self.content_stack.set_visible_child_name("history");
|
||||
self.content_page.set_title("History");
|
||||
self.history_view.refresh();
|
||||
self.history_view.set_category_filter(category_id);
|
||||
}
|
||||
|
||||
pub fn switch_to_insights(&self) {
|
||||
// Find the sidebar index for "insights"
|
||||
let idx = all_sidebar_items().iter().position(|item| item.id == "insights");
|
||||
if let Some(i) = idx {
|
||||
if let Some(row) = self.sidebar_list.row_at_index(i as i32) {
|
||||
self.sidebar_list.select_row(Some(&row));
|
||||
}
|
||||
}
|
||||
self.content_stack.set_visible_child_name("insights");
|
||||
self.content_page.set_title("Insights");
|
||||
self.insights_view.refresh();
|
||||
}
|
||||
|
||||
pub fn save_window_state(&self, db: &Database) {
|
||||
let (width, height) = self.window.default_size();
|
||||
db.set_setting("window_width", &width.to_string()).ok();
|
||||
db.set_setting("window_height", &height.to_string()).ok();
|
||||
db.set_setting(
|
||||
"window_maximized",
|
||||
if self.window.is_maximized() { "true" } else { "false" },
|
||||
)
|
||||
.ok();
|
||||
}
|
||||
|
||||
fn show_shortcuts_window(parent: &adw::ApplicationWindow) {
|
||||
let ui = r#"
|
||||
<interface>
|
||||
<object class="GtkShortcutsWindow" id="shortcuts_window">
|
||||
<property name="modal">true</property>
|
||||
<child>
|
||||
<object class="GtkShortcutsSection">
|
||||
<property name="title">Outlay</property>
|
||||
<property name="max-height">12</property>
|
||||
<child>
|
||||
<object class="GtkShortcutsGroup">
|
||||
<property name="title">Navigation</property>
|
||||
<child><object class="GtkShortcutsShortcut">
|
||||
<property name="accelerator"><Ctrl>1</property>
|
||||
<property name="title">Log</property>
|
||||
</object></child>
|
||||
<child><object class="GtkShortcutsShortcut">
|
||||
<property name="accelerator"><Ctrl>2</property>
|
||||
<property name="title">History</property>
|
||||
</object></child>
|
||||
<child><object class="GtkShortcutsShortcut">
|
||||
<property name="accelerator"><Ctrl>3</property>
|
||||
<property name="title">Charts</property>
|
||||
</object></child>
|
||||
<child><object class="GtkShortcutsShortcut">
|
||||
<property name="accelerator"><Ctrl>4</property>
|
||||
<property name="title">Budgets</property>
|
||||
</object></child>
|
||||
<child><object class="GtkShortcutsShortcut">
|
||||
<property name="accelerator"><Ctrl>5</property>
|
||||
<property name="title">Goals</property>
|
||||
</object></child>
|
||||
<child><object class="GtkShortcutsShortcut">
|
||||
<property name="accelerator"><Ctrl>6</property>
|
||||
<property name="title">Recurring</property>
|
||||
</object></child>
|
||||
<child><object class="GtkShortcutsShortcut">
|
||||
<property name="accelerator"><Ctrl>7</property>
|
||||
<property name="title">Subscriptions</property>
|
||||
</object></child>
|
||||
<child><object class="GtkShortcutsShortcut">
|
||||
<property name="accelerator"><Ctrl>8</property>
|
||||
<property name="title">Wishlist</property>
|
||||
</object></child>
|
||||
<child><object class="GtkShortcutsShortcut">
|
||||
<property name="accelerator"><Ctrl>9</property>
|
||||
<property name="title">Forecast</property>
|
||||
</object></child>
|
||||
<child><object class="GtkShortcutsShortcut">
|
||||
<property name="accelerator"><Ctrl>0</property>
|
||||
<property name="title">Insights</property>
|
||||
</object></child>
|
||||
<child><object class="GtkShortcutsShortcut">
|
||||
<property name="accelerator"><Ctrl>comma</property>
|
||||
<property name="title">Settings</property>
|
||||
</object></child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsGroup">
|
||||
<property name="title">Transaction Entry</property>
|
||||
<child><object class="GtkShortcutsShortcut">
|
||||
<property name="accelerator"><Ctrl>e</property>
|
||||
<property name="title">New expense</property>
|
||||
</object></child>
|
||||
<child><object class="GtkShortcutsShortcut">
|
||||
<property name="accelerator"><Ctrl>i</property>
|
||||
<property name="title">New income</property>
|
||||
</object></child>
|
||||
</object>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkShortcutsGroup">
|
||||
<property name="title">General</property>
|
||||
<child><object class="GtkShortcutsShortcut">
|
||||
<property name="accelerator"><Ctrl>question</property>
|
||||
<property name="title">Keyboard shortcuts</property>
|
||||
</object></child>
|
||||
<child><object class="GtkShortcutsShortcut">
|
||||
<property name="accelerator"><Ctrl>q</property>
|
||||
<property name="title">Quit</property>
|
||||
</object></child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</child>
|
||||
</object>
|
||||
</interface>"#;
|
||||
let builder = gtk::Builder::from_string(ui);
|
||||
let win: gtk::ShortcutsWindow = builder.object("shortcuts_window").unwrap();
|
||||
win.set_transient_for(Some(parent));
|
||||
win.present();
|
||||
}
|
||||
|
||||
fn make_sidebar_row(item: &SidebarItem) -> gtk::ListBoxRow {
|
||||
let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 12);
|
||||
hbox.set_margin_top(8);
|
||||
hbox.set_margin_bottom(8);
|
||||
hbox.set_margin_start(12);
|
||||
let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 14);
|
||||
hbox.set_margin_top(12);
|
||||
hbox.set_margin_bottom(12);
|
||||
hbox.set_margin_start(16);
|
||||
hbox.set_margin_end(12);
|
||||
|
||||
let icon = gtk::Image::from_icon_name(item.icon);
|
||||
let tinted = icon_theme::get_tinted_icon_name(item.icon, item.color);
|
||||
let icon = gtk::Image::from_icon_name(&tinted);
|
||||
icon.set_pixel_size(24);
|
||||
|
||||
let label = gtk::Label::new(Some(item.label));
|
||||
label.set_halign(gtk::Align::Start);
|
||||
label.add_css_class("sidebar-label");
|
||||
|
||||
hbox.append(&icon);
|
||||
hbox.append(&label);
|
||||
|
||||
374
outlay-gtk/src/wishlist_view.rs
Normal file
374
outlay-gtk/src/wishlist_view.rs
Normal file
@@ -0,0 +1,374 @@
|
||||
use adw::prelude::*;
|
||||
use outlay_core::db::Database;
|
||||
use std::rc::Rc;
|
||||
|
||||
use crate::icon_theme;
|
||||
|
||||
pub struct WishlistView {
|
||||
pub container: gtk::Box,
|
||||
}
|
||||
|
||||
impl WishlistView {
|
||||
pub fn new(db: Rc<Database>) -> Self {
|
||||
let container = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
let toast_overlay = adw::ToastOverlay::new();
|
||||
|
||||
let clamp = adw::Clamp::new();
|
||||
clamp.set_maximum_size(700);
|
||||
clamp.set_margin_start(16);
|
||||
clamp.set_margin_end(16);
|
||||
|
||||
let inner = gtk::Box::new(gtk::Orientation::Vertical, 20);
|
||||
inner.set_margin_top(20);
|
||||
inner.set_margin_bottom(20);
|
||||
|
||||
let total_card = gtk::Box::new(gtk::Orientation::Vertical, 4);
|
||||
total_card.add_css_class("card");
|
||||
total_card.set_margin_start(4);
|
||||
total_card.set_margin_end(4);
|
||||
|
||||
let total_title = gtk::Label::new(Some("WISHLIST TOTAL"));
|
||||
total_title.add_css_class("caption");
|
||||
total_title.add_css_class("dim-label");
|
||||
total_title.set_halign(gtk::Align::Start);
|
||||
total_title.set_margin_top(12);
|
||||
total_title.set_margin_start(12);
|
||||
|
||||
let base_currency = db.get_setting("base_currency")
|
||||
.ok().flatten()
|
||||
.unwrap_or_else(|| "USD".to_string());
|
||||
|
||||
let total = db.get_wishlist_total().unwrap_or(0.0);
|
||||
let total_label = gtk::Label::new(Some(&format!("{:.2} {}", total, base_currency)));
|
||||
total_label.add_css_class("title-1");
|
||||
total_label.set_halign(gtk::Align::Start);
|
||||
total_label.set_margin_start(12);
|
||||
total_label.set_margin_bottom(12);
|
||||
|
||||
total_card.append(&total_title);
|
||||
total_card.append(&total_label);
|
||||
|
||||
let active_group = adw::PreferencesGroup::builder()
|
||||
.title("WANTED")
|
||||
.build();
|
||||
|
||||
let purchased_group = adw::PreferencesGroup::builder()
|
||||
.title("PURCHASED")
|
||||
.build();
|
||||
|
||||
Self::load_items(&db, &active_group, &purchased_group, &total_label, &base_currency, &toast_overlay);
|
||||
|
||||
let add_btn = gtk::Button::with_label("Add Item");
|
||||
add_btn.add_css_class("suggested-action");
|
||||
add_btn.add_css_class("pill");
|
||||
add_btn.set_halign(gtk::Align::Center);
|
||||
add_btn.set_margin_top(8);
|
||||
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let active_ref = active_group.clone();
|
||||
let purchased_ref = purchased_group.clone();
|
||||
let total_ref = total_label.clone();
|
||||
let currency = base_currency.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
add_btn.connect_clicked(move |btn| {
|
||||
Self::show_add_dialog(btn, &db_ref, &active_ref, &purchased_ref, &total_ref, ¤cy, &toast_ref);
|
||||
});
|
||||
}
|
||||
|
||||
inner.append(&total_card);
|
||||
inner.append(&active_group);
|
||||
inner.append(&add_btn);
|
||||
inner.append(&purchased_group);
|
||||
|
||||
clamp.set_child(Some(&inner));
|
||||
|
||||
let scroll = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
.vexpand(true)
|
||||
.child(&clamp)
|
||||
.build();
|
||||
|
||||
toast_overlay.set_child(Some(&scroll));
|
||||
container.append(&toast_overlay);
|
||||
|
||||
WishlistView { container }
|
||||
}
|
||||
|
||||
fn load_items(
|
||||
db: &Rc<Database>,
|
||||
active_group: &adw::PreferencesGroup,
|
||||
purchased_group: &adw::PreferencesGroup,
|
||||
total_label: >k::Label,
|
||||
base_currency: &str,
|
||||
toast_overlay: &adw::ToastOverlay,
|
||||
) {
|
||||
Self::clear_group(active_group);
|
||||
Self::clear_group(purchased_group);
|
||||
|
||||
let total = db.get_wishlist_total().unwrap_or(0.0);
|
||||
total_label.set_label(&format!("{:.2} {}", total, base_currency));
|
||||
|
||||
let active = db.list_wishlist(false).unwrap_or_default();
|
||||
let purchased = db.list_wishlist(true).unwrap_or_default();
|
||||
|
||||
if active.is_empty() {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("No items on your wishlist")
|
||||
.build();
|
||||
row.add_css_class("dim-label");
|
||||
active_group.add(&row);
|
||||
} else {
|
||||
for item in &active {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&item.name)
|
||||
.activatable(true)
|
||||
.build();
|
||||
|
||||
if let Some(note) = &item.note {
|
||||
if !note.is_empty() {
|
||||
row.set_subtitle(note);
|
||||
}
|
||||
}
|
||||
|
||||
let priority_icon = match item.priority {
|
||||
3 => Some("#e74c3c"),
|
||||
2 => Some("#f39c12"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(color) = priority_icon {
|
||||
let tinted = icon_theme::get_tinted_icon_name("tabler-alert-circle", color);
|
||||
let icon = gtk::Image::from_icon_name(&tinted);
|
||||
icon.set_pixel_size(18);
|
||||
row.add_prefix(&icon);
|
||||
}
|
||||
|
||||
let amount_label = gtk::Label::new(Some(&format!("{:.2}", item.amount)));
|
||||
amount_label.add_css_class("amount-display");
|
||||
row.add_suffix(&amount_label);
|
||||
|
||||
let item_id = item.id;
|
||||
let db_ref = db.clone();
|
||||
let active_ref = active_group.clone();
|
||||
let purchased_ref = purchased_group.clone();
|
||||
let total_ref = total_label.clone();
|
||||
let currency = base_currency.to_string();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
row.connect_activated(move |row| {
|
||||
Self::show_item_actions(row, item_id, &db_ref, &active_ref, &purchased_ref, &total_ref, ¤cy, &toast_ref);
|
||||
});
|
||||
|
||||
active_group.add(&row);
|
||||
}
|
||||
}
|
||||
|
||||
if purchased.is_empty() {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("No purchased items")
|
||||
.build();
|
||||
row.add_css_class("dim-label");
|
||||
purchased_group.add(&row);
|
||||
} else {
|
||||
for item in purchased.iter().take(10) {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&item.name)
|
||||
.subtitle(&format!("{:.2}", item.amount))
|
||||
.build();
|
||||
row.add_css_class("dim-label");
|
||||
purchased_group.add(&row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn show_add_dialog(
|
||||
parent: >k::Button,
|
||||
db: &Rc<Database>,
|
||||
active_group: &adw::PreferencesGroup,
|
||||
purchased_group: &adw::PreferencesGroup,
|
||||
total_label: >k::Label,
|
||||
base_currency: &str,
|
||||
toast_overlay: &adw::ToastOverlay,
|
||||
) {
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title("Add Wishlist Item")
|
||||
.content_width(400)
|
||||
.content_height(350)
|
||||
.build();
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&adw::HeaderBar::new());
|
||||
|
||||
let content = gtk::Box::new(gtk::Orientation::Vertical, 16);
|
||||
content.set_margin_top(16);
|
||||
content.set_margin_bottom(16);
|
||||
content.set_margin_start(16);
|
||||
content.set_margin_end(16);
|
||||
|
||||
let form = adw::PreferencesGroup::new();
|
||||
|
||||
let name_row = adw::EntryRow::builder()
|
||||
.title("Item name")
|
||||
.build();
|
||||
form.add(&name_row);
|
||||
|
||||
let amount_row = adw::EntryRow::builder()
|
||||
.title("Estimated cost")
|
||||
.build();
|
||||
amount_row.set_input_purpose(gtk::InputPurpose::Number);
|
||||
crate::numpad::attach_numpad(&amount_row);
|
||||
form.add(&amount_row);
|
||||
|
||||
let priority_labels = ["Low", "Medium", "High"];
|
||||
let priority_model = gtk::StringList::new(&priority_labels);
|
||||
let priority_row = adw::ComboRow::builder()
|
||||
.title("Priority")
|
||||
.model(&priority_model)
|
||||
.selected(0)
|
||||
.build();
|
||||
form.add(&priority_row);
|
||||
|
||||
let note_row = adw::EntryRow::builder()
|
||||
.title("Note (optional)")
|
||||
.build();
|
||||
form.add(¬e_row);
|
||||
|
||||
let url_row = adw::EntryRow::builder()
|
||||
.title("URL (optional)")
|
||||
.build();
|
||||
form.add(&url_row);
|
||||
|
||||
let save_btn = gtk::Button::with_label("Save");
|
||||
save_btn.add_css_class("suggested-action");
|
||||
save_btn.add_css_class("pill");
|
||||
save_btn.set_halign(gtk::Align::Center);
|
||||
|
||||
content.append(&form);
|
||||
content.append(&save_btn);
|
||||
toolbar.set_content(Some(&content));
|
||||
dialog.set_child(Some(&toolbar));
|
||||
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let dialog_ref = dialog.clone();
|
||||
let active_ref = active_group.clone();
|
||||
let purchased_ref = purchased_group.clone();
|
||||
let total_ref = total_label.clone();
|
||||
let currency = base_currency.to_string();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
save_btn.connect_clicked(move |_| {
|
||||
let name = name_row.text().to_string();
|
||||
if name.trim().is_empty() {
|
||||
toast_ref.add_toast(adw::Toast::new("Please enter an item name"));
|
||||
return;
|
||||
}
|
||||
|
||||
let amount: f64 = match amount_row.text().trim().parse() {
|
||||
Ok(v) if v > 0.0 => v,
|
||||
_ => {
|
||||
toast_ref.add_toast(adw::Toast::new("Please enter a valid amount"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let priority = match priority_row.selected() {
|
||||
2 => 3,
|
||||
1 => 2,
|
||||
_ => 1,
|
||||
};
|
||||
|
||||
let note_text = note_row.text();
|
||||
let note = if note_text.is_empty() { None } else { Some(note_text.as_str()) };
|
||||
|
||||
let url_text = url_row.text();
|
||||
let url = if url_text.is_empty() { None } else { Some(url_text.as_str()) };
|
||||
|
||||
match db_ref.insert_wishlist_item(name.trim(), amount, None, url, note, priority) {
|
||||
Ok(_) => {
|
||||
dialog_ref.close();
|
||||
toast_ref.add_toast(adw::Toast::new("Item added to wishlist"));
|
||||
Self::load_items(&db_ref, &active_ref, &purchased_ref, &total_ref, ¤cy, &toast_ref);
|
||||
}
|
||||
Err(e) => {
|
||||
toast_ref.add_toast(adw::Toast::new(&format!("Error: {}", e)));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
dialog.present(Some(parent));
|
||||
}
|
||||
|
||||
fn show_item_actions(
|
||||
parent: &adw::ActionRow,
|
||||
item_id: i64,
|
||||
db: &Rc<Database>,
|
||||
active_group: &adw::PreferencesGroup,
|
||||
purchased_group: &adw::PreferencesGroup,
|
||||
total_label: >k::Label,
|
||||
base_currency: &str,
|
||||
toast_overlay: &adw::ToastOverlay,
|
||||
) {
|
||||
let alert = adw::AlertDialog::new(
|
||||
Some("Wishlist Item"),
|
||||
Some("What would you like to do with this item?"),
|
||||
);
|
||||
alert.add_response("cancel", "Cancel");
|
||||
alert.add_response("purchased", "Mark as purchased");
|
||||
alert.add_response("delete", "Delete");
|
||||
alert.set_response_appearance("delete", adw::ResponseAppearance::Destructive);
|
||||
alert.set_default_response(Some("cancel"));
|
||||
alert.set_close_response("cancel");
|
||||
|
||||
let db_ref = db.clone();
|
||||
let active_ref = active_group.clone();
|
||||
let purchased_ref = purchased_group.clone();
|
||||
let total_ref = total_label.clone();
|
||||
let currency = base_currency.to_string();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
alert.connect_response(None, move |_, response| {
|
||||
match response {
|
||||
"purchased" => {
|
||||
match db_ref.mark_wishlist_purchased(item_id) {
|
||||
Ok(()) => {
|
||||
toast_ref.add_toast(adw::Toast::new("Item marked as purchased"));
|
||||
Self::load_items(&db_ref, &active_ref, &purchased_ref, &total_ref, ¤cy, &toast_ref);
|
||||
}
|
||||
Err(e) => toast_ref.add_toast(adw::Toast::new(&format!("Error: {}", e))),
|
||||
}
|
||||
}
|
||||
"delete" => {
|
||||
match db_ref.delete_wishlist_item(item_id) {
|
||||
Ok(()) => {
|
||||
toast_ref.add_toast(adw::Toast::new("Item deleted"));
|
||||
Self::load_items(&db_ref, &active_ref, &purchased_ref, &total_ref, ¤cy, &toast_ref);
|
||||
}
|
||||
Err(e) => toast_ref.add_toast(adw::Toast::new(&format!("Error: {}", e))),
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
|
||||
alert.present(Some(parent));
|
||||
}
|
||||
|
||||
fn clear_group(group: &adw::PreferencesGroup) {
|
||||
let mut rows = Vec::new();
|
||||
Self::collect_action_rows(group.upcast_ref(), &mut rows);
|
||||
for row in &rows {
|
||||
group.remove(row);
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_action_rows(widget: >k::Widget, rows: &mut Vec<adw::ActionRow>) {
|
||||
if let Some(row) = widget.downcast_ref::<adw::ActionRow>() {
|
||||
rows.push(row.clone());
|
||||
return;
|
||||
}
|
||||
let mut child = widget.first_child();
|
||||
while let Some(c) = child {
|
||||
Self::collect_action_rows(&c, rows);
|
||||
child = c.next_sibling();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user