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:
3431
Cargo.lock
generated
Normal file
3431
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"] }
|
||||||
|
|||||||
@@ -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();
|
{
|
||||||
expense_btn.connect_toggled(move |btn| {
|
let db_ref = db.clone();
|
||||||
if btn.is_active() {
|
let model_ref = category_model.clone();
|
||||||
Self::populate_categories(&category_model_ref, true);
|
let ids_ref = category_ids.clone();
|
||||||
}
|
expense_btn.connect_toggled(move |btn| {
|
||||||
});
|
if btn.is_active() {
|
||||||
let category_model_ref2 = category_model.clone();
|
Self::populate_categories_from_db(
|
||||||
income_btn.connect_toggled(move |btn| {
|
&db_ref, &model_ref, &ids_ref, TransactionType::Expense,
|
||||||
if btn.is_active() {
|
);
|
||||||
Self::populate_categories(&category_model_ref2, false);
|
}
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
|
{
|
||||||
|
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()
|
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: >k::StringList, is_expense: bool) {
|
fn populate_categories_from_db(
|
||||||
// Remove existing items
|
db: &Database,
|
||||||
|
model: >k::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);
|
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
let cats = [
|
}
|
||||||
"Salary", "Freelance", "Investments", "Gifts Received",
|
|
||||||
"Refunds", "Other Income",
|
fn refresh_recent(db: &Database, group: &adw::PreferencesGroup) {
|
||||||
];
|
// Remove all existing children by rebuilding
|
||||||
for c in cats {
|
// PreferencesGroup doesn't have a clear method, so we remove then re-add
|
||||||
model.append(c);
|
// 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::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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user