mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
Refactor to fix update bug on first launch
This commit is contained in:
+120
-269
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user