mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 20:48:24 +00:00
Merge branch 'master' into feat/205-allow-managing-relays
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
analyzer:
|
||||
errors:
|
||||
dangling_library_doc_comments: ignore
|
||||
deprecated_member_use: ignore
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
|
||||
+95
-124
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@ class CategorizedAppsNotifier extends Notifier<CategorizedApps> {
|
||||
),
|
||||
},
|
||||
source: const LocalAndRemoteSource(relays: 'AppCatalog', stream: true),
|
||||
subscriptionPrefix: 'updates',
|
||||
subscriptionPrefix: 'app-updates',
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user