mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-06 04:58:23 +00:00
Floating overflow menu for apps and stacks
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user