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

3431
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,20 +1,18 @@
use adw::prelude::*; use adw::prelude::*;
use gtk::glib; 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 struct LogView {
pub container: gtk::Box, pub container: gtk::Box,
pub type_toggle: gtk::ToggleButton, pub toast_overlay: adw::ToastOverlay,
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,
} }
impl LogView { 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 container = gtk::Box::new(gtk::Orientation::Vertical, 0);
let clamp = adw::Clamp::new(); let clamp = adw::Clamp::new();
@@ -26,6 +24,9 @@ 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()));
// -- 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);
type_box.add_css_class("linked"); type_box.add_css_class("linked");
@@ -71,8 +72,8 @@ impl LogView {
.model(&category_model) .model(&category_model)
.build(); .build();
// Populate with default expense categories // Populate from database
Self::populate_categories(&category_model, true); Self::populate_categories_from_db(&db, &category_model, &category_ids, TransactionType::Expense);
form_group.add(&category_row); form_group.add(&category_row);
@@ -103,7 +104,6 @@ impl LogView {
date_row.add_suffix(&date_box); date_row.add_suffix(&date_box);
date_row.set_activatable_widget(Some(&date_menu_btn)); 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 date_label_ref = date_label.clone();
let popover_ref = popover.clone(); let popover_ref = popover.clone();
calendar.connect_day_selected(move |cal| { calendar.connect_day_selected(move |cal| {
@@ -128,30 +128,132 @@ 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 type toggle to filter categories -- // -- Wire type toggle to filter categories from DB --
let category_model_ref = category_model.clone(); {
let db_ref = db.clone();
let model_ref = category_model.clone();
let ids_ref = category_ids.clone();
expense_btn.connect_toggled(move |btn| { expense_btn.connect_toggled(move |btn| {
if btn.is_active() { if btn.is_active() {
Self::populate_categories(&category_model_ref, true); Self::populate_categories_from_db(
&db_ref, &model_ref, &ids_ref, TransactionType::Expense,
);
} }
}); });
let category_model_ref2 = category_model.clone(); }
{
let db_ref = db.clone();
let model_ref = category_model.clone();
let ids_ref = category_ids.clone();
income_btn.connect_toggled(move |btn| { income_btn.connect_toggled(move |btn| {
if btn.is_active() { if btn.is_active() {
Self::populate_categories(&category_model_ref2, false); Self::populate_categories_from_db(
&db_ref, &model_ref, &ids_ref, TransactionType::Income,
);
} }
}); });
}
// -- Recent transactions (placeholder) -- // -- Recent transactions --
let recent_group = adw::PreferencesGroup::builder() let recent_group = adw::PreferencesGroup::builder()
.title("Recent") .title("Recent")
.build(); .build();
let placeholder = adw::ActionRow::builder() Self::refresh_recent(&db, &recent_group);
.title("No transactions yet")
.build(); // -- Wire save button --
placeholder.add_css_class("dim-label"); {
recent_group.add(&placeholder); 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 -- // -- Assemble --
inner.append(&type_box); inner.append(&type_box);
@@ -160,44 +262,104 @@ impl LogView {
inner.append(&recent_group); inner.append(&recent_group);
clamp.set_child(Some(&inner)); clamp.set_child(Some(&inner));
container.append(&clamp); toast_overlay.set_child(Some(&clamp));
container.append(&toast_overlay);
LogView { LogView {
container, container,
type_toggle: expense_btn, toast_overlay,
amount_row,
currency_row,
category_row,
date_label,
note_row,
save_button,
recent_group,
} }
} }
fn populate_categories(model: &gtk::StringList, is_expense: bool) { fn populate_categories_from_db(
// Remove existing items db: &Database,
model: &gtk::StringList,
ids: &Rc<RefCell<Vec<i64>>>,
txn_type: TransactionType,
) {
while model.n_items() > 0 { while model.n_items() > 0 {
model.remove(0); model.remove(0);
} }
let mut id_list = ids.borrow_mut();
id_list.clear();
if is_expense { if let Ok(cats) = db.list_categories(Some(txn_type)) {
let cats = [ for cat in cats {
"Food & Dining", "Groceries", "Transport", "Housing", let display = match &cat.icon {
"Utilities", "Healthcare", "Entertainment", "Shopping", Some(icon) => format!("{} {}", icon, cat.name),
"Education", "Personal Care", "Travel", "Insurance", None => cat.name.clone(),
"Gifts & Donations", "Other Expense", };
]; model.append(&display);
for c in cats { id_list.push(cat.id);
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 { } else {
let cats = [ break;
"Salary", "Freelance", "Investments", "Gifts Received", }
"Refunds", "Other Income", }
];
for c in cats { match db.list_recent_transactions(5) {
model.append(c); 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::prelude::*;
use adw::Application; use adw::Application;
use gtk::glib;
use outlay_core::db::Database;
use std::rc::Rc;
const APP_ID: &str = "io.github.outlay"; const APP_ID: &str = "io.github.outlay";
@@ -16,6 +19,13 @@ fn main() {
} }
fn build_ui(app: &Application) { 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(); main_window.window.present();
} }

View File

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