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(); return const SizedBox.shrink();
} }
final savedAppsAsync = ref.watch(bookmarksProvider); // Always show heading when signed in
return Padding(
return savedAppsAsync.when( padding: const EdgeInsets.symmetric(horizontal: 16.0),
loading: () => const SizedBox.shrink(), child: Text('Saved Apps', style: context.textTheme.headlineSmall),
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),
);
},
); );
} }
} }
class _SavedAppsSection extends HookConsumerWidget { class _SavedAppsSection extends ConsumerWidget {
const _SavedAppsSection(); const _SavedAppsSection();
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final signedInPubkey = ref.watch(Signer.activePubkeyProvider); 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); final savedAppsAsync = ref.watch(bookmarksProvider);
return savedAppsAsync.when( // Keep previous value during refresh, if available.
loading: () => _savedAppsLoadingCard(context), final addressableIds = savedAppsAsync.valueOrNull;
error: (_, __) => const SizedBox.shrink(),
data: (addressableIds) {
if (addressableIds.isEmpty) {
return const SizedBox.shrink();
}
final identifiers = addressableIds // Show loading only on first load (when no value exists yet).
.map((id) { if (addressableIds == null) {
final parts = id.split(':'); return _savedAppsLoadingCard(context);
return parts.length >= 3 ? parts[2] : null; }
})
.whereType<String>()
.toSet();
if (identifiers.isEmpty) { final identifiers = _toIdentifiers(addressableIds);
return const SizedBox.shrink(); return _SavedAppsList(identifiers: identifiers);
} }
return _SavedAppsList( Set<String> _toIdentifiers(Set<String> addressableIds) {
identifiers: identifiers, return addressableIds
hasLoadedOnce: hasLoadedOnce, .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( return Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -1565,75 +1596,15 @@ class _SavedAppsSection extends HookConsumerWidget {
), ),
); );
} }
}
class _SavedAppsList extends ConsumerWidget { Widget _emptyState(BuildContext context) {
const _SavedAppsList({ final theme = Theme.of(context);
required this.identifiers, return Padding(
required this.hasLoadedOnce, padding: const EdgeInsets.symmetric(horizontal: 16),
}); child: Text(
'No saved apps yet',
final Set<String> identifiers; style: theme.textTheme.bodyMedium?.copyWith(
final ValueNotifier<bool> hasLoadedOnce; color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
@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(),
), ),
), ),
); );