Add feature batch 2, subscription/recurring sync, smooth charts, and app icon

This commit is contained in:
2026-03-03 21:18:37 +02:00
parent f9e293c30e
commit 577cd54a9e
10102 changed files with 107853 additions and 1318 deletions

File diff suppressed because it is too large Load Diff

View 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)
}
}

View 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

View 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: &gtk::Label,
limit_label: &gtk::Label,
util_bar: &gtk::LevelBar,
util_label: &gtk::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: &gtk::Label,
ll: &gtk::Label,
ub: &gtk::LevelBar,
ul: &gtk::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: &gtk::Label,
ll: &gtk::Label,
ub: &gtk::LevelBar,
ul: &gtk::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(&currency_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(&currency_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(&currency_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));
}
}

View 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)
}

View 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(&note_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: &gtk::FlowBox,
placeholder: &gtk::Button,
more_btn: &gtk::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(&gtk::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: &gtk::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
}

View 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)
}
}

View 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, &currency,
&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: &gtk::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: &gtk::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, &currency, 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

View 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);
});
}

View 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: &gtk::ListBox) {
while let Some(child) = list.first_child() {
list.remove(&child);
}
}
fn clear_box(bx: &gtk::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: &gtk::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

View File

@@ -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(
&gtk::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"), &notification);
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)),
&notif,
);
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)), &notification);
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"), &notification);
}
// 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
View 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: &gtk::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
View 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(&current[..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!("{}{}", &current[..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(&current) {
// 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
View 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(&note_row);
content.append(&note_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(&note_row);
content.append(&note_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

View 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
View 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;
}

View 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: &gtk::Label,
yearly_label: &gtk::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: &gtk::Label,
yearly_label: &gtk::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: &gtk::Button,
db: &Rc<Database>,
active_group: &adw::PreferencesGroup,
paused_group: &adw::PreferencesGroup,
monthly_label: &gtk::Label,
yearly_label: &gtk::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(&currency_label_refs);
let currency_codes: Vec<String> = currencies.iter().map(|(c, _)| c.to_string()).collect();
let currency_row = adw::ComboRow::builder()
.title("Currency")
.model(&currency_model)
.selected(0u32)
.build();
form2.add(&currency_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(&note_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: &gtk::Button,
db: &Rc<Database>,
sub: &Subscription,
active_group: &adw::PreferencesGroup,
paused_group: &adw::PreferencesGroup,
monthly_label: &gtk::Label,
yearly_label: &gtk::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(&currency_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(&currency_model)
.selected(selected_currency)
.build();
form.add(&currency_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(&note_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: &gtk::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
View 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);
}
}
});
});
}

View File

@@ -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 &section_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(&section_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(&gtk::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">&lt;Ctrl&gt;1</property>
<property name="title">Log</property>
</object></child>
<child><object class="GtkShortcutsShortcut">
<property name="accelerator">&lt;Ctrl&gt;2</property>
<property name="title">History</property>
</object></child>
<child><object class="GtkShortcutsShortcut">
<property name="accelerator">&lt;Ctrl&gt;3</property>
<property name="title">Charts</property>
</object></child>
<child><object class="GtkShortcutsShortcut">
<property name="accelerator">&lt;Ctrl&gt;4</property>
<property name="title">Budgets</property>
</object></child>
<child><object class="GtkShortcutsShortcut">
<property name="accelerator">&lt;Ctrl&gt;5</property>
<property name="title">Goals</property>
</object></child>
<child><object class="GtkShortcutsShortcut">
<property name="accelerator">&lt;Ctrl&gt;6</property>
<property name="title">Recurring</property>
</object></child>
<child><object class="GtkShortcutsShortcut">
<property name="accelerator">&lt;Ctrl&gt;7</property>
<property name="title">Subscriptions</property>
</object></child>
<child><object class="GtkShortcutsShortcut">
<property name="accelerator">&lt;Ctrl&gt;8</property>
<property name="title">Wishlist</property>
</object></child>
<child><object class="GtkShortcutsShortcut">
<property name="accelerator">&lt;Ctrl&gt;9</property>
<property name="title">Forecast</property>
</object></child>
<child><object class="GtkShortcutsShortcut">
<property name="accelerator">&lt;Ctrl&gt;0</property>
<property name="title">Insights</property>
</object></child>
<child><object class="GtkShortcutsShortcut">
<property name="accelerator">&lt;Ctrl&gt;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">&lt;Ctrl&gt;e</property>
<property name="title">New expense</property>
</object></child>
<child><object class="GtkShortcutsShortcut">
<property name="accelerator">&lt;Ctrl&gt;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">&lt;Ctrl&gt;question</property>
<property name="title">Keyboard shortcuts</property>
</object></child>
<child><object class="GtkShortcutsShortcut">
<property name="accelerator">&lt;Ctrl&gt;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);

View 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, &currency, &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: &gtk::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, &currency, &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: &gtk::Button,
db: &Rc<Database>,
active_group: &adw::PreferencesGroup,
purchased_group: &adw::PreferencesGroup,
total_label: &gtk::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(&note_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, &currency, &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: &gtk::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, &currency, &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, &currency, &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: &gtk::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();
}
}
}