Floating overflow menu for apps and stacks

This commit is contained in:
franzap
2026-04-08 15:24:17 -03:00
parent 41b5fba40b
commit a708523216
3 changed files with 385 additions and 376 deletions
+6 -339
View File
@@ -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: () => <String>{},
error: (_, __) => <String>{},
);
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<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'),
],
),
),
if (isSignedIn)
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: 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<void> _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<AppStack>(
authors: {signedInPubkey},
tags: {
'#d': {kAppBookmarksIdentifier},
},
).toRequest(),
source: const LocalSource(),
);
final existingStack = existingStackState.firstOrNull;
// Get existing app IDs by decrypting if stack exists
List<String> existingAppIds = [];
if (existingStack != null) {
try {
final decryptedContent = await signer.nip44Decrypt(
existingStack.content,
signedInPubkey,
);
existingAppIds = (jsonDecode(decryptedContent) as List)
.cast<String>();
} 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<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', technicalDetails: '$e');
}
}
}
Future<void> _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<void> _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');
}
}
}
}
}
+48 -37
View File
@@ -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,
),
],
),
+331
View File
@@ -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<String>(
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<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'),
],
),
),
_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: () => <String>{},
error: (_, __) => <String>{},
);
final appAddressableId =
'${app!.event.kind}:${app!.pubkey}:${app!.identifier}';
return savedAppIds.contains(appAddressableId);
}
static PopupMenuItem<String> _menuItem(
String value,
IconData icon,
String label,
) {
return PopupMenuItem<String>(
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<void> _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<void> _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<AppStack>(
authors: {signedInPubkey},
tags: {
'#d': {kAppBookmarksIdentifier},
},
).toRequest(),
source: const LocalSource(),
);
final existingStack = existingStackState.firstOrNull;
List<String> existingAppIds = [];
if (existingStack != null) {
try {
final decryptedContent = await signer.nip44Decrypt(
existingStack.content,
signedInPubkey,
);
existingAppIds =
(jsonDecode(decryptedContent) as List).cast<String>();
} 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<void> _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<void> _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';
}