Fix saved apps section stuck in loading or disappearing - #283

- Show "No saved apps yet" empty state instead of infinite loading
- Simplify
- Centralize empty state logic in _SavedAppsList
- Fix SizedBox.shrink() causing section to disappear when apps not in local cache
This commit is contained in:
Henrique Velloso
2026-01-23 19:30:54 -03:00
parent 3ccb1b70ac
commit cdc3bfdc35
+95 -124
View File
@@ -1480,80 +1480,111 @@ class _SavedAppsHeading extends ConsumerWidget {
return const SizedBox.shrink();
}
final savedAppsAsync = ref.watch(bookmarksProvider);
return savedAppsAsync.when(
loading: () => const SizedBox.shrink(),
error: (_, __) => const SizedBox.shrink(),
data: (addressableIds) {
if (addressableIds.isEmpty) {
return const SizedBox.shrink();
}
final identifiers = addressableIds
.map((id) {
final parts = id.split(':');
return parts.length >= 3 ? parts[2] : null;
})
.whereType<String>()
.toSet();
if (identifiers.isEmpty) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text('Saved Apps', style: context.textTheme.headlineSmall),
);
},
// Always show heading when signed in
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text('Saved Apps', style: context.textTheme.headlineSmall),
);
}
}
class _SavedAppsSection extends HookConsumerWidget {
class _SavedAppsSection extends ConsumerWidget {
const _SavedAppsSection();
@override
Widget build(BuildContext context, WidgetRef ref) {
final signedInPubkey = ref.watch(Signer.activePubkeyProvider);
final hasLoadedOnce = useState(false);
if (signedInPubkey == null) {
return const SizedBox.shrink();
}
if (signedInPubkey == null) return const SizedBox.shrink();
final savedAppsAsync = ref.watch(bookmarksProvider);
return savedAppsAsync.when(
loading: () => _savedAppsLoadingCard(context),
error: (_, __) => const SizedBox.shrink(),
data: (addressableIds) {
if (addressableIds.isEmpty) {
return const SizedBox.shrink();
}
// Keep previous value during refresh, if available.
final addressableIds = savedAppsAsync.valueOrNull;
final identifiers = addressableIds
.map((id) {
final parts = id.split(':');
return parts.length >= 3 ? parts[2] : null;
})
.whereType<String>()
.toSet();
// Show loading only on first load (when no value exists yet).
if (addressableIds == null) {
return _savedAppsLoadingCard(context);
}
if (identifiers.isEmpty) {
return const SizedBox.shrink();
}
final identifiers = _toIdentifiers(addressableIds);
return _SavedAppsList(identifiers: identifiers);
}
return _SavedAppsList(
identifiers: identifiers,
hasLoadedOnce: hasLoadedOnce,
);
},
Set<String> _toIdentifiers(Set<String> addressableIds) {
return addressableIds
.map((id) => id.split(':'))
.where((parts) => parts.length >= 3)
.map((parts) => parts[2])
.toSet();
}
Widget _savedAppsLoadingCard(BuildContext context) => Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: CircularProgressIndicator(
color: Theme.of(context).colorScheme.primary,
),
),
),
);
}
class _SavedAppsList extends ConsumerWidget {
const _SavedAppsList({required this.identifiers});
final Set<String> identifiers;
@override
Widget build(BuildContext context, WidgetRef ref) {
// No bookmarks saved - show empty state without querying
if (identifiers.isEmpty) {
return _emptyState(context);
}
final savedAppsState = ref.watch(
query<App>(
tags: {'#d': identifiers},
and: (app) => {app.latestRelease.query()},
source: const LocalSource(),
subscriptionPrefix: 'profile-saved-apps',
),
);
final isLoading = savedAppsState is StorageLoading;
final savedApps = savedAppsState.models.toList()
..sort(
(a, b) => (a.name ?? a.identifier).toLowerCase().compareTo(
(b.name ?? b.identifier).toLowerCase(),
),
);
// Show spinner only when we truly have nothing to render yet
// If we're refreshing but still have models, keep showing the list
if (isLoading && savedApps.isEmpty) {
return _loadingCard(context);
}
if (savedApps.isEmpty) {
return _emptyState(context);
}
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final app in savedApps)
AppCard(app: app, showUpdateArrow: false, showDescription: false),
],
),
),
);
}
Widget _savedAppsLoadingCard(BuildContext context) {
Widget _loadingCard(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -1565,75 +1596,15 @@ class _SavedAppsSection extends HookConsumerWidget {
),
);
}
}
class _SavedAppsList extends ConsumerWidget {
const _SavedAppsList({
required this.identifiers,
required this.hasLoadedOnce,
});
final Set<String> identifiers;
final ValueNotifier<bool> hasLoadedOnce;
@override
Widget build(BuildContext context, WidgetRef ref) {
final savedAppsState = ref.watch(
query<App>(
tags: {'#d': identifiers},
and: (app) => {app.latestRelease.query()},
source: const LocalSource(),
subscriptionPrefix: 'profile-saved-apps',
),
);
final savedApps = savedAppsState.models.toList()
..sort(
(a, b) => (a.name ?? a.identifier).toLowerCase().compareTo(
(b.name ?? b.identifier).toLowerCase(),
),
);
// Check if we have loaded at least once - defer state update to after build
if (savedApps.isNotEmpty && !hasLoadedOnce.value) {
WidgetsBinding.instance.addPostFrameCallback((_) {
hasLoadedOnce.value = true;
});
}
// Show loading state only if we haven't loaded any apps yet
if (savedApps.isEmpty && !hasLoadedOnce.value) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: CircularProgressIndicator(
color: Theme.of(context).colorScheme.primary,
),
),
),
);
}
// Hide if no apps after loading
if (savedApps.isEmpty) {
return const SizedBox.shrink();
}
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: savedApps
.map(
(app) => AppCard(
app: app,
showUpdateArrow: false,
showDescription: false,
),
)
.toList(),
Widget _emptyState(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'No saved apps yet',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
),
),
);