use adw::prelude::*; use chrono::Datelike; use gtk::{gio, glib}; use outlay_core::db::Database; use outlay_core::exchange::ExchangeRateService; use outlay_core::models::{NewTransaction, TransactionType}; use std::cell::{Cell, RefCell}; use std::rc::Rc; pub struct LogView { pub container: gtk::Box, pub toast_overlay: adw::ToastOverlay, } impl LogView { pub fn new(db: Rc, app: &adw::Application) -> Self { let toast_overlay = adw::ToastOverlay::new(); let container = gtk::Box::new(gtk::Orientation::Vertical, 0); let clamp = adw::Clamp::new(); clamp.set_maximum_size(600); clamp.set_margin_top(24); clamp.set_margin_bottom(24); clamp.set_margin_start(12); clamp.set_margin_end(12); let inner = gtk::Box::new(gtk::Orientation::Vertical, 24); 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); type_box.add_css_class("linked"); type_box.set_halign(gtk::Align::Center); let expense_btn = gtk::ToggleButton::with_label("Expense"); expense_btn.set_active(true); expense_btn.set_hexpand(true); let income_btn = gtk::ToggleButton::with_label("Income"); income_btn.set_group(Some(&expense_btn)); income_btn.set_hexpand(true); type_box.append(&expense_btn); type_box.append(&income_btn); // -- Form group -- let form_group = adw::PreferencesGroup::builder() .title("New Transaction") .build(); // Amount let amount_row = adw::EntryRow::builder() .title("Amount") .build(); amount_row.set_input_purpose(gtk::InputPurpose::Number); form_group.add(&amount_row); // Currency 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() .title("Category") .model(&category_model) .build(); Self::populate_categories_from_db(&db, &category_model, &category_ids, TransactionType::Expense); form_group.add(&category_row); // Date picker let today = glib::DateTime::now_local().unwrap(); let date_str = today.format("%Y-%m-%d").unwrap().to_string(); let date_label = gtk::Label::new(Some(&date_str)); date_label.set_halign(gtk::Align::End); date_label.set_hexpand(true); let calendar = gtk::Calendar::new(); let popover = gtk::Popover::new(); popover.set_child(Some(&calendar)); let date_menu_btn = gtk::MenuButton::new(); date_menu_btn.set_popover(Some(&popover)); date_menu_btn.set_icon_name("x-office-calendar-symbolic"); date_menu_btn.add_css_class("flat"); let date_box = gtk::Box::new(gtk::Orientation::Horizontal, 8); date_box.append(&date_label); date_box.append(&date_menu_btn); let date_row = adw::ActionRow::builder() .title("Date") .build(); date_row.add_suffix(&date_box); date_row.set_activatable_widget(Some(&date_menu_btn)); let date_label_ref = date_label.clone(); let popover_ref = popover.clone(); calendar.connect_day_selected(move |cal| { let dt = cal.date(); let formatted = dt.format("%Y-%m-%d").unwrap().to_string(); date_label_ref.set_label(&formatted); popover_ref.popdown(); }); form_group.add(&date_row); // Note let note_row = adw::EntryRow::builder() .title("Note (optional)") .build(); form_group.add(¬e_row); // -- Save button -- let save_button = gtk::Button::with_label("Save"); save_button.add_css_class("suggested-action"); save_button.add_css_class("pill"); 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(); let model_ref = category_model.clone(); let ids_ref = category_ids.clone(); expense_btn.connect_toggled(move |btn| { if btn.is_active() { Self::populate_categories_from_db( &db_ref, &model_ref, &ids_ref, TransactionType::Expense, ); } }); } { let db_ref = db.clone(); let model_ref = category_model.clone(); let ids_ref = category_ids.clone(); income_btn.connect_toggled(move |btn| { if btn.is_active() { Self::populate_categories_from_db( &db_ref, &model_ref, &ids_ref, TransactionType::Income, ); } }); } // -- Recent transactions -- let recent_group = adw::PreferencesGroup::builder() .title("Recent") .build(); Self::refresh_recent(&db, &recent_group); // -- Wire save button -- { let db_ref = db.clone(); let expense_btn_ref = expense_btn.clone(); let amount_row_ref = amount_row.clone(); let currency_row_ref = currency_row.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(); let app_ref = app.clone(); save_button.connect_clicked(move |_| { let amount_text = amount_row_ref.text(); let amount: f64 = match amount_text.trim().parse() { Ok(v) if v > 0.0 => v, _ => { let toast = adw::Toast::new("Please enter a valid amount"); toast_overlay_ref.add_toast(toast); return; } }; let txn_type = if expense_btn_ref.is_active() { TransactionType::Expense } else { TransactionType::Income }; let cat_idx = category_row_ref.selected() as usize; let ids = ids_ref.borrow(); let category_id = match ids.get(cat_idx) { Some(&id) => id, None => { let toast = adw::Toast::new("Please select a category"); toast_overlay_ref.add_toast(toast); return; } }; 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(); let date = chrono::NaiveDate::parse_from_str(&date_text, "%Y-%m-%d") .unwrap_or_else(|_| chrono::Local::now().date_naive()); let note_text = note_row_ref.text(); let note = if note_text.is_empty() { None } else { Some(note_text.to_string()) }; let new_txn = NewTransaction { amount, transaction_type: txn_type, category_id, currency, exchange_rate: rate_ref.get(), note, date, recurring_id: None, }; match db_ref.insert_transaction(&new_txn) { Ok(_) => { let msg = match txn_type { TransactionType::Expense => "Expense saved", TransactionType::Income => "Income saved", }; let toast = adw::Toast::new(msg); toast_overlay_ref.add_toast(toast); // Check budget notifications for expenses if txn_type == TransactionType::Expense { let month_str = format!("{:04}-{:02}", date.year(), date.month()); if let Ok(thresholds) = db_ref.check_budget_thresholds(category_id, &month_str) { let cat_name = db_ref .get_category(category_id) .map(|c| c.name) .unwrap_or_else(|_| "Category".to_string()); let progress = db_ref .get_budget_progress(category_id, &month_str) .ok() .flatten(); let pct = progress.map(|(_, _, p)| p).unwrap_or(0.0); for threshold in thresholds { Self::send_budget_notification( &app_ref, &cat_name, pct, threshold, ); db_ref.record_notification(category_id, &month_str, threshold).ok(); } } } amount_row_ref.set_text(""); note_row_ref.set_text(""); Self::refresh_recent(&db_ref, &recent_group_ref); } Err(e) => { let toast = adw::Toast::new(&format!("Error saving: {}", e)); toast_overlay_ref.add_toast(toast); } } }); } // -- Assemble -- inner.append(&type_box); inner.append(&form_group); inner.append(&rate_label); inner.append(&save_button); inner.append(&recent_group); clamp.set_child(Some(&inner)); toast_overlay.set_child(Some(&clamp)); container.append(&toast_overlay); LogView { container, toast_overlay, } } fn send_budget_notification( app: &adw::Application, category: &str, percentage: f64, threshold: u32, ) { let notification = gio::Notification::new("Budget Alert"); let body = match threshold { 75 => format!("{} is at {:.0}% of budget", category, percentage), 90 => format!("{} is at {:.0}% of budget - almost at limit!", category, percentage), 100 => format!("{} is over budget at {:.0}%!", category, percentage), _ => return, }; notification.set_body(Some(&body)); app.send_notification( Some(&format!("budget-{}-{}", category, threshold)), ¬ification, ); } fn populate_categories_from_db( db: &Database, model: >k::StringList, ids: &Rc>>, txn_type: TransactionType, ) { while model.n_items() > 0 { model.remove(0); } let mut id_list = ids.borrow_mut(); id_list.clear(); if let Ok(cats) = db.list_categories(Some(txn_type)) { for cat in cats { let display = match &cat.icon { Some(icon) => format!("{} {}", icon, cat.name), None => cat.name.clone(), }; model.append(&display); id_list.push(cat.id); } } } fn refresh_recent(db: &Database, group: &adw::PreferencesGroup) { while let Some(child) = group.first_child() { if let Some(row) = child.downcast_ref::() { group.remove(row); } else if let Some(row) = child.downcast_ref::() { if let Some(parent) = row.parent() { if let Some(listbox) = parent.downcast_ref::() { listbox.remove(row); } } } else { break; } } match db.list_recent_transactions(5) { Ok(txns) if !txns.is_empty() => { 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, }) .unwrap_or_else(|_| "Unknown".to_string()); let amount_str = match txn.transaction_type { TransactionType::Expense => format!("-{:.2} {}", txn.amount, txn.currency), TransactionType::Income => format!("+{:.2} {}", txn.amount, txn.currency), }; let subtitle = match &txn.note { Some(n) if !n.is_empty() => format!("{} - {}", txn.date, n), _ => txn.date.to_string(), }; let row = adw::ActionRow::builder() .title(&cat_name) .subtitle(&subtitle) .build(); let amount_label = gtk::Label::new(Some(&amount_str)); match txn.transaction_type { TransactionType::Expense => amount_label.add_css_class("error"), TransactionType::Income => amount_label.add_css_class("success"), } row.add_suffix(&amount_label); group.add(&row); } } _ => { let placeholder = adw::ActionRow::builder() .title("No transactions yet") .build(); placeholder.add_css_class("dim-label"); group.add(&placeholder); } } } }