Better sign-in prompts, better toast notifications and error reporting, minor fixes

This commit is contained in:
franzap
2025-12-15 16:22:31 -03:00
parent 7c96d8b4b2
commit 9dcca8a200
13 changed files with 597 additions and 596 deletions
+1 -2
View File
@@ -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;
+3 -2
View File
@@ -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());
}
}
}
+55 -22
View File
@@ -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)),
],
),
),
+133 -104
View File
@@ -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),
),
),
),
),
+5
View File
@@ -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);
+9 -48
View File
@@ -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
+87 -61
View File
@@ -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
View File
@@ -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 {
}
}
}
+32 -47
View File
@@ -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,
+20 -10
View File
@@ -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');
}
}
}
+13 -3
View File
@@ -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;
}
}
+2 -1
View File
@@ -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
View File
@@ -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) {