Wire log view to database for transaction persistence
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");
|
||||||
@@ -68,8 +69,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);
|
||||||
|
|
||||||
@@ -99,7 +100,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| {
|
||||||
@@ -123,30 +123,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);
|
||||||
@@ -155,44 +257,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