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.
This commit is contained in:
@@ -1,8 +1,9 @@
|
|||||||
use adw::prelude::*;
|
use adw::prelude::*;
|
||||||
use gtk::glib;
|
use gtk::glib;
|
||||||
use outlay_core::db::Database;
|
use outlay_core::db::Database;
|
||||||
|
use outlay_core::exchange::ExchangeRateService;
|
||||||
use outlay_core::models::{NewTransaction, TransactionType};
|
use outlay_core::models::{NewTransaction, TransactionType};
|
||||||
use std::cell::RefCell;
|
use std::cell::{Cell, RefCell};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
pub struct LogView {
|
pub struct LogView {
|
||||||
@@ -24,8 +25,25 @@ impl LogView {
|
|||||||
|
|
||||||
let inner = gtk::Box::new(gtk::Orientation::Vertical, 24);
|
let inner = gtk::Box::new(gtk::Orientation::Vertical, 24);
|
||||||
|
|
||||||
// Category IDs tracked alongside the model
|
|
||||||
let category_ids: Rc<RefCell<Vec<i64>>> = Rc::new(RefCell::new(Vec::new()));
|
let category_ids: Rc<RefCell<Vec<i64>>> = Rc::new(RefCell::new(Vec::new()));
|
||||||
|
let exchange_rate: Rc<Cell<f64>> = 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 --
|
// -- Transaction type toggle --
|
||||||
let type_box = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
let type_box = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
||||||
@@ -56,15 +74,27 @@ impl LogView {
|
|||||||
form_group.add(&amount_row);
|
form_group.add(&amount_row);
|
||||||
|
|
||||||
// Currency
|
// Currency
|
||||||
let currency_model = gtk::StringList::new(&[
|
let currency_labels: Vec<String> = currencies
|
||||||
"USD", "EUR", "GBP", "JPY", "CAD", "AUD", "CHF", "CNY", "INR", "BRL",
|
.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()
|
let currency_row = adw::ComboRow::builder()
|
||||||
.title("Currency")
|
.title("Currency")
|
||||||
.model(¤cy_model)
|
.model(¤cy_model)
|
||||||
|
.selected(base_idx as u32)
|
||||||
.build();
|
.build();
|
||||||
form_group.add(¤cy_row);
|
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
|
// Category
|
||||||
let category_model = gtk::StringList::new(&[]);
|
let category_model = gtk::StringList::new(&[]);
|
||||||
let category_row = adw::ComboRow::builder()
|
let category_row = adw::ComboRow::builder()
|
||||||
@@ -72,7 +102,6 @@ impl LogView {
|
|||||||
.model(&category_model)
|
.model(&category_model)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// Populate from database
|
|
||||||
Self::populate_categories_from_db(&db, &category_model, &category_ids, TransactionType::Expense);
|
Self::populate_categories_from_db(&db, &category_model, &category_ids, TransactionType::Expense);
|
||||||
|
|
||||||
form_group.add(&category_row);
|
form_group.add(&category_row);
|
||||||
@@ -128,6 +157,50 @@ impl LogView {
|
|||||||
save_button.set_halign(gtk::Align::Center);
|
save_button.set_halign(gtk::Align::Center);
|
||||||
save_button.set_margin_top(8);
|
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<String> = 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 --
|
// -- Wire type toggle to filter categories from DB --
|
||||||
{
|
{
|
||||||
let db_ref = db.clone();
|
let db_ref = db.clone();
|
||||||
@@ -167,13 +240,14 @@ impl LogView {
|
|||||||
let expense_btn_ref = expense_btn.clone();
|
let expense_btn_ref = expense_btn.clone();
|
||||||
let amount_row_ref = amount_row.clone();
|
let amount_row_ref = amount_row.clone();
|
||||||
let currency_row_ref = currency_row.clone();
|
let currency_row_ref = currency_row.clone();
|
||||||
let currency_model_ref = currency_model.clone();
|
|
||||||
let category_row_ref = category_row.clone();
|
let category_row_ref = category_row.clone();
|
||||||
let ids_ref = category_ids.clone();
|
let ids_ref = category_ids.clone();
|
||||||
let date_label_ref = date_label.clone();
|
let date_label_ref = date_label.clone();
|
||||||
let note_row_ref = note_row.clone();
|
let note_row_ref = note_row.clone();
|
||||||
let recent_group_ref = recent_group.clone();
|
let recent_group_ref = recent_group.clone();
|
||||||
let toast_overlay_ref = toast_overlay.clone();
|
let toast_overlay_ref = toast_overlay.clone();
|
||||||
|
let rate_ref = exchange_rate.clone();
|
||||||
|
let currency_codes_save: Vec<String> = currency_codes.iter().map(|s| s.to_string()).collect();
|
||||||
|
|
||||||
save_button.connect_clicked(move |_| {
|
save_button.connect_clicked(move |_| {
|
||||||
let amount_text = amount_row_ref.text();
|
let amount_text = amount_row_ref.text();
|
||||||
@@ -203,10 +277,10 @@ impl LogView {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let currency_idx = currency_row_ref.selected();
|
let currency_idx = currency_row_ref.selected() as usize;
|
||||||
let currency_item = currency_model_ref
|
let currency = currency_codes_save
|
||||||
.string(currency_idx)
|
.get(currency_idx)
|
||||||
.map(|s| s.to_string())
|
.cloned()
|
||||||
.unwrap_or_else(|| "USD".to_string());
|
.unwrap_or_else(|| "USD".to_string());
|
||||||
|
|
||||||
let date_text = date_label_ref.label();
|
let date_text = date_label_ref.label();
|
||||||
@@ -224,8 +298,8 @@ impl LogView {
|
|||||||
amount,
|
amount,
|
||||||
transaction_type: txn_type,
|
transaction_type: txn_type,
|
||||||
category_id,
|
category_id,
|
||||||
currency: currency_item,
|
currency,
|
||||||
exchange_rate: 1.0,
|
exchange_rate: rate_ref.get(),
|
||||||
note,
|
note,
|
||||||
date,
|
date,
|
||||||
recurring_id: None,
|
recurring_id: None,
|
||||||
@@ -240,11 +314,9 @@ impl LogView {
|
|||||||
let toast = adw::Toast::new(msg);
|
let toast = adw::Toast::new(msg);
|
||||||
toast_overlay_ref.add_toast(toast);
|
toast_overlay_ref.add_toast(toast);
|
||||||
|
|
||||||
// Clear form
|
|
||||||
amount_row_ref.set_text("");
|
amount_row_ref.set_text("");
|
||||||
note_row_ref.set_text("");
|
note_row_ref.set_text("");
|
||||||
|
|
||||||
// Refresh recent
|
|
||||||
Self::refresh_recent(&db_ref, &recent_group_ref);
|
Self::refresh_recent(&db_ref, &recent_group_ref);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -258,6 +330,7 @@ impl LogView {
|
|||||||
// -- Assemble --
|
// -- Assemble --
|
||||||
inner.append(&type_box);
|
inner.append(&type_box);
|
||||||
inner.append(&form_group);
|
inner.append(&form_group);
|
||||||
|
inner.append(&rate_label);
|
||||||
inner.append(&save_button);
|
inner.append(&save_button);
|
||||||
inner.append(&recent_group);
|
inner.append(&recent_group);
|
||||||
|
|
||||||
@@ -296,16 +369,10 @@ impl LogView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn refresh_recent(db: &Database, group: &adw::PreferencesGroup) {
|
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() {
|
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::<adw::ActionRow>() {
|
if let Some(row) = child.downcast_ref::<adw::ActionRow>() {
|
||||||
group.remove(row);
|
group.remove(row);
|
||||||
} else if let Some(row) = child.downcast_ref::<gtk::ListBoxRow>() {
|
} else if let Some(row) = child.downcast_ref::<gtk::ListBoxRow>() {
|
||||||
// Try generic removal via parent
|
|
||||||
if let Some(parent) = row.parent() {
|
if let Some(parent) = row.parent() {
|
||||||
if let Some(listbox) = parent.downcast_ref::<gtk::ListBox>() {
|
if let Some(listbox) = parent.downcast_ref::<gtk::ListBox>() {
|
||||||
listbox.remove(row);
|
listbox.remove(row);
|
||||||
@@ -321,11 +388,9 @@ impl LogView {
|
|||||||
for txn in &txns {
|
for txn in &txns {
|
||||||
let cat_name = db
|
let cat_name = db
|
||||||
.get_category(txn.category_id)
|
.get_category(txn.category_id)
|
||||||
.map(|c| {
|
.map(|c| match &c.icon {
|
||||||
match &c.icon {
|
|
||||||
Some(icon) => format!("{} {}", icon, c.name),
|
Some(icon) => format!("{} {}", icon, c.name),
|
||||||
None => c.name,
|
None => c.name,
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|_| "Unknown".to_string());
|
.unwrap_or_else(|_| "Unknown".to_string());
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user