mirror of
https://github.com/zapstore/zapstore.git
synced 2026-10-05 20:48:24 +00:00
Better sign-in prompts, better toast notifications and error reporting, minor fixes
This commit is contained in:
+1
-2
@@ -263,8 +263,7 @@ Future<void> _attemptAutoSignIn(Ref ref) async {
|
||||
}
|
||||
|
||||
/// Query AppCatalogRelayList after successful sign-in
|
||||
/// TODO: Fix ref type
|
||||
Future<void> onSignInSuccess(dynamic ref) async {
|
||||
Future<void> onSignInSuccess(Ref ref) async {
|
||||
final pubkey = ref.read(Signer.activePubkeyProvider);
|
||||
if (pubkey == null) return;
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,15 +136,28 @@ class _UpdatesTab extends HookConsumerWidget {
|
||||
children: [
|
||||
ColorFiltered(
|
||||
colorFilter: const ColorFilter.matrix(<double>[
|
||||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<void> 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<void> Function())> actions = const [],
|
||||
}) {
|
||||
// Always add Copy action for errors with description
|
||||
final allActions = <(String, Future<void> 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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) => ref);
|
||||
|
||||
@@ -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<AppPack>(
|
||||
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<void> _showAddToPackDialog(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
App app,
|
||||
List<AppPack> 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<void> _showSignInPrompt(BuildContext context, WidgetRef ref) async {
|
||||
await showBaseDialog(context: context, dialog: const SignInPromptDialog());
|
||||
}
|
||||
}
|
||||
|
||||
/// Skeleton loading state for app detail screen
|
||||
|
||||
@@ -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<void> 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+193
-163
@@ -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<String>();
|
||||
} 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<AppPack> 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<AppPack>(
|
||||
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<Set<String>>({});
|
||||
// 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<String, String>.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<String, String>.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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> _resumeDownload(WidgetRef ref) async {
|
||||
final downloadService = ref.read(downloadServiceProvider.notifier);
|
||||
await downloadService.resumeDownload(app.identifier);
|
||||
Future<void> _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<void> _pauseDownload(WidgetRef ref) async {
|
||||
Future<void> _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<void> _cancelDownload(WidgetRef ref) async {
|
||||
Future<void> _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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<LatestReleasesState> {
|
||||
}
|
||||
|
||||
/// Fetch authors for a page of apps (used during pagination)
|
||||
/// Note: Profiles are now loaded reactively via query<Profile> in individual widgets
|
||||
/// Note: Profiles are now loaded reactively via `query<Profile>` in individual widgets
|
||||
Future<void> _loadRelationshipsFor(List<App> appsPage) async {
|
||||
// No-op: profiles are now loaded reactively via query<Profile> with caching
|
||||
// No-op: profiles are now loaded reactively via `query<Profile>` with caching
|
||||
}
|
||||
|
||||
Future<void> loadMore() async {
|
||||
@@ -371,8 +380,9 @@ class LatestReleasesNotifier extends StateNotifier<LatestReleasesState> {
|
||||
} else {
|
||||
state = state.copyWith(isLoadingMore: false, hasMore: false);
|
||||
}
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
state = state.copyWith(isLoadingMore: false);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
+44
-133
@@ -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<bool>(
|
||||
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<bool>(
|
||||
context: context,
|
||||
dialog: const NWCConnectionDialogInline(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user