Files
outlay/outlay-gtk/src/credit_cards_view.rs
lashman 10a76e3003 Add feature batch 2, subscription/recurring sync, smooth charts, and app icon
- Implement subscriptions view with bidirectional recurring transaction sync
- Add cascade delete/pause/resume between subscriptions and recurring
- Fix foreign key constraints when deleting recurring transactions
- Add cross-view instant refresh via callback pattern
- Replace Bezier chart smoothing with Fritsch-Carlson monotone Hermite interpolation
- Smooth budget sparklines using shared monotone_subdivide function
- Add vertical spacing to budget rows
- Add app icon (receipt on GNOME blue) in all sizes for desktop, web, and AppImage
- Add calendar, credit cards, forecast, goals, insights, and wishlist views
- Add date picker, numpad, quick-add, category combo, and edit dialog components
- Add import/export for CSV, JSON, OFX, QIF formats
- Add NLP transaction parsing, OCR receipt scanning, expression evaluator
- Add notification support, Sankey chart, tray icon
- Add demo data seeder with full DB wipe
- Expand database schema with subscriptions, goals, credit cards, and more
2026-03-03 21:18:37 +02:00

568 lines
22 KiB
Rust

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);
// Summary card
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);
// Cards list
let cards_group = adw::PreferencesGroup::builder()
.title("CARDS")
.build();
// Populate
Self::populate_cards(
&db, &cards_group, &toast_overlay,
&balance_label, &limit_label, &util_bar, &util_label,
);
// Add card button
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,
) {
// Remove existing rows
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);
// Wire payment
{
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,
);
});
}
// Wire edit
{
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,
);
});
}
// Wire delete
{
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);
}
// Update summary
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));
}
}