After saving an expense, checks if the category budget reaches 75%, 90%, or 100% thresholds. Sends gio::Notification for each newly crossed threshold. Tracks sent notifications in database to prevent duplicates within the same month.
477 lines
18 KiB
Rust
477 lines
18 KiB
Rust
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<Database>, 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<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 --
|
|
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<String> = 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<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 --
|
|
{
|
|
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<String> = 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<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) {
|
|
while let Some(child) = group.first_child() {
|
|
if let Some(row) = child.downcast_ref::<adw::ActionRow>() {
|
|
group.remove(row);
|
|
} else if let Some(row) = child.downcast_ref::<gtk::ListBoxRow>() {
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|