diff --git a/lib/main.dart b/lib/main.dart index 45b2b38..e378839 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -263,8 +263,7 @@ Future _attemptAutoSignIn(Ref ref) async { } /// Query AppCatalogRelayList after successful sign-in -/// TODO: Fix ref type -Future onSignInSuccess(dynamic ref) async { +Future onSignInSuccess(Ref ref) async { final pubkey = ref.read(Signer.activePubkeyProvider); if (pubkey == null) return; diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index d0f0b9c..eb786ec 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -841,7 +841,8 @@ class _DebugMessagesSection extends HookConsumerWidget { Clipboard.setData(ClipboardData(text: req)); context.showInfo( 'REQ filter copied', - description: 'Paste into a Nostr client to debug this query.', + description: + 'Paste into a Nostr client to debug this query.', ); }, ), @@ -1096,7 +1097,7 @@ class _DataManagementSection extends ConsumerWidget { } catch (e) { if (context.mounted) { Navigator.of(context, rootNavigator: true).pop(); - context.showError('Restart failed', description: '${e.toString()}'); + context.showError('Restart failed', description: e.toString()); } } } diff --git a/lib/screens/updates_screen.dart b/lib/screens/updates_screen.dart index 464242f..5167a13 100644 --- a/lib/screens/updates_screen.dart +++ b/lib/screens/updates_screen.dart @@ -136,15 +136,28 @@ class _UpdatesTab extends HookConsumerWidget { children: [ ColorFiltered( colorFilter: const ColorFilter.matrix([ - 0.2126, 0.7152, 0.0722, 0, 0, - 0.2126, 0.7152, 0.0722, 0, 0, - 0.2126, 0.7152, 0.0722, 0, 0, - 0, 0, 0, 1, 0, + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0.2126, + 0.7152, + 0.0722, + 0, + 0, + 0, + 0, + 0, + 1, + 0, ]), - child: const Text( - '🎉', - style: TextStyle(fontSize: 48), - ), + child: const Text('🎉', style: TextStyle(fontSize: 48)), ), const SizedBox(height: 12), Text( @@ -449,15 +462,39 @@ class _UpdatesTab extends HookConsumerWidget { ], ), ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), - child: Text( - 'Confirming the Android system prompt is required', - style: context.textTheme.bodyMedium?.copyWith( - color: Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.6), - ), + Container( + margin: const EdgeInsets.fromLTRB(16, 0, 16, 8), + 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( + 'Apps not installed or updated by the latest Zapstore will show here ' + 'and require manual confirmation of the Android system prompt once per app.', + style: context.textTheme.bodyMedium?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ), + ], ), ), ...manualUpdates.map( @@ -622,11 +659,7 @@ class _TabLabelWithBadge extends StatelessWidget { children: [ Text(label), if (count > 0) - Positioned( - top: -6, - right: -18, - child: _CountBadge(count: count), - ), + Positioned(top: -6, right: -18, child: _CountBadge(count: count)), ], ), ), diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 8f088b2..57541b1 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -1,32 +1,48 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:zapstore/theme.dart'; extension ContextX on BuildContext { ThemeData get theme => Theme.of(this); - void showInfo(String title, {String? description, IconData? icon}) { - _showCustomToast( - context: this, - title: title, - description: description, - icon: icon ?? Icons.info_outline_rounded, - type: _ToastType.info, - ); - } - - void showError( + void showInfo( String title, { String? description, IconData? icon, List<(String, Future Function())> actions = const [], }) { + _showCustomToast( + context: this, + title: title, + description: description, + icon: icon ?? Icons.info_outline_rounded, + type: _ToastType.info, + actions: actions, + ); + } + + void showError( + String title, { + String? description, + IconData? icon, + List<(String, Future Function())> actions = const [], + }) { + // Always add Copy action for errors with description + final allActions = <(String, Future Function())>[ + ...actions, + if (description != null) + ('Copy', () async { + await Clipboard.setData(ClipboardData(text: '$title\n\n$description')); + }), + ]; + _showCustomToast( context: this, title: title, description: description, icon: icon ?? Icons.error_outline_rounded, type: _ToastType.error, - actions: actions, + actions: allActions, ); } } @@ -122,9 +138,13 @@ class _ToastOverlayState extends State<_ToastOverlay> } void _startAutoClose() { - final duration = widget.type == _ToastType.error - ? const Duration(seconds: 6) - : const Duration(seconds: 4); + // Longer duration when there are actions, or for errors + final hasActions = widget.actions.isNotEmpty; + final duration = hasActions + ? const Duration(seconds: 10) + : widget.type == _ToastType.error + ? const Duration(seconds: 8) + : const Duration(seconds: 6); Future.delayed(duration, () { if (mounted && !_isHovered) { @@ -152,22 +172,18 @@ class _ToastOverlayState extends State<_ToastOverlay> final isError = widget.type == _ToastType.error; - // Colors + // Colors - info: darker light blue, error: red final backgroundColor = isError - ? const Color(0xFF1A1215) // Dark reddish-black - : const Color(0xFF111827); // Dark blue-black + ? const Color(0xFFDC2626) // Red + : const Color(0xFF0284C7); // Darker light blue (sky-600) final borderColor = isError - ? const Color(0xFF7F1D1D).withValues(alpha: 0.6) // Dark red border - : AppColors.darkPrimary.withValues(alpha: 0.4); + ? const Color(0xFFB91C1C).withValues(alpha: 0.8) // Darker red border + : const Color(0xFF0369A1).withValues(alpha: 0.8); // Even darker blue border (sky-700) - final accentColor = isError - ? const Color(0xFFEF4444) // Red - : AppColors.darkPrimary; + final accentColor = Colors.white; - final iconBgColor = isError - ? const Color(0xFF7F1D1D).withValues(alpha: 0.3) - : AppColors.darkPrimary.withValues(alpha: 0.15); + final iconBgColor = Colors.white.withValues(alpha: 0.2); return Positioned( top: topPadding + 8, @@ -283,95 +299,108 @@ class _ToastContent extends StatelessWidget { @override Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.center, + final hasExtraContent = description != null || actions.isNotEmpty; + + // Use Stack to position X at top-right always + return Stack( children: [ - // Icon - Container( - width: 38, - height: 38, - decoration: BoxDecoration( - color: iconBgColor, - borderRadius: BorderRadius.circular(10), - ), - child: Icon( - icon, - color: accentColor, - size: 20, - ), - ), - const SizedBox(width: 14), - // Text content - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + // Main content with padding for X button + Padding( + padding: const EdgeInsets.only(right: 36), // Space for X button + child: Row( + // Center align when no description, top align when there's extra content + crossAxisAlignment: hasExtraContent + ? CrossAxisAlignment.start + : CrossAxisAlignment.center, children: [ - // Title - Text( - title, - style: const TextStyle( - fontFamily: kFontFamily, - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.white, - height: 1.35, + // Icon + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: iconBgColor, + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + icon, + color: accentColor, + size: 20, ), ), - // Description - if (description != null) ...[ - const SizedBox(height: 6), - Flexible( - child: SingleChildScrollView( - child: Text( - description!, - style: TextStyle( + const SizedBox(width: 14), + // Text content + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Title + Text( + title, + style: const TextStyle( fontFamily: kFontFamily, - fontSize: 13, - fontWeight: FontWeight.w400, - color: Colors.white.withValues(alpha: 0.7), - height: 1.45, + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + height: 1.35, ), ), - ), + // Description + if (description != null) ...[ + const SizedBox(height: 6), + Text( + description!, + style: TextStyle( + fontFamily: kFontFamily, + fontSize: 13, + fontWeight: FontWeight.w400, + color: Colors.white.withValues(alpha: 0.7), + height: 1.45, + ), + ), + ], + // Actions + if (actions.isNotEmpty) ...[ + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: actions + .map((action) => _ToastActionButton( + label: action.$1, + onPressed: () async { + await action.$2(); + onDismiss(); + }, + accentColor: accentColor, + )) + .toList(), + ), + ], + ], ), - ], - // Actions - if (actions.isNotEmpty) ...[ - const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 8, - children: actions - .map((action) => _ToastActionButton( - label: action.$1, - onPressed: () async { - await action.$2(); - onDismiss(); - }, - accentColor: accentColor, - )) - .toList(), - ), - ], + ), ], ), ), - // Close button - const SizedBox(width: 8), - GestureDetector( - onTap: onDismiss, - child: Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.06), - borderRadius: BorderRadius.circular(6), - ), - child: Icon( - Icons.close_rounded, - size: 16, - color: Colors.white.withValues(alpha: 0.5), + // Close button - always top-right + Positioned( + top: 0, + right: 0, + child: GestureDetector( + onTap: onDismiss, + child: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + Icons.close_rounded, + size: 16, + color: Colors.white.withValues(alpha: 0.5), + ), ), ), ), diff --git a/lib/utils/debug_utils.dart b/lib/utils/debug_utils.dart index 46a0fee..6db324b 100644 --- a/lib/utils/debug_utils.dart +++ b/lib/utils/debug_utils.dart @@ -1,6 +1,7 @@ /// Debug utilities for development and testing library; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; /// Check if the current user is in debug mode based on their pubkey @@ -29,3 +30,7 @@ bool isDebugMode(String? pubkey) { return false; } + +/// Provider that exposes a Ref for use in functions that need consistent Ref type +/// Usage: ref.read(refProvider) to get a Ref from WidgetRef context +final refProvider = Provider((ref) => ref); diff --git a/lib/widgets/app_detail_widgets.dart b/lib/widgets/app_detail_widgets.dart index a207c83..1108cc5 100644 --- a/lib/widgets/app_detail_widgets.dart +++ b/lib/widgets/app_detail_widgets.dart @@ -12,7 +12,6 @@ import 'package:zapstore/services/download_service.dart'; import 'package:zapstore/services/notification_service.dart'; import 'package:zapstore/theme.dart'; import 'package:zapstore/utils/extensions.dart'; -import 'package:zapstore/widgets/auth_widgets.dart'; import 'package:zapstore/widgets/bookmark_widgets.dart'; import 'package:zapstore/widgets/common/base_dialog.dart'; import 'package:zapstore/widgets/expandable_markdown.dart'; @@ -84,29 +83,6 @@ class SocialActionsRow extends HookConsumerWidget { '${app.event.kind}:${app.pubkey}:${app.identifier}'; final isPrivatelySaved = bookmarkedIds.contains(appAddressableId); - // Query public packs for the dialog - final publicPacksState = isSignedIn - ? ref.watch( - query( - authors: {signedInPubkey}, - and: (pack) => {pack.apps}, - source: const LocalAndRemoteSource( - relays: 'social', - stream: false, - ), - andSource: const LocalSource(), - subscriptionPrefix: 'user-packs', - ), - ) - : null; - - final allPacks = publicPacksState?.models ?? []; - - // Split packs for dialog (exclude the bookmark pack) - final publicPacks = allPacks - .where((pack) => pack.identifier != kAppBookmarksIdentifier) - .toList(); - // Query zaps for zappers list final latestMetadata = app.latestFileMetadata; final zapsState = latestMetadata != null @@ -157,13 +133,8 @@ class SocialActionsRow extends HookConsumerWidget { Expanded( flex: 16, child: FilledButton( - onPressed: () { - if (isSignedIn) { - _showBookmarkDialog(context, ref, app, isPrivatelySaved); - } else { - _showSignInPrompt(context, ref); - } - }, + onPressed: () => + _showBookmarkDialog(context, ref, app, isPrivatelySaved), style: FilledButton.styleFrom( padding: EdgeInsets.zero, backgroundColor: isPrivatelySaved && isSignedIn @@ -193,13 +164,7 @@ class SocialActionsRow extends HookConsumerWidget { Expanded( flex: 16, child: FilledButton( - onPressed: () { - if (isSignedIn) { - _showAddToPackDialog(context, ref, app, publicPacks); - } else { - _showSignInPrompt(context, ref); - } - }, + onPressed: () => _showAddToPackDialog(context, app), style: FilledButton.styleFrom( padding: EdgeInsets.zero, backgroundColor: @@ -219,7 +184,7 @@ class SocialActionsRow extends HookConsumerWidget { Expanded( flex: 16, child: FilledButton( - onPressed: () => _shareApp(app), + onPressed: () => _shareApp(context, app), style: FilledButton.styleFrom( padding: EdgeInsets.zero, backgroundColor: @@ -262,17 +227,15 @@ class SocialActionsRow extends HookConsumerWidget { Future _showAddToPackDialog( BuildContext context, - WidgetRef ref, App app, - List publicPacks, ) async { await showBaseDialog( context: context, - dialog: AddToPackDialog(app: app, publicPacks: publicPacks), + dialog: AddToPackDialog(app: app), ); } - void _shareApp(App app) { + void _shareApp(BuildContext context, App app) { try { // Generate naddr for the app final naddr = Utils.encodeShareableIdentifier( @@ -288,13 +251,11 @@ class SocialActionsRow extends HookConsumerWidget { // Share using Android's share sheet SharePlus.instance.share(ShareParams(text: shareUrl)); } catch (e) { - // Failed to share app + if (context.mounted) { + context.showError('Failed to share app', description: '$e'); + } } } - - Future _showSignInPrompt(BuildContext context, WidgetRef ref) async { - await showBaseDialog(context: context, dialog: const SignInPromptDialog()); - } } /// Skeleton loading state for app detail screen diff --git a/lib/widgets/auth_widgets.dart b/lib/widgets/auth_widgets.dart index d3eb2b4..9c641cc 100644 --- a/lib/widgets/auth_widgets.dart +++ b/lib/widgets/auth_widgets.dart @@ -1,74 +1,100 @@ -import 'package:async_button_builder/async_button_builder.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:zapstore/main.dart'; import 'package:zapstore/services/notification_service.dart'; -import 'package:zapstore/widgets/common/base_dialog.dart'; +import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/utils/debug_utils.dart'; -/// Dialog prompting user to sign in for bookmark features -class SignInPromptDialog extends HookConsumerWidget { - const SignInPromptDialog({super.key}); +const _amberPackageId = 'com.greenart7c3.nostrsigner'; +const _amberNaddr = + 'naddr1qqdkxmmd9enhyet9deshyaphvvejumn0wd68yumfvahx2uszyp6hjpmdntls5n8aa7n7ypzlyjrv0ewch33ml3452wtjx0smhl93jqcyqqq8uzcgpp6ky'; + +/// Reusable sign-in prompt widget for dialogs and bottom sheets. +/// Displays a styled tappable message prompting the user to sign in. +class SignInPrompt extends HookConsumerWidget { + const SignInPrompt({super.key, required this.message}); + + /// Description of what signing in enables + final String message; @override Widget build(BuildContext context, WidgetRef ref) { - return BaseDialog( - titleIcon: const Icon(Icons.bookmark), - title: Text( - 'Sign in to bookmark', - style: Theme.of(context).textTheme.headlineSmall, - ), - content: BaseDialogContent( - children: [ - const SizedBox(height: 8), - Text( - 'Sign in to bookmark apps and create app packs.', - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - ), - actions: [ - BaseDialogAction( - onPressed: () => Navigator.pop(context), - child: const Text('Cancel'), - ), - AsyncButtonBuilder( - onPressed: () async { - try { - await ref.read(amberSignerProvider).signIn(); - onSignInSuccess(ref); - if (context.mounted) { - Navigator.pop(context); - } - } catch (e) { - if (context.mounted) { - context.showError( - 'Sign-in failed', - description: - 'Amber could not complete the sign-in. Make sure Amber is installed and try again.\n\n$e', - ); - } - } - }, - builder: (context, child, callback, buttonState) { - return FilledButton.icon( - onPressed: buttonState.maybeWhen( - loading: () => null, - orElse: () => callback, - ), - icon: buttonState.maybeWhen( - loading: () => const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ), - orElse: () => const Icon(Icons.login, size: 18), - ), - label: const Text('Sign in'), + final theme = Theme.of(context); + final packageManager = ref.watch(packageManagerProvider); + final isAmberInstalled = packageManager.any( + (p) => p.appId == _amberPackageId, + ); + final isLoading = useState(false); + + Future handleSignIn() async { + if (isLoading.value) return; + + if (!isAmberInstalled) { + context.showInfo('Install Amber to sign in with your Nostr identity'); + context.push('/profile/apps/$_amberNaddr'); + } else { + isLoading.value = true; + try { + await ref.read(amberSignerProvider).signIn(); + onSignInSuccess(ref.read(refProvider)); + } catch (e) { + if (context.mounted) { + context.showError( + 'Sign-in failed', + description: + 'Amber could not complete the sign-in. Make sure Amber is installed and try again.\n\n$e', ); - }, - child: const SizedBox.shrink(), + } + } finally { + if (context.mounted) { + isLoading.value = false; + } + } + } + } + + return Material( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: handleSignIn, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + if (isLoading.value) + SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.primary, + ), + ) + else + Icon(Icons.login, size: 20, color: theme.colorScheme.primary), + const SizedBox(width: 12), + Expanded( + child: Text( + message, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + ), + const SizedBox(width: 8), + Icon( + Icons.chevron_right, + size: 20, + color: theme.colorScheme.onSurface.withValues(alpha: 0.5), + ), + ], + ), ), - ], + ), ); } } diff --git a/lib/widgets/bookmark_widgets.dart b/lib/widgets/bookmark_widgets.dart index 6e2f3d5..0b4f01c 100644 --- a/lib/widgets/bookmark_widgets.dart +++ b/lib/widgets/bookmark_widgets.dart @@ -7,6 +7,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; import 'package:zapstore/services/notification_service.dart'; 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) @@ -22,28 +23,16 @@ class BookmarkDialog extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final isSignedIn = ref.watch(Signer.activePubkeyProvider) != null; + return BaseDialog( titleIcon: const Icon(Icons.bookmark), - title: Text('Bookmark', style: Theme.of(context).textTheme.headlineSmall), + title: Text( + 'Private bookmark', + style: Theme.of(context).textTheme.headlineSmall, + ), content: BaseDialogContent( children: [ - const SizedBox(height: 8), - Row( - children: [ - Icon( - Icons.lock, - size: 16, - color: Theme.of(context).colorScheme.primary, - ), - const SizedBox(width: 8), - Text( - 'Private bookmark', - style: Theme.of( - context, - ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), - ), - ], - ), const SizedBox(height: 8), Text( 'Encrypted and only visible to you. Save apps to find them easily later.', @@ -53,6 +42,10 @@ class BookmarkDialog extends HookConsumerWidget { ).colorScheme.onSurface.withValues(alpha: 0.7), ), ), + if (!isSignedIn) ...[ + const SizedBox(height: 16), + const SignInPrompt(message: 'Sign in to bookmark apps privately.'), + ], ], ), actions: [ @@ -65,10 +58,12 @@ class BookmarkDialog extends HookConsumerWidget { _togglePrivateSave(context, ref, app, isPrivatelySaved), builder: (context, child, callback, buttonState) { return FilledButton.icon( - onPressed: buttonState.maybeWhen( - loading: () => null, - orElse: () => callback, - ), + onPressed: !isSignedIn + ? null + : buttonState.maybeWhen( + loading: () => null, + orElse: () => callback, + ), icon: buttonState.maybeWhen( loading: () => const SizedBox( width: 16, @@ -132,7 +127,13 @@ class BookmarkDialog extends HookConsumerWidget { existingAppIds = (jsonDecode(decryptedContent) as List) .cast(); } catch (e) { - // Failed to decrypt existing pack + if (context.mounted) { + context.showError( + 'Could not read existing bookmarks', + description: + 'Your previous bookmarks could not be decrypted. Starting fresh.\n\n$e', + ); + } } } @@ -176,19 +177,36 @@ class BookmarkDialog extends HookConsumerWidget { /// Dialog for managing app packs (public collections) class AddToPackDialog extends HookConsumerWidget { - const AddToPackDialog({ - super.key, - required this.app, - required this.publicPacks, - }); + const AddToPackDialog({super.key, required this.app}); final App app; - final List publicPacks; @override Widget build(BuildContext context, WidgetRef ref) { + final signedInPubkey = ref.watch(Signer.activePubkeyProvider); + final isSignedIn = signedInPubkey != null; final newPackNameController = useTextEditingController(); + // Query public packs when signed in + final publicPacksState = isSignedIn + ? ref.watch( + query( + authors: {signedInPubkey}, + and: (pack) => {pack.apps}, + source: const LocalAndRemoteSource( + relays: 'social', + stream: false, + ), + andSource: const LocalSource(), + subscriptionPrefix: 'user-packs-dialog', + ), + ) + : null; + + final publicPacks = (publicPacksState?.models ?? []) + .where((pack) => pack.identifier != kAppBookmarksIdentifier) + .toList(); + // Check which packs contain this app final selectedCollections = useState>({}); // Track new packs with their names (identifier -> name) @@ -246,139 +264,150 @@ class AddToPackDialog extends HookConsumerWidget { ), const SizedBox(height: 16), - // Show existing packs and newly created packs as selectable chips - if (publicPacks.isNotEmpty || newPackNames.value.isNotEmpty) ...[ - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - // Existing packs - ...publicPacks.map((pack) { - final isSelected = selectedCollections.value.contains( - pack.identifier, - ); - final displayName = pack.name ?? pack.identifier; - return FilterChip( - label: Text(displayName), - selected: isSelected, - onSelected: (selected) { - if (selected) { - selectedCollections.value = { - ...selectedCollections.value, - pack.identifier, - }; - } else { - selectedCollections.value = { - ...selectedCollections.value, - }..remove(pack.identifier); - } - }, - backgroundColor: isSelected - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.surfaceContainerHighest, - selectedColor: Theme.of(context).colorScheme.primary, - labelStyle: TextStyle( - color: isSelected - ? Theme.of(context).colorScheme.onPrimary - : Theme.of(context).colorScheme.onSurface, - fontSize: 13, - ), - labelPadding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 0, - ), - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 6, - ), - side: BorderSide.none, - ); - }), - // Newly created packs not yet in publicPacks - ...newPackNames.value.entries - .where( - (entry) => !publicPacks.any( - (pack) => pack.identifier == entry.key, + if (!isSignedIn) ...[ + const SignInPrompt( + message: 'Sign in to create and manage your public app packs.', + ), + ] else ...[ + // Show existing packs and newly created packs as selectable chips + if (publicPacks.isNotEmpty || newPackNames.value.isNotEmpty) ...[ + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + // Existing packs + ...publicPacks.map((pack) { + final isSelected = selectedCollections.value.contains( + pack.identifier, + ); + final displayName = pack.name ?? pack.identifier; + return FilterChip( + label: Text(displayName), + selected: isSelected, + onSelected: (selected) { + if (selected) { + selectedCollections.value = { + ...selectedCollections.value, + pack.identifier, + }; + } else { + selectedCollections.value = { + ...selectedCollections.value, + }..remove(pack.identifier); + } + }, + backgroundColor: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + selectedColor: Theme.of(context).colorScheme.primary, + labelStyle: TextStyle( + color: isSelected + ? Theme.of(context).colorScheme.onPrimary + : Theme.of(context).colorScheme.onSurface, + fontSize: 13, ), - ) - .map((entry) { - final identifier = entry.key; - final name = entry.value; - final isSelected = selectedCollections.value.contains( - identifier, - ); - return FilterChip( - label: Text(name), - selected: isSelected, - onSelected: (selected) { - if (selected) { - selectedCollections.value = { - ...selectedCollections.value, - identifier, - }; - } else { - selectedCollections.value = { - ...selectedCollections.value, - }..remove(identifier); - // Also remove from newPackNames if deselected - final updatedNames = Map.from( - newPackNames.value, - )..remove(identifier); - newPackNames.value = updatedNames; - } - }, - backgroundColor: isSelected - ? Theme.of(context).colorScheme.primary - : Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - selectedColor: Theme.of(context).colorScheme.primary, - labelStyle: TextStyle( - color: isSelected - ? Theme.of(context).colorScheme.onPrimary - : Theme.of(context).colorScheme.onSurface, - fontSize: 13, + labelPadding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 0, + ), + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 6, + ), + side: BorderSide.none, + ); + }), + // Newly created packs not yet in publicPacks + ...newPackNames.value.entries + .where( + (entry) => !publicPacks.any( + (pack) => pack.identifier == entry.key, ), - labelPadding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 0, - ), - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 6, - ), - side: BorderSide.none, - ); - }), - ], - ), - const SizedBox(height: 16), - ], + ) + .map((entry) { + final identifier = entry.key; + final name = entry.value; + final isSelected = selectedCollections.value.contains( + identifier, + ); + return FilterChip( + label: Text(name), + selected: isSelected, + onSelected: (selected) { + if (selected) { + selectedCollections.value = { + ...selectedCollections.value, + identifier, + }; + } else { + selectedCollections.value = { + ...selectedCollections.value, + }..remove(identifier); + // Also remove from newPackNames if deselected + final updatedNames = Map.from( + newPackNames.value, + )..remove(identifier); + newPackNames.value = updatedNames; + } + }, + backgroundColor: isSelected + ? Theme.of(context).colorScheme.primary + : Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + selectedColor: Theme.of(context).colorScheme.primary, + labelStyle: TextStyle( + color: isSelected + ? Theme.of(context).colorScheme.onPrimary + : Theme.of(context).colorScheme.onSurface, + fontSize: 13, + ), + labelPadding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 0, + ), + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 6, + ), + side: BorderSide.none, + ); + }), + ], + ), + const SizedBox(height: 16), + ], - // Input for new pack - TextField( - controller: newPackNameController, - maxLength: 20, - decoration: const InputDecoration( - labelText: 'Add to new app pack', - hintText: 'e.g., Favorite Apps', - border: OutlineInputBorder(), + // Input for new pack + TextField( + controller: newPackNameController, + maxLength: 20, + decoration: const InputDecoration( + labelText: 'Add to new app pack', + hintText: 'e.g., Favorite Apps', + border: OutlineInputBorder(), + ), + onSubmitted: (text) { + final name = text.trim(); + final identifier = slugify(name); + if (name.isNotEmpty && + identifier.isNotEmpty && + !selectedCollections.value.contains(identifier)) { + selectedCollections.value = { + ...selectedCollections.value, + identifier, + }; + newPackNames.value = { + ...newPackNames.value, + identifier: name, + }; + newPackNameController.clear(); + } + }, ), - onSubmitted: (text) { - final name = text.trim(); - final identifier = slugify(name); - if (name.isNotEmpty && - identifier.isNotEmpty && - !selectedCollections.value.contains(identifier)) { - selectedCollections.value = { - ...selectedCollections.value, - identifier, - }; - newPackNames.value = {...newPackNames.value, identifier: name}; - newPackNameController.clear(); - } - }, - ), + ], ], ), actions: [ @@ -418,10 +447,12 @@ class AddToPackDialog extends HookConsumerWidget { }, builder: (context, child, callback, buttonState) { return FilledButton.icon( - onPressed: buttonState.maybeWhen( - loading: () => null, - orElse: () => callback, - ), + onPressed: !isSignedIn + ? null + : buttonState.maybeWhen( + loading: () => null, + orElse: () => callback, + ), icon: buttonState.maybeWhen( loading: () => const SizedBox( width: 16, @@ -559,4 +590,3 @@ class AddToPackDialog extends HookConsumerWidget { } } } - diff --git a/lib/widgets/comments_section.dart b/lib/widgets/comments_section.dart index 8cd8710..7eb7d51 100644 --- a/lib/widgets/comments_section.dart +++ b/lib/widgets/comments_section.dart @@ -1,15 +1,15 @@ +import 'package:async_button_builder/async_button_builder.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:models/models.dart'; -import 'package:async_button_builder/async_button_builder.dart'; -import 'package:zapstore/utils/extensions.dart'; -import 'package:zapstore/widgets/common/profile_avatar.dart'; import 'package:intl/intl.dart'; -import 'package:zapstore/widgets/sign_in_button.dart'; +import 'package:models/models.dart'; import 'package:zapstore/services/notification_service.dart'; -import 'package:zapstore/widgets/pill_widget.dart'; import 'package:zapstore/theme.dart'; +import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/widgets/auth_widgets.dart'; +import 'package:zapstore/widgets/common/profile_avatar.dart'; +import 'package:zapstore/widgets/pill_widget.dart'; class CommentsSection extends HookConsumerWidget { const CommentsSection({super.key, required this.app, this.fileMetadata}); @@ -245,37 +245,12 @@ class _AddCommentButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final signer = ref.watch(Signer.activeSignerProvider); - - if (signer == null) { - return SizedBox( - width: double.infinity, - child: Theme( - data: Theme.of(context).copyWith( - filledButtonTheme: FilledButtonThemeData( - style: FilledButton.styleFrom( - backgroundColor: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - foregroundColor: Theme.of(context).colorScheme.onSurface, - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ), - child: SignInButton(label: 'Sign in to comment', minimal: false), - ), - ); - } - return SizedBox( width: double.infinity, child: FilledButton.icon( onPressed: () => _showCommentComposer(context), icon: const Icon(Icons.add_comment), - label: Text('Add Comment'), + label: const Text('Add Comment'), style: FilledButton.styleFrom( backgroundColor: Theme.of( context, @@ -304,6 +279,7 @@ class _CommentComposer extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final isSignedIn = ref.watch(Signer.activePubkeyProvider) != null; final textController = useTextEditingController(); // Rebuild on text changes so the Post button enables/disables correctly useListenable(textController); @@ -339,30 +315,39 @@ class _CommentComposer extends HookConsumerWidget { ], ), const SizedBox(height: 16), - TextField( - controller: textController, - decoration: InputDecoration( - hintText: - 'Share your thoughts about $appName $versionToComment...', - border: const OutlineInputBorder(), + if (!isSignedIn) ...[ + const SignInPrompt( + message: 'Sign in to share your thoughts and help others discover great apps.', ), - maxLines: 4, - autofocus: true, - ), + ] else ...[ + TextField( + controller: textController, + decoration: InputDecoration( + hintText: + 'Share your thoughts about $appName $versionToComment...', + border: const OutlineInputBorder(), + ), + maxLines: 4, + autofocus: true, + ), + ], const SizedBox(height: 16), SizedBox( width: double.infinity, child: AsyncButtonBuilder( - child: Text('Post Comment'), + child: const Text('Post Comment'), onPressed: () => _publishComment(ref, textController.text, context), builder: (context, child, callback, buttonState) { return FilledButton( - onPressed: buttonState.maybeWhen( - loading: () => null, - orElse: () => - textController.text.trim().isEmpty ? null : callback, - ), + onPressed: !isSignedIn + ? null + : buttonState.maybeWhen( + loading: () => null, + orElse: () => textController.text.trim().isEmpty + ? null + : callback, + ), child: buttonState.maybeWhen( loading: () => const SizedBox( width: 20, diff --git a/lib/widgets/install_button.dart b/lib/widgets/install_button.dart index d811a13..995607a 100644 --- a/lib/widgets/install_button.dart +++ b/lib/widgets/install_button.dart @@ -171,7 +171,7 @@ class InstallButton extends ConsumerWidget { progress: progress, text: _formatDownloadProgress(progress, totalSizeMb), fontSize: fontSize, - onTap: () => _pauseDownload(ref), + onTap: () => _pauseDownload(context, ref), ), // Download paused @@ -182,7 +182,7 @@ class InstallButton extends ConsumerWidget { progress: progress, text: _formatDownloadProgress(progress, totalSizeMb, paused: true), fontSize: fontSize, - onTap: () => _resumeDownload(ref), + onTap: () => _resumeDownload(context, ref), ), // Download enqueued @@ -409,7 +409,7 @@ class InstallButton extends ConsumerWidget { ); return FilledButton( - onPressed: onTap ?? () => _cancelDownload(ref), + onPressed: onTap ?? () => _cancelDownload(context, ref), style: FilledButton.styleFrom( backgroundColor: Colors.transparent, disabledBackgroundColor: Colors.transparent, @@ -561,26 +561,36 @@ class InstallButton extends ConsumerWidget { } } - Future _resumeDownload(WidgetRef ref) async { - final downloadService = ref.read(downloadServiceProvider.notifier); - await downloadService.resumeDownload(app.identifier); + Future _resumeDownload(BuildContext context, WidgetRef ref) async { + try { + final downloadService = ref.read(downloadServiceProvider.notifier); + await downloadService.resumeDownload(app.identifier); + } catch (e) { + if (context.mounted) { + context.showError('Failed to resume download', description: '$e'); + } + } } - Future _pauseDownload(WidgetRef ref) async { + Future _pauseDownload(BuildContext context, WidgetRef ref) async { try { final downloadService = ref.read(downloadServiceProvider.notifier); await downloadService.pauseDownload(app.identifier); } catch (e) { - // Failed to pause download + if (context.mounted) { + context.showError('Failed to pause download', description: '$e'); + } } } - Future _cancelDownload(WidgetRef ref) async { + Future _cancelDownload(BuildContext context, WidgetRef ref) async { try { final downloadService = ref.read(downloadServiceProvider.notifier); await downloadService.cancelDownload(app.identifier); } catch (e) { - // Failed to cancel download + if (context.mounted) { + context.showError('Failed to cancel download', description: '$e'); + } } } diff --git a/lib/widgets/latest_releases_container.dart b/lib/widgets/latest_releases_container.dart index b8e35e7..269e65c 100644 --- a/lib/widgets/latest_releases_container.dart +++ b/lib/widgets/latest_releases_container.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; import 'package:async_button_builder/async_button_builder.dart'; +import 'package:zapstore/services/notification_service.dart'; import 'package:zapstore/utils/extensions.dart'; import 'app_card.dart'; @@ -131,6 +132,14 @@ class LatestReleasesContainer extends HookConsumerWidget { ), ), onPressed: isLoadingMore ? null : onLoadMore, + onError: () { + if (context.mounted) { + context.showError( + 'Failed to load more apps', + description: 'Please check your connection and try again.', + ); + } + }, builder: (context, child, callback, state) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), @@ -301,9 +310,9 @@ class LatestReleasesNotifier extends StateNotifier { } /// Fetch authors for a page of apps (used during pagination) - /// Note: Profiles are now loaded reactively via query in individual widgets + /// Note: Profiles are now loaded reactively via `query` in individual widgets Future _loadRelationshipsFor(List appsPage) async { - // No-op: profiles are now loaded reactively via query with caching + // No-op: profiles are now loaded reactively via `query` with caching } Future loadMore() async { @@ -371,8 +380,9 @@ class LatestReleasesNotifier extends StateNotifier { } else { state = state.copyWith(isLoadingMore: false, hasMore: false); } - } catch (_) { + } catch (e) { state = state.copyWith(isLoadingMore: false); + rethrow; } } diff --git a/lib/widgets/sign_in_button.dart b/lib/widgets/sign_in_button.dart index d74cc6d..65a8b96 100644 --- a/lib/widgets/sign_in_button.dart +++ b/lib/widgets/sign_in_button.dart @@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:zapstore/main.dart'; import 'package:zapstore/services/notification_service.dart'; import 'package:zapstore/services/package_manager/package_manager.dart'; +import 'package:zapstore/utils/debug_utils.dart'; const _amberPackageId = 'com.greenart7c3.nostrsigner'; const _amberNaddr = @@ -37,7 +38,7 @@ class SignInButton extends ConsumerWidget { } else { try { await ref.read(amberSignerProvider).signIn(); - onSignInSuccess(ref); + onSignInSuccess(ref.read(refProvider)); } catch (e) { if (context.mounted) { context.showError('Sign-in failed: $e'); diff --git a/lib/widgets/zap_widgets.dart b/lib/widgets/zap_widgets.dart index 8801b9c..d63ee8a 100644 --- a/lib/widgets/zap_widgets.dart +++ b/lib/widgets/zap_widgets.dart @@ -2,14 +2,12 @@ import 'package:async_button_builder/async_button_builder.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:models/models.dart'; -import 'package:zapstore/main.dart'; import 'package:zapstore/services/notification_service.dart'; -import 'package:zapstore/services/package_manager/package_manager.dart'; import 'package:zapstore/services/secure_storage_service.dart'; import 'package:zapstore/utils/extensions.dart'; +import 'package:zapstore/widgets/auth_widgets.dart'; import 'package:zapstore/widgets/common/base_dialog.dart'; import 'package:zapstore/widgets/common/profile_avatar.dart'; @@ -307,112 +305,12 @@ class ZapAmountDialog extends HookConsumerWidget { ], if (pubkey == null) ...[ - InkWell( - onTap: () async { - final packageManager = ref.read(packageManagerProvider); - const amberPackageId = 'com.greenart7c3.nostrsigner'; - final isAmberInstalled = packageManager.any( - (p) => p.appId == amberPackageId, - ); - - if (!isAmberInstalled) { - // Close the zap dialog first - if (context.mounted) { - Navigator.of(context).pop(); - } - // Navigate to Amber app page - if (context.mounted) { - context.showInfo( - 'Amber required', - description: - 'Install Amber to sign in and unlock zapping features.', - ); - context.push( - '/profile/apps/naddr1qqdkxmmd9enhyet9deshyaphvvejumn0wd68yumfvahx2uszyp6hjpmdntls5n8aa7n7ypzlyjrv0ewch33ml3452wtjx0smhl93jqcyqqq8uzcgpp6ky', - ); - } - } else { - try { - await ref.read(amberSignerProvider).signIn(); - // After successful sign-in, the dialog will update automatically - // because pubkey will no longer be null - } catch (e) { - if (context.mounted) { - context.showError( - 'Sign-in failed', - description: - 'Amber could not complete the sign-in. Make sure Amber is installed and try again.\n\n$e', - ); - } - } - } - }, - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(top: 2), - child: Icon( - Icons.info_outline, - size: 20, - color: Theme.of(context).colorScheme.primary, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - 'Zapping anonymously. Tap to sign in.', - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - ], - ), - ), + const SignInPrompt( + message: 'Zapping anonymously. Sign in to zap with your identity.', ), const SizedBox(height: 12), ], - if (!hasWalletConnection.value) ...[ - InkWell( - onTap: () async { - final connected = await showBaseDialog( - context: context, - dialog: const NWCConnectionDialogInline(), - ); - if (connected == true) { - hasWalletConnection.value = true; - } - }, - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(top: 2), - child: Icon( - Icons.info_outline, - size: 20, - color: Theme.of(context).colorScheme.primary, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - 'Tap to connect NWC for zapping, otherwise an invoice will be copied to the clipboard.', - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - ], - ), - ), - ), - const SizedBox(height: 12), - ], // Quick amount buttons (always visible) Wrap( @@ -519,39 +417,52 @@ class ZapAmountDialog extends HookConsumerWidget { if (nwcString != null && nwcString.isNotEmpty) { final amount = selectedAmount.value; - // Capture ScaffoldMessenger before popping (survives navigation) - final messenger = ScaffoldMessenger.maybeOf(context); - // Optimistic UX: show success immediately, pop, fire payment - context.showInfo('⚡ Zap sent! $amount sats'); - navigator.pop(true); - - // Fire payment in background - errors shown via ScaffoldMessenger - // ignore: unawaited_futures - _executeZapPayment( - signedZapRequest, - nwcString, - ref.ref, - ).then( - (_) {}, - onError: (Object e, StackTrace st) { - if (messenger != null) { - messenger.showSnackBar( - SnackBar( - content: Text('Zap failed: $e'), - backgroundColor: Colors.red, - duration: const Duration(seconds: 5), - ), - ); - } - }, - ); + // Execute payment and wait for result + try { + await _executeZapPayment( + signedZapRequest, + nwcString, + ref.ref, + ); + if (context.mounted) { + context.showInfo('⚡ Zap sent! $amount sats'); + navigator.pop(true); + } + } catch (e) { + if (context.mounted) { + context.showError('Zap failed', description: '$e'); + navigator.pop(false); + } + } } else { final invoice = await signedZapRequest.getInvoice(); - await Clipboard.setData(ClipboardData(text: invoice)); if (context.mounted) { - context.showInfo('Invoice copied to clipboard'); navigator.pop(true); + context.showInfo( + 'Lightning invoice ready', + description: + 'Copy the invoice and pay with your Lightning wallet. Setup NWC to zap directly from the app.', + actions: [ + ( + 'Copy Invoice', + () async { + await Clipboard.setData( + ClipboardData(text: invoice), + ); + }, + ), + ( + 'Setup NWC', + () async { + await showBaseDialog( + context: context, + dialog: const NWCConnectionDialogInline(), + ); + }, + ), + ], + ); } } } catch (e) {