Files
outlay/outlay-gtk/src/log_view.rs

362 lines
13 KiB
Rust

use adw::prelude::*;
use gtk::glib;
use outlay_core::db::Database;
use outlay_core::models::{NewTransaction, TransactionType};
use std::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<Database>) -> 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);
// Category IDs tracked alongside the model
let category_ids: Rc<RefCell<Vec<i64>>> = Rc::new(RefCell::new(Vec::new()));
// -- 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();
let amount_row = adw::EntryRow::builder()
.title("Amount")
.build();
amount_row.set_input_purpose(gtk::InputPurpose::Number);
form_group.add(&amount_row);
let currency_model = gtk::StringList::new(&[
"USD", "EUR", "GBP", "JPY", "CAD", "AUD", "CHF", "CNY", "INR", "BRL",
]);
let currency_row = adw::ComboRow::builder()
.title("Currency")
.model(&currency_model)
.build();
form_group.add(&currency_row);
let category_model = gtk::StringList::new(&[]);
let category_row = adw::ComboRow::builder()
.title("Category")
.model(&category_model)
.build();
// Populate from database
Self::populate_categories_from_db(&db, &category_model, &category_ids, TransactionType::Expense);
form_group.add(&category_row);
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);
let note_row = adw::EntryRow::builder()
.title("Note (optional)")
.build();
form_group.add(&note_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 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 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();
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();
let currency_item = currency_model_ref
.string(currency_idx)
.map(|s| s.to_string())
.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: currency_item,
exchange_rate: 1.0,
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);
// Clear form
amount_row_ref.set_text("");
note_row_ref.set_text("");
// Refresh recent
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(&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 populate_categories_from_db(
db: &Database,
model: &gtk::StringList,
ids: &Rc<RefCell<Vec<i64>>>,
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) {
// 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::<adw::ActionRow>() {
group.remove(row);
} else if let Some(row) = child.downcast_ref::<gtk::ListBoxRow>() {
// Try generic removal via parent
if let Some(parent) = row.parent() {
if let Some(listbox) = parent.downcast_ref::<gtk::ListBox>() {
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);
}
}
}
}