Add recurring transactions view and launch catch-up
This commit is contained in:
@@ -2,12 +2,14 @@ mod budgets_view;
|
||||
mod charts_view;
|
||||
mod history_view;
|
||||
mod log_view;
|
||||
mod recurring_view;
|
||||
mod window;
|
||||
|
||||
use adw::prelude::*;
|
||||
use adw::Application;
|
||||
use gtk::glib;
|
||||
use outlay_core::db::Database;
|
||||
use outlay_core::recurring::generate_missed_transactions;
|
||||
use std::rc::Rc;
|
||||
|
||||
const APP_ID: &str = "io.github.outlay";
|
||||
@@ -27,8 +29,20 @@ fn build_ui(app: &Application) {
|
||||
let db_path = data_dir.join("outlay.db");
|
||||
|
||||
let db = Database::open(&db_path).expect("Failed to open database");
|
||||
|
||||
// Generate any missed recurring transactions on launch
|
||||
let recurring_count = generate_missed_transactions(&db, chrono::Local::now().date_naive())
|
||||
.unwrap_or(0);
|
||||
|
||||
let db = Rc::new(db);
|
||||
|
||||
let main_window = window::MainWindow::new(app, db);
|
||||
|
||||
if recurring_count > 0 {
|
||||
let msg = format!("Added {} recurring transaction(s)", recurring_count);
|
||||
let toast = adw::Toast::new(&msg);
|
||||
main_window.log_view.toast_overlay.add_toast(toast);
|
||||
}
|
||||
|
||||
main_window.window.present();
|
||||
}
|
||||
|
||||
754
outlay-gtk/src/recurring_view.rs
Normal file
754
outlay-gtk/src/recurring_view.rs
Normal file
@@ -0,0 +1,754 @@
|
||||
use adw::prelude::*;
|
||||
use chrono::NaiveDate;
|
||||
use outlay_core::db::Database;
|
||||
use outlay_core::exchange::ExchangeRateService;
|
||||
use outlay_core::models::{
|
||||
Frequency, NewRecurringTransaction, RecurringTransaction, TransactionType,
|
||||
};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
pub struct RecurringView {
|
||||
pub container: gtk::Box,
|
||||
}
|
||||
|
||||
impl RecurringView {
|
||||
pub fn new(db: Rc<Database>) -> Self {
|
||||
let container = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
let toast_overlay = adw::ToastOverlay::new();
|
||||
|
||||
let clamp = adw::Clamp::new();
|
||||
clamp.set_maximum_size(700);
|
||||
clamp.set_margin_start(12);
|
||||
clamp.set_margin_end(12);
|
||||
|
||||
let inner = gtk::Box::new(gtk::Orientation::Vertical, 16);
|
||||
inner.set_margin_top(16);
|
||||
inner.set_margin_bottom(16);
|
||||
|
||||
// Active section
|
||||
let active_group = adw::PreferencesGroup::builder()
|
||||
.title("Active")
|
||||
.build();
|
||||
|
||||
// Paused section
|
||||
let paused_group = adw::PreferencesGroup::builder()
|
||||
.title("Paused")
|
||||
.build();
|
||||
|
||||
// Add button
|
||||
let add_btn = gtk::Button::with_label("Add Recurring");
|
||||
add_btn.add_css_class("suggested-action");
|
||||
add_btn.add_css_class("pill");
|
||||
add_btn.set_halign(gtk::Align::Center);
|
||||
add_btn.set_margin_top(8);
|
||||
|
||||
Self::load_recurring(&db, &active_group, &paused_group, &toast_overlay);
|
||||
|
||||
// Wire add button
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let active_ref = active_group.clone();
|
||||
let paused_ref = paused_group.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
add_btn.connect_clicked(move |btn| {
|
||||
Self::show_add_dialog(btn, &db_ref, &active_ref, &paused_ref, &toast_ref);
|
||||
});
|
||||
}
|
||||
|
||||
inner.append(&active_group);
|
||||
inner.append(&paused_group);
|
||||
inner.append(&add_btn);
|
||||
|
||||
clamp.set_child(Some(&inner));
|
||||
|
||||
let scroll = gtk::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk::PolicyType::Never)
|
||||
.vexpand(true)
|
||||
.child(&clamp)
|
||||
.build();
|
||||
|
||||
toast_overlay.set_child(Some(&scroll));
|
||||
container.append(&toast_overlay);
|
||||
|
||||
RecurringView { container }
|
||||
}
|
||||
|
||||
fn load_recurring(
|
||||
db: &Rc<Database>,
|
||||
active_group: &adw::PreferencesGroup,
|
||||
paused_group: &adw::PreferencesGroup,
|
||||
toast_overlay: &adw::ToastOverlay,
|
||||
) {
|
||||
Self::clear_group(active_group);
|
||||
Self::clear_group(paused_group);
|
||||
|
||||
let all = db.list_recurring(false).unwrap_or_default();
|
||||
let active: Vec<&RecurringTransaction> = all.iter().filter(|r| r.active).collect();
|
||||
let paused: Vec<&RecurringTransaction> = all.iter().filter(|r| !r.active).collect();
|
||||
|
||||
if active.is_empty() {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("No active recurring transactions")
|
||||
.build();
|
||||
row.add_css_class("dim-label");
|
||||
active_group.add(&row);
|
||||
} else {
|
||||
for rec in &active {
|
||||
let row = Self::make_row(db, rec, active_group, paused_group, toast_overlay);
|
||||
active_group.add(&row);
|
||||
}
|
||||
}
|
||||
|
||||
if paused.is_empty() {
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("No paused recurring transactions")
|
||||
.build();
|
||||
row.add_css_class("dim-label");
|
||||
paused_group.add(&row);
|
||||
} else {
|
||||
for rec in &paused {
|
||||
let row = Self::make_row(db, rec, active_group, paused_group, toast_overlay);
|
||||
paused_group.add(&row);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn make_row(
|
||||
db: &Rc<Database>,
|
||||
rec: &RecurringTransaction,
|
||||
active_group: &adw::PreferencesGroup,
|
||||
paused_group: &adw::PreferencesGroup,
|
||||
toast_overlay: &adw::ToastOverlay,
|
||||
) -> adw::ActionRow {
|
||||
let cat_name = db
|
||||
.get_category(rec.category_id)
|
||||
.map(|c| match &c.icon {
|
||||
Some(icon) => format!("{} {}", icon, c.name),
|
||||
None => c.name,
|
||||
})
|
||||
.unwrap_or_else(|_| "Unknown".to_string());
|
||||
|
||||
let type_prefix = match rec.transaction_type {
|
||||
TransactionType::Expense => "-",
|
||||
TransactionType::Income => "+",
|
||||
};
|
||||
let freq_label = Self::frequency_label(rec.frequency);
|
||||
let subtitle = format!(
|
||||
"{}{:.2} {} - {}",
|
||||
type_prefix, rec.amount, rec.currency, freq_label
|
||||
);
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&cat_name)
|
||||
.subtitle(&subtitle)
|
||||
.activatable(true)
|
||||
.build();
|
||||
|
||||
if !rec.active {
|
||||
row.add_css_class("dim-label");
|
||||
}
|
||||
|
||||
// Next date indicator
|
||||
let next_text = match rec.end_date {
|
||||
Some(end) if end < chrono::Local::now().date_naive() => "Ended".to_string(),
|
||||
_ => {
|
||||
if let Some(note) = &rec.note {
|
||||
note.clone()
|
||||
} else {
|
||||
format!("from {}", rec.start_date)
|
||||
}
|
||||
}
|
||||
};
|
||||
let next_label = gtk::Label::new(Some(&next_text));
|
||||
next_label.add_css_class("dim-label");
|
||||
next_label.add_css_class("caption");
|
||||
row.add_suffix(&next_label);
|
||||
|
||||
let rec_id = rec.id;
|
||||
let db_ref = db.clone();
|
||||
let active_ref = active_group.clone();
|
||||
let paused_ref = paused_group.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
row.connect_activated(move |row| {
|
||||
Self::show_edit_dialog(row, rec_id, &db_ref, &active_ref, &paused_ref, &toast_ref);
|
||||
});
|
||||
|
||||
row
|
||||
}
|
||||
|
||||
fn show_add_dialog(
|
||||
parent: >k::Button,
|
||||
db: &Rc<Database>,
|
||||
active_group: &adw::PreferencesGroup,
|
||||
paused_group: &adw::PreferencesGroup,
|
||||
toast_overlay: &adw::ToastOverlay,
|
||||
) {
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title("Add Recurring Transaction")
|
||||
.content_width(400)
|
||||
.content_height(500)
|
||||
.build();
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&adw::HeaderBar::new());
|
||||
|
||||
let content = gtk::Box::new(gtk::Orientation::Vertical, 16);
|
||||
content.set_margin_top(16);
|
||||
content.set_margin_bottom(16);
|
||||
content.set_margin_start(16);
|
||||
content.set_margin_end(16);
|
||||
|
||||
// Type toggle
|
||||
let type_box = gtk::Box::new(gtk::Orientation::Horizontal, 0);
|
||||
type_box.add_css_class("linked");
|
||||
type_box.set_halign(gtk::Align::Center);
|
||||
|
||||
let expense_btn = gtk::ToggleButton::with_label("Expense");
|
||||
expense_btn.set_active(true);
|
||||
expense_btn.set_hexpand(true);
|
||||
|
||||
let income_btn = gtk::ToggleButton::with_label("Income");
|
||||
income_btn.set_group(Some(&expense_btn));
|
||||
income_btn.set_hexpand(true);
|
||||
|
||||
type_box.append(&expense_btn);
|
||||
type_box.append(&income_btn);
|
||||
|
||||
let form = adw::PreferencesGroup::new();
|
||||
|
||||
// Amount
|
||||
let amount_row = adw::EntryRow::builder()
|
||||
.title("Amount")
|
||||
.build();
|
||||
amount_row.set_input_purpose(gtk::InputPurpose::Number);
|
||||
form.add(&amount_row);
|
||||
|
||||
// Currency
|
||||
let base_currency = db
|
||||
.get_setting("base_currency")
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| "USD".to_string());
|
||||
let currencies = ExchangeRateService::supported_currencies();
|
||||
let currency_labels: Vec<String> = currencies
|
||||
.iter()
|
||||
.map(|(code, name)| format!("{} - {}", code, name))
|
||||
.collect();
|
||||
let currency_label_refs: Vec<&str> = currency_labels.iter().map(|s| s.as_str()).collect();
|
||||
let currency_model = gtk::StringList::new(¤cy_label_refs);
|
||||
let currency_codes: Vec<String> = currencies.iter().map(|(c, _)| c.to_string()).collect();
|
||||
let base_idx = currency_codes
|
||||
.iter()
|
||||
.position(|c| c.eq_ignore_ascii_case(&base_currency))
|
||||
.unwrap_or(0);
|
||||
let currency_row = adw::ComboRow::builder()
|
||||
.title("Currency")
|
||||
.model(¤cy_model)
|
||||
.selected(base_idx as u32)
|
||||
.build();
|
||||
form.add(¤cy_row);
|
||||
|
||||
// Category
|
||||
let category_model = gtk::StringList::new(&[]);
|
||||
let category_ids: Rc<RefCell<Vec<i64>>> = Rc::new(RefCell::new(Vec::new()));
|
||||
let category_row = adw::ComboRow::builder()
|
||||
.title("Category")
|
||||
.model(&category_model)
|
||||
.build();
|
||||
Self::populate_categories(db, &category_model, &category_ids, TransactionType::Expense);
|
||||
form.add(&category_row);
|
||||
|
||||
// Frequency
|
||||
let freq_labels = ["Daily", "Weekly", "Biweekly", "Monthly", "Yearly"];
|
||||
let freq_model = gtk::StringList::new(&freq_labels);
|
||||
let freq_row = adw::ComboRow::builder()
|
||||
.title("Frequency")
|
||||
.model(&freq_model)
|
||||
.selected(3) // Monthly default
|
||||
.build();
|
||||
form.add(&freq_row);
|
||||
|
||||
// Start date
|
||||
let today_str = chrono::Local::now().date_naive().format("%Y-%m-%d").to_string();
|
||||
let start_row = adw::EntryRow::builder()
|
||||
.title("Start Date (YYYY-MM-DD)")
|
||||
.text(&today_str)
|
||||
.build();
|
||||
form.add(&start_row);
|
||||
|
||||
// End date (optional)
|
||||
let end_row = adw::EntryRow::builder()
|
||||
.title("End Date (optional, YYYY-MM-DD)")
|
||||
.build();
|
||||
form.add(&end_row);
|
||||
|
||||
// Note
|
||||
let note_row = adw::EntryRow::builder()
|
||||
.title("Note (optional)")
|
||||
.build();
|
||||
form.add(¬e_row);
|
||||
|
||||
// Wire type toggle to filter categories
|
||||
{
|
||||
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(
|
||||
&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(
|
||||
&db_ref, &model_ref, &ids_ref, TransactionType::Income,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let save_btn = gtk::Button::with_label("Save");
|
||||
save_btn.add_css_class("suggested-action");
|
||||
save_btn.add_css_class("pill");
|
||||
save_btn.set_halign(gtk::Align::Center);
|
||||
|
||||
content.append(&type_box);
|
||||
content.append(&form);
|
||||
content.append(&save_btn);
|
||||
toolbar.set_content(Some(&content));
|
||||
dialog.set_child(Some(&toolbar));
|
||||
|
||||
// Wire save
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let dialog_ref = dialog.clone();
|
||||
let active_ref = active_group.clone();
|
||||
let paused_ref = paused_group.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
let ids_ref = category_ids.clone();
|
||||
let currency_codes_ref = currency_codes.clone();
|
||||
save_btn.connect_clicked(move |_| {
|
||||
let amount: f64 = match amount_row.text().trim().parse() {
|
||||
Ok(v) if v > 0.0 => v,
|
||||
_ => {
|
||||
let toast = adw::Toast::new("Please enter a valid amount");
|
||||
toast_ref.add_toast(toast);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let txn_type = if expense_btn.is_active() {
|
||||
TransactionType::Expense
|
||||
} else {
|
||||
TransactionType::Income
|
||||
};
|
||||
|
||||
let cat_idx = category_row.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_ref.add_toast(toast);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let curr_idx = currency_row.selected() as usize;
|
||||
let currency = currency_codes_ref
|
||||
.get(curr_idx)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "USD".to_string());
|
||||
|
||||
let freq = match freq_row.selected() {
|
||||
0 => Frequency::Daily,
|
||||
1 => Frequency::Weekly,
|
||||
2 => Frequency::Biweekly,
|
||||
3 => Frequency::Monthly,
|
||||
_ => Frequency::Yearly,
|
||||
};
|
||||
|
||||
let start_text = start_row.text();
|
||||
let start_date = match NaiveDate::parse_from_str(start_text.trim(), "%Y-%m-%d") {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
let toast = adw::Toast::new("Invalid start date format");
|
||||
toast_ref.add_toast(toast);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let end_text = end_row.text();
|
||||
let end_date = if end_text.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
match NaiveDate::parse_from_str(end_text.trim(), "%Y-%m-%d") {
|
||||
Ok(d) => Some(d),
|
||||
Err(_) => {
|
||||
let toast = adw::Toast::new("Invalid end date format");
|
||||
toast_ref.add_toast(toast);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let note_text = note_row.text();
|
||||
let note = if note_text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(note_text.to_string())
|
||||
};
|
||||
|
||||
let new_rec = NewRecurringTransaction {
|
||||
amount,
|
||||
transaction_type: txn_type,
|
||||
category_id,
|
||||
currency,
|
||||
note,
|
||||
frequency: freq,
|
||||
start_date,
|
||||
end_date,
|
||||
};
|
||||
|
||||
match db_ref.insert_recurring(&new_rec) {
|
||||
Ok(_) => {
|
||||
dialog_ref.close();
|
||||
let toast = adw::Toast::new("Recurring transaction added");
|
||||
toast_ref.add_toast(toast);
|
||||
Self::load_recurring(&db_ref, &active_ref, &paused_ref, &toast_ref);
|
||||
}
|
||||
Err(e) => {
|
||||
let toast = adw::Toast::new(&format!("Error: {}", e));
|
||||
toast_ref.add_toast(toast);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
dialog.present(Some(parent));
|
||||
}
|
||||
|
||||
fn show_edit_dialog(
|
||||
parent: &adw::ActionRow,
|
||||
rec_id: i64,
|
||||
db: &Rc<Database>,
|
||||
active_group: &adw::PreferencesGroup,
|
||||
paused_group: &adw::PreferencesGroup,
|
||||
toast_overlay: &adw::ToastOverlay,
|
||||
) {
|
||||
let rec = match db.get_recurring(rec_id) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title("Edit Recurring Transaction")
|
||||
.content_width(400)
|
||||
.content_height(500)
|
||||
.build();
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&adw::HeaderBar::new());
|
||||
|
||||
let content = gtk::Box::new(gtk::Orientation::Vertical, 16);
|
||||
content.set_margin_top(16);
|
||||
content.set_margin_bottom(16);
|
||||
content.set_margin_start(16);
|
||||
content.set_margin_end(16);
|
||||
|
||||
let form = adw::PreferencesGroup::new();
|
||||
|
||||
// Amount
|
||||
let amount_row = adw::EntryRow::builder()
|
||||
.title("Amount")
|
||||
.text(&format!("{:.2}", rec.amount))
|
||||
.build();
|
||||
amount_row.set_input_purpose(gtk::InputPurpose::Number);
|
||||
form.add(&amount_row);
|
||||
|
||||
// Frequency
|
||||
let freq_labels = ["Daily", "Weekly", "Biweekly", "Monthly", "Yearly"];
|
||||
let freq_model = gtk::StringList::new(&freq_labels);
|
||||
let freq_idx = match rec.frequency {
|
||||
Frequency::Daily => 0,
|
||||
Frequency::Weekly => 1,
|
||||
Frequency::Biweekly => 2,
|
||||
Frequency::Monthly => 3,
|
||||
Frequency::Yearly => 4,
|
||||
};
|
||||
let freq_row = adw::ComboRow::builder()
|
||||
.title("Frequency")
|
||||
.model(&freq_model)
|
||||
.selected(freq_idx)
|
||||
.build();
|
||||
form.add(&freq_row);
|
||||
|
||||
// Start date
|
||||
let start_row = adw::EntryRow::builder()
|
||||
.title("Start Date (YYYY-MM-DD)")
|
||||
.text(&rec.start_date.format("%Y-%m-%d").to_string())
|
||||
.build();
|
||||
form.add(&start_row);
|
||||
|
||||
// End date
|
||||
let end_text = rec.end_date.map(|d| d.format("%Y-%m-%d").to_string()).unwrap_or_default();
|
||||
let end_row = adw::EntryRow::builder()
|
||||
.title("End Date (optional, YYYY-MM-DD)")
|
||||
.text(&end_text)
|
||||
.build();
|
||||
form.add(&end_row);
|
||||
|
||||
// Note
|
||||
let note_row = adw::EntryRow::builder()
|
||||
.title("Note (optional)")
|
||||
.text(rec.note.as_deref().unwrap_or(""))
|
||||
.build();
|
||||
form.add(¬e_row);
|
||||
|
||||
// Buttons
|
||||
let btn_box = gtk::Box::new(gtk::Orientation::Horizontal, 12);
|
||||
btn_box.set_halign(gtk::Align::Center);
|
||||
|
||||
let delete_btn = gtk::Button::with_label("Delete");
|
||||
delete_btn.add_css_class("destructive-action");
|
||||
delete_btn.add_css_class("pill");
|
||||
|
||||
let pause_label = if rec.active { "Pause" } else { "Resume" };
|
||||
let pause_btn = gtk::Button::with_label(pause_label);
|
||||
pause_btn.add_css_class("pill");
|
||||
|
||||
let save_btn = gtk::Button::with_label("Save");
|
||||
save_btn.add_css_class("suggested-action");
|
||||
save_btn.add_css_class("pill");
|
||||
|
||||
btn_box.append(&delete_btn);
|
||||
btn_box.append(&pause_btn);
|
||||
btn_box.append(&save_btn);
|
||||
|
||||
content.append(&form);
|
||||
content.append(&btn_box);
|
||||
toolbar.set_content(Some(&content));
|
||||
dialog.set_child(Some(&toolbar));
|
||||
|
||||
// Save
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let dialog_ref = dialog.clone();
|
||||
let active_ref = active_group.clone();
|
||||
let paused_ref = paused_group.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
let rec_clone = rec.clone();
|
||||
save_btn.connect_clicked(move |_| {
|
||||
let amount: f64 = match amount_row.text().trim().parse() {
|
||||
Ok(v) if v > 0.0 => v,
|
||||
_ => {
|
||||
let toast = adw::Toast::new("Please enter a valid amount");
|
||||
toast_ref.add_toast(toast);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let freq = match freq_row.selected() {
|
||||
0 => Frequency::Daily,
|
||||
1 => Frequency::Weekly,
|
||||
2 => Frequency::Biweekly,
|
||||
3 => Frequency::Monthly,
|
||||
_ => Frequency::Yearly,
|
||||
};
|
||||
|
||||
let start_text = start_row.text();
|
||||
let start_date = match NaiveDate::parse_from_str(start_text.trim(), "%Y-%m-%d") {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
let toast = adw::Toast::new("Invalid start date format");
|
||||
toast_ref.add_toast(toast);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let end_text_val = end_row.text();
|
||||
let end_date = if end_text_val.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
match NaiveDate::parse_from_str(end_text_val.trim(), "%Y-%m-%d") {
|
||||
Ok(d) => Some(d),
|
||||
Err(_) => {
|
||||
let toast = adw::Toast::new("Invalid end date format");
|
||||
toast_ref.add_toast(toast);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let note_text_val = note_row.text();
|
||||
let note = if note_text_val.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(note_text_val.to_string())
|
||||
};
|
||||
|
||||
let updated = RecurringTransaction {
|
||||
id: rec_clone.id,
|
||||
amount,
|
||||
transaction_type: rec_clone.transaction_type,
|
||||
category_id: rec_clone.category_id,
|
||||
currency: rec_clone.currency.clone(),
|
||||
note,
|
||||
frequency: freq,
|
||||
start_date,
|
||||
end_date,
|
||||
last_generated: rec_clone.last_generated,
|
||||
active: rec_clone.active,
|
||||
};
|
||||
|
||||
match db_ref.update_recurring(&updated) {
|
||||
Ok(()) => {
|
||||
dialog_ref.close();
|
||||
let toast = adw::Toast::new("Recurring transaction updated");
|
||||
toast_ref.add_toast(toast);
|
||||
Self::load_recurring(&db_ref, &active_ref, &paused_ref, &toast_ref);
|
||||
}
|
||||
Err(e) => {
|
||||
let toast = adw::Toast::new(&format!("Error: {}", e));
|
||||
toast_ref.add_toast(toast);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Pause/Resume
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let dialog_ref = dialog.clone();
|
||||
let active_ref = active_group.clone();
|
||||
let paused_ref = paused_group.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
let is_active = rec.active;
|
||||
pause_btn.connect_clicked(move |_| {
|
||||
match db_ref.toggle_recurring_active(rec_id, !is_active) {
|
||||
Ok(()) => {
|
||||
dialog_ref.close();
|
||||
let msg = if is_active {
|
||||
"Recurring transaction paused"
|
||||
} else {
|
||||
"Recurring transaction resumed"
|
||||
};
|
||||
let toast = adw::Toast::new(msg);
|
||||
toast_ref.add_toast(toast);
|
||||
Self::load_recurring(&db_ref, &active_ref, &paused_ref, &toast_ref);
|
||||
}
|
||||
Err(e) => {
|
||||
let toast = adw::Toast::new(&format!("Error: {}", e));
|
||||
toast_ref.add_toast(toast);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Delete
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let dialog_ref = dialog.clone();
|
||||
let active_ref = active_group.clone();
|
||||
let paused_ref = paused_group.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
delete_btn.connect_clicked(move |btn| {
|
||||
let alert = adw::AlertDialog::new(
|
||||
Some("Delete this recurring transaction?"),
|
||||
Some("This will not remove previously generated transactions."),
|
||||
);
|
||||
alert.add_response("cancel", "Cancel");
|
||||
alert.add_response("delete", "Delete");
|
||||
alert.set_response_appearance("delete", adw::ResponseAppearance::Destructive);
|
||||
alert.set_default_response(Some("cancel"));
|
||||
alert.set_close_response("cancel");
|
||||
|
||||
let db_del = db_ref.clone();
|
||||
let dialog_del = dialog_ref.clone();
|
||||
let active_del = active_ref.clone();
|
||||
let paused_del = paused_ref.clone();
|
||||
let toast_del = toast_ref.clone();
|
||||
alert.connect_response(None, move |_, response| {
|
||||
if response == "delete" {
|
||||
match db_del.delete_recurring(rec_id) {
|
||||
Ok(()) => {
|
||||
dialog_del.close();
|
||||
let toast = adw::Toast::new("Recurring transaction deleted");
|
||||
toast_del.add_toast(toast);
|
||||
Self::load_recurring(
|
||||
&db_del, &active_del, &paused_del, &toast_del,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
let toast = adw::Toast::new(&format!("Error: {}", e));
|
||||
toast_del.add_toast(toast);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
alert.present(Some(btn));
|
||||
});
|
||||
}
|
||||
|
||||
dialog.present(Some(parent));
|
||||
}
|
||||
|
||||
fn populate_categories(
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_group(group: &adw::PreferencesGroup) {
|
||||
while let Some(child) = group.first_child() {
|
||||
if let Some(row) = child.downcast_ref::<adw::ActionRow>() {
|
||||
group.remove(row);
|
||||
} else if let Some(row) = child.downcast_ref::<gtk::ListBoxRow>() {
|
||||
if let Some(parent) = row.parent() {
|
||||
if let Some(listbox) = parent.downcast_ref::<gtk::ListBox>() {
|
||||
listbox.remove(row);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn frequency_label(freq: Frequency) -> &'static str {
|
||||
match freq {
|
||||
Frequency::Daily => "Daily",
|
||||
Frequency::Weekly => "Weekly",
|
||||
Frequency::Biweekly => "Biweekly",
|
||||
Frequency::Monthly => "Monthly",
|
||||
Frequency::Yearly => "Yearly",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ use crate::budgets_view::BudgetsView;
|
||||
use crate::charts_view::ChartsView;
|
||||
use crate::history_view::HistoryView;
|
||||
use crate::log_view::LogView;
|
||||
use crate::recurring_view::RecurringView;
|
||||
|
||||
pub struct MainWindow {
|
||||
pub window: adw::ApplicationWindow,
|
||||
@@ -58,8 +59,12 @@ impl MainWindow {
|
||||
let budgets_view = BudgetsView::new(db.clone());
|
||||
content_stack.add_named(&budgets_view.container, Some("budgets"));
|
||||
|
||||
// Remaining pages are placeholders for now
|
||||
for item in &SIDEBAR_ITEMS[4..] {
|
||||
// Recurring view
|
||||
let recurring_view = RecurringView::new(db.clone());
|
||||
content_stack.add_named(&recurring_view.container, Some("recurring"));
|
||||
|
||||
// Settings placeholder
|
||||
for item in &SIDEBAR_ITEMS[5..] {
|
||||
let page = adw::StatusPage::builder()
|
||||
.title(item.label)
|
||||
.icon_name(item.icon)
|
||||
|
||||
Reference in New Issue
Block a user