use adw::prelude::*; use std::path::Path; use std::rc::Rc; use gtk::gio; use crate::config::APP_ID; use crate::core::database::Database; use crate::core::updater; use crate::i18n::i18n; use crate::ui::update_dialog; use crate::ui::widgets; /// Build the Updates top-level view. /// Returns a ToolbarView that can be added to the ViewStack. pub fn build_updates_view(db: &Rc) -> adw::ToolbarView { let toast_overlay = adw::ToastOverlay::new(); let header = adw::HeaderBar::new(); let title = adw::WindowTitle::builder() .title(&i18n("Updates")) .build(); header.set_title_widget(Some(&title)); // Check Now button let check_btn = gtk::Button::builder() .icon_name("view-refresh-symbolic") .tooltip_text(&i18n("Check for updates")) .build(); header.pack_end(&check_btn); // Update All button (only visible when updates exist) let update_all_btn = gtk::Button::builder() .label(&i18n("Update All")) .css_classes(["suggested-action", "pill"]) .visible(false) .build(); header.pack_start(&update_all_btn); // Content stack: empty state vs checking vs update list let stack = gtk::Stack::new(); // Empty state - all up to date let empty_page = adw::StatusPage::builder() .icon_name("emblem-ok-symbolic") .title(&i18n("Everything is Up to Date")) .description(&i18n("All your AppImages are running the latest version")) .build(); stack.add_named(&empty_page, Some("empty")); // Checking state let checking_page = adw::StatusPage::builder() .icon_name("emblem-synchronizing-symbolic") .title(&i18n("Checking for Updates")) .description(&i18n("Looking for new versions of your AppImages...")) .build(); let spinner = gtk::Spinner::builder() .spinning(true) .width_request(32) .height_request(32) .halign(gtk::Align::Center) .build(); checking_page.set_child(Some(&spinner)); stack.add_named(&checking_page, Some("checking")); // Update list let list_box = gtk::ListBox::builder() .selection_mode(gtk::SelectionMode::None) .css_classes(["boxed-list"]) .margin_start(18) .margin_end(18) .margin_top(12) .margin_bottom(24) .build(); let last_checked_label = gtk::Label::builder() .css_classes(["dim-label", "caption"]) .halign(gtk::Align::Start) .margin_start(18) .margin_top(6) .build(); let updates_content = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(6) .build(); updates_content.append(&last_checked_label); let clamp = adw::Clamp::builder() .maximum_size(800) .tightening_threshold(600) .child(&list_box) .build(); updates_content.append(&clamp); let scrolled = gtk::ScrolledWindow::builder() .child(&updates_content) .vexpand(true) .build(); stack.add_named(&scrolled, Some("updates")); toast_overlay.set_child(Some(&stack)); // Shared state for refresh let state = Rc::new(UpdatesState { db: db.clone(), list_box: list_box.clone(), stack: stack.clone(), update_all_btn: update_all_btn.clone(), title: title.clone(), last_checked_label: last_checked_label.clone(), toast_overlay: toast_overlay.clone(), }); // Initial population populate_update_list(&state); // Check Now handler { let state_ref = state.clone(); check_btn.connect_clicked(move |btn| { btn.set_sensitive(false); state_ref.stack.set_visible_child_name("checking"); let state_c = state_ref.clone(); let btn_c = btn.clone(); glib::spawn_future_local(async move { let db_bg = Database::open().ok(); let _result = gio::spawn_blocking(move || { if let Some(ref db) = db_bg { update_dialog::batch_check_updates(db); } }).await; btn_c.set_sensitive(true); // Update last-checked timestamp let settings = gio::Settings::new(APP_ID); let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); settings.set_string("last-update-check", &now).ok(); // Reload from main DB let fresh_db = state_c.db.clone(); // Re-read records from the shared db so UI picks up changes from the bg thread drop(fresh_db); populate_update_list(&state_c); state_c.toast_overlay.add_toast(adw::Toast::new(&i18n("Update check complete"))); }); }); } // Update All handler { let state_ref = state.clone(); update_all_btn.connect_clicked(move |btn| { btn.set_sensitive(false); let state_c = state_ref.clone(); let btn_c = btn.clone(); glib::spawn_future_local(async move { let db_bg = Database::open().ok(); let result = gio::spawn_blocking(move || { let mut updated = 0u32; if let Some(ref db) = db_bg { let updatable = db.get_appimages_with_updates().unwrap_or_default(); for rec in &updatable { let path = Path::new(&rec.path); if path.exists() { if updater::perform_update( path, rec.update_url.as_deref(), true, None, ).is_ok() { db.clear_update_available(rec.id).ok(); updated += 1; } } } } updated }).await; btn_c.set_sensitive(true); let count = result.unwrap_or(0); if count > 0 { state_c.toast_overlay.add_toast( adw::Toast::new(&format!("{} apps updated", count)), ); } populate_update_list(&state_c); }); }); } let toolbar_view = adw::ToolbarView::new(); toolbar_view.add_top_bar(&header); toolbar_view.set_content(Some(&toast_overlay)); toolbar_view } struct UpdatesState { db: Rc, list_box: gtk::ListBox, stack: gtk::Stack, update_all_btn: gtk::Button, title: adw::WindowTitle, last_checked_label: gtk::Label, toast_overlay: adw::ToastOverlay, } fn populate_update_list(state: &Rc) { // Clear existing rows while let Some(row) = state.list_box.row_at_index(0) { state.list_box.remove(&row); } // Re-read from database to pick up changes from background threads let db_fresh = Database::open().ok(); let updatable = db_fresh .as_ref() .and_then(|db| db.get_appimages_with_updates().ok()) .unwrap_or_default(); // Update last checked label let settings = gio::Settings::new(APP_ID); let last_check = settings.string("last-update-check"); if last_check.is_empty() || last_check.as_str() == "never" { state.last_checked_label.set_label(&i18n("Never checked")); } else { state.last_checked_label.set_label( &format!("Last checked: {}", widgets::relative_time(&last_check)), ); } if updatable.is_empty() { state.stack.set_visible_child_name("empty"); state.update_all_btn.set_visible(false); state.title.set_subtitle(&i18n("All up to date")); return; } state.stack.set_visible_child_name("updates"); state.update_all_btn.set_visible(true); state.title.set_subtitle(&format!("{} updates available", updatable.len())); for record in &updatable { let name = record.app_name.as_deref().unwrap_or(&record.filename); let row = adw::ActionRow::builder() .title(name) .activatable(false) .build(); // Show version info: current -> latest let current = record.app_version.as_deref().unwrap_or("unknown"); let latest = record.latest_version.as_deref().unwrap_or("unknown"); row.set_subtitle(&format!("{} -> {}", current, latest)); // App icon let icon = widgets::app_icon(record.icon_path.as_deref(), name, 32); row.add_prefix(&icon); // Individual update button let update_btn = gtk::Button::builder() .icon_name("software-update-available-symbolic") .valign(gtk::Align::Center) .tooltip_text(&i18n("Update this app")) .css_classes(["flat"]) .build(); let app_id = record.id; let update_url = record.update_url.clone(); let app_path = record.path.clone(); let state_ref = state.clone(); update_btn.connect_clicked(move |btn| { btn.set_sensitive(false); let state_c = state_ref.clone(); let path = app_path.clone(); let url = update_url.clone(); let btn_c = btn.clone(); let aid = app_id; glib::spawn_future_local(async move { let db_bg = Database::open().ok(); let result = gio::spawn_blocking(move || { let p = Path::new(&path); if p.exists() { let ok = updater::perform_update( p, url.as_deref(), true, None, ).is_ok(); if ok { if let Some(ref db) = db_bg { db.clear_update_available(aid).ok(); } } ok } else { false } }).await; btn_c.set_sensitive(true); if result.unwrap_or(false) { state_c.toast_overlay.add_toast( adw::Toast::new(&i18n("Update complete")), ); } else { state_c.toast_overlay.add_toast( adw::Toast::new(&i18n("Update failed")), ); } populate_update_list(&state_c); }); }); row.add_suffix(&update_btn); state.list_box.append(&row); } }