Files
outlay/outlay-gtk/src/subscriptions_view.rs

947 lines
35 KiB
Rust

use adw::prelude::*;
use chrono::NaiveDate;
use outlay_core::db::Database;
use outlay_core::exchange::ExchangeRateService;
use outlay_core::models::{Frequency, NewSubscription, Subscription};
use std::cell::RefCell;
use std::rc::Rc;
use crate::icon_theme;
type ChangeCb = Rc<RefCell<Option<Box<dyn Fn()>>>>;
pub struct SubscriptionsView {
pub container: gtk::Box,
db: Rc<Database>,
active_group: adw::PreferencesGroup,
paused_group: adw::PreferencesGroup,
monthly_label: gtk::Label,
yearly_label: gtk::Label,
base_currency: String,
toast_overlay: adw::ToastOverlay,
on_change: ChangeCb,
}
impl SubscriptionsView {
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(16);
clamp.set_margin_end(16);
let inner = gtk::Box::new(gtk::Orientation::Vertical, 20);
inner.set_margin_top(20);
inner.set_margin_bottom(20);
let base_currency = db.get_setting("base_currency")
.ok().flatten()
.unwrap_or_else(|| "USD".to_string());
// Cost summary card
let cost_card = gtk::Box::new(gtk::Orientation::Vertical, 4);
cost_card.add_css_class("card");
cost_card.set_margin_start(4);
cost_card.set_margin_end(4);
let cost_title = gtk::Label::new(Some("SUBSCRIPTION COST"));
cost_title.add_css_class("caption");
cost_title.add_css_class("dim-label");
cost_title.set_halign(gtk::Align::Start);
cost_title.set_margin_top(12);
cost_title.set_margin_start(12);
let monthly = db.get_subscription_monthly_total().unwrap_or(0.0);
let yearly = db.get_subscription_yearly_total().unwrap_or(0.0);
let monthly_label = gtk::Label::new(Some(&format!("{:.2} {}/month", monthly, base_currency)));
monthly_label.add_css_class("title-1");
monthly_label.set_halign(gtk::Align::Start);
monthly_label.set_margin_start(12);
let yearly_label = gtk::Label::new(Some(&format!("{:.2} {}/year", yearly, base_currency)));
yearly_label.add_css_class("caption");
yearly_label.add_css_class("dim-label");
yearly_label.set_halign(gtk::Align::Start);
yearly_label.set_margin_start(12);
yearly_label.set_margin_bottom(12);
cost_card.append(&cost_title);
cost_card.append(&monthly_label);
cost_card.append(&yearly_label);
let active_group = adw::PreferencesGroup::builder()
.title("ACTIVE SUBSCRIPTIONS")
.build();
let paused_group = adw::PreferencesGroup::builder()
.title("PAUSED SUBSCRIPTIONS")
.build();
let on_change: ChangeCb = Rc::new(RefCell::new(None));
Self::load_subscriptions(
&db, &active_group, &paused_group,
&monthly_label, &yearly_label, &base_currency,
&toast_overlay, &on_change,
);
let add_btn = gtk::Button::with_label("Add Subscription");
add_btn.add_css_class("suggested-action");
add_btn.add_css_class("pill");
add_btn.set_halign(gtk::Align::Center);
{
let db_ref = db.clone();
let active_ref = active_group.clone();
let paused_ref = paused_group.clone();
let monthly_ref = monthly_label.clone();
let yearly_ref = yearly_label.clone();
let base_ref = base_currency.clone();
let toast_ref = toast_overlay.clone();
let change_ref = on_change.clone();
add_btn.connect_clicked(move |btn| {
Self::show_add_dialog(
btn, &db_ref, &active_ref, &paused_ref,
&monthly_ref, &yearly_ref, &base_ref, &toast_ref,
&change_ref,
);
});
}
inner.append(&cost_card);
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);
SubscriptionsView {
container,
db,
active_group,
paused_group,
monthly_label,
yearly_label,
base_currency,
toast_overlay,
on_change,
}
}
pub fn refresh(&self) {
Self::load_subscriptions(
&self.db, &self.active_group, &self.paused_group,
&self.monthly_label, &self.yearly_label, &self.base_currency,
&self.toast_overlay, &self.on_change,
);
}
pub fn set_on_change(&self, cb: impl Fn() + 'static) {
*self.on_change.borrow_mut() = Some(Box::new(cb));
}
fn notify_change(on_change: &ChangeCb) {
if let Some(cb) = on_change.borrow().as_ref() {
cb();
}
}
fn load_subscriptions(
db: &Rc<Database>,
active_group: &adw::PreferencesGroup,
paused_group: &adw::PreferencesGroup,
monthly_label: &gtk::Label,
yearly_label: &gtk::Label,
base_currency: &str,
toast_overlay: &adw::ToastOverlay,
on_change: &ChangeCb,
) {
Self::clear_group(active_group);
Self::clear_group(paused_group);
let monthly = db.get_subscription_monthly_total().unwrap_or(0.0);
let yearly = db.get_subscription_yearly_total().unwrap_or(0.0);
monthly_label.set_label(&format!("{:.2} {}/month", monthly, base_currency));
yearly_label.set_label(&format!("{:.2} {}/year", yearly, base_currency));
let subs = db.list_subscriptions_v2().unwrap_or_default();
let active_subs: Vec<&Subscription> = subs.iter().filter(|s| s.active).collect();
let paused_subs: Vec<&Subscription> = subs.iter().filter(|s| !s.active).collect();
if active_subs.is_empty() {
let row = adw::ActionRow::builder()
.title("No active subscriptions")
.subtitle("Add your first subscription")
.build();
row.add_css_class("dim-label");
active_group.add(&row);
} else {
for sub in &active_subs {
let row = Self::make_subscription_row(
db, sub, active_group, paused_group,
monthly_label, yearly_label, base_currency, toast_overlay,
on_change,
);
active_group.add(&row);
}
}
if paused_subs.is_empty() {
paused_group.set_visible(false);
} else {
paused_group.set_visible(true);
for sub in &paused_subs {
let row = Self::make_subscription_row(
db, sub, active_group, paused_group,
monthly_label, yearly_label, base_currency, toast_overlay,
on_change,
);
paused_group.add(&row);
}
}
}
fn make_subscription_row(
db: &Rc<Database>,
sub: &Subscription,
active_group: &adw::PreferencesGroup,
paused_group: &adw::PreferencesGroup,
monthly_label: &gtk::Label,
yearly_label: &gtk::Label,
base_currency: &str,
toast_overlay: &adw::ToastOverlay,
on_change: &ChangeCb,
) -> adw::ActionRow {
let cat = db.get_subscription_category(sub.category_id).ok();
let cat_icon = cat.as_ref().and_then(|c| c.icon.clone());
let cat_color = cat.as_ref().and_then(|c| c.color.clone());
let freq_label = match sub.frequency {
Frequency::Daily => "Daily",
Frequency::Weekly => "Weekly",
Frequency::Biweekly => "Biweekly",
Frequency::Monthly => "Monthly",
Frequency::Yearly => "Yearly",
};
let row = adw::ActionRow::builder()
.title(&sub.name)
.subtitle(freq_label)
.build();
let icon_name = icon_theme::resolve_category_icon(&cat_icon, &cat_color);
if let Some(name) = &icon_name {
let icon = gtk::Image::from_icon_name(name);
icon.set_pixel_size(24);
row.add_prefix(&icon);
}
let suffix_box = gtk::Box::new(gtk::Orientation::Horizontal, 4);
suffix_box.set_valign(gtk::Align::Center);
let monthly_equiv = match sub.frequency {
Frequency::Daily => sub.amount * 30.0,
Frequency::Weekly => sub.amount * 4.33,
Frequency::Biweekly => sub.amount * 2.17,
Frequency::Monthly => sub.amount,
Frequency::Yearly => sub.amount / 12.0,
};
let equiv_label = gtk::Label::new(Some(&format!("{:.2} {}/mo", monthly_equiv, sub.currency)));
equiv_label.add_css_class("caption");
equiv_label.add_css_class("dim-label");
suffix_box.append(&equiv_label);
let pause_btn = if sub.active {
let btn = gtk::Button::from_icon_name("tabler-player-pause");
btn.set_tooltip_text(Some("Pause"));
btn
} else {
let btn = gtk::Button::from_icon_name("tabler-player-play");
btn.set_tooltip_text(Some("Resume"));
btn
};
pause_btn.add_css_class("flat");
{
let sub_id = sub.id;
let db_ref = db.clone();
let active_ref = active_group.clone();
let paused_ref = paused_group.clone();
let monthly_ref = monthly_label.clone();
let yearly_ref = yearly_label.clone();
let base_ref = base_currency.to_string();
let toast_ref = toast_overlay.clone();
let change_ref = on_change.clone();
pause_btn.connect_clicked(move |_| {
let _ = db_ref.toggle_subscription_active(sub_id);
Self::load_subscriptions(
&db_ref, &active_ref, &paused_ref,
&monthly_ref, &yearly_ref, &base_ref, &toast_ref,
&change_ref,
);
Self::notify_change(&change_ref);
});
}
suffix_box.append(&pause_btn);
let edit_btn = gtk::Button::from_icon_name("tabler-edit");
edit_btn.add_css_class("flat");
edit_btn.set_tooltip_text(Some("Edit"));
{
let sub_clone = sub.clone();
let db_ref = db.clone();
let active_ref = active_group.clone();
let paused_ref = paused_group.clone();
let monthly_ref = monthly_label.clone();
let yearly_ref = yearly_label.clone();
let base_ref = base_currency.to_string();
let toast_ref = toast_overlay.clone();
let change_ref = on_change.clone();
edit_btn.connect_clicked(move |btn| {
Self::show_edit_dialog(
btn, &db_ref, &sub_clone, &active_ref, &paused_ref,
&monthly_ref, &yearly_ref, &base_ref, &toast_ref,
&change_ref,
);
});
}
suffix_box.append(&edit_btn);
let del_btn = gtk::Button::from_icon_name("tabler-trash");
del_btn.add_css_class("flat");
del_btn.set_tooltip_text(Some("Delete"));
{
let sub_id = sub.id;
let db_ref = db.clone();
let active_ref = active_group.clone();
let paused_ref = paused_group.clone();
let monthly_ref = monthly_label.clone();
let yearly_ref = yearly_label.clone();
let base_ref = base_currency.to_string();
let toast_ref = toast_overlay.clone();
let change_ref = on_change.clone();
del_btn.connect_clicked(move |_| {
let _ = db_ref.delete_subscription_with_cascade(sub_id);
Self::load_subscriptions(
&db_ref, &active_ref, &paused_ref,
&monthly_ref, &yearly_ref, &base_ref, &toast_ref,
&change_ref,
);
Self::notify_change(&change_ref);
toast_ref.add_toast(adw::Toast::new("Subscription deleted"));
});
}
suffix_box.append(&del_btn);
row.add_suffix(&suffix_box);
row
}
fn show_add_dialog(
parent: &gtk::Button,
db: &Rc<Database>,
active_group: &adw::PreferencesGroup,
paused_group: &adw::PreferencesGroup,
monthly_label: &gtk::Label,
yearly_label: &gtk::Label,
base_currency: &str,
toast_overlay: &adw::ToastOverlay,
on_change: &ChangeCb,
) {
let dialog = adw::Dialog::builder()
.title("Add Subscription")
.content_width(400)
.content_height(500)
.build();
let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&adw::HeaderBar::new());
// Wizard stack with two steps
let wizard = gtk::Stack::new();
wizard.set_transition_type(gtk::StackTransitionType::SlideLeftRight);
// --- Step 1: Essentials ---
let step1 = gtk::Box::new(gtk::Orientation::Vertical, 16);
step1.set_margin_top(16);
step1.set_margin_bottom(16);
step1.set_margin_start(16);
step1.set_margin_end(16);
let form1 = adw::PreferencesGroup::new();
let name_row = adw::EntryRow::builder()
.title("Name")
.build();
form1.add(&name_row);
let amount_row = adw::EntryRow::builder()
.title("Amount")
.build();
amount_row.set_input_purpose(gtk::InputPurpose::Number);
crate::numpad::attach_numpad(&amount_row);
form1.add(&amount_row);
let cat_model = gtk::StringList::new(&[]);
let cat_ids: Rc<RefCell<Vec<i64>>> = Rc::new(RefCell::new(Vec::new()));
if let Ok(cats) = db.list_subscription_categories() {
for cat in &cats {
let icon_name = icon_theme::resolve_category_icon(&cat.icon, &cat.color);
let entry = match &icon_name {
Some(icon) => format!("{}\t{}", icon, cat.name),
None => cat.name.clone(),
};
cat_model.append(&entry);
cat_ids.borrow_mut().push(cat.id);
}
}
let cat_row = adw::ComboRow::builder()
.title("Category")
.model(&cat_model)
.build();
cat_row.set_factory(Some(&Self::make_category_factory()));
cat_row.set_list_factory(Some(&Self::make_category_factory()));
form1.add(&cat_row);
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(3u32) // Monthly default
.build();
form1.add(&freq_row);
let step_label = gtk::Label::new(Some("Step 1 of 2"));
step_label.add_css_class("caption");
step_label.add_css_class("dim-label");
let next_btn = gtk::Button::with_label("Next");
next_btn.add_css_class("suggested-action");
next_btn.add_css_class("pill");
next_btn.set_halign(gtk::Align::Center);
step1.append(&form1);
step1.append(&step_label);
step1.append(&next_btn);
// --- Step 2: Details ---
let step2 = gtk::Box::new(gtk::Orientation::Vertical, 16);
step2.set_margin_top(16);
step2.set_margin_bottom(16);
step2.set_margin_start(16);
step2.set_margin_end(16);
let form2 = adw::PreferencesGroup::new();
let base_cur = db.get_setting("base_currency")
.ok().flatten()
.unwrap_or_else(|| "USD".to_string());
let mut currencies = ExchangeRateService::supported_currencies();
if let Some(pos) = currencies.iter().position(|(c, _)| c.eq_ignore_ascii_case(&base_cur)) {
let base = currencies.remove(pos);
currencies.insert(0, base);
}
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(&currency_label_refs);
let currency_codes: Vec<String> = currencies.iter().map(|(c, _)| c.to_string()).collect();
let currency_row = adw::ComboRow::builder()
.title("Currency")
.model(&currency_model)
.selected(0u32)
.build();
form2.add(&currency_row);
let today_str = chrono::Local::now().date_naive().format("%Y-%m-%d").to_string();
let (start_row, start_date_label) = crate::date_picker::make_date_row("Start date", &today_str);
form2.add(&start_row);
let note_row = adw::EntryRow::builder()
.title("Note (optional)")
.build();
form2.add(&note_row);
let url_row = adw::EntryRow::builder()
.title("URL (optional)")
.build();
form2.add(&url_row);
let step2_label = gtk::Label::new(Some("Step 2 of 2"));
step2_label.add_css_class("caption");
step2_label.add_css_class("dim-label");
let btn_box = gtk::Box::new(gtk::Orientation::Horizontal, 12);
btn_box.set_halign(gtk::Align::Center);
let back_btn = gtk::Button::with_label("Back");
back_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(&back_btn);
btn_box.append(&save_btn);
step2.append(&form2);
step2.append(&step2_label);
step2.append(&btn_box);
// Wire wizard navigation
{
let w = wizard.clone();
let name_ref = name_row.clone();
let amount_ref = amount_row.clone();
let ids_ref = cat_ids.clone();
let cat_ref = cat_row.clone();
let toast_ref = toast_overlay.clone();
next_btn.connect_clicked(move |_| {
if name_ref.text().trim().is_empty() {
toast_ref.add_toast(adw::Toast::new("Please enter a name"));
return;
}
let _: f64 = match amount_ref.text().trim().parse() {
Ok(v) if v > 0.0 => v,
_ => {
toast_ref.add_toast(adw::Toast::new("Please enter a valid amount"));
return;
}
};
let cat_idx = cat_ref.selected() as usize;
if ids_ref.borrow().get(cat_idx).is_none() {
toast_ref.add_toast(adw::Toast::new("Please select a category"));
return;
}
w.set_visible_child_name("step2");
});
}
{
let w = wizard.clone();
back_btn.connect_clicked(move |_| {
w.set_visible_child_name("step1");
});
}
wizard.add_named(&step1, Some("step1"));
wizard.add_named(&step2, Some("step2"));
let scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.vexpand(true)
.child(&wizard)
.build();
toolbar.set_content(Some(&scroll));
dialog.set_child(Some(&toolbar));
{
let db_ref = db.clone();
let dialog_ref = dialog.clone();
let active_ref = active_group.clone();
let paused_ref = paused_group.clone();
let monthly_ref = monthly_label.clone();
let yearly_ref = yearly_label.clone();
let base_ref = base_currency.to_string();
let toast_ref = toast_overlay.clone();
let cat_ids = cat_ids.clone();
let currency_codes = currency_codes.clone();
let change_ref = on_change.clone();
save_btn.connect_clicked(move |_| {
let name = name_row.text().to_string();
if name.trim().is_empty() {
toast_ref.add_toast(adw::Toast::new("Please enter a name"));
return;
}
let amount_text = amount_row.text().to_string();
let amount: f64 = match amount_text.parse() {
Ok(v) if v > 0.0 => v,
_ => {
toast_ref.add_toast(adw::Toast::new("Please enter a valid amount"));
return;
}
};
let currency = currency_codes
.get(currency_row.selected() as usize)
.cloned()
.unwrap_or_else(|| "USD".to_string());
let frequency = match freq_row.selected() {
0 => Frequency::Daily,
1 => Frequency::Weekly,
2 => Frequency::Biweekly,
3 => Frequency::Monthly,
_ => Frequency::Yearly,
};
let cat_idx = cat_row.selected() as usize;
let ids = cat_ids.borrow();
let category_id = match ids.get(cat_idx) {
Some(&id) => id,
None => {
toast_ref.add_toast(adw::Toast::new("Please select a category"));
return;
}
};
let start_str = start_date_label.text().to_string();
let start_date = NaiveDate::parse_from_str(&start_str, "%Y-%m-%d")
.unwrap_or_else(|_| chrono::Local::now().date_naive());
let note_text = note_row.text();
let note = if note_text.is_empty() { None } else { Some(note_text.to_string()) };
let url_text = url_row.text();
let url = if url_text.is_empty() { None } else { Some(url_text.to_string()) };
let new_sub = NewSubscription {
name: name.trim().to_string(),
amount,
currency,
frequency,
category_id,
start_date,
note,
url,
recurring_id: None,
};
let sub_cat_id = db_ref.find_subscriptions_category_id()
.ok().flatten()
.unwrap_or(category_id);
match db_ref.insert_linked_subscription_and_recurring(&new_sub, sub_cat_id) {
Ok(_) => {
dialog_ref.close();
Self::load_subscriptions(
&db_ref, &active_ref, &paused_ref,
&monthly_ref, &yearly_ref, &base_ref, &toast_ref,
&change_ref,
);
Self::notify_change(&change_ref);
toast_ref.add_toast(adw::Toast::new("Subscription added"));
}
Err(e) => {
toast_ref.add_toast(adw::Toast::new(&format!("Error: {}", e)));
}
}
});
}
dialog.present(Some(parent));
}
fn show_edit_dialog(
parent: &gtk::Button,
db: &Rc<Database>,
sub: &Subscription,
active_group: &adw::PreferencesGroup,
paused_group: &adw::PreferencesGroup,
monthly_label: &gtk::Label,
yearly_label: &gtk::Label,
base_currency: &str,
toast_overlay: &adw::ToastOverlay,
on_change: &ChangeCb,
) {
let dialog = adw::Dialog::builder()
.title("Edit Subscription")
.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();
let name_row = adw::EntryRow::builder()
.title("Name")
.text(&sub.name)
.build();
form.add(&name_row);
let amount_row = adw::EntryRow::builder()
.title("Amount")
.text(&format!("{:.2}", sub.amount))
.build();
amount_row.set_input_purpose(gtk::InputPurpose::Number);
crate::numpad::attach_numpad(&amount_row);
form.add(&amount_row);
let mut currencies = ExchangeRateService::supported_currencies();
let base_cur = db.get_setting("base_currency")
.ok().flatten()
.unwrap_or_else(|| "USD".to_string());
if let Some(pos) = currencies.iter().position(|(c, _)| c.eq_ignore_ascii_case(&base_cur)) {
let base = currencies.remove(pos);
currencies.insert(0, base);
}
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(&currency_label_refs);
let currency_codes: Vec<String> = currencies.iter().map(|(c, _)| c.to_string()).collect();
let selected_currency = currency_codes.iter().position(|c| c == &sub.currency).unwrap_or(0) as u32;
let currency_row = adw::ComboRow::builder()
.title("Currency")
.model(&currency_model)
.selected(selected_currency)
.build();
form.add(&currency_row);
let freq_labels = ["Daily", "Weekly", "Biweekly", "Monthly", "Yearly"];
let freq_model = gtk::StringList::new(&freq_labels);
let selected_freq = match sub.frequency {
Frequency::Daily => 0u32,
Frequency::Weekly => 1,
Frequency::Biweekly => 2,
Frequency::Monthly => 3,
Frequency::Yearly => 4,
};
let freq_row = adw::ComboRow::builder()
.title("Frequency")
.model(&freq_model)
.selected(selected_freq)
.build();
form.add(&freq_row);
let cat_model = gtk::StringList::new(&[]);
let cat_ids: Rc<RefCell<Vec<i64>>> = Rc::new(RefCell::new(Vec::new()));
let mut selected_cat = 0u32;
if let Ok(cats) = db.list_subscription_categories() {
for (i, cat) in cats.iter().enumerate() {
let icon_name = icon_theme::resolve_category_icon(&cat.icon, &cat.color);
let entry = match &icon_name {
Some(icon) => format!("{}\t{}", icon, cat.name),
None => cat.name.clone(),
};
cat_model.append(&entry);
cat_ids.borrow_mut().push(cat.id);
if cat.id == sub.category_id {
selected_cat = i as u32;
}
}
}
let cat_row = adw::ComboRow::builder()
.title("Category")
.model(&cat_model)
.selected(selected_cat)
.build();
cat_row.set_factory(Some(&Self::make_category_factory()));
cat_row.set_list_factory(Some(&Self::make_category_factory()));
form.add(&cat_row);
let start_str = sub.start_date.format("%Y-%m-%d").to_string();
let (start_row, start_date_label) = crate::date_picker::make_date_row("Start date", &start_str);
form.add(&start_row);
let note_row = adw::EntryRow::builder()
.title("Note (optional)")
.text(sub.note.as_deref().unwrap_or(""))
.build();
form.add(&note_row);
let url_row = adw::EntryRow::builder()
.title("URL (optional)")
.text(sub.url.as_deref().unwrap_or(""))
.build();
form.add(&url_row);
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(&form);
content.append(&save_btn);
let scroll = gtk::ScrolledWindow::builder()
.hscrollbar_policy(gtk::PolicyType::Never)
.child(&content)
.build();
toolbar.set_content(Some(&scroll));
dialog.set_child(Some(&toolbar));
{
let sub_id = sub.id;
let sub_active = sub.active;
let sub_recurring_id = sub.recurring_id;
let db_ref = db.clone();
let dialog_ref = dialog.clone();
let active_ref = active_group.clone();
let paused_ref = paused_group.clone();
let monthly_ref = monthly_label.clone();
let yearly_ref = yearly_label.clone();
let base_ref = base_currency.to_string();
let toast_ref = toast_overlay.clone();
let cat_ids = cat_ids.clone();
let currency_codes = currency_codes.clone();
let change_ref = on_change.clone();
save_btn.connect_clicked(move |_| {
let name = name_row.text().to_string();
if name.trim().is_empty() {
toast_ref.add_toast(adw::Toast::new("Please enter a name"));
return;
}
let amount_text = amount_row.text().to_string();
let amount: f64 = match amount_text.parse() {
Ok(v) if v > 0.0 => v,
_ => {
toast_ref.add_toast(adw::Toast::new("Please enter a valid amount"));
return;
}
};
let currency = currency_codes
.get(currency_row.selected() as usize)
.cloned()
.unwrap_or_else(|| "USD".to_string());
let frequency = match freq_row.selected() {
0 => Frequency::Daily,
1 => Frequency::Weekly,
2 => Frequency::Biweekly,
3 => Frequency::Monthly,
_ => Frequency::Yearly,
};
let cat_idx = cat_row.selected() as usize;
let ids = cat_ids.borrow();
let category_id = match ids.get(cat_idx) {
Some(&id) => id,
None => {
toast_ref.add_toast(adw::Toast::new("Please select a category"));
return;
}
};
let start_str = start_date_label.text().to_string();
let start_date = NaiveDate::parse_from_str(&start_str, "%Y-%m-%d")
.unwrap_or_else(|_| chrono::Local::now().date_naive());
let note_text = note_row.text();
let note = if note_text.is_empty() { None } else { Some(note_text.to_string()) };
let url_text = url_row.text();
let url = if url_text.is_empty() { None } else { Some(url_text.to_string()) };
let updated = Subscription {
id: sub_id,
name: name.trim().to_string(),
amount,
currency: currency.clone(),
frequency,
category_id,
start_date,
next_due: start_date,
active: sub_active,
note: note.clone(),
url,
recurring_id: sub_recurring_id,
};
match db_ref.update_subscription(&updated) {
Ok(_) => {
if let Some(rec_id) = sub_recurring_id {
if let Ok(mut rec) = db_ref.get_recurring(rec_id) {
rec.amount = amount;
rec.frequency = frequency;
rec.currency = currency;
rec.note = note;
let _ = db_ref.update_recurring(&rec);
}
}
dialog_ref.close();
Self::load_subscriptions(
&db_ref, &active_ref, &paused_ref,
&monthly_ref, &yearly_ref, &base_ref, &toast_ref,
&change_ref,
);
Self::notify_change(&change_ref);
toast_ref.add_toast(adw::Toast::new("Subscription updated"));
}
Err(e) => {
toast_ref.add_toast(adw::Toast::new(&format!("Error: {}", e)));
}
}
});
}
dialog.present(Some(parent));
}
fn make_category_factory() -> gtk::SignalListItemFactory {
let factory = gtk::SignalListItemFactory::new();
factory.connect_setup(|_, item| {
let item = item.downcast_ref::<gtk::ListItem>().unwrap();
let hbox = gtk::Box::new(gtk::Orientation::Horizontal, 8);
let icon = gtk::Image::new();
icon.set_pixel_size(20);
let label = gtk::Label::new(None);
hbox.append(&icon);
hbox.append(&label);
item.set_child(Some(&hbox));
});
factory.connect_bind(|_, item| {
let item = item.downcast_ref::<gtk::ListItem>().unwrap();
let string_obj = item.item().and_downcast::<gtk::StringObject>().unwrap();
let text = string_obj.string();
let hbox = item.child().and_downcast::<gtk::Box>().unwrap();
let icon = hbox.first_child().and_downcast::<gtk::Image>().unwrap();
let label = icon.next_sibling().and_downcast::<gtk::Label>().unwrap();
if let Some((icon_name, name)) = text.split_once('\t') {
icon.set_icon_name(Some(icon_name));
icon.set_visible(true);
label.set_label(name);
} else {
icon.set_visible(false);
label.set_label(&text);
}
});
factory
}
fn clear_group(group: &adw::PreferencesGroup) {
let mut rows = Vec::new();
Self::collect_action_rows(group.upcast_ref(), &mut rows);
for row in &rows {
group.remove(row);
}
}
fn collect_action_rows(widget: &gtk::Widget, rows: &mut Vec<adw::ActionRow>) {
if let Some(row) = widget.downcast_ref::<adw::ActionRow>() {
rows.push(row.clone());
return;
}
let mut child = widget.first_child();
while let Some(c) = child {
Self::collect_action_rows(&c, rows);
child = c.next_sibling();
}
}
}