Wire log view to database for transaction persistence

Initialize SQLite database at XDG data directory on app startup.
Categories now load from database with emoji icons. Save button
validates input, inserts transaction, shows toast notification,
clears the form, and refreshes the recent transactions list.
This commit is contained in:
2026-03-02 00:07:39 +02:00
parent bc4a3453f8
commit 00de036de8
5 changed files with 3667 additions and 61 deletions

View File

@@ -7,5 +7,6 @@ edition.workspace = true
outlay-core = { path = "../outlay-core" }
gtk = { package = "gtk4", version = "0.11" }
adw = { package = "libadwaita", version = "0.9", features = ["v1_8"] }
chrono = "0.4"
plotters = { version = "0.3", default-features = false, features = ["bitmap_backend", "bitmap_encoder", "line_series", "area_series"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }

View File

@@ -1,20 +1,18 @@
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 type_toggle: gtk::ToggleButton,
pub amount_row: adw::EntryRow,
pub currency_row: adw::ComboRow,
pub category_row: adw::ComboRow,
pub date_label: gtk::Label,
pub note_row: adw::EntryRow,
pub save_button: gtk::Button,
pub recent_group: adw::PreferencesGroup,
pub toast_overlay: adw::ToastOverlay,
}
impl LogView {
pub fn new() -> Self {
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();
@@ -26,6 +24,9 @@ impl LogView {
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");
@@ -71,8 +72,8 @@ impl LogView {
.model(&category_model)
.build();
// Populate with default expense categories
Self::populate_categories(&category_model, true);
// Populate from database
Self::populate_categories_from_db(&db, &category_model, &category_ids, TransactionType::Expense);
form_group.add(&category_row);
@@ -103,7 +104,6 @@ impl LogView {
date_row.add_suffix(&date_box);
date_row.set_activatable_widget(Some(&date_menu_btn));
// Connect calendar day-selected to update the label
let date_label_ref = date_label.clone();
let popover_ref = popover.clone();
calendar.connect_day_selected(move |cal| {
@@ -128,30 +128,132 @@ impl LogView {
save_button.set_halign(gtk::Align::Center);
save_button.set_margin_top(8);
// -- Wire type toggle to filter categories --
let category_model_ref = category_model.clone();
expense_btn.connect_toggled(move |btn| {
if btn.is_active() {
Self::populate_categories(&category_model_ref, true);
}
});
let category_model_ref2 = category_model.clone();
income_btn.connect_toggled(move |btn| {
if btn.is_active() {
Self::populate_categories(&category_model_ref2, false);
}
});
// -- 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 (placeholder) --
// -- Recent transactions --
let recent_group = adw::PreferencesGroup::builder()
.title("Recent")
.build();
let placeholder = adw::ActionRow::builder()
.title("No transactions yet")
.build();
placeholder.add_css_class("dim-label");
recent_group.add(&placeholder);
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);
@@ -160,44 +262,104 @@ impl LogView {
inner.append(&recent_group);
clamp.set_child(Some(&inner));
container.append(&clamp);
toast_overlay.set_child(Some(&clamp));
container.append(&toast_overlay);
LogView {
container,
type_toggle: expense_btn,
amount_row,
currency_row,
category_row,
date_label,
note_row,
save_button,
recent_group,
toast_overlay,
}
}
fn populate_categories(model: &gtk::StringList, is_expense: bool) {
// Remove existing items
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 is_expense {
let cats = [
"Food & Dining", "Groceries", "Transport", "Housing",
"Utilities", "Healthcare", "Entertainment", "Shopping",
"Education", "Personal Care", "Travel", "Insurance",
"Gifts & Donations", "Other Expense",
];
for c in cats {
model.append(c);
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);
}
} else {
let cats = [
"Salary", "Freelance", "Investments", "Gifts Received",
"Refunds", "Other Income",
];
for c in cats {
model.append(c);
}
}
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);
}
}
}

View File

@@ -3,6 +3,9 @@ mod window;
use adw::prelude::*;
use adw::Application;
use gtk::glib;
use outlay_core::db::Database;
use std::rc::Rc;
const APP_ID: &str = "io.github.outlay";
@@ -16,6 +19,13 @@ fn main() {
}
fn build_ui(app: &Application) {
let main_window = window::MainWindow::new(app);
let data_dir = glib::user_data_dir().join("outlay");
std::fs::create_dir_all(&data_dir).expect("Failed to create data directory");
let db_path = data_dir.join("outlay.db");
let db = Database::open(&db_path).expect("Failed to open database");
let db = Rc::new(db);
let main_window = window::MainWindow::new(app, db);
main_window.window.present();
}

View File

@@ -1,4 +1,6 @@
use adw::prelude::*;
use outlay_core::db::Database;
use std::rc::Rc;
use crate::log_view::LogView;
@@ -25,12 +27,12 @@ const SIDEBAR_ITEMS: &[SidebarItem] = &[
];
impl MainWindow {
pub fn new(app: &adw::Application) -> Self {
pub fn new(app: &adw::Application, db: Rc<Database>) -> Self {
let content_stack = gtk::Stack::new();
content_stack.set_transition_type(gtk::StackTransitionType::Crossfade);
// Log view - real widget
let log_view = LogView::new();
let log_view = LogView::new(db);
let log_scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.child(&log_view.container)