Refactor to fix update bug on first launch

This commit is contained in:
franzap
2026-04-04 01:06:07 -03:00
parent aaf2625a4a
commit 63a19b2133
3 changed files with 207 additions and 375 deletions
+120 -269
View File
@@ -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<String> installingAppIds;
final List<App> automaticUpdates;
final List<App> manualUpdates;
final List<App> upToDateApps;
final List<PackageInfo> 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<App> installingApps;
if (installingAppIds.isEmpty) {
if (activeAppIds.isEmpty) {
installingApps = const [];
} else {
final installingAppsState = ref.watch(
query<App>(
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<App> automaticUpdates;
final List<App> manualUpdates;
final List<App> installingApps;
final List<App> upToDateApps;
final List<PackageInfo> 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(<double>[
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<Timer?>(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<App> 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,
@@ -109,6 +109,9 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
/// 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<PackageManagerState> {
@override
void dispose() {
_watchdogTimer?.cancel();
_autoClearTimer?.cancel();
_downloader.unregisterCallbacks();
super.dispose();
}
@@ -285,6 +289,7 @@ abstract class PackageManager extends StateNotifier<PackageManagerState> {
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<PackageManagerState> {
}
/// 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<PackageManagerState> {
_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.
+61 -104
View File
@@ -104,25 +104,18 @@ class UpdatePollerNotifier extends Notifier<UpdatePollerState> {
/// 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<void> 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<UpdatePollerState> {
);
} 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<UpdatePollerState> {
);
// 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 <FileMetadata>[];
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<CategorizedUpdates> {
@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<CategorizedUpdates> {
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<App>(
tags: {
@@ -324,64 +323,39 @@ class CategorizedUpdatesNotifier extends Notifier<CategorizedUpdates> {
'#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<PackageInfo> 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<CategorizedUpdates> {
List<PackageInfo> installedPackages,
Set<String> 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 = <App>[];
final manualUpdates = <App>[];
final upToDateApps = <App>[];
// 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<CategorizedUpdates> {
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,
);
}