diff --git a/lib/main.dart b/lib/main.dart index a38243f..a119f3a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -319,7 +319,13 @@ class ZapstoreHome extends StatelessWidget { const _kDefaultAppCatalogRelay = 'wss://relay.zapstore.dev'; -final appInitializationProvider = FutureProvider((ref) async { +/// Ready as soon as local storage is usable. +/// +/// UI skeleton gates MUST depend on this provider — NOT on +/// [appInitializationProvider] — because this phase does not touch the +/// network. See spec/guidelines/INVARIANTS.md → "Local data must be +/// sufficient to render meaningful UI state". +final storageReadyProvider = FutureProvider((ref) async { final dir = await getApplicationSupportDirectory(); final dbPath = path.join(dir.path, 'zapstore.db'); @@ -332,12 +338,16 @@ final appInitializationProvider = FutureProvider((ref) async { // Load local relay config BEFORE storage init // This ensures custom relays work even when signed out final settings = await ref.read(settingsServiceProvider).load(); - final appCatalogRelays = settings.appCatalogRelays ?? {_kDefaultAppCatalogRelay}; + final appCatalogRelays = + settings.appCatalogRelays ?? {_kDefaultAppCatalogRelay}; // Apply persisted log level (default is `debug`). LogService.I.level = settings.logLevel; - // Initialize storage with local relay config + // Initialize storage with local relay config. + // NOTE: `initializationProvider` opens SQLite and starts the worker + // isolate. It does NOT establish relay connections — those happen + // lazily on first query or via notifier.connect(). await ref.read( initializationProvider( StorageConfiguration( @@ -361,6 +371,14 @@ final appInitializationProvider = FutureProvider((ref) async { ), ).future, ); +}); + +/// Full app initialization — runs everything non-UI-critical after +/// [storageReadyProvider] resolves. Includes auto-sign-in (which may +/// trigger cache warm-up queries), so this provider is NOT safe to use +/// as a skeleton gate offline. +final appInitializationProvider = FutureProvider((ref) async { + await ref.read(storageReadyProvider.future); // Initialize device capabilities (used for dynamic download concurrency) await DeviceCapabilitiesCache.initialize(); @@ -426,7 +444,7 @@ Future _maybeCopySeedDatabase(String dbPath) async { Future _attemptAutoSignIn(Ref ref) async { try { await ref.read(amberSignerProvider).attemptAutoSignIn(); - await onSignInSuccess(ref); + onSignInSuccess(ref); } catch (e, st) { // Auto sign-in fails on first install — that's fine, just continue. // Logged at debug because this is expected for new users. @@ -439,19 +457,34 @@ Future _attemptAutoSignIn(Ref ref) async { } } -/// Query ContactList after successful sign-in -Future onSignInSuccess(Ref ref) async { +/// Warm the contact-list cache after successful sign-in. +/// +/// This is a best-effort remote fetch used for stack sorting. It MUST NOT +/// block the init chain — consumers of the contact list are reactive and +/// will re-render when data lands locally. Blocking here gates the UI +/// skeleton on a network round-trip, violating local-first guarantees. +void onSignInSuccess(Ref ref) { final pubkey = ref.read(Signer.activePubkeyProvider); if (pubkey == null) return; final storage = ref.read(storageNotifierProvider.notifier) as PurplebaseStorageNotifier; - // Fetch contact list for stack sorting (await to ensure it's cached) - await storage.query( - RequestFilter(authors: {pubkey}).toRequest(), - source: const RemoteSource(relays: 'social', stream: false), - subscriptionPrefix: 'app-contact-list', + unawaited( + storage + .query( + RequestFilter(authors: {pubkey}).toRequest(), + source: const RemoteSource(relays: 'social', stream: false), + subscriptionPrefix: 'app-contact-list', + ) + .catchError((error, stack) { + LogService.I.debug( + 'contact list warm-up failed', + tag: 'signin', + err: error, + ); + return const []; + }), ); } diff --git a/lib/screens/app_stacks_screen.dart b/lib/screens/app_stacks_screen.dart index 97ef49a..641f3f4 100644 --- a/lib/screens/app_stacks_screen.dart +++ b/lib/screens/app_stacks_screen.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -49,15 +51,46 @@ class StacksNotifier extends PagedSubscriptionNotifier { DateTime until, ) async { final storage = ref.read(storageNotifierProvider.notifier); - final items = await storage.query( - RequestFilter( - tags: _tags, - until: until, - limit: pageSize, - ).toRequest(), - source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), - subscriptionPrefix: 'app-stacks-older', - ); + final req = RequestFilter( + tags: _tags, + until: until, + limit: pageSize, + ).toRequest(); + + // Local-first: show cached older stacks immediately. Offline, this is + // the only path that yields results; online, a background hydrate warms + // the cache for the next scroll. + final local = storage.querySync(req); + if (local.isNotEmpty) { + unawaited( + storage + .query( + req, + source: const LocalAndRemoteSource( + relays: 'AppCatalog', + stream: false, + ), + subscriptionPrefix: 'app-stacks-older', + ) + .catchError((_) => const []), + ); + return (items: local, count: local.length); + } + + // Cold cache: try remote with a short timeout so we don't hang the + // scroll spinner indefinitely. + final items = await storage + .query( + req, + source: + const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), + subscriptionPrefix: 'app-stacks-older', + ) + .timeout( + const Duration(seconds: 5), + onTimeout: () => const [], + ) + .catchError((_) => const []); return (items: items, count: items.length); } diff --git a/lib/screens/diagnostics_screen.dart b/lib/screens/diagnostics_screen.dart index 28d3afd..ab6f126 100644 --- a/lib/screens/diagnostics_screen.dart +++ b/lib/screens/diagnostics_screen.dart @@ -22,8 +22,6 @@ class DiagnosticsScreen extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final selectedLevel = useState(null); - final filterText = useState(''); final tickRefresh = useState(0); // Re-read disk tail every time the screen is opened so the user @@ -36,51 +34,31 @@ class DiagnosticsScreen extends HookConsumerWidget { final ringEntries = LogService.I.ringSnapshot(); final entries = _mergeEntries(ringEntries, tailSnapshot.data ?? const []); - final filtered = _filterEntries( - entries, - level: selectedLevel.value, - query: filterText.value, - ); + final settingsAsync = ref.watch(localSettingsProvider); + final currentLevel = settingsAsync.valueOrNull?.logLevel ?? LogLevel.info; + final filtered = _filterEntries(entries, level: currentLevel); return Scaffold( appBar: AppBar(title: const Text('Diagnostics')), body: Column( children: [ - _LogLevelControl(), - const Divider(height: 1), - _ToolbarRow( + _Toolbar( + level: currentLevel, + onLevelChanged: (level) async { + await ref.read(settingsServiceProvider).update( + (s) => s.copyWith(logLevel: level), + ); + LogService.I.level = level; + ref.invalidate(localSettingsProvider); + }, + onRefresh: () => tickRefresh.value++, onExport: () => _exportLogs(context), onClear: () => _confirmAndClear(context, () { tickRefresh.value++; }), - onRefresh: () => tickRefresh.value++, entryCount: filtered.length, ), const Divider(height: 1), - Padding( - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Row( - children: [ - Expanded( - child: TextField( - onChanged: (v) => filterText.value = v, - decoration: const InputDecoration( - hintText: 'Filter…', - prefixIcon: Icon(Icons.search), - isDense: true, - border: OutlineInputBorder(), - ), - ), - ), - ], - ), - ), - _LevelChips( - selected: selectedLevel.value, - onSelected: (l) => selectedLevel.value = l, - ), - const Divider(height: 1), Expanded( child: filtered.isEmpty ? const _EmptyState() @@ -114,18 +92,11 @@ class DiagnosticsScreen extends HookConsumerWidget { static List _filterEntries( List entries, { - LogLevel? level, - String? query, + required LogLevel level, }) { - final q = (query ?? '').trim().toLowerCase(); - return entries.where((e) { - if (level != null && e.level.index < level.index) return false; - if (q.isEmpty) return true; - return e.msg.toLowerCase().contains(q) || - e.tag.toLowerCase().contains(q) || - (e.err?.toLowerCase().contains(q) ?? false) || - (e.fields?.toString().toLowerCase().contains(q) ?? false); - }).toList(growable: false); + return entries + .where((e) => e.level.index >= level.index) + .toList(growable: false); } Future _exportLogs(BuildContext context) async { @@ -224,61 +195,22 @@ class DiagnosticsScreen extends HookConsumerWidget { // Sub-widgets // ============================================================================= -class _LogLevelControl extends ConsumerWidget { - @override - Widget build(BuildContext context, WidgetRef ref) { - final settingsAsync = ref.watch(localSettingsProvider); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - const Icon(Icons.tune, size: 18), - const SizedBox(width: 8), - const Text('Log level'), - const Spacer(), - settingsAsync.when( - data: (settings) => DropdownButton( - value: settings.logLevel, - underline: const SizedBox.shrink(), - items: const [ - DropdownMenuItem( - value: LogLevel.debug, child: Text('Debug (verbose)')), - DropdownMenuItem(value: LogLevel.info, child: Text('Info')), - DropdownMenuItem(value: LogLevel.warn, child: Text('Warn')), - ], - onChanged: (level) async { - if (level == null) return; - await ref.read(settingsServiceProvider).update( - (s) => s.copyWith(logLevel: level), - ); - LogService.I.level = level; - ref.invalidate(localSettingsProvider); - }, - ), - loading: () => const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(strokeWidth: 2), - ), - error: (_, __) => const Text('—'), - ), - ], - ), - ); - } -} - -class _ToolbarRow extends StatelessWidget { - const _ToolbarRow({ +/// Single compact row containing level filter + actions + entry count. +class _Toolbar extends StatelessWidget { + const _Toolbar({ + required this.level, + required this.onLevelChanged, + required this.onRefresh, required this.onExport, required this.onClear, - required this.onRefresh, required this.entryCount, }); + final LogLevel level; + final ValueChanged onLevelChanged; + final VoidCallback onRefresh; final VoidCallback onExport; final VoidCallback onClear; - final VoidCallback onRefresh; final int entryCount; @override @@ -287,61 +219,63 @@ class _ToolbarRow extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Row( children: [ + DropdownButtonHideUnderline( + child: DropdownButton( + value: level, + isDense: true, + items: const [ + DropdownMenuItem( + value: LogLevel.debug, + child: Text('Debug'), + ), + DropdownMenuItem( + value: LogLevel.info, + child: Text('Info'), + ), + DropdownMenuItem( + value: LogLevel.warn, + child: Text('Warn'), + ), + ], + onChanged: (v) { + if (v != null) onLevelChanged(v); + }, + ), + ), + const Spacer(), + Text( + '$entryCount', + style: Theme.of(context).textTheme.labelSmall, + ), IconButton( tooltip: 'Refresh', onPressed: onRefresh, + iconSize: 18, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.all(6), + constraints: const BoxConstraints(), icon: const Icon(Icons.refresh), ), + const SizedBox(width: 4), IconButton( tooltip: 'Export logs', onPressed: onExport, + iconSize: 18, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.all(6), + constraints: const BoxConstraints(), icon: const Icon(Icons.ios_share), ), + const SizedBox(width: 4), IconButton( tooltip: 'Clear logs', onPressed: onClear, + iconSize: 18, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.all(6), + constraints: const BoxConstraints(), icon: const Icon(Icons.delete_outline), ), - const Spacer(), - Text('$entryCount entries', - style: Theme.of(context).textTheme.labelSmall), - const SizedBox(width: 8), - ], - ), - ); - } -} - -class _LevelChips extends StatelessWidget { - const _LevelChips({required this.selected, required this.onSelected}); - - final LogLevel? selected; - final ValueChanged onSelected; - - @override - Widget build(BuildContext context) { - Widget chip(String label, LogLevel? value) { - final isSelected = selected == value; - return Padding( - padding: const EdgeInsets.only(right: 6), - child: FilterChip( - label: Text(label), - selected: isSelected, - onSelected: (_) => onSelected(value), - ), - ); - } - - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - child: Row( - children: [ - chip('All', null), - chip('Debug+', LogLevel.debug), - chip('Info+', LogLevel.info), - chip('Warn+', LogLevel.warn), - chip('Error+', LogLevel.error), ], ), ); diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index d9f5fd3..eb1a904 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -22,8 +22,10 @@ class SearchScreen extends HookConsumerWidget { final searchFocusNode = useFocusNode(); final searchQuery = useState(''); - // Check if storage is initialized - final initState = ref.watch(appInitializationProvider); + // Skeleton unlocks as soon as local storage is usable. Gating on + // `appInitializationProvider` would wait on the full init chain + // (including network warm-ups), which violates local-first. + final storageState = ref.watch(storageReadyProvider); // Get platform from package manager final platform = ref.read(packageManagerProvider.notifier).platform; @@ -121,13 +123,15 @@ class SearchScreen extends HookConsumerWidget { Padding( padding: const EdgeInsets.only(bottom: 14), child: AppStackContainer( - showSkeleton: !(initState.hasValue || initState.hasError), + showSkeleton: + !(storageState.hasValue || storageState.hasError), ), ), // Latest Releases Container LatestReleasesContainer( - showSkeleton: !(initState.hasValue || initState.hasError), + showSkeleton: + !(storageState.hasValue || storageState.hasError), scrollController: scrollController, ), diff --git a/lib/services/updates_service.dart b/lib/services/updates_service.dart index 21f4bf2..edb8442 100644 --- a/lib/services/updates_service.dart +++ b/lib/services/updates_service.dart @@ -55,6 +55,7 @@ class UpdatePollerState { this.lastCheckTime, this.lastError, this.catalogedIds = const {}, + this.hasHydrated = false, }); final bool isChecking; @@ -65,18 +66,26 @@ class UpdatePollerState { /// to know which installed apps to query (with relationships) from local DB. final Set catalogedIds; + /// True once the local DB has been scanned at least once (via + /// [UpdatePollerNotifier.refreshFromLocal] or a successful remote check). + /// UI gates its skeleton on this — NOT on [lastCheckTime] — so the list + /// renders from local data without waiting on the network. + final bool hasHydrated; + UpdatePollerState copyWith({ bool? isChecking, DateTime? lastCheckTime, String? lastError, bool clearError = false, Set? catalogedIds, + bool? hasHydrated, }) { return UpdatePollerState( isChecking: isChecking ?? this.isChecking, lastCheckTime: lastCheckTime ?? this.lastCheckTime, lastError: clearError ? null : (lastError ?? this.lastError), catalogedIds: catalogedIds ?? this.catalogedIds, + hasHydrated: hasHydrated ?? this.hasHydrated, ); } } @@ -95,17 +104,43 @@ class UpdatePollerNotifier extends StateNotifier { List _lastBackedUpIds = []; void _init() { - ref.listen>(appInitializationProvider, (prev, next) { + // Hydrate from local storage as soon as SQLite is ready — no network. + // This populates `catalogedIds` and flips `hasHydrated`, unblocking the + // Updates screen UI without waiting on any remote poll. + ref.listen>(storageReadyProvider, (prev, next) { if (prev is! AsyncData && next is AsyncData) { - _startPolling(); + unawaited(_hydrateAndStartPolling()); } }, fireImmediately: true); } + Future _hydrateAndStartPolling() async { + // Ensure we know what's installed before categorizing. `syncInstalledPackages` + // is a local native call — safe offline. + try { + await ref.read(packageManagerProvider.notifier).syncInstalledPackages(); + } catch (e, st) { + LogService.I.debug( + 'initial package sync failed', + tag: 'updates', + err: e, + stack: st, + ); + } + + // Local-only hydration. Sets `hasHydrated: true` so the UI renders. + await refreshFromLocal(); + + _startPolling(); + } + void _startPolling() { _pollTimer?.cancel(); _pollTimer = Timer.periodic(_pollInterval, (_) => checkNow()); - checkNow(); + // Fire-and-forget: a remote check blocks for tens of seconds offline, + // but must not delay the local-first UI render that [refreshFromLocal] + // already produced. + unawaited(checkNow()); } /// Trigger an update check. Called by timer and pull-to-refresh. @@ -126,6 +161,7 @@ class UpdatePollerNotifier extends StateNotifier { isChecking: false, lastCheckTime: DateTime.now(), clearError: true, + hasHydrated: true, ); unawaited(_backupInstalledApps()); } catch (e, st) { @@ -175,22 +211,43 @@ class UpdatePollerNotifier extends StateNotifier { } /// Re-derive catalog IDs from local DB without hitting relays. - /// Call when returning to the updates screen so that data written by - /// other code paths (detail screen, background service) is picked up - /// without waiting for the next poll cycle. + /// Called on startup (offline-safe) and when returning to the updates + /// screen so that data written by other code paths (detail screen, + /// background service) is picked up without waiting for a poll cycle. + /// + /// Always sets `hasHydrated: true` — even when the installed set is empty + /// — so the UI can exit its skeleton state. Future refreshFromLocal() async { final pmState = ref.read(packageManagerProvider); - if (pmState.installed.isEmpty) return; - final result = await fetchCatalog( - storage: ref.read(storageNotifierProvider.notifier), - installedIds: pmState.installed.keys.toSet(), - platform: ref.read(packageManagerProvider.notifier).platform, - subscriptionPrefix: 'app-updates-local', - localOnly: true, - ); + if (pmState.installed.isEmpty) { + state = state.copyWith(hasHydrated: true); + return; + } - state = state.copyWith(catalogedIds: result.catalogedIds); + try { + final result = await fetchCatalog( + storage: ref.read(storageNotifierProvider.notifier), + installedIds: pmState.installed.keys.toSet(), + platform: ref.read(packageManagerProvider.notifier).platform, + subscriptionPrefix: 'app-updates-local', + localOnly: true, + ); + state = state.copyWith( + catalogedIds: result.catalogedIds, + hasHydrated: true, + ); + } catch (e, st) { + LogService.I.warn( + 'local refresh failed', + tag: 'updates', + err: e, + stack: st, + ); + // Still flip hasHydrated so the UI doesn't stay on skeleton forever; + // categorization falls through to "uncataloged". + state = state.copyWith(hasHydrated: true); + } } /// Best-effort backup of installed apps as an encrypted private stack. @@ -283,7 +340,7 @@ final categorizedUpdatesProvider = Provider((ref) { packageManagerProvider.select((s) => s.installed), ); - if (pollerState.lastCheckTime == null) { + if (!pollerState.hasHydrated) { return CategorizedUpdates.empty; } diff --git a/lib/widgets/latest_releases_container.dart b/lib/widgets/latest_releases_container.dart index f50f334..d5073c1 100644 --- a/lib/widgets/latest_releases_container.dart +++ b/lib/widgets/latest_releases_container.dart @@ -1,9 +1,10 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; import 'package:zapstore/services/updates_service.dart'; -import 'package:zapstore/utils/app_query.dart'; import 'package:zapstore/utils/extensions.dart'; import 'app_card.dart'; @@ -64,6 +65,25 @@ class LatestReleasesState { // Notifier // --------------------------------------------------------------------------- +/// Local-first, reactive notifier for the "Latest Releases" home section. +/// +/// Design: +/// - The outer `query(...)` subscription carries an `and:` chain that +/// loads `release.app` (+ nested `app.author`) and `release.softwareAssets` +/// in the background via `NestedQueryManager`. Relationship queries are +/// fire-and-forget; they NEVER block the state update. +/// - The listener reacts to every emission — `StorageLoading(localReleases)` +/// during `awaitingRemote` and `StorageData` once EOSE lands. Local Releases +/// are displayed within one frame regardless of network state. +/// - `appsByIdentifier` is computed synchronously from local storage +/// (`storage.querySync`) on every emission. As the `and:` relationship +/// queries populate storage in the background, subsequent emissions pick +/// up the newly-available Apps. +/// +/// INVARIANTS (see spec/guidelines/INVARIANTS.md): +/// - Local data renders immediately; no await on any network path. +/// - Remote failures degrade gracefully; rows without a resolved App are +/// simply skipped by the consumer widget. class LatestReleasesNotifier extends StateNotifier { LatestReleasesNotifier(this.ref) : super(LatestReleasesState.loading()) { _subscribe(); @@ -79,35 +99,62 @@ class LatestReleasesNotifier extends StateNotifier { limit: _kPageSize, source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true), subscriptionPrefix: 'app-latest-releases', + and: (release) => { + release.app.query( + source: const LocalAndRemoteSource( + relays: 'AppCatalog', + stream: false, + ), + and: (app) => { + app.author.query( + source: const LocalAndRemoteSource( + relays: {'vertex', 'social'}, + cachedFor: Duration(hours: 2), + ), + ), + }, + ), + release.softwareAssets.query( + source: const LocalAndRemoteSource( + relays: 'AppCatalog', + stream: false, + ), + ), + }, ), - (_, next) async { - if (next is StorageData) { - final liveIds = next.models.map((r) => r.id).toSet(); - final filteredOlder = state.olderPages - .where((r) => !liveIds.contains(r.id)) - .toList(); - final unresolved = next.models - .where( - (r) => !state.appsByIdentifier.containsKey(r.appIdentifier), - ) - .toList(); - final apps = await _resolveRelated(unresolved); - if (mounted) { - state = state.copyWith( - firstPage: next.models, - olderPages: filteredOlder, - appsByIdentifier: {...state.appsByIdentifier, ...apps}, - error: null, - ); - } - } else if (next is StorageError) { + (_, next) { + if (next is StorageError) { state = state.copyWith(error: next.exception); + return; } + _applyFirstPage(next.models); }, fireImmediately: true, ); } + void _applyFirstPage(List releases) { + final storage = ref.read(storageNotifierProvider.notifier); + final liveIds = releases.map((r) => r.id).toSet(); + final filteredOlder = state.olderPages + .where((r) => !liveIds.contains(r.id)) + .toList(); + + final keepIds = { + ...releases.map((r) => r.appIdentifier), + ...filteredOlder.map((r) => r.appIdentifier), + }..removeWhere((id) => id.isEmpty); + + final appsByIdentifier = _resolveAppsFromLocal(storage, keepIds); + + state = state.copyWith( + firstPage: releases, + olderPages: filteredOlder, + appsByIdentifier: appsByIdentifier, + error: null, + ); + } + Future loadMore() async { final all = state.allReleases; if (state.isLoadingMore || !state.hasMore || all.isEmpty) return; @@ -121,26 +168,73 @@ class LatestReleasesNotifier extends StateNotifier { try { final storage = ref.read(storageNotifierProvider.notifier); - final releases = await storage.query( - RequestFilter(until: oldest, limit: _kPageSize).toRequest(), - source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false), - subscriptionPrefix: 'app-latest-releases-older', - ); + final req = + RequestFilter(until: oldest, limit: _kPageSize).toRequest(); + + // Local-first: read whatever is already cached, so offline scroll + // shows older items immediately without waiting on the relay. + final localReleases = storage.querySync(req); + + List releases; + if (localReleases.isNotEmpty) { + releases = localReleases; + // Background hydrate: bring in any older items the relay has that + // aren't cached yet. Results land via the live subscription's + // general storage-update path; next scroll picks them up. + unawaited( + storage + .query( + req, + source: const LocalAndRemoteSource( + relays: 'AppCatalog', + stream: false, + ), + subscriptionPrefix: 'app-latest-releases-older', + ) + .catchError((_) => const []), + ); + } else { + // Nothing local. Try remote with a short timeout; fall back to empty. + releases = await storage + .query( + req, + source: const LocalAndRemoteSource( + relays: 'AppCatalog', + stream: false, + ), + subscriptionPrefix: 'app-latest-releases-older', + ) + .timeout( + const Duration(seconds: 5), + onTimeout: () => const [], + ) + .catchError((_) => const []); + } if (releases.isEmpty) { state = state.copyWith(isLoadingMore: false, hasMore: false); return; } - final apps = await _resolveRelated(releases); - final existingIds = all.map((r) => r.id).toSet(); final unique = releases .where((r) => !existingIds.contains(r.id)) .toList(); + + // Fire relationship queries for the older page in the background. + // The first-page subscription's general-update path will refresh + // `appsByIdentifier` as apps/authors/assets land in storage. + _hydrateOlderPageRelationships(unique); + + final keepIds = { + ...state.firstPage.map((r) => r.appIdentifier), + ...state.olderPages.map((r) => r.appIdentifier), + ...unique.map((r) => r.appIdentifier), + }..removeWhere((id) => id.isEmpty); + state = state.copyWith( olderPages: [...state.olderPages, ...unique], - appsByIdentifier: {...state.appsByIdentifier, ...apps}, + appsByIdentifier: _resolveAppsFromLocal(storage, keepIds), isLoadingMore: false, hasMore: releases.length >= _kPageSize, ); @@ -149,65 +243,68 @@ class LatestReleasesNotifier extends StateNotifier { } } - /// Parallel fetch: assets by e-tag IDs + apps by i-tag identifiers. - /// Returns resolved apps keyed by identifier. - Future> _resolveRelated(List releases) async { - if (releases.isEmpty) return const {}; + /// Synchronously map app identifiers → App using local storage only. + /// Never awaits network; returns an empty entry for ids not yet in cache. + Map _resolveAppsFromLocal( + StorageNotifier storage, + Set appIds, + ) { + final result = {}; + for (final id in appIds) { + final matches = storage.querySync( + RequestFilter( + tags: { + '#d': {id}, + }, + limit: 1, + ).toRequest(), + ); + if (matches.isNotEmpty) { + result[id] = matches.first; + } + } + return result; + } + + /// Fire-and-forget relationship hydration for older-page Releases. + /// Results land in local storage and trigger a refresh via the outer + /// subscription's `handleStorageUpdate`. + void _hydrateOlderPageRelationships(List releases) { + if (releases.isEmpty) return; final storage = ref.read(storageNotifierProvider.notifier); - final assetIds = releases - .expand((r) => r.event.getTagSetValues('e')) - .toSet(); final appIds = releases .map((r) => r.appIdentifier) .where((id) => id.isNotEmpty) .toSet(); + final assetIds = releases + .expand((r) => r.event.getTagSetValues('e')) + .toSet(); - await Future.wait([ - if (assetIds.isNotEmpty) - storage.query( - RequestFilter( - ids: assetIds, - tags: { - '#f': {'android-arm64-v8a'}, - }, - ).toRequest(), - source: const LocalAndRemoteSource( - relays: 'AppCatalog', - stream: false, - ), - subscriptionPrefix: 'app-latest-releases-assets', - ), - if (appIds.isNotEmpty) + if (appIds.isNotEmpty) { + unawaited( storage.query( RequestFilter(tags: {'#d': appIds}).toRequest(), source: const LocalAndRemoteSource( relays: 'AppCatalog', stream: false, ), - subscriptionPrefix: 'app-latest-releases-apps', + subscriptionPrefix: 'app-latest-releases-older-apps', ), - ]); - - if (appIds.isEmpty) return const {}; - - final apps = appIds - .expand( - (id) => storage.querySync( - RequestFilter( - tags: { - '#d': {id}, - }, - limit: 1, - ).toRequest(), + ); + } + if (assetIds.isNotEmpty) { + unawaited( + storage.query( + RequestFilter(ids: assetIds).toRequest(), + source: const LocalAndRemoteSource( + relays: 'AppCatalog', + stream: false, ), - ) - .cast() - .toList(); - '${apps.map((a) => a.identifier).toList()}'; - await loadAuthors(storage, apps, 'app-latest-releases-authors'); - - return {for (final app in apps) app.identifier: app}; + subscriptionPrefix: 'app-latest-releases-older-assets', + ), + ); + } } @override diff --git a/lib/widgets/sign_in_button.dart b/lib/widgets/sign_in_button.dart index 39c028d..589fdaa 100644 --- a/lib/widgets/sign_in_button.dart +++ b/lib/widgets/sign_in_button.dart @@ -40,7 +40,7 @@ class SignInButton extends ConsumerWidget { } else { try { await ref.read(amberSignerProvider).signIn(); - await onSignInSuccess(ref.read(refProvider)); + onSignInSuccess(ref.read(refProvider)); } catch (e) { if (context.mounted) { context.showError('Sign-in failed', technicalDetails: '$e'); diff --git a/spec/work/WORK-009-offline-first-latest-releases.md b/spec/work/WORK-009-offline-first-latest-releases.md new file mode 100644 index 0000000..a543109 --- /dev/null +++ b/spec/work/WORK-009-offline-first-latest-releases.md @@ -0,0 +1,143 @@ +# WORK-009 — Offline-First Home Screen + +**Feature:** (bug fix — restores INVARIANTS.md: local-first + no UI gating on network) +**Status:** In Progress + +## Context + +The home screen (Latest Releases, App Stacks) stays on skeletons for 1+ minute when offline, even though all data is already in SQLite. + +### First pass — fixed a real bug, but not the one the user saw + +Initial diagnosis focused on `LatestReleasesNotifier` in `lib/widgets/latest_releases_container.dart`: + +1. The first-page listener only reacted to `StorageData`. `RequestNotifier` delivers local-first data as `StorageLoading(localModels)` during the `awaitingRemote` phase (see `request_notifier.dart` `_emit`: `StorageLoading` during `initializing`/`awaitingRemote`, `StorageData` only after EOSE or `responseTimeout`). Local Releases sat unconsumed. +2. The listener then `await`ed `_resolveRelated(...)` which ran three imperative `storage.query(..., LocalAndRemoteSource(stream: false))` calls. Imperative `storage.query` with `LocalAndRemoteSource` blocks on `RemoteQueryOp` before returning any local data (see `purplebase_storage.dart` line ~254). Each call waited the absolute EOSE timeout. + +Both real. Both fixed (see tasks below). **Neither was the dominant cause** of the user-visible hang. + +### Second pass — actual root cause + +The containers are hard-gated in `search_screen.dart`: + +```dart +AppStackContainer( + showSkeleton: !(initState.hasValue || initState.hasError), +), +LatestReleasesContainer( + showSkeleton: !(initState.hasValue || initState.hasError), + ... +), +``` + +where `initState = ref.watch(appInitializationProvider)`. That provider awaits the full init chain — including `_attemptAutoSignIn` → `onSignInSuccess`, which did: + +```dart +await storage.query( + RequestFilter(authors: {pubkey}).toRequest(), + source: const RemoteSource(relays: 'social', stream: false), + subscriptionPrefix: 'app-contact-list', +); +``` + +Offline, `RemoteSource(stream: false)` blocks on EOSE per relay: up to 5 s connect × per-relay retries × 3 social relays, plus the `eoseTimeoutSingleRelay` / `eoseTimeout` windows (30 s / 15 s) from purplebase's `PoolConfiguration`, plus reconnect backoff. That matches the observed "1+ minute" hang exactly. + +While `showSkeleton` is true, the notifier providers aren't even watched — so the first-pass fix had no user-visible effect because the notifier never ran. + +## Decision — no model/purplebase primitives needed + +`LocalAndRemoteSource` + the `and:` callback on `query(...)` already implement local-first with background relationship resolution (`NestedQueryManager._executeNestedQuery` fires relationships via `_queryBuffer.bufferQuery(...).then(...)`, never awaited on the emission path). The fix is to stop reinventing this at the widget layer and use the primitive as intended. + +See `spec/knowledge/` (to be promoted after merge): guidance that multi-hop loads MUST be expressed via `and:` on an outer reactive query, not via imperative `storage.query` chains. + +## Tasks + +- [x] 1. Rewrite `LatestReleasesNotifier._subscribe` to: + - Add `and:` on outer `query(...)` that pulls `release.app` (+ nested `app.author`) and `release.softwareAssets`. + - React to every state emission (local data carried by both `StorageLoading(models)` and `StorageData(models)`). Only `StorageError` short-circuits. + - Compute `appsByIdentifier` via `storage.querySync` — no `await` on any network path. + - Delete `_resolveRelated` entirely. +- [x] 2. Rewrite `loadMore`: + - Keep imperative `storage.query` for older Releases (user-initiated, acceptable to await). + - Drop the imperative `_resolveRelated`. Fire a non-blocking relationship fetch for the older page (`unawaited`) — the first-page listener's general-update path will refresh `appsByIdentifier` as Apps land. + - Resolve whatever is already local via `storage.querySync` before returning. +- [x] 3. Audit other notifiers for the same `if (next is StorageData)` footgun. + - `PagedSubscriptionNotifier.updateFirstPage` — already OK (copies `firstPage: next` in the else branch, widgets use `.combined`/`.models`). + - `app_stacks_screen.dart`, `profile_screen.dart`, `app_detail_screen.dart` — reviewed, use `StorageLoading && models.isEmpty` idiom correctly. + - `updates_service.dart` imperative `storage.query` is on `LocalSource`, so not a network-blocking path. +- [x] 4. Self-review against INVARIANTS.md — clean (UI safety, async discipline, local-first guarantees all upheld). +- [x] 5. `fvm flutter analyze` clean. +- [x] 6. `fvm flutter test` — existing suite still passes. +- [x] 7. **Split initialization** to decouple local-first UI from network warm-ups. + - Added `storageReadyProvider` (`lib/main.dart`): returns as soon as SQLite + worker isolate are ready. Zero network dependencies. + - `appInitializationProvider` now `await ref.read(storageReadyProvider.future)` first, then continues with device capabilities, package sync, deep links, auto-sign-in. + - `search_screen.dart` now gates the skeleton on `storageReadyProvider` instead of `appInitializationProvider`. Other consumers of `appInitializationProvider` (updates polling in `updates_service.dart`; error overlay in `main.dart`) keep the original gate — they legitimately want the full init done. +- [x] 8. **De-block `onSignInSuccess`** — the contact-list fetch is now `unawaited` + `catchError`. It was a cache warm-up, never a gate. Consumers of `ContactList` read it reactively via storage queries and re-render when it lands. + - Signature changed from `Future` → `void`. Call sites updated (`main.dart`, `widgets/sign_in_button.dart`). +- [x] 9. **Updates screen: decouple display from polling** (`lib/services/updates_service.dart`). + - Added `UpdatePollerState.hasHydrated`. The categorizer now gates on `hasHydrated` instead of `lastCheckTime == null`. + - `_init` switched from `appInitializationProvider` to `storageReadyProvider`. On storage-ready, the poller runs `refreshFromLocal()` first (local-only, offline-safe) which flips `hasHydrated: true`. UI unblocks immediately. + - `_startPolling` now fires the remote `checkNow()` as `unawaited` — polling is purely background; it no longer gates the render. Offline, the "Checking for updates..." indicator spins at the top, but the list of apps renders from local data. + - `refreshFromLocal()` now always sets `hasHydrated: true`, even when installed set is empty or a local read fails, so the UI never gets stuck on skeleton. +- [x] 10. **Infinite scroll offline** for Latest Releases and App Stacks. + - `LatestReleasesNotifier.loadMore` and `StacksNotifier.fetchOlderPage` now read local via `storage.querySync` first. If local has results, those are used immediately and a background `LocalAndRemoteSource(stream: false)` fetch warms the cache for the next page. If cold-cache, the remote fetch is tried with a 5 s timeout and falls back to empty — so offline scroll fails fast instead of hanging indefinitely. + +## Test Coverage + +| Scenario | Expected | Status | +|----------|----------|--------| +| Offline, local Releases present | First page renders within one frame; no await on network | [ ] manual | +| Offline, no local Releases | Skeleton stays until relationship fetch resolves/fails; no hard error | [ ] manual | +| Online, cold cache | Same render timing as before (no regression) | [ ] manual | +| `loadMore` while offline | Older page attempt fails fast; first page remains rendered | [ ] manual | + +**Automated regression test:** deferred. A meaningful test requires simulating a blocking relay that never sends EOSE. `DummyStorageNotifier` does not model relay-blocking — its `LocalAndRemoteSource` returns immediately, which makes the old code appear to work offline in tests. Proper coverage belongs in `purplebase`'s integration tests (against its stub relay) or in a Patrol integration test that toggles airplane mode. Tracked here so it isn't silently skipped. + +Code review evidence that the invariant now holds: +- No `await` on any `storage.query(..., RemoteSource)` inside the state-update path of the first-page listener (`_applyFirstPage` is synchronous). +- All relationship loading goes through `and:` → `NestedQueryManager._executeNestedQuery` which uses `_queryBuffer.bufferQuery(...).then(...)` — non-blocking. +- `loadMore`'s imperative remote `storage.query` remains, but is user-initiated and wrapped in try/catch; failure resets `isLoadingMore` without affecting `firstPage`. + +## Decisions + +### 2026-04-30 — Keep Release-first (don't restore asset-first) + +**Context:** WORK-007 (FEAT-004) specified asset-first queries for the home screen; subsequent commits ("Refactored latest releases container, basic again" → 6bb9c7f) reverted `LatestReleasesNotifier` to Release-first. Restoring asset-first is a larger change and out of scope for this fix. +**Decision:** Keep current Release-first query shape; fix only the blocking/guard bugs. +**Rationale:** Smallest change that restores the invariant. FEAT-004 can be revisited separately. + +### 2026-04-30 — Don't change models/purplebase + +**Context:** We considered whether `LocalAndRemoteSource` needs new semantics (e.g., imperative `storage.query` returning local-first). There are two separate improvements worth considering, but neither is required for this fix: +1. Rename `StorageLoading(models)` to something like `StorageFresh`/`StoragePartial` so consumers can't accidentally gate on `is StorageData`. +2. Make imperative `storage.query(..., LocalAndRemoteSource)` return local immediately and fire remote in the background. + +**Decision:** Defer. File as follow-ups. +**Rationale:** The reactive path (`ref.watch(query(and:))`) already implements local-first correctly. The app-layer fix restores the invariant without touching model semantics. + +## Spec Issues + +_None_ + +## Progress Notes + +**2026-04-30 (1):** Diagnosed, then rewrote `LatestReleasesNotifier` to use `and:` + local-synchronous app resolution. Dropped `_resolveRelated`. Missed the dominant root cause — the skeleton gate was on the full init chain, so the notifier was never constructed until the contact-list remote query timed out. + +**2026-04-30 (2):** Second pass. Split `appInitializationProvider` into `storageReadyProvider` (pre-network, used by UI skeletons) and `appInitializationProvider` (full chain). Made `onSignInSuccess` non-blocking. Home screen now renders local data as soon as SQLite is open, regardless of network state. + +**2026-04-30 (3):** Third pass, same class of bug in two more places: +- **Updates screen** was gated on `pollerState.lastCheckTime == null`, which only flips after a successful remote poll. Offline, that never happens for 1+ minute (three social relays + AppCatalog timeouts). Replaced with `hasHydrated` flag flipped by a local-only `refreshFromLocal()` called on `storageReadyProvider`. Remote polling is now `unawaited` — purely a background cache refresh. +- **Infinite scroll** on Latest Releases / App Stacks used imperative `storage.query(..., LocalAndRemoteSource(stream: false))` which blocks on `RemoteQueryOp` before returning local data. Offline this hung the spinner forever. Changed to local-first via `querySync` with a background hydrate, or — for cold cache — a remote fetch with a 5 s timeout and empty fallback. + +## Lesson + +Three passes, same pattern: **an awaited remote call was sitting in the render path**. In each case the "gate" had a sensible justification at the time (don't show stale data, don't render before storage is open, don't paginate without a relay answer), but each gate violated the offline-first invariant by making the UI wait on the network. + +Generalizable rules: +- If a widget uses `showSkeleton: !(someProvider.hasValue || someProvider.hasError)`, audit *every awaited call* in that provider. Any remote `storage.query` — even `LocalAndRemoteSource(stream: false)` — is a hang offline. +- Imperative `storage.query(..., LocalAndRemoteSource(stream: false))` is not local-first. It awaits remote first, then reads local. Use `storage.querySync` up front when rendering, and fire the `LocalAndRemoteSource` version as a background hydrate. +- "First successful remote poll" is not a safe precondition for rendering. If the UI needs to categorize or paginate, derive from local state and let the remote enhance asynchronously. + +## On Merge + +Delete this work packet. Promote the "multi-hop loads MUST use `and:`" guidance to `spec/knowledge/DEC-XXX-relationship-queries.md` if no existing knowledge entry covers it.