Merge branch 'master' into feat/205-allow-managing-relays

This commit is contained in:
franzap
2026-01-27 12:15:41 -03:00
10 changed files with 193 additions and 186 deletions
+1
View File
@@ -10,6 +10,7 @@
analyzer:
errors:
dangling_library_doc_comments: ignore
deprecated_member_use: ignore
include: package:flutter_lints/flutter.yaml
linter:
+95 -124
View File
@@ -1483,80 +1483,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),
@@ -1568,75 +1599,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),
),
),
);
+1 -1
View File
@@ -172,7 +172,7 @@ class _SearchResultsSection extends HookConsumerWidget {
// Force the search to hit the default relay group (relay.zapstore.dev)
// so a connection appears in Debug Info when searching.
source: const RemoteSource(relays: 'AppCatalog', stream: false),
subscriptionPrefix: 'search-results',
subscriptionPrefix: 'app-search-results',
),
);
+3 -3
View File
@@ -40,9 +40,9 @@ class _ConnectionStatusIndicator extends ConsumerWidget {
final poolState = ref.watch(poolStateProvider);
final subscriptions = poolState?.subscriptions ?? {};
// Filter to only "updates" subscription
// Filter to only "app-updates" subscription
final updatesSubs = subscriptions.entries
.where((e) => e.key.startsWith('updates'))
.where((e) => e.key.startsWith('app-updates'))
.map((e) => e.value);
// Check relay status for updates subscription only
@@ -189,7 +189,7 @@ class _UpdatesListBodyWithInstallingAppIds extends ConsumerWidget {
tags: {'#d': installingAppIds},
and: (app) => {app.latestRelease.query()},
source: const LocalAndRemoteSource(relays: 'AppCatalog'),
subscriptionPrefix: 'installing-apps',
subscriptionPrefix: 'app-installing-apps',
),
);
+43 -26
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -49,7 +50,7 @@ class UserScreen extends HookConsumerWidget {
),
},
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
subscriptionPrefix: 'user-apps',
subscriptionPrefix: 'app-user-apps',
),
);
@@ -382,34 +383,50 @@ class _NpubRow extends StatelessWidget {
final abbreviatedNpub =
'${npub.substring(0, 12)}...${npub.substring(npub.length - 8)}';
return GestureDetector(
onTap: () => launchUrl(
Uri.parse('https://npub.world/$npub'),
mode: LaunchMode.externalApplication,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.key,
size: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
abbreviatedNpub,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(width: 4),
Icon(
Icons.open_in_new,
return Row(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: () {
Clipboard.setData(ClipboardData(text: npub));
},
child: Icon(
Icons.copy,
size: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
),
const SizedBox(width: 4),
GestureDetector(
onTap: () => launchUrl(
Uri.parse('https://npub.world/$npub'),
mode: LaunchMode.externalApplication,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.key,
size: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(width: 4),
Text(
abbreviatedNpub,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(width: 4),
Icon(
Icons.open_in_new,
size: 12,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
),
],
);
}
}
+1 -1
View File
@@ -104,7 +104,7 @@ class CategorizedAppsNotifier extends Notifier<CategorizedApps> {
),
},
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true),
subscriptionPrefix: 'updates',
subscriptionPrefix: 'app-updates',
),
);
+1 -1
View File
@@ -735,7 +735,7 @@ class DebugVersionsSection extends HookConsumerWidget {
tags: app.event.addressableIdTagMap,
and: (release) => {release.latestMetadata.query(), release.latestAsset.query()},
source: LocalAndRemoteSource(relays: 'AppCatalog', stream: false),
subscriptionPrefix: 'debug-releases',
subscriptionPrefix: 'app-debug-releases',
),
);
+41 -27
View File
@@ -31,11 +31,7 @@ String? _extractIdentifier(String addressableId) {
List<String> _getPreviewIdentifiers(AppStack stack) {
final rawTags = _getRawAppTagValues(stack).toList()
..shuffle(Random(stack.id.hashCode));
return rawTags
.take(3)
.map(_extractIdentifier)
.whereType<String>()
.toList();
return rawTags.take(3).map(_extractIdentifier).whereType<String>().toList();
}
/// Sort app stacks: franzap/following first, then by recency
@@ -67,10 +63,12 @@ List<AppStack> _sortStacks(
others.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
return [...followed, ...others];
} else {
final franzapStacks =
stacks.where((s) => s.pubkey == kFranzapPubkey).toList();
final otherStacks =
stacks.where((s) => s.pubkey != kFranzapPubkey).toList();
final franzapStacks = stacks
.where((s) => s.pubkey == kFranzapPubkey)
.toList();
final otherStacks = stacks
.where((s) => s.pubkey != kFranzapPubkey)
.toList();
franzapStacks.shuffle(random);
otherStacks.sort((a, b) => b.event.createdAt.compareTo(a.event.createdAt));
@@ -198,8 +196,10 @@ class AppStackContainer extends HookConsumerWidget {
scrollController.position.maxScrollExtent - 200) {
// Load more if we haven't shown all stacks yet
if (visibleCount.value < sortedStacks.length) {
visibleCount.value = (visibleCount.value + _kBatchSize)
.clamp(0, sortedStacks.length);
visibleCount.value = (visibleCount.value + _kBatchSize).clamp(
0,
sortedStacks.length,
);
}
}
}
@@ -231,9 +231,13 @@ class AppStackContainer extends HookConsumerWidget {
if (col * 2 < displayedStacks.length)
_StackCard(
stack: displayedStacks[col * 2],
author: authorsMap[displayedStacks[col * 2].event.pubkey],
isAuthorLoading: isAuthorLoading(displayedStacks[col * 2].event.pubkey),
previewIdentifiers: stackPreviewIds[displayedStacks[col * 2].id] ?? [],
author:
authorsMap[displayedStacks[col * 2].event.pubkey],
isAuthorLoading: isAuthorLoading(
displayedStacks[col * 2].event.pubkey,
),
previewIdentifiers:
stackPreviewIds[displayedStacks[col * 2].id] ?? [],
appsMap: appsMap,
),
// Bottom item
@@ -241,9 +245,16 @@ class AppStackContainer extends HookConsumerWidget {
const SizedBox(height: 10),
_StackCard(
stack: displayedStacks[col * 2 + 1],
author: authorsMap[displayedStacks[col * 2 + 1].event.pubkey],
isAuthorLoading: isAuthorLoading(displayedStacks[col * 2 + 1].event.pubkey),
previewIdentifiers: stackPreviewIds[displayedStacks[col * 2 + 1].id] ?? [],
author:
authorsMap[displayedStacks[col * 2 + 1]
.event
.pubkey],
isAuthorLoading: isAuthorLoading(
displayedStacks[col * 2 + 1].event.pubkey,
),
previewIdentifiers:
stackPreviewIds[displayedStacks[col * 2 + 1].id] ??
[],
appsMap: appsMap,
),
],
@@ -450,9 +461,7 @@ class _AppIconsRow extends StatelessWidget {
aspectRatio: 1,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
color: Theme.of(context).colorScheme.surfaceContainerHighest
.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(10),
),
@@ -481,10 +490,9 @@ class _AppIconsRow extends StatelessWidget {
aspectRatio: 1,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.5),
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(10),
),
),
@@ -520,10 +528,14 @@ class _AppIconTile extends StatelessWidget {
fit: BoxFit.cover,
fadeInDuration: const Duration(milliseconds: 200),
placeholder: (_, __) => Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
),
errorWidget: (_, __, ___) => Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
child: const Icon(
Icons.broken_image_outlined,
size: 16,
@@ -532,7 +544,9 @@ class _AppIconTile extends StatelessWidget {
),
)
: Container(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
child: const Icon(
Icons.apps_outlined,
size: 16,
+3 -3
View File
@@ -321,7 +321,7 @@ class LatestReleasesNotifier extends StateNotifier<LatestReleasesState> {
},
// NOTE: It must stream=true
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true),
subscriptionPrefix: 'latest',
subscriptionPrefix: 'app-latest',
),
(previous, next) async {
// Always mirror storage state and ensure olderApps don't duplicate the live head
@@ -446,9 +446,9 @@ class _LatestReleasesStatusDot extends ConsumerWidget {
final poolState = ref.watch(poolStateProvider);
final subscriptions = poolState?.subscriptions ?? {};
// Filter to only "latest" subscription
// Filter to only "app-latest" subscription
final latestSubs = subscriptions.entries
.where((e) => e.key.startsWith('latest'))
.where((e) => e.key.startsWith('app-latest'))
.map((e) => e.value);
// Check relay status for latest subscription
+4
View File
@@ -95,4 +95,8 @@ onPressed: () async {
}
```
### Subscription prefix naming
All queries using the `AppCatalog` relay group MUST prefix their `subscriptionPrefix` with `app-`. This is used in the backend.
For detailed API, see models/purplebase READMEs in pub cache.