- 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
568 lines
22 KiB
Rust
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: >k::Label,
|
|
limit_label: >k::Label,
|
|
util_bar: >k::LevelBar,
|
|
util_label: >k::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: >k::Label,
|
|
ll: >k::Label,
|
|
ub: >k::LevelBar,
|
|
ul: >k::Label,
|
|
) {
|
|
let alert = adw::AlertDialog::new(
|
|
Some("Record Payment"),
|
|
Some(&format!("Enter payment amount for {}", card_name)),
|
|
);
|
|
alert.add_response("cancel", "Cancel");
|
|
alert.add_response("pay", "Record");
|
|
alert.set_response_appearance("pay", adw::ResponseAppearance::Suggested);
|
|
alert.set_default_response(Some("pay"));
|
|
|
|
let entry = adw::EntryRow::builder()
|
|
.title("Amount")
|
|
.build();
|
|
entry.set_input_purpose(gtk::InputPurpose::Number);
|
|
alert.set_extra_child(Some(&entry));
|
|
|
|
let db_ref = db.clone();
|
|
let group_ref = group.clone();
|
|
let toast_ref = toast.clone();
|
|
let card_name = card_name.to_string();
|
|
let bl = bl.clone();
|
|
let ll = ll.clone();
|
|
let ub = ub.clone();
|
|
let ul = ul.clone();
|
|
alert.connect_response(None, move |_, resp| {
|
|
if resp == "pay" {
|
|
let text = entry.text();
|
|
if let Some(amount) = outlay_core::expr::eval_expr(&text) {
|
|
if amount > 0.0 {
|
|
if db_ref.record_card_payment(card_id, amount).is_ok() {
|
|
// Create expense transaction for the payment
|
|
let today = Local::now().date_naive();
|
|
let base_currency = db_ref.get_setting("base_currency")
|
|
.ok().flatten()
|
|
.unwrap_or_else(|| "USD".to_string());
|
|
// Use first expense category as fallback
|
|
let cat_id = db_ref.list_categories(Some(outlay_core::models::TransactionType::Expense))
|
|
.unwrap_or_default()
|
|
.first()
|
|
.map(|c| c.id)
|
|
.unwrap_or(1);
|
|
let txn = outlay_core::models::NewTransaction {
|
|
amount,
|
|
transaction_type: outlay_core::models::TransactionType::Expense,
|
|
category_id: cat_id,
|
|
currency: base_currency,
|
|
exchange_rate: 1.0,
|
|
note: Some(format!("Credit card payment - {}", card_name)),
|
|
date: today,
|
|
recurring_id: None,
|
|
payee: None,
|
|
};
|
|
let _ = db_ref.insert_transaction(&txn);
|
|
Self::populate_cards(&db_ref, &group_ref, &toast_ref, &bl, &ll, &ub, &ul);
|
|
toast_ref.add_toast(adw::Toast::new(&format!("Payment of {:.2} recorded", amount)));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
alert.present(Some(parent));
|
|
}
|
|
|
|
fn show_card_dialog(
|
|
parent: &impl IsA<gtk::Widget>,
|
|
db: &Rc<Database>,
|
|
card_id: Option<i64>,
|
|
group: &adw::PreferencesGroup,
|
|
toast: &adw::ToastOverlay,
|
|
bl: >k::Label,
|
|
ll: >k::Label,
|
|
ub: >k::LevelBar,
|
|
ul: >k::Label,
|
|
) {
|
|
let existing = card_id.and_then(|id| db.get_credit_card(id).ok());
|
|
let is_edit = existing.is_some();
|
|
|
|
let dialog = adw::AlertDialog::new(
|
|
Some(if is_edit { "Edit Card" } else { "Add Card" }),
|
|
None,
|
|
);
|
|
dialog.add_response("cancel", "Cancel");
|
|
dialog.add_response("save", if is_edit { "Save" } else { "Add" });
|
|
dialog.set_response_appearance("save", adw::ResponseAppearance::Suggested);
|
|
dialog.set_default_response(Some("save"));
|
|
|
|
let form = gtk::Box::new(gtk::Orientation::Vertical, 8);
|
|
|
|
let name_entry = adw::EntryRow::builder()
|
|
.title("Card Name")
|
|
.text(existing.as_ref().map(|c| c.name.as_str()).unwrap_or(""))
|
|
.build();
|
|
|
|
let existing_currency = existing.as_ref().map(|c| c.currency.as_str()).unwrap_or("USD");
|
|
let limit_entry = adw::EntryRow::builder()
|
|
.title(&format!("Credit Limit ({})", existing_currency))
|
|
.text(&existing.as_ref().and_then(|c| c.credit_limit).map(|l| format!("{:.2}", l)).unwrap_or_default())
|
|
.build();
|
|
limit_entry.set_input_purpose(gtk::InputPurpose::Number);
|
|
crate::numpad::attach_numpad(&limit_entry);
|
|
|
|
let close_spin = adw::SpinRow::with_range(1.0, 31.0, 1.0);
|
|
close_spin.set_title("Statement Close Day");
|
|
close_spin.set_value(existing.as_ref().map(|c| c.statement_close_day as f64).unwrap_or(25.0));
|
|
|
|
let due_spin = adw::SpinRow::with_range(1.0, 31.0, 1.0);
|
|
due_spin.set_title("Payment Due Day");
|
|
due_spin.set_value(existing.as_ref().map(|c| c.due_day as f64).unwrap_or(15.0));
|
|
|
|
let min_spin = adw::SpinRow::with_range(0.0, 100.0, 0.5);
|
|
min_spin.set_title("Minimum Payment %");
|
|
min_spin.set_value(existing.as_ref().map(|c| c.min_payment_pct).unwrap_or(2.0));
|
|
|
|
let currencies = ExchangeRateService::supported_currencies();
|
|
let currency_labels: Vec<String> = currencies
|
|
.iter()
|
|
.map(|(code, name)| format!("{} - {}", code, name))
|
|
.collect();
|
|
let currency_label_refs: Vec<&str> = currency_labels.iter().map(|s| s.as_str()).collect();
|
|
let currency_model = gtk::StringList::new(¤cy_label_refs);
|
|
let currency_codes: Vec<String> = currencies.iter().map(|(c, _)| c.to_string()).collect();
|
|
|
|
let currency_idx = currency_codes
|
|
.iter()
|
|
.position(|c| c.eq_ignore_ascii_case(existing_currency))
|
|
.unwrap_or(0);
|
|
|
|
let currency_combo = adw::ComboRow::builder()
|
|
.title("Currency")
|
|
.model(¤cy_model)
|
|
.selected(currency_idx as u32)
|
|
.build();
|
|
|
|
{
|
|
let limit_ref = limit_entry.clone();
|
|
let codes = currency_codes.clone();
|
|
currency_combo.connect_selected_notify(move |combo| {
|
|
if let Some(code) = codes.get(combo.selected() as usize) {
|
|
limit_ref.set_title(&format!("Credit Limit ({})", code));
|
|
}
|
|
});
|
|
}
|
|
|
|
let list = gtk::ListBox::new();
|
|
list.add_css_class("boxed-list");
|
|
list.set_selection_mode(gtk::SelectionMode::None);
|
|
list.append(&name_entry);
|
|
list.append(&limit_entry);
|
|
list.append(&close_spin);
|
|
list.append(&due_spin);
|
|
list.append(&min_spin);
|
|
list.append(¤cy_combo);
|
|
|
|
form.append(&list);
|
|
dialog.set_extra_child(Some(&form));
|
|
|
|
let db_ref = db.clone();
|
|
let group_ref = group.clone();
|
|
let toast_ref = toast.clone();
|
|
let bl = bl.clone();
|
|
let ll = ll.clone();
|
|
let ub = ub.clone();
|
|
let ul = ul.clone();
|
|
dialog.connect_response(None, move |_, resp| {
|
|
if resp == "save" {
|
|
let name = name_entry.text().to_string();
|
|
if name.trim().is_empty() {
|
|
toast_ref.add_toast(adw::Toast::new("Card name is required"));
|
|
return;
|
|
}
|
|
let limit_text = limit_entry.text().to_string();
|
|
let credit_limit = if limit_text.is_empty() {
|
|
None
|
|
} else {
|
|
limit_text.parse::<f64>().ok()
|
|
};
|
|
let close_day = close_spin.value() as i32;
|
|
let due_day = due_spin.value() as i32;
|
|
let min_pct = min_spin.value();
|
|
let currency = currency_codes
|
|
.get(currency_combo.selected() as usize)
|
|
.cloned()
|
|
.unwrap_or_else(|| "USD".to_string());
|
|
|
|
if let Some(id) = card_id {
|
|
let card = outlay_core::models::CreditCard {
|
|
id,
|
|
name: name.trim().to_string(),
|
|
credit_limit,
|
|
statement_close_day: close_day,
|
|
due_day,
|
|
min_payment_pct: min_pct,
|
|
current_balance: existing.as_ref().map(|c| c.current_balance).unwrap_or(0.0),
|
|
currency,
|
|
color: None,
|
|
active: true,
|
|
};
|
|
let _ = db_ref.update_credit_card(&card);
|
|
} else {
|
|
let card = outlay_core::models::NewCreditCard {
|
|
name: name.trim().to_string(),
|
|
credit_limit,
|
|
statement_close_day: close_day,
|
|
due_day,
|
|
min_payment_pct: min_pct,
|
|
currency,
|
|
color: None,
|
|
};
|
|
let _ = db_ref.insert_credit_card(&card);
|
|
}
|
|
Self::populate_cards(&db_ref, &group_ref, &toast_ref, &bl, &ll, &ub, &ul);
|
|
toast_ref.add_toast(adw::Toast::new(if is_edit { "Card updated" } else { "Card added" }));
|
|
}
|
|
});
|
|
dialog.present(Some(parent));
|
|
}
|
|
}
|