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) -> 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, 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::() { 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, db: &Rc, 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, db: &Rc, card_id: Option, 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 = 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 = 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::().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)); } }