diff --git a/lib/screens/app_detail_screen.dart b/lib/screens/app_detail_screen.dart index 7c8e37f..2fff4c1 100644 --- a/lib/screens/app_detail_screen.dart +++ b/lib/screens/app_detail_screen.dart @@ -1,16 +1,11 @@ -import 'dart:convert'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:gap/gap.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'; @@ -21,6 +16,7 @@ import 'package:zapstore/widgets/author_container.dart'; import 'package:zapstore/widgets/comments_section.dart'; import 'package:zapstore/widgets/download_text_container.dart'; import 'package:zapstore/widgets/expandable_markdown.dart'; +import 'package:zapstore/widgets/floating_overflow_menu.dart'; import 'package:zapstore/widgets/install_button.dart'; import 'package:zapstore/widgets/screenshots_gallery.dart'; @@ -149,7 +145,6 @@ class _AppDetailContent extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final signedInPubkey = ref.watch(Signer.activePubkeyProvider); - final isSignedIn = signedInPubkey != null; final showDebugSections = isDebugMode(signedInPubkey); // Query author profile from social relays @@ -168,12 +163,6 @@ class _AppDetailContent extends HookConsumerWidget { final latestRelease = app.latestRelease.value; final latestMetadata = app.installable; - // Check if app is installed for menu options - final installedPackage = ref.watch( - installedPackageProvider(app.identifier), - ); - final isInstalled = installedPackage != null; - return Scaffold( body: SafeArea( child: Stack( @@ -399,7 +388,11 @@ class _AppDetailContent extends HookConsumerWidget { InstallButton(app: app), // Floating three-dot menu - _buildFloatingMenu(context, ref, app, isInstalled, isSignedIn), + FloatingOverflowMenu( + shareUrl: getAppShareUrl(app), + publisherPubkey: app.pubkey, + app: app, + ), ], ), @@ -407,330 +400,4 @@ class _AppDetailContent extends HookConsumerWidget { ); } - Widget _buildFloatingMenu( - BuildContext context, - WidgetRef ref, - App app, - bool isInstalled, - bool isSignedIn, - ) { - return Positioned( - top: 8, - right: 8, - child: _buildOverflowMenu(context, ref, app, isInstalled, isSignedIn), - ); - } - - Widget _buildOverflowMenu( - BuildContext context, - WidgetRef ref, - App app, - bool isInstalled, - bool isSignedIn, - ) { - // Watch saved apps to check if app is saved - final savedAppsAsync = ref.watch(bookmarksProvider); - final savedAppIds = savedAppsAsync.when( - data: (ids) => ids, - loading: () => {}, - error: (_, __) => {}, - ); - final appAddressableId = - '${app.event.kind}:${app.pubkey}:${app.identifier}'; - final isSaved = savedAppIds.contains(appAddressableId); - - return Material( - color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.9), - shape: const CircleBorder(), - elevation: 2, - child: PopupMenuButton( - 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( - value: 'share', - child: Row( - children: [Icon(Icons.share), SizedBox(width: 12), Text('Share')], - ), - ), - const PopupMenuItem( - value: 'copy_link', - child: Row( - children: [ - Icon(Icons.link), - SizedBox(width: 12), - Text('Copy link'), - ], - ), - ), - if (isSignedIn) - PopupMenuItem( - 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( - value: 'view_publisher', - child: Row( - children: [ - Icon(Icons.person), - SizedBox(width: 12), - Text('View publisher'), - ], - ), - ), - const PopupMenuItem( - value: 'open_browser', - child: Row( - children: [ - Icon(Icons.open_in_browser), - SizedBox(width: 12), - Text('Open in browser'), - ], - ), - ), - if (isInstalled) ...[ - const PopupMenuItem( - value: 'open', - child: Row( - children: [ - Icon(Icons.open_in_new), - SizedBox(width: 12), - Text('Open'), - ], - ), - ), - const PopupMenuItem( - 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: const [kDefaultRelay], - ), - ); - 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', technicalDetails: '$e'); - } - } - } - - void _copyLink(BuildContext context, App app) { - try { - final shareUrl = _getAppUrl(app); - Clipboard.setData(ClipboardData(text: shareUrl)); - } catch (e) { - if (context.mounted) { - context.showError('Failed to copy link', technicalDetails: '$e'); - } - } - } - - Future _toggleSaveApp( - BuildContext context, - WidgetRef ref, - App app, - bool isCurrentlySaved, - ) async { - try { - final signer = ref.read(Signer.activeSignerProvider); - final signedInPubkey = ref.read(Signer.activePubkeyProvider); - - if (signer == null || signedInPubkey == null) { - if (context.mounted) { - context.showError( - 'Sign in required', - description: 'You need to sign in to save apps.', - ); - } - return; - } - - // Query for existing stack - final existingStackState = await ref.storage.query( - RequestFilter( - authors: {signedInPubkey}, - tags: { - '#d': {kAppBookmarksIdentifier}, - }, - ).toRequest(), - source: const LocalSource(), - ); - final existingStack = existingStackState.firstOrNull; - - // Get existing app IDs by decrypting if stack exists - List existingAppIds = []; - if (existingStack != null) { - try { - final decryptedContent = await signer.nip44Decrypt( - existingStack.content, - signedInPubkey, - ); - existingAppIds = (jsonDecode(decryptedContent) as List) - .cast(); - } catch (e) { - if (context.mounted) { - context.showError( - 'Could not read existing saved apps', - description: - 'Your previous saved apps could not be decrypted. Starting fresh.', - technicalDetails: '$e', - ); - } - } - } - - // Modify the list - final appAddressableId = - '${app.event.kind}:${app.pubkey}:${app.identifier}'; - - if (isCurrentlySaved) { - existingAppIds.remove(appAddressableId); - } else { - if (!existingAppIds.contains(appAddressableId)) { - existingAppIds.add(appAddressableId); - } - } - - // Create new partial stack with updated list - final platform = ref.read(packageManagerProvider.notifier).platform; - final partialStack = PartialAppStack.withEncryptedApps( - name: 'Saved Apps', - identifier: kAppBookmarksIdentifier, - apps: existingAppIds, - platform: platform, - ); - - // Sign (encrypts the content) - final signedStack = await partialStack.signWith(signer); - - // Save to local storage and publish to relays - await ref.storage.save({signedStack}); - ref.storage.publish({ - signedStack, - }, relays: {'social', 'AppCatalog'}); - - if (context.mounted) { - context.showInfo( - isCurrentlySaved ? 'App removed from saved' : 'App saved', - ); - } - } catch (e) { - if (context.mounted) { - context.showError('Failed to update bookmark', technicalDetails: '$e'); - } - } - } - - 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 _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', technicalDetails: '$e'); - } - } - } - - Future _openApp(BuildContext context, WidgetRef ref, App app) async { - try { - final packageManager = ref.read(packageManagerProvider.notifier); - 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.', - technicalDetails: '$e', - ); - } - } - - Future _uninstallApp( - BuildContext context, - WidgetRef ref, - App app, - ) async { - try { - final packageManager = ref.read(packageManagerProvider.notifier); - await packageManager.uninstall(app.identifier); - } catch (e) { - if (context.mounted) { - // Don't show error for user cancellation - final message = e.toString(); - if (!message.contains('cancelled')) { - context.showError('Uninstall failed', technicalDetails: '$e'); - } - } - } - } } diff --git a/lib/screens/app_stack_screen.dart b/lib/screens/app_stack_screen.dart index 7f2102b..cddd69c 100644 --- a/lib/screens/app_stack_screen.dart +++ b/lib/screens/app_stack_screen.dart @@ -11,6 +11,7 @@ import 'package:zapstore/widgets/author_container.dart'; import 'package:zapstore/widgets/comments_section.dart'; import 'package:zapstore/widgets/common/badges.dart'; import 'package:zapstore/widgets/common/time_utils.dart'; +import 'package:zapstore/widgets/floating_overflow_menu.dart'; import 'package:zapstore/theme.dart'; class AppStackScreen extends HookConsumerWidget { @@ -167,50 +168,60 @@ class _AppStackContent extends HookConsumerWidget { final totalApps = sortedApps.length; return Scaffold( - body: SingleChildScrollView( - padding: const EdgeInsets.only(top: 16, bottom: 32), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + body: SafeArea( + child: Stack( children: [ - // Stack header - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: _StackHeader( - stack: stack, - author: author, - isAuthorLoading: isAuthorLoading, - ), - ), - const SizedBox(height: 24), - // Apps section header with count badge - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( + SingleChildScrollView( + padding: const EdgeInsets.only(top: 16, bottom: 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Apps in this stack', - style: context.textTheme.titleLarge, + // Stack header + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: _StackHeader( + stack: stack, + author: author, + isAuthorLoading: isAuthorLoading, + ), ), - const SizedBox(width: 8), - CountBadge( - count: totalApps, - color: AppColors.darkPillBackground, + const SizedBox(height: 24), + // Apps section header with count badge + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Text( + 'Apps in this stack', + style: context.textTheme.titleLarge, + ), + const SizedBox(width: 8), + CountBadge( + count: totalApps, + color: AppColors.darkPillBackground, + ), + ], + ), + ), + const SizedBox(height: 12), + // Apps list + if (sortedApps.isEmpty) + _EmptyAppsPlaceholder() + else + ...sortedApps.map( + (app) => AppCard(app: app, showUpdateArrow: app.hasUpdate), + ), + // Comments section + Padding( + padding: const EdgeInsets.only(top: 24), + child: StackCommentsSection(stack: stack), ), ], ), ), - const SizedBox(height: 12), - // Apps list - if (sortedApps.isEmpty) - _EmptyAppsPlaceholder() - else - ...sortedApps.map( - (app) => AppCard(app: app, showUpdateArrow: app.hasUpdate), - ), - // Comments section - Padding( - padding: const EdgeInsets.only(top: 24), - child: StackCommentsSection(stack: stack), + FloatingOverflowMenu( + shareUrl: getStackShareUrl(stack), + publisherPubkey: stack.pubkey, ), ], ), diff --git a/lib/widgets/floating_overflow_menu.dart b/lib/widgets/floating_overflow_menu.dart new file mode 100644 index 0000000..4cf8361 --- /dev/null +++ b/lib/widgets/floating_overflow_menu.dart @@ -0,0 +1,331 @@ +import 'dart:convert'; +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: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/services/package_manager/package_manager.dart'; +import 'package:zapstore/utils/extensions.dart'; + +/// Floating three-dot overflow menu reusable across detail screens. +/// +/// For apps: pass [app] to enable save/open/delete actions. +/// For stacks or other entities: omit [app] — only share, copy link, +/// view publisher, and open-in-browser are shown. +class FloatingOverflowMenu extends HookConsumerWidget { + const FloatingOverflowMenu({ + super.key, + required this.shareUrl, + required this.publisherPubkey, + this.app, + }); + + final String shareUrl; + final String publisherPubkey; + final App? app; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final signedInPubkey = ref.watch(Signer.activePubkeyProvider); + final isSignedIn = signedInPubkey != null; + + final isInstalled = app != null && + ref.watch(installedPackageProvider(app!.identifier)) != null; + + // Bookmark state (only relevant when app is provided) + final isSaved = app != null ? _watchIsSaved(ref) : false; + + return Positioned( + top: 8, + right: 8, + child: Material( + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.9), + shape: const CircleBorder(), + elevation: 2, + child: PopupMenuButton( + icon: const Icon(Icons.more_vert), + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + onSelected: (value) => _onSelected( + context, + ref, + value, + isSaved: isSaved, + ), + itemBuilder: (_) => [ + _menuItem('share', Icons.share, 'Share'), + _menuItem('copy_link', Icons.link, 'Copy link'), + if (app != null && isSignedIn) + PopupMenuItem( + value: 'save_app', + child: Row( + children: [ + Icon(isSaved ? Icons.bookmark : Icons.bookmark_border), + const SizedBox(width: 12), + Text(isSaved ? 'Remove from saved' : 'Save app'), + ], + ), + ), + _menuItem('view_publisher', Icons.person, 'View publisher'), + _menuItem('open_browser', Icons.open_in_browser, 'Open in browser'), + if (app != null && isInstalled) ...[ + _menuItem('open', Icons.open_in_new, 'Open'), + _menuItem('delete', Icons.delete_outline, 'Delete'), + ], + ], + ), + ), + ); + } + + bool _watchIsSaved(WidgetRef ref) { + final savedAppsAsync = ref.watch(bookmarksProvider); + final savedAppIds = savedAppsAsync.when( + data: (ids) => ids, + loading: () => {}, + error: (_, __) => {}, + ); + final appAddressableId = + '${app!.event.kind}:${app!.pubkey}:${app!.identifier}'; + return savedAppIds.contains(appAddressableId); + } + + static PopupMenuItem _menuItem( + String value, + IconData icon, + String label, + ) { + return PopupMenuItem( + value: value, + child: Row( + children: [Icon(icon), const SizedBox(width: 12), Text(label)], + ), + ); + } + + void _onSelected( + BuildContext context, + WidgetRef ref, + String value, { + required bool isSaved, + }) { + switch (value) { + case 'share': + _share(context); + case 'copy_link': + _copyLink(context); + case 'save_app': + _toggleSaveApp(context, ref, isSaved); + case 'view_publisher': + _viewPublisher(context); + case 'open_browser': + _openInBrowser(context); + case 'open': + _openApp(context, ref); + case 'delete': + _uninstallApp(context, ref); + } + } + + // -- Actions --------------------------------------------------------------- + + void _share(BuildContext context) { + try { + SharePlus.instance.share(ShareParams(text: shareUrl)); + } catch (e) { + if (context.mounted) { + context.showError('Failed to share', technicalDetails: '$e'); + } + } + } + + void _copyLink(BuildContext context) { + try { + Clipboard.setData(ClipboardData(text: shareUrl)); + } catch (e) { + if (context.mounted) { + context.showError('Failed to copy link', technicalDetails: '$e'); + } + } + } + + void _viewPublisher(BuildContext context) { + final segments = GoRouterState.of(context).uri.pathSegments; + final first = segments.isNotEmpty ? segments.first : 'search'; + context.push('/$first/user/$publisherPubkey'); + } + + Future _openInBrowser(BuildContext context) async { + try { + final uri = Uri.parse(shareUrl); + 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', technicalDetails: '$e'); + } + } + } + + // -- App-only actions ------------------------------------------------------ + + Future _toggleSaveApp( + BuildContext context, + WidgetRef ref, + bool isCurrentlySaved, + ) async { + final a = app; + if (a == null) return; + + try { + final signer = ref.read(Signer.activeSignerProvider); + final signedInPubkey = ref.read(Signer.activePubkeyProvider); + + if (signer == null || signedInPubkey == null) { + if (context.mounted) { + context.showError( + 'Sign in required', + description: 'You need to sign in to save apps.', + ); + } + return; + } + + final existingStackState = await ref.storage.query( + RequestFilter( + authors: {signedInPubkey}, + tags: { + '#d': {kAppBookmarksIdentifier}, + }, + ).toRequest(), + source: const LocalSource(), + ); + final existingStack = existingStackState.firstOrNull; + + List existingAppIds = []; + if (existingStack != null) { + try { + final decryptedContent = await signer.nip44Decrypt( + existingStack.content, + signedInPubkey, + ); + existingAppIds = + (jsonDecode(decryptedContent) as List).cast(); + } catch (e) { + if (context.mounted) { + context.showError( + 'Could not read existing saved apps', + description: + 'Your previous saved apps could not be decrypted. Starting fresh.', + technicalDetails: '$e', + ); + } + } + } + + final appAddressableId = + '${a.event.kind}:${a.pubkey}:${a.identifier}'; + + if (isCurrentlySaved) { + existingAppIds.remove(appAddressableId); + } else { + if (!existingAppIds.contains(appAddressableId)) { + existingAppIds.add(appAddressableId); + } + } + + final platform = ref.read(packageManagerProvider.notifier).platform; + final partialStack = PartialAppStack.withEncryptedApps( + name: 'Saved Apps', + identifier: kAppBookmarksIdentifier, + apps: existingAppIds, + platform: platform, + ); + + final signedStack = await partialStack.signWith(signer); + + await ref.storage.save({signedStack}); + ref.storage.publish({signedStack}, relays: {'social', 'AppCatalog'}); + + if (context.mounted) { + context.showInfo( + isCurrentlySaved ? 'App removed from saved' : 'App saved', + ); + } + } catch (e) { + if (context.mounted) { + context.showError('Failed to update bookmark', technicalDetails: '$e'); + } + } + } + + Future _openApp(BuildContext context, WidgetRef ref) async { + final a = app; + if (a == null) return; + + try { + final packageManager = ref.read(packageManagerProvider.notifier); + await packageManager.launchApp(a.identifier); + } catch (e) { + if (!context.mounted) return; + context.showError( + 'Failed to launch ${a.name ?? a.identifier}', + description: + 'The app may have been uninstalled or moved. Try reinstalling.', + technicalDetails: '$e', + ); + } + } + + Future _uninstallApp(BuildContext context, WidgetRef ref) async { + final a = app; + if (a == null) return; + + try { + final packageManager = ref.read(packageManagerProvider.notifier); + await packageManager.uninstall(a.identifier); + } catch (e) { + if (context.mounted) { + final message = e.toString(); + if (!message.contains('cancelled')) { + context.showError('Uninstall failed', technicalDetails: '$e'); + } + } + } + } +} + +/// Build a shareable URL for an [App]. +String getAppShareUrl(App app) { + final naddr = Utils.encodeShareableIdentifier( + AddressInput( + identifier: app.identifier, + author: app.pubkey, + kind: app.event.kind, + relays: const [kDefaultRelay], + ), + ); + return 'https://zapstore.dev/apps/$naddr'; +} + +/// Build a shareable URL for an [AppStack]. +String getStackShareUrl(AppStack stack) { + final naddr = Utils.encodeShareableIdentifier( + AddressInput( + identifier: stack.identifier, + author: stack.pubkey, + kind: stack.event.kind, + relays: const [kDefaultRelay], + ), + ); + return 'https://zapstore.dev/stacks/$naddr'; +}