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:
@@ -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"] }
|
||||
|
||||
@@ -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: >k::StringList, is_expense: bool) {
|
||||
// Remove existing items
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user