mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 12:38:24 +00:00
MAOA - Make app offline-first again
This commit is contained in:
+44
-11
@@ -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>[];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user