Add feature batch 2, subscription/recurring sync, smooth charts, and app icon
- Implement subscriptions view with bidirectional recurring transaction sync - Add cascade delete/pause/resume between subscriptions and recurring - Fix foreign key constraints when deleting recurring transactions - Add cross-view instant refresh via callback pattern - Replace Bezier chart smoothing with Fritsch-Carlson monotone Hermite interpolation - Smooth budget sparklines using shared monotone_subdivide function - Add vertical spacing to budget rows - Add app icon (receipt on GNOME blue) in all sizes for desktop, web, and AppImage - Add calendar, credit cards, forecast, goals, insights, and wishlist views - Add date picker, numpad, quick-add, category combo, and edit dialog components - Add import/export for CSV, JSON, OFX, QIF formats - Add NLP transaction parsing, OCR receipt scanning, expression evaluator - Add notification support, Sankey chart, tray icon - Add demo data seeder with full DB wipe - Expand database schema with subscriptions, goals, credit cards, and more
This commit is contained in:
606
outlay-gtk/src/goals_view.rs
Normal file
606
outlay-gtk/src/goals_view.rs
Normal file
@@ -0,0 +1,606 @@
|
||||
use adw::prelude::*;
|
||||
use outlay_core::db::Database;
|
||||
use std::rc::Rc;
|
||||
|
||||
pub struct GoalsView {
|
||||
pub container: gtk::Box,
|
||||
}
|
||||
|
||||
impl GoalsView {
|
||||
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);
|
||||
|
||||
// Active goals group
|
||||
let active_group = adw::PreferencesGroup::builder()
|
||||
.title("SAVINGS GOALS")
|
||||
.build();
|
||||
|
||||
// Completed goals group
|
||||
let completed_group = adw::PreferencesGroup::builder()
|
||||
.title("COMPLETED")
|
||||
.build();
|
||||
completed_group.set_visible(false);
|
||||
|
||||
// Add goal button
|
||||
let add_btn = gtk::Button::with_label("Add Goal");
|
||||
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_goals(&db, &active_group, &completed_group, &toast_overlay);
|
||||
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let active_ref = active_group.clone();
|
||||
let completed_ref = completed_group.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
add_btn.connect_clicked(move |btn| {
|
||||
Self::show_add_dialog(btn, &db_ref, &active_ref, &completed_ref, &toast_ref);
|
||||
});
|
||||
}
|
||||
|
||||
inner.append(&active_group);
|
||||
inner.append(&add_btn);
|
||||
inner.append(&completed_group);
|
||||
|
||||
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);
|
||||
|
||||
GoalsView { container }
|
||||
}
|
||||
|
||||
fn load_goals(
|
||||
db: &Rc<Database>,
|
||||
active_group: &adw::PreferencesGroup,
|
||||
completed_group: &adw::PreferencesGroup,
|
||||
toast_overlay: &adw::ToastOverlay,
|
||||
) {
|
||||
// Clear existing rows
|
||||
Self::clear_group(active_group);
|
||||
Self::clear_group(completed_group);
|
||||
|
||||
let goals = db.list_goals().unwrap_or_default();
|
||||
let mut has_completed = false;
|
||||
|
||||
let base_currency = db
|
||||
.get_setting("base_currency")
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| "USD".to_string());
|
||||
|
||||
for goal in &goals {
|
||||
let pct = if goal.target > 0.0 {
|
||||
(goal.saved / goal.target * 100.0).min(100.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let is_complete = goal.saved >= goal.target;
|
||||
|
||||
let remaining = goal.target - goal.saved;
|
||||
let subtitle = if is_complete {
|
||||
format!("Completed - {:.2} {}", goal.target, goal.currency)
|
||||
} else {
|
||||
let mut parts = vec![
|
||||
format!("{:.2} / {:.2} {} ({:.0}%)", goal.saved, goal.target, goal.currency, pct),
|
||||
];
|
||||
if remaining > 0.0 {
|
||||
parts.push(format!("{:.2} remaining", remaining));
|
||||
}
|
||||
if let Some(deadline) = goal.deadline {
|
||||
let today = chrono::Local::now().date_naive();
|
||||
let days_left = (deadline - today).num_days();
|
||||
if days_left > 0 {
|
||||
parts.push(format!("{} days left", days_left));
|
||||
} else if days_left == 0 {
|
||||
parts.push("Due today".to_string());
|
||||
} else {
|
||||
parts.push(format!("{} days overdue", days_left.abs()));
|
||||
}
|
||||
}
|
||||
// Monthly amount needed
|
||||
if let Ok(Some(monthly)) = db.get_required_monthly(goal.id) {
|
||||
if monthly > 0.0 {
|
||||
parts.push(format!("Need {:.2}/month", monthly));
|
||||
}
|
||||
}
|
||||
// Projection based on average contribution rate
|
||||
if let Ok(avg_rate) = db.get_goal_avg_monthly_contribution(goal.id) {
|
||||
if avg_rate > 0.0 && remaining > 0.0 {
|
||||
let months_remaining = (remaining / avg_rate).ceil() as i32;
|
||||
let today = chrono::Local::now().date_naive();
|
||||
let projected = today + chrono::Months::new(months_remaining as u32);
|
||||
if let Some(deadline) = goal.deadline {
|
||||
let margin_days = (deadline - projected).num_days();
|
||||
if margin_days >= 0 {
|
||||
let margin_months = margin_days / 30;
|
||||
parts.push(format!("On track - ~{} month{} ahead", margin_months, if margin_months == 1 { "" } else { "s" }));
|
||||
} else {
|
||||
let catch_up = remaining / ((deadline - today).num_days().max(1) as f64 / 30.0);
|
||||
parts.push(format!("Behind - need {:.2}/month to catch up", catch_up));
|
||||
}
|
||||
} else {
|
||||
parts.push(format!("Reachable by {}", projected.format("%b %Y")));
|
||||
}
|
||||
} else if remaining > 0.0 {
|
||||
parts.push("Start contributing to see projection".to_string());
|
||||
}
|
||||
}
|
||||
parts.join(" - ")
|
||||
};
|
||||
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(&goal.name)
|
||||
.subtitle(&subtitle)
|
||||
.activatable(true)
|
||||
.build();
|
||||
|
||||
// Progress bar
|
||||
let level = gtk::LevelBar::builder()
|
||||
.min_value(0.0)
|
||||
.max_value(1.0)
|
||||
.value(pct / 100.0)
|
||||
.hexpand(true)
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
level.set_width_request(120);
|
||||
|
||||
if is_complete {
|
||||
let check = gtk::Image::from_icon_name("object-select-symbolic");
|
||||
check.add_css_class("success");
|
||||
row.add_prefix(&check);
|
||||
} else if let Some(ref icon_name) = goal.icon {
|
||||
let icon = gtk::Image::from_icon_name(icon_name);
|
||||
icon.set_pixel_size(24);
|
||||
row.add_prefix(&icon);
|
||||
}
|
||||
|
||||
let suffix_box = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
suffix_box.set_valign(gtk::Align::Center);
|
||||
|
||||
if !is_complete {
|
||||
let contribute_btn = gtk::Button::from_icon_name("list-add-symbolic");
|
||||
contribute_btn.add_css_class("flat");
|
||||
contribute_btn.set_tooltip_text(Some("Add funds"));
|
||||
contribute_btn.set_valign(gtk::Align::Center);
|
||||
|
||||
let goal_id = goal.id;
|
||||
let db_contribute = db.clone();
|
||||
let active_c = active_group.clone();
|
||||
let completed_c = completed_group.clone();
|
||||
let toast_c = toast_overlay.clone();
|
||||
let currency = goal.currency.clone();
|
||||
contribute_btn.connect_clicked(move |btn| {
|
||||
Self::show_contribute_dialog(
|
||||
btn, goal_id, ¤cy,
|
||||
&db_contribute, &active_c, &completed_c, &toast_c,
|
||||
);
|
||||
});
|
||||
suffix_box.append(&contribute_btn);
|
||||
}
|
||||
|
||||
suffix_box.append(&level);
|
||||
row.add_suffix(&suffix_box);
|
||||
|
||||
// Click to edit
|
||||
let goal_id = goal.id;
|
||||
let db_edit = db.clone();
|
||||
let active_e = active_group.clone();
|
||||
let completed_e = completed_group.clone();
|
||||
let toast_e = toast_overlay.clone();
|
||||
row.connect_activated(move |row| {
|
||||
Self::show_edit_dialog(
|
||||
row, goal_id,
|
||||
&db_edit, &active_e, &completed_e, &toast_e,
|
||||
);
|
||||
});
|
||||
|
||||
if is_complete {
|
||||
completed_group.add(&row);
|
||||
has_completed = true;
|
||||
} else {
|
||||
active_group.add(&row);
|
||||
}
|
||||
}
|
||||
|
||||
completed_group.set_visible(has_completed);
|
||||
|
||||
if goals.iter().all(|g| g.saved >= g.target) && !goals.is_empty() {
|
||||
// All goals completed - no empty state needed
|
||||
} else if goals.iter().filter(|g| g.saved < g.target).count() == 0 && goals.is_empty() {
|
||||
let empty_row = adw::ActionRow::builder()
|
||||
.title("No savings goals yet")
|
||||
.subtitle("Set a goal and track your progress")
|
||||
.activatable(false)
|
||||
.build();
|
||||
empty_row.add_css_class("dim-label");
|
||||
active_group.add(&empty_row);
|
||||
}
|
||||
}
|
||||
|
||||
fn show_add_dialog(
|
||||
parent: >k::Button,
|
||||
db: &Rc<Database>,
|
||||
active_group: &adw::PreferencesGroup,
|
||||
completed_group: &adw::PreferencesGroup,
|
||||
toast_overlay: &adw::ToastOverlay,
|
||||
) {
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title("Add Savings Goal")
|
||||
.content_width(360)
|
||||
.content_height(350)
|
||||
.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 name_row = adw::EntryRow::builder()
|
||||
.title("Goal Name")
|
||||
.build();
|
||||
|
||||
let target_row = adw::EntryRow::builder()
|
||||
.title("Target Amount")
|
||||
.build();
|
||||
target_row.set_input_purpose(gtk::InputPurpose::Number);
|
||||
crate::numpad::attach_numpad(&target_row);
|
||||
|
||||
let base_currency = db
|
||||
.get_setting("base_currency")
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_else(|| "USD".to_string());
|
||||
|
||||
let (deadline_row, deadline_label) = crate::date_picker::make_date_row("Deadline (optional)", "");
|
||||
|
||||
let form = adw::PreferencesGroup::new();
|
||||
form.add(&name_row);
|
||||
form.add(&target_row);
|
||||
form.add(&deadline_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);
|
||||
toolbar.set_content(Some(&content));
|
||||
dialog.set_child(Some(&toolbar));
|
||||
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let dialog_ref = dialog.clone();
|
||||
let active_ref = active_group.clone();
|
||||
let completed_ref = completed_group.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
let bc = base_currency.clone();
|
||||
save_btn.connect_clicked(move |_| {
|
||||
let name = name_row.text();
|
||||
if name.is_empty() {
|
||||
let toast = adw::Toast::new("Please enter a goal name");
|
||||
toast_ref.add_toast(toast);
|
||||
return;
|
||||
}
|
||||
let target: f64 = match target_row.text().trim().parse() {
|
||||
Ok(v) if v > 0.0 => v,
|
||||
_ => {
|
||||
let toast = adw::Toast::new("Please enter a valid target amount");
|
||||
toast_ref.add_toast(toast);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let deadline_text = deadline_label.label();
|
||||
let deadline = if deadline_text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
chrono::NaiveDate::parse_from_str(deadline_text.trim(), "%Y-%m-%d").ok()
|
||||
};
|
||||
|
||||
match db_ref.insert_goal(&name, target, &bc, deadline, None, None) {
|
||||
Ok(_) => {
|
||||
dialog_ref.close();
|
||||
let toast = adw::Toast::new("Goal created");
|
||||
toast_ref.add_toast(toast);
|
||||
Self::load_goals(&db_ref, &active_ref, &completed_ref, &toast_ref);
|
||||
}
|
||||
Err(e) => {
|
||||
let toast = adw::Toast::new(&format!("Error: {}", e));
|
||||
toast_ref.add_toast(toast);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
dialog.present(Some(parent));
|
||||
}
|
||||
|
||||
fn show_contribute_dialog(
|
||||
parent: >k::Button,
|
||||
goal_id: i64,
|
||||
currency: &str,
|
||||
db: &Rc<Database>,
|
||||
active_group: &adw::PreferencesGroup,
|
||||
completed_group: &adw::PreferencesGroup,
|
||||
toast_overlay: &adw::ToastOverlay,
|
||||
) {
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title("Add Funds")
|
||||
.content_width(320)
|
||||
.content_height(180)
|
||||
.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 amount_row = adw::EntryRow::builder()
|
||||
.title(&format!("Amount ({})", currency))
|
||||
.build();
|
||||
amount_row.set_input_purpose(gtk::InputPurpose::Number);
|
||||
crate::numpad::attach_numpad(&amount_row);
|
||||
|
||||
let form = adw::PreferencesGroup::new();
|
||||
form.add(&amount_row);
|
||||
|
||||
let save_btn = gtk::Button::with_label("Add");
|
||||
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);
|
||||
toolbar.set_content(Some(&content));
|
||||
dialog.set_child(Some(&toolbar));
|
||||
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let dialog_ref = dialog.clone();
|
||||
let active_ref = active_group.clone();
|
||||
let completed_ref = completed_group.clone();
|
||||
let toast_ref = toast_overlay.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;
|
||||
}
|
||||
};
|
||||
|
||||
match db_ref.contribute_to_goal(goal_id, amount) {
|
||||
Ok(()) => {
|
||||
dialog_ref.close();
|
||||
let toast = adw::Toast::new(&format!("Added {:.2}", amount));
|
||||
toast_ref.add_toast(toast);
|
||||
Self::load_goals(&db_ref, &active_ref, &completed_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,
|
||||
goal_id: i64,
|
||||
db: &Rc<Database>,
|
||||
active_group: &adw::PreferencesGroup,
|
||||
completed_group: &adw::PreferencesGroup,
|
||||
toast_overlay: &adw::ToastOverlay,
|
||||
) {
|
||||
let goal = match db.get_goal(goal_id) {
|
||||
Ok(g) => g,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title("Edit Goal")
|
||||
.content_width(360)
|
||||
.content_height(350)
|
||||
.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 name_row = adw::EntryRow::builder()
|
||||
.title("Goal Name")
|
||||
.text(&goal.name)
|
||||
.build();
|
||||
|
||||
let target_row = adw::EntryRow::builder()
|
||||
.title("Target Amount")
|
||||
.text(&format!("{:.2}", goal.target))
|
||||
.build();
|
||||
target_row.set_input_purpose(gtk::InputPurpose::Number);
|
||||
crate::numpad::attach_numpad(&target_row);
|
||||
|
||||
let initial_deadline = goal.deadline
|
||||
.map(|d| d.format("%Y-%m-%d").to_string())
|
||||
.unwrap_or_default();
|
||||
let (deadline_row, deadline_label) = crate::date_picker::make_date_row("Deadline (optional)", &initial_deadline);
|
||||
|
||||
let form = adw::PreferencesGroup::new();
|
||||
form.add(&name_row);
|
||||
form.add(&target_row);
|
||||
form.add(&deadline_row);
|
||||
|
||||
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 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(&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 completed_ref = completed_group.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
let currency = goal.currency.clone();
|
||||
save_btn.connect_clicked(move |_| {
|
||||
let name = name_row.text();
|
||||
if name.is_empty() {
|
||||
let toast = adw::Toast::new("Please enter a goal name");
|
||||
toast_ref.add_toast(toast);
|
||||
return;
|
||||
}
|
||||
let target: f64 = match target_row.text().trim().parse() {
|
||||
Ok(v) if v > 0.0 => v,
|
||||
_ => {
|
||||
let toast = adw::Toast::new("Please enter a valid target amount");
|
||||
toast_ref.add_toast(toast);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let deadline_text = deadline_label.label();
|
||||
let deadline = if deadline_text.is_empty() {
|
||||
None
|
||||
} else {
|
||||
chrono::NaiveDate::parse_from_str(deadline_text.trim(), "%Y-%m-%d").ok()
|
||||
};
|
||||
|
||||
match db_ref.update_goal(goal_id, &name, target, ¤cy, deadline, None, None) {
|
||||
Ok(()) => {
|
||||
dialog_ref.close();
|
||||
let toast = adw::Toast::new("Goal updated");
|
||||
toast_ref.add_toast(toast);
|
||||
Self::load_goals(&db_ref, &active_ref, &completed_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 completed_ref = completed_group.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
delete_btn.connect_clicked(move |btn| {
|
||||
let alert = adw::AlertDialog::new(
|
||||
Some("Delete this goal?"),
|
||||
Some("This will permanently remove this savings goal."),
|
||||
);
|
||||
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 completed_del = completed_ref.clone();
|
||||
let toast_del = toast_ref.clone();
|
||||
alert.connect_response(None, move |_, response| {
|
||||
if response == "delete" {
|
||||
match db_del.delete_goal(goal_id) {
|
||||
Ok(()) => {
|
||||
dialog_del.close();
|
||||
let toast = adw::Toast::new("Goal deleted");
|
||||
toast_del.add_toast(toast);
|
||||
Self::load_goals(&db_del, &active_del, &completed_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 clear_group(group: &adw::PreferencesGroup) {
|
||||
let mut rows = Vec::new();
|
||||
let mut child = group.upcast_ref::<gtk::Widget>().first_child();
|
||||
while let Some(c) = child {
|
||||
if let Some(row) = c.downcast_ref::<adw::ActionRow>() {
|
||||
rows.push(row.clone());
|
||||
}
|
||||
let mut inner = c.first_child();
|
||||
while let Some(ic) = inner {
|
||||
if let Some(row) = ic.downcast_ref::<adw::ActionRow>() {
|
||||
rows.push(row.clone());
|
||||
}
|
||||
let mut inner2 = ic.first_child();
|
||||
while let Some(ic2) = inner2 {
|
||||
if let Some(row) = ic2.downcast_ref::<adw::ActionRow>() {
|
||||
rows.push(row.clone());
|
||||
}
|
||||
inner2 = ic2.next_sibling();
|
||||
}
|
||||
inner = ic.next_sibling();
|
||||
}
|
||||
child = c.next_sibling();
|
||||
}
|
||||
for row in &rows {
|
||||
group.remove(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user