MAOA - Make app offline-first again

This commit is contained in:
franzap
2026-04-30 21:43:19 -03:00
parent bd396d17f5
commit cadea402cb
8 changed files with 550 additions and 249 deletions
+44 -11
View File
@@ -319,7 +319,13 @@ class ZapstoreHome extends StatelessWidget {
const _kDefaultAppCatalogRelay = 'wss://relay.zapstore.dev';
final appInitializationProvider = FutureProvider<void>((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<void>((ref) async {
final dir = await getApplicationSupportDirectory();
final dbPath = path.join(dir.path, 'zapstore.db');
@@ -332,12 +338,16 @@ final appInitializationProvider = FutureProvider<void>((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<void>((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<void>((ref) async {
await ref.read(storageReadyProvider.future);
// Initialize device capabilities (used for dynamic download concurrency)
await DeviceCapabilitiesCache.initialize();
@@ -426,7 +444,7 @@ Future<void> _maybeCopySeedDatabase(String dbPath) async {
Future<void> _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<void> _attemptAutoSignIn(Ref ref) async {
}
}
/// Query ContactList after successful sign-in
Future<void> 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<ContactList>(authors: {pubkey}).toRequest(),
source: const RemoteSource(relays: 'social', stream: false),
subscriptionPrefix: 'app-contact-list',
unawaited(
storage
.query(
RequestFilter<ContactList>(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 <ContactList>[];
}),
);
}
+42 -9
View File
@@ -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<AppStack> {
DateTime until,
) async {
final storage = ref.read(storageNotifierProvider.notifier);
final items = await storage.query(
RequestFilter<AppStack>(
tags: _tags,
until: until,
limit: pageSize,
).toRequest(),
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
subscriptionPrefix: 'app-stacks-older',
);
final req = RequestFilter<AppStack>(
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 <AppStack>[]),
);
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 <AppStack>[],
)
.catchError((_) => const <AppStack>[]);
return (items: items, count: items.length);
}
+68 -134
View File
@@ -22,8 +22,6 @@ class DiagnosticsScreen extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedLevel = useState<LogLevel?>(null);
final filterText = useState<String>('');
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<LogEntry> _filterEntries(
List<LogEntry> 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<void> _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<LogLevel>(
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<LogLevel> 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<LogLevel>(
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<LogLevel?> 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),
],
),
);
+8 -4
View File
@@ -22,8 +22,10 @@ class SearchScreen extends HookConsumerWidget {
final searchFocusNode = useFocusNode();
final searchQuery = useState<String>('');
// 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,
),
+73 -16
View File
@@ -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<String> 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<String>? 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<UpdatePollerState> {
List<String> _lastBackedUpIds = [];
void _init() {
ref.listen<AsyncValue<void>>(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<AsyncValue<void>>(storageReadyProvider, (prev, next) {
if (prev is! AsyncData && next is AsyncData) {
_startPolling();
unawaited(_hydrateAndStartPolling());
}
}, fireImmediately: true);
}
Future<void> _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<UpdatePollerState> {
isChecking: false,
lastCheckTime: DateTime.now(),
clearError: true,
hasHydrated: true,
);
unawaited(_backupInstalledApps());
} catch (e, st) {
@@ -175,22 +211,43 @@ class UpdatePollerNotifier extends StateNotifier<UpdatePollerState> {
}
/// 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<void> 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<CategorizedUpdates>((ref) {
packageManagerProvider.select((s) => s.installed),
);
if (pollerState.lastCheckTime == null) {
if (!pollerState.hasHydrated) {
return CategorizedUpdates.empty;
}
+171 -74
View File
@@ -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<Release>(...)` 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<LatestReleasesState> {
LatestReleasesNotifier(this.ref) : super(LatestReleasesState.loading()) {
_subscribe();
@@ -79,35 +99,62 @@ class LatestReleasesNotifier extends StateNotifier<LatestReleasesState> {
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<Release>) {
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<Release>) {
(_, next) {
if (next is StorageError<Release>) {
state = state.copyWith(error: next.exception);
return;
}
_applyFirstPage(next.models);
},
fireImmediately: true,
);
}
void _applyFirstPage(List<Release> 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<void> loadMore() async {
final all = state.allReleases;
if (state.isLoadingMore || !state.hasMore || all.isEmpty) return;
@@ -121,26 +168,73 @@ class LatestReleasesNotifier extends StateNotifier<LatestReleasesState> {
try {
final storage = ref.read(storageNotifierProvider.notifier);
final releases = await storage.query(
RequestFilter<Release>(until: oldest, limit: _kPageSize).toRequest(),
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
subscriptionPrefix: 'app-latest-releases-older',
);
final req =
RequestFilter<Release>(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<Release> 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 <Release>[]),
);
} 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 <Release>[],
)
.catchError((_) => const <Release>[]);
}
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<LatestReleasesState> {
}
}
/// Parallel fetch: assets by e-tag IDs + apps by i-tag identifiers.
/// Returns resolved apps keyed by identifier.
Future<Map<String, App>> _resolveRelated(List<Release> 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<String, App> _resolveAppsFromLocal(
StorageNotifier storage,
Set<String> appIds,
) {
final result = <String, App>{};
for (final id in appIds) {
final matches = storage.querySync(
RequestFilter<App>(
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<Release> 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<SoftwareAsset>(
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<App>(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<App>(
tags: {
'#d': {id},
},
limit: 1,
).toRequest(),
);
}
if (assetIds.isNotEmpty) {
unawaited(
storage.query(
RequestFilter<SoftwareAsset>(ids: assetIds).toRequest(),
source: const LocalAndRemoteSource(
relays: 'AppCatalog',
stream: false,
),
)
.cast<App>()
.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
+1 -1
View File
@@ -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');
@@ -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<Release>`. `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<ContactList>(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<T>(...)` 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<Release>(...)` 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<T>)` 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>` → `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<T>(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.