mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 20:48:24 +00:00
Use floating menu and update saved/app packs UI
This commit is contained in:
@@ -9,15 +9,15 @@ const kZapstorePubkey =
|
||||
const kFranzapPubkey =
|
||||
'726a1e261cc6474674e8285e3951b3bb139be9a773d1acf49dc868db861a1c11';
|
||||
|
||||
/// Identifier for storing user bookmarks
|
||||
/// Identifier for storing user saved apps
|
||||
const kAppBookmarksIdentifier = 'zapstore-bookmarks';
|
||||
|
||||
/// Event filter for app packs - excludes bookmarks and packs with zero App references
|
||||
/// Event filter for app packs - excludes saved apps and packs with zero App references
|
||||
bool appPackEventFilter(Map<String, dynamic> event) {
|
||||
final tags = event['tags'] as List<dynamic>?;
|
||||
if (tags == null) return false;
|
||||
|
||||
// Check for bookmarks identifier in 'd' tag
|
||||
// Check for saved apps identifier in 'd' tag
|
||||
for (final tag in tags) {
|
||||
if (tag is List && tag.isNotEmpty && tag[0] == 'd') {
|
||||
if (tag.length > 1 && tag[1] == kAppBookmarksIdentifier) {
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:models/models.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:zapstore/services/bookmarks_service.dart';
|
||||
import 'package:zapstore/services/notification_service.dart';
|
||||
import 'package:zapstore/utils/debug_utils.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
import 'package:zapstore/services/package_manager/package_manager.dart';
|
||||
@@ -11,7 +16,9 @@ import 'package:zapstore/widgets/app_detail_widgets.dart';
|
||||
import 'package:zapstore/widgets/app_header.dart';
|
||||
import 'package:zapstore/widgets/app_info_table.dart';
|
||||
import 'package:zapstore/widgets/author_container.dart';
|
||||
import 'package:zapstore/widgets/bookmark_widgets.dart';
|
||||
import 'package:zapstore/widgets/comments_section.dart';
|
||||
import 'package:zapstore/widgets/common/base_dialog.dart';
|
||||
import 'package:zapstore/widgets/download_text_container.dart';
|
||||
import 'package:zapstore/widgets/expandable_markdown.dart';
|
||||
import 'package:zapstore/widgets/install_button.dart';
|
||||
@@ -128,6 +135,13 @@ class _AppDetailContent extends HookConsumerWidget {
|
||||
final latestRelease = app.latestRelease.value;
|
||||
final latestMetadata = app.latestFileMetadata;
|
||||
|
||||
// Check if app is installed for menu options
|
||||
final installedPackage = ref
|
||||
.watch(packageManagerProvider)
|
||||
.where((p) => p.appId == app.identifier)
|
||||
.firstOrNull;
|
||||
final isInstalled = installedPackage != null;
|
||||
|
||||
// Show skeleton while relationships are loading
|
||||
if (latestRelease == null || latestMetadata == null) {
|
||||
return Scaffold(
|
||||
@@ -142,6 +156,7 @@ class _AppDetailContent extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
InstallButton(app: app, release: latestRelease),
|
||||
_buildFloatingMenu(context, ref, app, isInstalled),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -304,9 +319,252 @@ class _AppDetailContent extends HookConsumerWidget {
|
||||
|
||||
// Sticky install button
|
||||
InstallButton(app: app, release: latestRelease),
|
||||
|
||||
// Floating three-dot menu
|
||||
_buildFloatingMenu(context, ref, app, isInstalled),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFloatingMenu(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
App app,
|
||||
bool isInstalled,
|
||||
) {
|
||||
return Positioned(
|
||||
top: 8,
|
||||
right: 8,
|
||||
child: _buildOverflowMenu(context, ref, app, isInstalled),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOverflowMenu(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
App app,
|
||||
bool isInstalled,
|
||||
) {
|
||||
// Watch saved apps to check if app is saved
|
||||
final savedAppsAsync = ref.watch(bookmarksProvider);
|
||||
final savedAppIds = savedAppsAsync.when(
|
||||
data: (ids) => ids,
|
||||
loading: () => <String>{},
|
||||
error: (_, __) => <String>{},
|
||||
);
|
||||
final appAddressableId =
|
||||
'${app.event.kind}:${app.pubkey}:${app.identifier}';
|
||||
final isSaved = savedAppIds.contains(appAddressableId);
|
||||
|
||||
return PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
onSelected: (value) {
|
||||
switch (value) {
|
||||
case 'share':
|
||||
_shareApp(context, app);
|
||||
break;
|
||||
case 'copy_link':
|
||||
_copyLink(context, app);
|
||||
break;
|
||||
case 'save_app':
|
||||
_toggleSaveApp(context, ref, app, isSaved);
|
||||
break;
|
||||
case 'view_publisher':
|
||||
_viewPublisher(context, app);
|
||||
break;
|
||||
case 'open_browser':
|
||||
_openInBrowser(context, app);
|
||||
break;
|
||||
case 'open':
|
||||
_openApp(context, ref, app);
|
||||
break;
|
||||
case 'delete':
|
||||
_uninstallApp(context, ref, app);
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem<String>(
|
||||
value: 'share',
|
||||
child: Row(
|
||||
children: [Icon(Icons.share), SizedBox(width: 12), Text('Share')],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem<String>(
|
||||
value: 'copy_link',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.link),
|
||||
SizedBox(width: 12),
|
||||
Text('Copy link'),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
value: 'save_app',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(isSaved ? Icons.bookmark : Icons.bookmark_border),
|
||||
const SizedBox(width: 12),
|
||||
Text(isSaved ? 'Remove from saved' : 'Save app'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem<String>(
|
||||
value: 'view_publisher',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.person),
|
||||
SizedBox(width: 12),
|
||||
Text('View publisher'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem<String>(
|
||||
value: 'open_browser',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.open_in_browser),
|
||||
SizedBox(width: 12),
|
||||
Text('Open in browser'),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isInstalled) ...[
|
||||
const PopupMenuItem<String>(
|
||||
value: 'open',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.open_in_new),
|
||||
SizedBox(width: 12),
|
||||
Text('Open'),
|
||||
],
|
||||
),
|
||||
),
|
||||
const PopupMenuItem<String>(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.delete_outline),
|
||||
SizedBox(width: 12),
|
||||
Text('Delete'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _getAppUrl(App app) {
|
||||
final naddr = Utils.encodeShareableIdentifier(
|
||||
AddressInput(
|
||||
identifier: app.identifier,
|
||||
author: app.pubkey,
|
||||
kind: app.event.kind,
|
||||
relays: [],
|
||||
),
|
||||
);
|
||||
return 'https://zapstore.dev/apps/$naddr';
|
||||
}
|
||||
|
||||
void _shareApp(BuildContext context, App app) {
|
||||
try {
|
||||
final shareUrl = _getAppUrl(app);
|
||||
SharePlus.instance.share(ShareParams(text: shareUrl));
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
context.showError('Failed to share app', description: '$e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _copyLink(BuildContext context, App app) {
|
||||
try {
|
||||
final shareUrl = _getAppUrl(app);
|
||||
Clipboard.setData(ClipboardData(text: shareUrl));
|
||||
context.showInfo('Link copied to clipboard');
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
context.showError('Failed to copy link', description: '$e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleSaveApp(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
App app,
|
||||
bool isCurrentlySaved,
|
||||
) async {
|
||||
await showBaseDialog(
|
||||
context: context,
|
||||
dialog: SaveAppDialog(app: app, isPrivatelySaved: isCurrentlySaved),
|
||||
);
|
||||
}
|
||||
|
||||
void _viewPublisher(BuildContext context, App app) {
|
||||
final segments = GoRouterState.of(context).uri.pathSegments;
|
||||
final first = segments.isNotEmpty ? segments.first : 'search';
|
||||
context.push('/$first/user/${app.pubkey}');
|
||||
}
|
||||
|
||||
Future<void> _openInBrowser(BuildContext context, App app) async {
|
||||
try {
|
||||
final url = _getAppUrl(app);
|
||||
final uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
} else {
|
||||
if (context.mounted) {
|
||||
context.showError('Could not open browser');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
context.showError('Failed to open browser', description: '$e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openApp(BuildContext context, WidgetRef ref, App app) async {
|
||||
try {
|
||||
final packageManager = ref.read(packageManagerProvider.notifier);
|
||||
context.showInfo('Launching ${app.name ?? app.identifier}...');
|
||||
await packageManager.launchApp(app.identifier);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
context.showError(
|
||||
'Failed to launch ${app.name ?? app.identifier}',
|
||||
description:
|
||||
'The app may have been uninstalled or moved. Try reinstalling.\n\n$e',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _uninstallApp(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
App app,
|
||||
) async {
|
||||
try {
|
||||
final packageManager = ref.read(packageManagerProvider.notifier);
|
||||
await packageManager.uninstall(app.identifier);
|
||||
// Only reaches here after successful uninstall
|
||||
if (context.mounted) {
|
||||
context.showInfo('${app.name ?? app.identifier} has been uninstalled');
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
// Don't show error for user cancellation
|
||||
final message = e.toString();
|
||||
if (!message.contains('cancelled')) {
|
||||
context.showError('Uninstall failed', description: '$e');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ class UpdatesScreen extends HookConsumerWidget {
|
||||
final updatesCount =
|
||||
categorized.automaticUpdates.length + categorized.manualUpdates.length;
|
||||
final upToDateCount = categorized.upToDateApps.length;
|
||||
final bookmarksAsync = ref.watch(bookmarksProvider);
|
||||
final bookmarksCount = bookmarksAsync.maybeWhen(
|
||||
final savedAppsAsync = ref.watch(bookmarksProvider);
|
||||
final savedAppsCount = savedAppsAsync.maybeWhen(
|
||||
data: (ids) => ids.length,
|
||||
orElse: () => 0,
|
||||
);
|
||||
@@ -60,8 +60,8 @@ class UpdatesScreen extends HookConsumerWidget {
|
||||
),
|
||||
Tab(
|
||||
child: _TabLabelWithBadge(
|
||||
label: 'Bookmarks',
|
||||
count: bookmarksCount,
|
||||
label: 'Saved',
|
||||
count: savedAppsCount,
|
||||
textStyle: tabLabelStyle,
|
||||
badgeColor: Colors.blue.shade700.withValues(alpha: 0.4),
|
||||
),
|
||||
@@ -72,7 +72,7 @@ class UpdatesScreen extends HookConsumerWidget {
|
||||
body: const Padding(
|
||||
padding: EdgeInsets.only(top: 16),
|
||||
child: TabBarView(
|
||||
children: [_UpdatesTab(), _UpToDateTab(), _BookmarksTab()],
|
||||
children: [_UpdatesTab(), _UpToDateTab(), _SavedAppsTab()],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -539,8 +539,8 @@ class _UpToDateTab extends HookConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _BookmarksTab extends HookConsumerWidget {
|
||||
const _BookmarksTab();
|
||||
class _SavedAppsTab extends HookConsumerWidget {
|
||||
const _SavedAppsTab();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -551,7 +551,7 @@ class _BookmarksTab extends HookConsumerWidget {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'Sign in to view bookmarks',
|
||||
'Sign in to view saved apps',
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
@@ -562,9 +562,9 @@ class _BookmarksTab extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
final bookmarksAsync = ref.watch(bookmarksProvider);
|
||||
final savedAppsAsync = ref.watch(bookmarksProvider);
|
||||
|
||||
return bookmarksAsync.when(
|
||||
return savedAppsAsync.when(
|
||||
loading: () => Center(
|
||||
child: CircularProgressIndicator(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
@@ -572,7 +572,7 @@ class _BookmarksTab extends HookConsumerWidget {
|
||||
),
|
||||
error: (_, __) => Center(
|
||||
child: Text(
|
||||
'Error loading bookmarks',
|
||||
'Error loading saved apps',
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
@@ -586,48 +586,48 @@ class _BookmarksTab extends HookConsumerWidget {
|
||||
})
|
||||
.whereType<String>()
|
||||
.toSet();
|
||||
return _BookmarkedAppsSection(identifiers: identifiers);
|
||||
return _SavedAppsSection(identifiers: identifiers);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BookmarkedAppsSection extends StatelessWidget {
|
||||
const _BookmarkedAppsSection({required this.identifiers});
|
||||
class _SavedAppsSection extends StatelessWidget {
|
||||
const _SavedAppsSection({required this.identifiers});
|
||||
|
||||
final Set<String> identifiers;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (identifiers.isEmpty) return const _NoBookmarkedAppsEmptyState();
|
||||
return _BookmarkedAppsSectionWithIds(identifiers: identifiers);
|
||||
if (identifiers.isEmpty) return const _NoSavedAppsEmptyState();
|
||||
return _SavedAppsSectionWithIds(identifiers: identifiers);
|
||||
}
|
||||
}
|
||||
|
||||
class _BookmarkedAppsSectionWithIds extends ConsumerWidget {
|
||||
const _BookmarkedAppsSectionWithIds({required this.identifiers});
|
||||
class _SavedAppsSectionWithIds extends ConsumerWidget {
|
||||
const _SavedAppsSectionWithIds({required this.identifiers});
|
||||
|
||||
final Set<String> identifiers;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final bookmarkedAppsState = ref.watch(
|
||||
final savedAppsState = ref.watch(
|
||||
query<App>(
|
||||
tags: {'#d': identifiers},
|
||||
and: (app) => {app.latestRelease},
|
||||
source: const LocalSource(),
|
||||
subscriptionPrefix: 'bookmark-apps',
|
||||
subscriptionPrefix: 'saved-apps',
|
||||
),
|
||||
);
|
||||
|
||||
final savedApps = bookmarkedAppsState.models.toList()
|
||||
final savedApps = savedAppsState.models.toList()
|
||||
..sort(
|
||||
(a, b) => (a.name ?? a.identifier).toLowerCase().compareTo(
|
||||
(b.name ?? b.identifier).toLowerCase(),
|
||||
),
|
||||
);
|
||||
|
||||
if (savedApps.isEmpty) return const _NoBookmarkedAppsEmptyState();
|
||||
if (savedApps.isEmpty) return const _NoSavedAppsEmptyState();
|
||||
|
||||
return ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
@@ -643,8 +643,8 @@ class _BookmarkedAppsSectionWithIds extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _NoBookmarkedAppsEmptyState extends StatelessWidget {
|
||||
const _NoBookmarkedAppsEmptyState();
|
||||
class _NoSavedAppsEmptyState extends StatelessWidget {
|
||||
const _NoSavedAppsEmptyState();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -652,7 +652,7 @@ class _NoBookmarkedAppsEmptyState extends StatelessWidget {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'No bookmarked apps yet',
|
||||
'No saved apps yet',
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
|
||||
@@ -3,7 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:models/models.dart';
|
||||
import 'package:zapstore/utils/extensions.dart';
|
||||
|
||||
/// Provider that watches the user's bookmark pack and provides decrypted bookmark state
|
||||
/// Provider that watches the user's saved apps pack and provides decrypted saved apps state
|
||||
/// Fetches from remote once on boot, then uses local storage only
|
||||
final bookmarksProvider = FutureProvider<Set<String>>((ref) async {
|
||||
final signer = ref.watch(Signer.activeSignerProvider);
|
||||
@@ -13,7 +13,7 @@ final bookmarksProvider = FutureProvider<Set<String>>((ref) async {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Query bookmark pack - stream enabled to auto-update when bookmarks change locally
|
||||
// Query saved apps pack - stream enabled to auto-update when saved apps change locally
|
||||
// Initial fetch from remote, then watches local storage for changes
|
||||
final packState = ref.watch(
|
||||
query<AppPack>(
|
||||
@@ -25,7 +25,7 @@ final bookmarksProvider = FutureProvider<Set<String>>((ref) async {
|
||||
relays: 'social',
|
||||
stream: false,
|
||||
),
|
||||
subscriptionPrefix: 'user-bookmarks',
|
||||
subscriptionPrefix: 'user-saved-apps',
|
||||
),
|
||||
);
|
||||
|
||||
@@ -58,27 +58,27 @@ final bookmarksProvider = FutureProvider<Set<String>>((ref) async {
|
||||
}
|
||||
});
|
||||
|
||||
/// Extension to check if an app is bookmarked
|
||||
extension BookmarkChecker on WidgetRef {
|
||||
/// Check if the given app is bookmarked
|
||||
bool isAppBookmarked(App app) {
|
||||
final bookmarksState = watch(bookmarksProvider);
|
||||
/// Extension to check if an app is saved
|
||||
extension SavedAppsChecker on WidgetRef {
|
||||
/// Check if the given app is saved
|
||||
bool isAppSaved(App app) {
|
||||
final savedAppsState = watch(bookmarksProvider);
|
||||
final appAddressableId =
|
||||
'${app.event.kind}:${app.pubkey}:${app.identifier}';
|
||||
|
||||
return bookmarksState.when(
|
||||
data: (bookmarks) => bookmarks.contains(appAddressableId),
|
||||
return savedAppsState.when(
|
||||
data: (savedApps) => savedApps.contains(appAddressableId),
|
||||
loading: () => false,
|
||||
error: (_, __) => false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Get all bookmarked app addressable IDs
|
||||
Set<String> getBookmarkedIds() {
|
||||
final bookmarksState = watch(bookmarksProvider);
|
||||
/// Get all saved app addressable IDs
|
||||
Set<String> getSavedAppIds() {
|
||||
final savedAppsState = watch(bookmarksProvider);
|
||||
|
||||
return bookmarksState.when(
|
||||
data: (bookmarks) => bookmarks,
|
||||
return savedAppsState.when(
|
||||
data: (savedApps) => savedApps,
|
||||
loading: () => {},
|
||||
error: (_, __) => {},
|
||||
);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:async_button_builder/async_button_builder.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:models/models.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:zapstore/services/bookmarks_service.dart';
|
||||
@@ -70,46 +71,53 @@ class SocialActionsRow extends HookConsumerWidget {
|
||||
final signedInPubkey = ref.watch(Signer.activePubkeyProvider);
|
||||
final isSignedIn = signedInPubkey != null;
|
||||
|
||||
// Watch bookmarks from centralized provider (handles decryption)
|
||||
final bookmarksAsync = ref.watch(bookmarksProvider);
|
||||
final bookmarkedIds = bookmarksAsync.when(
|
||||
// Watch saved apps from centralized provider (handles decryption)
|
||||
final savedAppsAsync = ref.watch(bookmarksProvider);
|
||||
final savedAppIds = savedAppsAsync.when(
|
||||
data: (ids) => ids,
|
||||
loading: () => <String>{},
|
||||
error: (_, __) => <String>{},
|
||||
);
|
||||
|
||||
// Check if this app is bookmarked
|
||||
// Check if this app is saved
|
||||
final appAddressableId =
|
||||
'${app.event.kind}:${app.pubkey}:${app.identifier}';
|
||||
final isPrivatelySaved = bookmarkedIds.contains(appAddressableId);
|
||||
final isPrivatelySaved = savedAppIds.contains(appAddressableId);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Zap + Bookmark + App Pack + Share button row (50% / 16% / 16% / 16% split)
|
||||
// Zap + Save App + App Pack button row
|
||||
SizedBox(
|
||||
height: 36,
|
||||
child: Row(
|
||||
children: [
|
||||
// Zap button (50%)
|
||||
// Zap button
|
||||
Expanded(
|
||||
flex: 50,
|
||||
flex: 68,
|
||||
child: ZapButton(app: app, author: author),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Bookmark button (16%)
|
||||
// Save App button
|
||||
Expanded(
|
||||
flex: 16,
|
||||
child: FilledButton(
|
||||
onPressed: () =>
|
||||
_showBookmarkDialog(context, ref, app, isPrivatelySaved),
|
||||
onPressed: () => _handleSaveApp(
|
||||
context,
|
||||
ref,
|
||||
app,
|
||||
isPrivatelySaved,
|
||||
isSignedIn,
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
backgroundColor: isPrivatelySaved && isSignedIn
|
||||
? (Theme.of(context).brightness == Brightness.dark
|
||||
? const Color(0xFF4A6BA0) // Deep blue
|
||||
: const Color(0xFF1E4D8B)) // Darker blue
|
||||
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
? const Color(0xFF9B4F5E) // Red wine/burgundy
|
||||
: const Color(0xFF7D3C4D)) // Deep burgundy
|
||||
: (Theme.of(context).brightness == Brightness.dark
|
||||
? const Color(0xFF3A3A3F) // Dark neutral
|
||||
: const Color(0xFFE8E3E8)), // Light neutral
|
||||
foregroundColor: isPrivatelySaved && isSignedIn
|
||||
? Colors.white
|
||||
: Theme.of(
|
||||
@@ -128,7 +136,7 @@ class SocialActionsRow extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// App Pack button (16%)
|
||||
// App Pack button
|
||||
Expanded(
|
||||
flex: 16,
|
||||
child: FilledButton(
|
||||
@@ -137,8 +145,8 @@ class SocialActionsRow extends HookConsumerWidget {
|
||||
padding: EdgeInsets.zero,
|
||||
backgroundColor:
|
||||
Theme.of(context).brightness == Brightness.dark
|
||||
? const Color(0xFF3A6FCC) // Theme dark primary
|
||||
: const Color(0xFF2563A8), // Muted blue
|
||||
? const Color(0xFF4A7BA7) // Soft steel blue
|
||||
: const Color(0xFF5B8FB9), // Light ocean blue
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
@@ -147,26 +155,6 @@ class SocialActionsRow extends HookConsumerWidget {
|
||||
child: const Icon(Icons.apps, size: 20),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Share button (16%)
|
||||
Expanded(
|
||||
flex: 16,
|
||||
child: FilledButton(
|
||||
onPressed: () => _shareApp(context, app),
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
backgroundColor:
|
||||
Theme.of(context).brightness == Brightness.dark
|
||||
? const Color(0xFF2D5A8F) // Navy blue
|
||||
: const Color(0xFF1A4673), // Darker navy
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.share, size: 20),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -177,16 +165,92 @@ class SocialActionsRow extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showBookmarkDialog(
|
||||
Future<void> _handleSaveApp(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
App app,
|
||||
bool isPrivatelySaved,
|
||||
bool isSignedIn,
|
||||
) async {
|
||||
await showBaseDialog(
|
||||
context: context,
|
||||
dialog: BookmarkDialog(app: app, isPrivatelySaved: isPrivatelySaved),
|
||||
);
|
||||
// If not signed in, show dialog to prompt sign in
|
||||
if (!isSignedIn) {
|
||||
await showBaseDialog(
|
||||
context: context,
|
||||
dialog: SaveAppDialog(app: app, isPrivatelySaved: isPrivatelySaved),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// If signed in, save directly without dialog
|
||||
try {
|
||||
final signer = ref.read(Signer.activeSignerProvider);
|
||||
final signedInPubkey = ref.read(Signer.activePubkeyProvider);
|
||||
|
||||
if (signer == null || signedInPubkey == null) return;
|
||||
|
||||
// Query for existing pack
|
||||
final existingPackState = await ref.storage.query(
|
||||
RequestFilter<AppPack>(
|
||||
authors: {signedInPubkey},
|
||||
tags: {
|
||||
'#d': {kAppBookmarksIdentifier},
|
||||
},
|
||||
).toRequest(),
|
||||
source: const LocalSource(),
|
||||
);
|
||||
final existingPack = existingPackState.firstOrNull;
|
||||
|
||||
// Get existing app IDs by decrypting if pack exists
|
||||
List<String> existingAppIds = [];
|
||||
if (existingPack != null) {
|
||||
try {
|
||||
final decryptedContent = await signer.nip44Decrypt(
|
||||
existingPack.content,
|
||||
signedInPubkey,
|
||||
);
|
||||
existingAppIds = (jsonDecode(decryptedContent) as List)
|
||||
.cast<String>();
|
||||
} catch (_) {
|
||||
// Silently start fresh if decryption fails
|
||||
}
|
||||
}
|
||||
|
||||
// Modify the list
|
||||
final appAddressableId =
|
||||
'${app.event.kind}:${app.pubkey}:${app.identifier}';
|
||||
|
||||
if (isPrivatelySaved) {
|
||||
existingAppIds.remove(appAddressableId);
|
||||
} else {
|
||||
if (!existingAppIds.contains(appAddressableId)) {
|
||||
existingAppIds.add(appAddressableId);
|
||||
}
|
||||
}
|
||||
|
||||
// Create new partial pack with updated list
|
||||
final partialPack = PartialAppPack.withEncryptedApps(
|
||||
name: 'Saved Apps',
|
||||
identifier: kAppBookmarksIdentifier,
|
||||
apps: existingAppIds,
|
||||
);
|
||||
|
||||
// Sign (encrypts the content)
|
||||
final signedPack = await partialPack.signWith(signer);
|
||||
|
||||
// Save to local storage and publish to relays
|
||||
await ref.storage.save({signedPack});
|
||||
ref.storage.publish({signedPack}, source: RemoteSource(relays: 'social'));
|
||||
|
||||
if (context.mounted) {
|
||||
context.showInfo(
|
||||
isPrivatelySaved ? 'App removed from saved' : 'App saved privately',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
context.showError('Failed to save app', description: '$e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showAddToPackDialog(BuildContext context, App app) async {
|
||||
@@ -195,28 +259,6 @@ class SocialActionsRow extends HookConsumerWidget {
|
||||
dialog: AddToPackDialog(app: app),
|
||||
);
|
||||
}
|
||||
|
||||
void _shareApp(BuildContext context, App app) {
|
||||
try {
|
||||
// Generate naddr for the app
|
||||
final naddr = Utils.encodeShareableIdentifier(
|
||||
AddressInput(
|
||||
identifier: app.identifier,
|
||||
author: app.pubkey,
|
||||
kind: app.event.kind,
|
||||
relays: [],
|
||||
),
|
||||
);
|
||||
final shareUrl = 'https://zapstore.dev/apps/$naddr';
|
||||
|
||||
// Share using Android's share sheet
|
||||
SharePlus.instance.share(ShareParams(text: shareUrl));
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
context.showError('Failed to share app', description: '$e');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _ZappersListSection extends StatelessWidget {
|
||||
|
||||
@@ -10,9 +10,9 @@ import 'package:zapstore/utils/extensions.dart';
|
||||
import 'package:zapstore/widgets/auth_widgets.dart';
|
||||
import 'package:zapstore/widgets/common/base_dialog.dart';
|
||||
|
||||
/// Dialog for bookmarking an app privately (encrypted)
|
||||
class BookmarkDialog extends HookConsumerWidget {
|
||||
const BookmarkDialog({
|
||||
/// Dialog for saving an app privately (encrypted)
|
||||
class SaveAppDialog extends HookConsumerWidget {
|
||||
const SaveAppDialog({
|
||||
super.key,
|
||||
required this.app,
|
||||
required this.isPrivatelySaved,
|
||||
@@ -28,7 +28,7 @@ class BookmarkDialog extends HookConsumerWidget {
|
||||
return BaseDialog(
|
||||
titleIcon: const Icon(Icons.bookmark),
|
||||
title: Text(
|
||||
'Private bookmark',
|
||||
'Save App Privately',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
content: BaseDialogContent(
|
||||
@@ -44,7 +44,7 @@ class BookmarkDialog extends HookConsumerWidget {
|
||||
),
|
||||
if (!isSignedIn) ...[
|
||||
const SizedBox(height: 16),
|
||||
const SignInPrompt(message: 'Sign in to bookmark apps privately.'),
|
||||
const SignInPrompt(message: 'Sign in to save apps privately.'),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -75,7 +75,7 @@ class BookmarkDialog extends HookConsumerWidget {
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
label: Text(isPrivatelySaved ? 'Remove bookmark' : 'Bookmark'),
|
||||
label: Text(isPrivatelySaved ? 'Remove' : 'Save'),
|
||||
);
|
||||
},
|
||||
child: const SizedBox.shrink(),
|
||||
@@ -98,7 +98,7 @@ class BookmarkDialog extends HookConsumerWidget {
|
||||
if (context.mounted) {
|
||||
context.showError(
|
||||
'Sign in required',
|
||||
description: 'You need to sign in to save apps to your bookmarks.',
|
||||
description: 'You need to sign in to save apps privately.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -129,9 +129,9 @@ class BookmarkDialog extends HookConsumerWidget {
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
context.showError(
|
||||
'Could not read existing bookmarks',
|
||||
'Could not read existing saved apps',
|
||||
description:
|
||||
'Your previous bookmarks could not be decrypted. Starting fresh.\n\n$e',
|
||||
'Your previous saved apps could not be decrypted. Starting fresh.\n\n$e',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,7 @@ class BookmarkDialog extends HookConsumerWidget {
|
||||
|
||||
// Create new partial pack with updated list
|
||||
final partialPack = PartialAppPack.withEncryptedApps(
|
||||
name: 'Bookmarks',
|
||||
name: 'Saved Apps',
|
||||
identifier: kAppBookmarksIdentifier,
|
||||
apps: existingAppIds,
|
||||
);
|
||||
@@ -165,7 +165,9 @@ class BookmarkDialog extends HookConsumerWidget {
|
||||
|
||||
if (context.mounted) {
|
||||
Navigator.pop(context);
|
||||
context.showInfo(isCurrentlySaved ? 'Bookmark removed' : 'Bookmarked');
|
||||
context.showInfo(
|
||||
isCurrentlySaved ? 'App removed from saved' : 'App saved privately',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
@@ -201,31 +203,44 @@ class _AddToPackDialogSignedOut extends StatelessWidget {
|
||||
return BaseDialog(
|
||||
titleIcon: const Icon(Icons.apps),
|
||||
title: Text(
|
||||
'Manage App Packs',
|
||||
'Add to App Packs',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
content: BaseDialogContent(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.public,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Add or remove ${app.name} from public app packs',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Icon(
|
||||
Icons.info_outline,
|
||||
size: 18,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Add or remove ${app.name} from public app packs that you share with others.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SignInPrompt(
|
||||
@@ -304,31 +319,44 @@ class _AddToPackDialogSignedIn extends HookConsumerWidget {
|
||||
return BaseDialog(
|
||||
titleIcon: const Icon(Icons.apps),
|
||||
title: Text(
|
||||
'Manage App Packs',
|
||||
'Add to App Packs',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
content: BaseDialogContent(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.public,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Add or remove ${app.name} from public app packs',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Icon(
|
||||
Icons.info_outline,
|
||||
size: 18,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Add or remove ${app.name} from public app packs that you share with others.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user