From 63a19b21339e33ed9fc8192e51c8b3e39da2b784 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Sat, 4 Apr 2026 01:06:07 -0300 Subject: [PATCH] Refactor to fix update bug on first launch --- lib/screens/updates_screen.dart | 389 ++++++------------ .../package_manager/package_manager.dart | 28 +- lib/services/updates_service.dart | 165 +++----- 3 files changed, 207 insertions(+), 375 deletions(-) diff --git a/lib/screens/updates_screen.dart b/lib/screens/updates_screen.dart index 4249f80..8404c4e 100644 --- a/lib/screens/updates_screen.dart +++ b/lib/screens/updates_screen.dart @@ -12,7 +12,6 @@ import 'package:zapstore/widgets/batch_progress_banner.dart'; import 'package:zapstore/widgets/common/badges.dart'; import 'package:zapstore/widgets/app_card.dart'; -/// Screen for managing app updates class UpdatesScreen extends ConsumerWidget { const UpdatesScreen({super.key}); @@ -20,7 +19,6 @@ class UpdatesScreen extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final categorized = ref.watch(categorizedUpdatesProvider); - // Show skeleton only on cold start (no installed apps matched yet) if (categorized.showSkeleton) { return Scaffold( body: Padding( @@ -33,19 +31,17 @@ class UpdatesScreen extends ConsumerWidget { return Scaffold( body: Padding( padding: const EdgeInsets.only(top: 16), - child: _UpdatesContent(categorized: categorized), + child: _UpdatesList(categorized: categorized), ), ); } } -/// Loading skeleton shown while fetching updates on cold start class _LoadingSkeleton extends StatelessWidget { @override Widget build(BuildContext context) { return ListView( children: [ - // "Checking for updates..." indicator at top Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( @@ -68,7 +64,6 @@ class _LoadingSkeleton extends StatelessWidget { ], ), ), - // Skeleton app cards using existing AppCard skeleton const AppCard(isLoading: true), const AppCard(isLoading: true), const AppCard(isLoading: true), @@ -77,7 +72,6 @@ class _LoadingSkeleton extends StatelessWidget { } } -/// Shows when updates were last checked class _LastCheckedIndicator extends HookConsumerWidget { const _LastCheckedIndicator(); @@ -169,31 +163,8 @@ class _LastCheckedIndicator extends HookConsumerWidget { } } -/// Item types for the updates list -enum _UpdatesItemType { - lastChecked, - installingHeader, - installingApp, - automaticHeader, - automaticApp, - manualHeader, - manualApp, - upToDateHeader, - upToDateApp, - uncatalogedHeader, - uncatalogedApp, -} - -class _UpdatesItem { - final _UpdatesItemType type; - final App? app; - final PackageInfo? packageInfo; - - const _UpdatesItem(this.type, {this.app, this.packageInfo}); -} - -class _UpdatesContent extends HookConsumerWidget { - const _UpdatesContent({required this.categorized}); +class _UpdatesList extends ConsumerWidget { + const _UpdatesList({required this.categorized}); final CategorizedUpdates categorized; @@ -204,7 +175,7 @@ class _UpdatesContent extends HookConsumerWidget { final upToDateApps = categorized.upToDateApps; final uncatalogedApps = categorized.uncatalogedApps; - // Watch operations from PackageManager + // Resolve installing apps (active operations not already in update lists) final operations = ref.watch( packageManagerProvider.select((s) => s.operations), ); @@ -213,47 +184,18 @@ class _UpdatesContent extends HookConsumerWidget { .map((entry) => entry.key) .toSet(); - // Always use the same widget type to preserve scroll position - return _UpdatesListBodyWithInstallingAppIds( - installingAppIds: activeAppIds, - automaticUpdates: automaticUpdates, - manualUpdates: manualUpdates, - upToDateApps: upToDateApps, - uncatalogedApps: uncatalogedApps, - ); - } -} - -class _UpdatesListBodyWithInstallingAppIds extends ConsumerWidget { - const _UpdatesListBodyWithInstallingAppIds({ - required this.installingAppIds, - required this.automaticUpdates, - required this.manualUpdates, - required this.upToDateApps, - required this.uncatalogedApps, - }); - - final Set installingAppIds; - final List automaticUpdates; - final List manualUpdates; - final List upToDateApps; - final List uncatalogedApps; - - @override - Widget build(BuildContext context, WidgetRef ref) { final updateAppIds = { ...automaticUpdates.map((a) => a.identifier), ...manualUpdates.map((a) => a.identifier), }; - // Only query for installing apps if there are active operations final List installingApps; - if (installingAppIds.isEmpty) { + if (activeAppIds.isEmpty) { installingApps = const []; } else { final installingAppsState = ref.watch( query( - tags: {'#d': installingAppIds}, + tags: {'#d': activeAppIds}, and: (app) => {app.latestRelease.query()}, source: const LocalAndRemoteSource(relays: 'AppCatalog'), subscriptionPrefix: 'app-installing-apps', @@ -262,74 +204,30 @@ class _UpdatesListBodyWithInstallingAppIds extends ConsumerWidget { installingApps = installingAppsState.models .where( (app) => - installingAppIds.contains(app.identifier) && + activeAppIds.contains(app.identifier) && !updateAppIds.contains(app.identifier), ) .toList(); } - return _UpdatesListBody( - automaticUpdates: automaticUpdates, - manualUpdates: manualUpdates, - installingApps: installingApps, - upToDateApps: upToDateApps, - uncatalogedApps: uncatalogedApps, - ); - } -} - -class _UpdatesListBody extends HookConsumerWidget { - const _UpdatesListBody({ - required this.automaticUpdates, - required this.manualUpdates, - required this.installingApps, - required this.upToDateApps, - required this.uncatalogedApps, - }); - - final List automaticUpdates; - final List manualUpdates; - final List installingApps; - final List upToDateApps; - final List uncatalogedApps; - - @override - Widget build(BuildContext context, WidgetRef ref) { + // Empty state if (automaticUpdates.isEmpty && manualUpdates.isEmpty && installingApps.isEmpty && upToDateApps.isEmpty && uncatalogedApps.isEmpty) { final theme = Theme.of(context); - return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ ColorFiltered( colorFilter: const ColorFilter.matrix([ - 0.2126, - 0.7152, - 0.0722, - 0, - 0, - 0.2126, - 0.7152, - 0.0722, - 0, - 0, - 0.2126, - 0.7152, - 0.0722, - 0, - 0, - 0, - 0, - 0, - 1, - 0, + 0.2126, 0.7152, 0.0722, 0, 0, + 0.2126, 0.7152, 0.0722, 0, 0, + 0.2126, 0.7152, 0.0722, 0, 0, + 0, 0, 0, 1, 0, ]), child: const Text('🎉', style: TextStyle(fontSize: 48)), ), @@ -352,78 +250,9 @@ class _UpdatesListBody extends HookConsumerWidget { ); } - // Build flat list of items for ListView.builder - final items = <_UpdatesItem>[]; - - // Add last checked indicator first - items.add(const _UpdatesItem(_UpdatesItemType.lastChecked)); - - if (installingApps.isNotEmpty) { - items.add(const _UpdatesItem(_UpdatesItemType.installingHeader)); - for (final app in installingApps) { - items.add(_UpdatesItem(_UpdatesItemType.installingApp, app: app)); - } - } - - if (automaticUpdates.isNotEmpty) { - items.add(const _UpdatesItem(_UpdatesItemType.automaticHeader)); - for (final app in automaticUpdates) { - items.add(_UpdatesItem(_UpdatesItemType.automaticApp, app: app)); - } - } - - if (manualUpdates.isNotEmpty) { - items.add(const _UpdatesItem(_UpdatesItemType.manualHeader)); - for (final app in manualUpdates) { - items.add(_UpdatesItem(_UpdatesItemType.manualApp, app: app)); - } - } - - if (upToDateApps.isNotEmpty) { - items.add(const _UpdatesItem(_UpdatesItemType.upToDateHeader)); - for (final app in upToDateApps) { - items.add(_UpdatesItem(_UpdatesItemType.upToDateApp, app: app)); - } - } - - if (uncatalogedApps.isNotEmpty) { - items.add(const _UpdatesItem(_UpdatesItemType.uncatalogedHeader)); - for (final pkg in uncatalogedApps) { - items.add( - _UpdatesItem(_UpdatesItemType.uncatalogedApp, packageInfo: pkg), - ); - } - } - - // Combine all updates for the Update All button final allUpdates = [...automaticUpdates, ...manualUpdates]; - // Auto-clear completed operations after 3 seconds - final progress = ref.watch(batchProgressProvider); - final wasInProgress = useRef(false); - final autoClearTimer = useRef(null); - useEffect(() { - if (progress != null && progress.hasInProgress) { - autoClearTimer.value?.cancel(); - wasInProgress.value = true; - } else if (wasInProgress.value && - progress != null && - progress.isAllComplete) { - wasInProgress.value = false; - autoClearTimer.value = Timer(const Duration(seconds: 3), () { - ref - .read(packageManagerProvider.notifier) - .clearCompletedOperations(); - }); - } else if (progress == null) { - autoClearTimer.value?.cancel(); - wasInProgress.value = false; - } - return () => autoClearTimer.value?.cancel(); - }, [progress?.hasInProgress, progress?.isAllComplete]); - return RefreshIndicator( - // Hide the spinner - _LastCheckedIndicator already shows "Checking for updates..." color: Colors.transparent, backgroundColor: Colors.transparent, elevation: 0, @@ -431,97 +260,122 @@ class _UpdatesListBody extends HookConsumerWidget { onRefresh: () => ref.read(updatePollerProvider.notifier).checkNow(), child: CustomScrollView( slivers: [ - // Update All button (when idle with 2+ updates available) if (allUpdates.length > 1) SliverToBoxAdapter(child: UpdateAllRow(allUpdates: allUpdates)), - // Main content - SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final item = items[index]; - switch (item.type) { - case _UpdatesItemType.lastChecked: - return const _LastCheckedIndicator(); - case _UpdatesItemType.installingHeader: - return _SectionHeader( - icon: Icons.downloading, - title: 'Installing', - count: installingApps.length, - ); - case _UpdatesItemType.installingApp: - return AppCard( - key: ValueKey('installing_${item.app?.identifier}'), - app: item.app, - showUpdateArrow: false, - showUpdateButton: true, - showZapEncouragement: true, - showDescription: false, - ); - case _UpdatesItemType.automaticHeader: - return _SectionHeader( - icon: Icons.system_update, - title: 'Updates', - count: automaticUpdates.length, - ); - case _UpdatesItemType.automaticApp: - return AppCard( - key: ValueKey('automatic_${item.app?.identifier}'), - app: item.app, - showUpdateArrow: true, - showUpdateButton: true, - showZapEncouragement: true, - showDescription: false, - ); - case _UpdatesItemType.manualHeader: - return _SectionHeader( - icon: Icons.touch_app, - title: 'Manual Updates', - count: manualUpdates.length, - trailing: _ManualUpdatesHelpIcon(), - ); - case _UpdatesItemType.manualApp: - return AppCard( - key: ValueKey('manual_${item.app?.identifier}'), - app: item.app, - showUpdateArrow: true, - showUpdateButton: true, - showZapEncouragement: true, - showDescription: false, - ); - case _UpdatesItemType.upToDateHeader: - return _SectionHeader( - icon: Icons.check_circle, - title: 'Up to date', - count: upToDateApps.length, - ); - case _UpdatesItemType.upToDateApp: - return AppCard( - key: ValueKey('uptodate_${item.app?.identifier}'), - app: item.app, - showUpdateArrow: false, - showDescription: false, - ); - case _UpdatesItemType.uncatalogedHeader: - return _SectionHeader( - icon: Icons.help_outline, - title: 'Other installed', - count: uncatalogedApps.length, - iconColor: AppColors.darkOnSurfaceSecondary, - ); - case _UpdatesItemType.uncatalogedApp: - return _UncatalogedAppCard( - key: ValueKey('uncataloged_${item.packageInfo?.appId}'), - packageInfo: item.packageInfo!, - ); - } - }, childCount: items.length), - ), + const SliverToBoxAdapter(child: _LastCheckedIndicator()), + if (installingApps.isNotEmpty) + _AppSection( + icon: Icons.downloading, + title: 'Installing', + apps: installingApps, + keyPrefix: 'installing', + showUpdateButton: true, + showZapEncouragement: true, + ), + if (automaticUpdates.isNotEmpty) + _AppSection( + icon: Icons.system_update, + title: 'Updates', + apps: automaticUpdates, + keyPrefix: 'automatic', + showUpdateArrow: true, + showUpdateButton: true, + showZapEncouragement: true, + ), + if (manualUpdates.isNotEmpty) + _AppSection( + icon: Icons.touch_app, + title: 'Manual Updates', + apps: manualUpdates, + keyPrefix: 'manual', + showUpdateArrow: true, + showUpdateButton: true, + showZapEncouragement: true, + headerTrailing: _ManualUpdatesHelpIcon(), + ), + if (upToDateApps.isNotEmpty) + _AppSection( + icon: Icons.check_circle, + title: 'Up to date', + apps: upToDateApps, + keyPrefix: 'uptodate', + ), + if (uncatalogedApps.isNotEmpty) ...[ + SliverToBoxAdapter( + child: _SectionHeader( + icon: Icons.help_outline, + title: 'Other installed', + count: uncatalogedApps.length, + iconColor: AppColors.darkOnSurfaceSecondary, + ), + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) => _UncatalogedAppCard( + key: ValueKey('uncataloged_${uncatalogedApps[index].appId}'), + packageInfo: uncatalogedApps[index], + ), + childCount: uncatalogedApps.length, + ), + ), + ], ], ), ); } } -/// Section header with icon, title, count badge, and optional trailing widget. +/// A section: header + list of AppCards, rendered as a single sliver. +class _AppSection extends StatelessWidget { + const _AppSection({ + required this.icon, + required this.title, + required this.apps, + required this.keyPrefix, + this.showUpdateArrow = false, + this.showUpdateButton = false, + this.showZapEncouragement = false, + this.headerTrailing, + }); + + final IconData icon; + final String title; + final List apps; + final String keyPrefix; + final bool showUpdateArrow; + final bool showUpdateButton; + final bool showZapEncouragement; + final Widget? headerTrailing; + + @override + Widget build(BuildContext context) { + return SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index == 0) { + return _SectionHeader( + icon: icon, + title: title, + count: apps.length, + trailing: headerTrailing, + ); + } + final app = apps[index - 1]; + return AppCard( + key: ValueKey('${keyPrefix}_${app.identifier}'), + app: app, + showUpdateArrow: showUpdateArrow, + showUpdateButton: showUpdateButton, + showZapEncouragement: showZapEncouragement, + showDescription: false, + ); + }, + childCount: apps.length + 1, + ), + ); + } +} + class _SectionHeader extends StatelessWidget { const _SectionHeader({ required this.icon, @@ -558,7 +412,6 @@ class _SectionHeader extends StatelessWidget { } } -/// Help icon that shows explanation for Manual Updates section. class _ManualUpdatesHelpIcon extends StatelessWidget { @override Widget build(BuildContext context) { @@ -607,7 +460,6 @@ class _UncatalogedAppCard extends StatelessWidget { ), child: Row( children: [ - // Generic app icon Container( width: 50, height: 50, @@ -642,7 +494,6 @@ class _UncatalogedAppCard extends StatelessWidget { overflow: TextOverflow.ellipsis, ), const SizedBox(height: 8), - // Version pill Container( padding: const EdgeInsets.symmetric( horizontal: 9, diff --git a/lib/services/package_manager/package_manager.dart b/lib/services/package_manager/package_manager.dart index 1d33b0e..9fd1f62 100644 --- a/lib/services/package_manager/package_manager.dart +++ b/lib/services/package_manager/package_manager.dart @@ -109,6 +109,9 @@ abstract class PackageManager extends StateNotifier { /// Watchdog timer for detecting stale operations (Dart-side fallback) Timer? _watchdogTimer; + /// Auto-clears terminal operations 3s after all operations finish + Timer? _autoClearTimer; + // ═══════════════════════════════════════════════════════════════════════════ // EXPLICIT QUEUE TRACKING // Queues are the source of truth for order; operations map is for UI state. @@ -192,6 +195,7 @@ abstract class PackageManager extends StateNotifier { @override void dispose() { _watchdogTimer?.cancel(); + _autoClearTimer?.cancel(); _downloader.unregisterCallbacks(); super.dispose(); } @@ -285,6 +289,7 @@ abstract class PackageManager extends StateNotifier { void setOperation(String appId, InstallOperation op) { state = state.copyWith(operations: {...state.operations, appId: op}); _updateWatchdogTimer(); + _scheduleAutoClearIfDone(); } void clearOperation(String appId) { @@ -295,9 +300,10 @@ abstract class PackageManager extends StateNotifier { } /// Clear all terminal operations from the map. - /// Called after the batch completion display timeout. /// Terminal states: Completed, OperationFailed, InstallCancelled. void clearCompletedOperations() { + _autoClearTimer?.cancel(); + _autoClearTimer = null; final remaining = Map.of(state.operations) ..removeWhere( (_, op) => @@ -307,6 +313,25 @@ abstract class PackageManager extends StateNotifier { _updateWatchdogTimer(); } + /// Auto-clear terminal operations 3s after every operation reaches a + /// terminal state. Resets if a new non-terminal operation arrives. + void _scheduleAutoClearIfDone() { + final ops = state.operations.values; + final hasActive = ops.any((op) => op.isActive); + + if (hasActive || ops.isEmpty) { + _autoClearTimer?.cancel(); + _autoClearTimer = null; + return; + } + + // All operations are terminal — schedule cleanup + _autoClearTimer ??= Timer(const Duration(seconds: 3), () { + _autoClearTimer = null; + clearCompletedOperations(); + }); + } + // ═══════════════════════════════════════════════════════════════════════════ // DOWNLOAD OPERATIONS // ═══════════════════════════════════════════════════════════════════════════ @@ -1508,7 +1533,6 @@ class BatchProgress { /// Whether all operations are complete (all terminal) bool get isAllComplete => !hasInProgress && total > 0; - } /// Provider for batch progress - ALL state derived from operations map. diff --git a/lib/services/updates_service.dart b/lib/services/updates_service.dart index 3b5daec..e155771 100644 --- a/lib/services/updates_service.dart +++ b/lib/services/updates_service.dart @@ -104,25 +104,18 @@ class UpdatePollerNotifier extends Notifier { /// Trigger an update check. Called by timer and pull-to-refresh. /// - /// Throttling behavior (per FEAT-003): - /// - If already checking: returns immediately (UI already showing spinner) - /// - If checked <30s ago: shows "checking" state for 2s without network (fake fetch) + /// - If already checking: returns immediately + /// - If checked <30s ago: silently skipped Future checkNow() async { // Already checking - UI is already showing spinner, just return if (state.isChecking) return; - // Throttle: if checked recently, do a fake fetch (but allow first fetch always) + // Throttle: skip if checked recently (but allow first fetch always) if (_hasCompletedFirstFetch && state.lastCheckTime != null) { final timeSinceLastCheck = DateTime.now().difference( state.lastCheckTime!, ); - if (timeSinceLastCheck < _refreshCooldown) { - // Fake fetch: show spinner for 2s without hitting network - state = state.copyWith(isChecking: true); - await Future.delayed(const Duration(seconds: 2)); - state = state.copyWith(isChecking: false); - return; - } + if (timeSinceLastCheck < _refreshCooldown) return; } state = state.copyWith(isChecking: true); @@ -142,8 +135,12 @@ class UpdatePollerNotifier extends Notifier { ); } catch (e) { debugPrint('[UpdatePoller] Check failed: $e'); + // Mark as completed even on failure so the UI escapes the skeleton and + // shows uncataloged apps rather than spinning forever. + _hasCompletedFirstFetch = true; state = state.copyWith( isChecking: false, + lastCheckTime: DateTime.now(), lastError: 'Update check failed — will retry', ); } @@ -173,7 +170,8 @@ class UpdatePollerNotifier extends Notifier { ); // 2. Fetch FileMetadata (1063) for apps not covered by 3063 - final coveredIds = assets.map((a) => a.appIdentifier).toSet(); + final coveredIds = + assets.map((a) => a.appIdentifier).where((id) => id.isNotEmpty).toSet(); final uncoveredIds = installedIds.difference(coveredIds); var metadatas = const []; if (uncoveredIds.isNotEmpty) { @@ -239,7 +237,7 @@ final updatePollerProvider = ); // ═══════════════════════════════════════════════════════════════════════════════ -// CATEGORIZED UPDATES - Reactive local-only query for UI +// CATEGORIZED UPDATES - Reactive query for UI // ═══════════════════════════════════════════════════════════════════════════════ /// Categorized apps state for the Updates screen @@ -276,18 +274,15 @@ class CategorizedUpdatesNotifier extends Notifier { @override CategorizedUpdates build() { - // Keep poller alive; only rebuild when completion status changes - final pollerHasCompleted = ref.watch( - updatePollerProvider.select((s) => s.lastCheckTime != null), - ); + // Keep poller alive for periodic background refresh. + // Select a constant so poller state changes don't trigger rebuilds. + ref.watch(updatePollerProvider.select((_) => null)); - // Wait for app initialization final initState = ref.watch(appInitializationProvider); if (initState is! AsyncData) { return CategorizedUpdates.empty; } - // Watch only installed map and scanning flag (ignore operations) final installed = ref.watch( packageManagerProvider.select((s) => s.installed), ); @@ -314,9 +309,13 @@ class CategorizedUpdatesNotifier extends Notifier { final platform = ref.read(packageManagerProvider.notifier).platform; - // Local-only query: App with both installable paths loaded. - // 3063 apps resolve via latestAsset; 1063-only apps via latestRelease chain. - // The poller writes all data to local DB first; this just reads it. + // Reactive query: reads local cache immediately, fetches from relay on + // cold start, and refreshes whenever new data is written to local DB + // (e.g. by the periodic poller). + const source = LocalAndRemoteSource( + relays: 'AppCatalog', + stream: false, + ); final appsState = ref.watch( query( tags: { @@ -324,64 +323,39 @@ class CategorizedUpdatesNotifier extends Notifier { '#f': {platform}, }, and: (app) => { - app.latestAsset.query(source: const LocalSource()), + app.latestAsset.query(source: source), app.latestRelease.query( - source: const LocalSource(), + source: source, and: (release) => { - release.latestMetadata.query(source: const LocalSource()), + release.latestMetadata.query(source: source), }, ), }, - source: const LocalSource(), - subscriptionPrefix: 'app-updates-local', + source: source, + subscriptionPrefix: 'app-updates', ), ); - // Preserve previous categorization during transient loading/error phases - // so the screen doesn't flash back to skeleton or uncataloged state. - final result = switch (appsState) { - StorageLoading() => _lastCategorization ?? - (pollerHasCompleted - ? _buildUncatalogedOnly(installedPackages) - : CategorizedUpdates.empty), - StorageError() => _lastCategorization ?? - const CategorizedUpdates( - automaticUpdates: [], - manualUpdates: [], - upToDateApps: [], - uncatalogedApps: [], - showSkeleton: false, - ), - StorageData(:final models) => _categorize( - models, - installedPackages, - installedIds, - ), - }; - - if (appsState is StorageData) { - _lastCategorization = result; - } - - return result; - } - - /// Build state with all packages as uncataloged (when query hasn't loaded yet - /// but poller completed - means relay has no data for these apps) - CategorizedUpdates _buildUncatalogedOnly(List installedPackages) { - final sorted = installedPackages.toList() - ..sort( - (a, b) => (a.name ?? a.appId).toLowerCase().compareTo( - (b.name ?? b.appId).toLowerCase(), + return switch (appsState) { + // Loading: use cached categorization if available, otherwise skeleton + StorageLoading(:final models) when models.isNotEmpty => + _categorize(models, installedPackages, installedIds), + StorageLoading() => + _lastCategorization ?? CategorizedUpdates.empty, + StorageError() => + _lastCategorization ?? const CategorizedUpdates( + automaticUpdates: [], + manualUpdates: [], + upToDateApps: [], + uncatalogedApps: [], + showSkeleton: false, ), - ); - return CategorizedUpdates( - automaticUpdates: const [], - manualUpdates: const [], - upToDateApps: const [], - uncatalogedApps: sorted, - showSkeleton: false, - ); + StorageData(:final models) => () { + final result = _categorize(models, installedPackages, installedIds); + _lastCategorization = result; + return result; + }(), + }; } CategorizedUpdates _categorize( @@ -389,29 +363,18 @@ class CategorizedUpdatesNotifier extends Notifier { List installedPackages, Set installedIds, ) { + final installedMap = {for (final pkg in installedPackages) pkg.appId: pkg}; + final catalogedAppIds = apps.map((a) => a.identifier).toSet(); + final pm = ref.read(packageManagerProvider.notifier); + final automaticUpdates = []; final manualUpdates = []; final upToDateApps = []; - // Build lookup map from passed-in data - final installedMap = {for (final pkg in installedPackages) pkg.appId: pkg}; + for (final app in apps) { + final pkg = installedMap[app.identifier]; + if (pkg == null) continue; - // Track ALL apps returned from local query as "cataloged" - final catalogedAppIds = apps.map((a) => a.identifier).toSet(); - - // Check if ANY installed app has a match in local DB - // If not, we should show skeleton (cold start state) - final hasAnyMatch = installedIds.any(catalogedAppIds.contains); - - // Only process apps that are actually installed - final installedApps = apps.where( - (a) => installedMap.containsKey(a.identifier), - ); - - final pm = ref.read(packageManagerProvider.notifier); - - for (final app in installedApps) { - final pkg = installedMap[app.identifier]!; final latest = app.installable; final hasUpdate = latest != null && pm.hasUpdate(app.identifier, latest); @@ -422,37 +385,31 @@ class CategorizedUpdatesNotifier extends Notifier { manualUpdates.add(app); } } else { - // No update available - app is up to date upToDateApps.add(app); } } - // Find installed packages without catalog metadata - final uncatalogedApps = - installedPackages - .where((pkg) => !catalogedAppIds.contains(pkg.appId)) - .toList() - ..sort( - (a, b) => (a.name ?? a.appId).toLowerCase().compareTo( - (b.name ?? b.appId).toLowerCase(), - ), - ); - int byName(App a, App b) => (a.name ?? a.identifier) .toLowerCase() .compareTo((b.name ?? b.identifier).toLowerCase()); - automaticUpdates.sort(byName); manualUpdates.sort(byName); upToDateApps.sort(byName); + final uncatalogedApps = installedPackages + .where((pkg) => !catalogedAppIds.contains(pkg.appId)) + .toList() + ..sort( + (a, b) => (a.name ?? a.appId).toLowerCase().compareTo( + (b.name ?? b.appId).toLowerCase(), + ), + ); + return CategorizedUpdates( automaticUpdates: automaticUpdates, manualUpdates: manualUpdates, upToDateApps: upToDateApps, uncatalogedApps: uncatalogedApps, - // Show skeleton only if installed apps exist but NONE match local DB - showSkeleton: installedIds.isNotEmpty && !hasAnyMatch, ); }