From 0a198db8c61804db1746ea44c2a45e5b96817665 Mon Sep 17 00:00:00 2001 From: lashman Date: Mon, 2 Mar 2026 00:16:47 +0200 Subject: [PATCH] Add currency selection with live exchange rate fetching Currency dropdown now uses 30 currencies from ExchangeRateService. Defaults to base currency from settings. Selecting a different currency fetches the exchange rate asynchronously and displays it. Rate is stored with the transaction on save. --- outlay-gtk/src/log_view.rs | 117 ++++++++++++++++++++++++++++--------- 1 file changed, 91 insertions(+), 26 deletions(-) diff --git a/outlay-gtk/src/log_view.rs b/outlay-gtk/src/log_view.rs index a776425..e9cae8a 100644 --- a/outlay-gtk/src/log_view.rs +++ b/outlay-gtk/src/log_view.rs @@ -1,8 +1,9 @@ use adw::prelude::*; use gtk::glib; use outlay_core::db::Database; +use outlay_core::exchange::ExchangeRateService; use outlay_core::models::{NewTransaction, TransactionType}; -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::rc::Rc; pub struct LogView { @@ -24,8 +25,25 @@ impl LogView { let inner = gtk::Box::new(gtk::Orientation::Vertical, 24); - // Category IDs tracked alongside the model let category_ids: Rc>> = Rc::new(RefCell::new(Vec::new())); + let exchange_rate: Rc> = Rc::new(Cell::new(1.0)); + + // Get base currency from settings + let base_currency = db + .get_setting("base_currency") + .ok() + .flatten() + .unwrap_or_else(|| "USD".to_string()); + + // Currency codes list + let currencies = ExchangeRateService::supported_currencies(); + let currency_codes: Vec<&str> = currencies.iter().map(|(code, _)| *code).collect(); + + // Find index of base currency + let base_idx = currency_codes + .iter() + .position(|c| c.eq_ignore_ascii_case(&base_currency)) + .unwrap_or(0); // -- Transaction type toggle -- let type_box = gtk::Box::new(gtk::Orientation::Horizontal, 0); @@ -56,15 +74,27 @@ impl LogView { form_group.add(&amount_row); // Currency - let currency_model = gtk::StringList::new(&[ - "USD", "EUR", "GBP", "JPY", "CAD", "AUD", "CHF", "CNY", "INR", "BRL", - ]); + 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_row = adw::ComboRow::builder() .title("Currency") .model(¤cy_model) + .selected(base_idx as u32) .build(); form_group.add(¤cy_row); + // Exchange rate info label + let rate_label = gtk::Label::new(None); + rate_label.add_css_class("dim-label"); + rate_label.add_css_class("caption"); + rate_label.set_halign(gtk::Align::Start); + rate_label.set_margin_start(16); + rate_label.set_visible(false); + // Category let category_model = gtk::StringList::new(&[]); let category_row = adw::ComboRow::builder() @@ -72,7 +102,6 @@ impl LogView { .model(&category_model) .build(); - // Populate from database Self::populate_categories_from_db(&db, &category_model, &category_ids, TransactionType::Expense); form_group.add(&category_row); @@ -128,6 +157,50 @@ impl LogView { save_button.set_halign(gtk::Align::Center); save_button.set_margin_top(8); + // -- Wire currency change to fetch exchange rate -- + { + let db_ref = db.clone(); + let rate_ref = exchange_rate.clone(); + let rate_label_ref = rate_label.clone(); + let base_currency_clone = base_currency.clone(); + let currency_codes_clone: Vec = currency_codes.iter().map(|s| s.to_string()).collect(); + currency_row.connect_selected_notify(move |row| { + let idx = row.selected() as usize; + if idx >= currency_codes_clone.len() { + return; + } + let selected = ¤cy_codes_clone[idx]; + if selected.eq_ignore_ascii_case(&base_currency_clone) { + rate_ref.set(1.0); + rate_label_ref.set_visible(false); + } else { + // Fetch rate asynchronously + let db_async = db_ref.clone(); + let base = base_currency_clone.clone(); + let target = selected.clone(); + let rate_async = rate_ref.clone(); + let label_async = rate_label_ref.clone(); + glib::spawn_future_local(async move { + let service = ExchangeRateService::new(&db_async); + match service.get_rate(&base, &target).await { + Ok(rate) => { + rate_async.set(rate); + label_async.set_label(&format!( + "1 {} = {:.4} {}", base, rate, target + )); + label_async.set_visible(true); + } + Err(_) => { + rate_async.set(1.0); + label_async.set_label("Could not fetch exchange rate"); + label_async.set_visible(true); + } + } + }); + } + }); + } + // -- Wire type toggle to filter categories from DB -- { let db_ref = db.clone(); @@ -167,13 +240,14 @@ impl LogView { let expense_btn_ref = expense_btn.clone(); let amount_row_ref = amount_row.clone(); let currency_row_ref = currency_row.clone(); - let currency_model_ref = currency_model.clone(); let category_row_ref = category_row.clone(); let ids_ref = category_ids.clone(); let date_label_ref = date_label.clone(); let note_row_ref = note_row.clone(); let recent_group_ref = recent_group.clone(); let toast_overlay_ref = toast_overlay.clone(); + let rate_ref = exchange_rate.clone(); + let currency_codes_save: Vec = currency_codes.iter().map(|s| s.to_string()).collect(); save_button.connect_clicked(move |_| { let amount_text = amount_row_ref.text(); @@ -203,10 +277,10 @@ impl LogView { } }; - let currency_idx = currency_row_ref.selected(); - let currency_item = currency_model_ref - .string(currency_idx) - .map(|s| s.to_string()) + let currency_idx = currency_row_ref.selected() as usize; + let currency = currency_codes_save + .get(currency_idx) + .cloned() .unwrap_or_else(|| "USD".to_string()); let date_text = date_label_ref.label(); @@ -224,8 +298,8 @@ impl LogView { amount, transaction_type: txn_type, category_id, - currency: currency_item, - exchange_rate: 1.0, + currency, + exchange_rate: rate_ref.get(), note, date, recurring_id: None, @@ -240,11 +314,9 @@ impl LogView { let toast = adw::Toast::new(msg); toast_overlay_ref.add_toast(toast); - // Clear form amount_row_ref.set_text(""); note_row_ref.set_text(""); - // Refresh recent Self::refresh_recent(&db_ref, &recent_group_ref); } Err(e) => { @@ -258,6 +330,7 @@ impl LogView { // -- Assemble -- inner.append(&type_box); inner.append(&form_group); + inner.append(&rate_label); inner.append(&save_button); inner.append(&recent_group); @@ -296,16 +369,10 @@ impl LogView { } fn refresh_recent(db: &Database, group: &adw::PreferencesGroup) { - // Remove all existing children by rebuilding - // PreferencesGroup doesn't have a clear method, so we remove then re-add - // We track children via a helper: iterate and remove while let Some(child) = group.first_child() { - // The group has an internal listbox; we need to find ActionRows - // Actually, PreferencesGroup.remove() takes a widget ref if let Some(row) = child.downcast_ref::() { group.remove(row); } else if let Some(row) = child.downcast_ref::() { - // Try generic removal via parent if let Some(parent) = row.parent() { if let Some(listbox) = parent.downcast_ref::() { listbox.remove(row); @@ -321,11 +388,9 @@ impl LogView { for txn in &txns { let cat_name = db .get_category(txn.category_id) - .map(|c| { - match &c.icon { - Some(icon) => format!("{} {}", icon, c.name), - None => c.name, - } + .map(|c| match &c.icon { + Some(icon) => format!("{} {}", icon, c.name), + None => c.name, }) .unwrap_or_else(|_| "Unknown".to_string());