From 7c96d8b4b2baeb6500982b763478d87521948fb2 Mon Sep 17 00:00:00 2001 From: franzap <_@franzap.com> Date: Mon, 15 Dec 2025 13:16:21 -0300 Subject: [PATCH] Improve toast notifications/actions --- lib/screens/profile_screen.dart | 11 +- lib/services/notification_service.dart | 452 +++++++++++++++++++++---- lib/widgets/auth_widgets.dart | 6 +- lib/widgets/bookmark_widgets.dart | 14 +- lib/widgets/comments_section.dart | 12 +- lib/widgets/install_button.dart | 32 +- lib/widgets/nwc_widgets.dart | 26 +- lib/widgets/zap_widgets.dart | 26 +- pubspec.lock | 24 -- pubspec.yaml | 1 - 10 files changed, 484 insertions(+), 120 deletions(-) diff --git a/lib/screens/profile_screen.dart b/lib/screens/profile_screen.dart index 64e34b8..d0f0b9c 100644 --- a/lib/screens/profile_screen.dart +++ b/lib/screens/profile_screen.dart @@ -224,7 +224,7 @@ class _AuthenticationSection extends ConsumerWidget { await ref.read(amberSignerProvider).signOut(); } catch (e) { if (context.mounted) { - context.showError('Sign out failed: $e'); + context.showError('Sign out failed', description: '$e'); } } } @@ -760,7 +760,7 @@ class _DebugMessagesSection extends HookConsumerWidget { if (log.exception != null) 'Error: ${log.exception}', ].where((e) => e.isNotEmpty).join(' | '); Clipboard.setData(ClipboardData(text: text)); - context.showInfo('Copied'); + context.showInfo('Debug info copied'); }, ), ], @@ -839,7 +839,10 @@ class _DebugMessagesSection extends HookConsumerWidget { constraints: const BoxConstraints(), onPressed: () { Clipboard.setData(ClipboardData(text: req)); - context.showInfo('REQ copied'); + context.showInfo( + 'REQ filter copied', + description: 'Paste into a Nostr client to debug this query.', + ); }, ), ], @@ -1093,7 +1096,7 @@ class _DataManagementSection extends ConsumerWidget { } catch (e) { if (context.mounted) { Navigator.of(context, rootNavigator: true).pop(); - context.showError('Failed to restart: ${e.toString()}'); + context.showError('Restart failed', description: '${e.toString()}'); } } } diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 4ff3621..8f088b2 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -1,100 +1,424 @@ import 'package:flutter/material.dart'; -import 'package:toastification/toastification.dart'; import 'package:zapstore/theme.dart'; extension ContextX on BuildContext { ThemeData get theme => Theme.of(this); - void showInfo(String message, {String? description, Icon? icon}) { - const actionColor = AppColors.darkActionPrimary; - toastification.show( + void showInfo(String title, {String? description, IconData? icon}) { + _showCustomToast( context: this, - type: ToastificationType.info, - icon: icon ?? const Icon(Icons.info, color: Colors.white), - style: ToastificationStyle.fillColored, - alignment: Alignment.topCenter, - title: _ToastTitleWidget(message), - description: description != null - ? _ToastDescriptionWidget(description: description) - : null, - autoCloseDuration: const Duration(seconds: 4), - showProgressBar: false, - closeOnClick: true, - primaryColor: actionColor, - backgroundColor: actionColor, - foregroundColor: Colors.white, + title: title, + description: description, + icon: icon ?? Icons.info_outline_rounded, + type: _ToastType.info, ); } void showError( String title, { String? description, - Icon? icon, + IconData? icon, List<(String, Future Function())> actions = const [], }) { - toastification.show( + _showCustomToast( context: this, - type: ToastificationType.error, - style: ToastificationStyle.fillColored, - alignment: Alignment.topCenter, - icon: icon ?? const Icon(Icons.error, color: Colors.white), - title: _ToastTitleWidget(title), - autoCloseDuration: const Duration(seconds: 5), - showProgressBar: false, - closeOnClick: true, - foregroundColor: Colors.white, - description: description != null - ? _ToastDescriptionWidget(description: description, actions: actions) - : null, + title: title, + description: description, + icon: icon ?? Icons.error_outline_rounded, + type: _ToastType.error, + actions: actions, ); } } -class _ToastTitleWidget extends StatelessWidget { - final String text; - const _ToastTitleWidget(this.text); +enum _ToastType { info, error } - @override - Widget build(BuildContext context) { - return Text( - text, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Colors.white, - ), - overflow: TextOverflow.ellipsis, - maxLines: 4, - ); - } +OverlayEntry? _currentToast; + +void _showCustomToast({ + required BuildContext context, + required String title, + String? description, + required IconData icon, + required _ToastType type, + List<(String, Future Function())> actions = const [], +}) { + // Dismiss existing toast + _currentToast?.remove(); + _currentToast = null; + + final overlay = Overlay.of(context); + + late OverlayEntry entry; + entry = OverlayEntry( + builder: (context) => _ToastOverlay( + title: title, + description: description, + icon: icon, + type: type, + actions: actions, + onDismiss: () { + entry.remove(); + if (_currentToast == entry) _currentToast = null; + }, + ), + ); + + _currentToast = entry; + overlay.insert(entry); } -class _ToastDescriptionWidget extends StatelessWidget { +class _ToastOverlay extends StatefulWidget { + final String title; final String? description; + final IconData icon; + final _ToastType type; final List<(String, Future Function())> actions; + final VoidCallback onDismiss; - const _ToastDescriptionWidget({this.description, this.actions = const []}); + const _ToastOverlay({ + required this.title, + this.description, + required this.icon, + required this.type, + required this.actions, + required this.onDismiss, + }); + + @override + State<_ToastOverlay> createState() => _ToastOverlayState(); +} + +class _ToastOverlayState extends State<_ToastOverlay> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _fadeAnimation; + late Animation _slideAnimation; + bool _isHovered = false; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + + _fadeAnimation = CurvedAnimation( + parent: _controller, + curve: Curves.easeOut, + ); + + _slideAnimation = Tween( + begin: const Offset(0, -1), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.easeOutCubic, + )); + + _controller.forward(); + _startAutoClose(); + } + + void _startAutoClose() { + final duration = widget.type == _ToastType.error + ? const Duration(seconds: 6) + : const Duration(seconds: 4); + + Future.delayed(duration, () { + if (mounted && !_isHovered) { + _dismiss(); + } + }); + } + + void _dismiss() async { + await _controller.reverse(); + widget.onDismiss(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - RichText( - text: TextSpan( - text: description, - style: const TextStyle(fontSize: 16, color: Colors.white), + final screenHeight = MediaQuery.of(context).size.height; + final maxHeight = screenHeight * 0.7; + final topPadding = MediaQuery.of(context).padding.top; + + final isError = widget.type == _ToastType.error; + + // Colors + final backgroundColor = isError + ? const Color(0xFF1A1215) // Dark reddish-black + : const Color(0xFF111827); // Dark blue-black + + final borderColor = isError + ? const Color(0xFF7F1D1D).withValues(alpha: 0.6) // Dark red border + : AppColors.darkPrimary.withValues(alpha: 0.4); + + final accentColor = isError + ? const Color(0xFFEF4444) // Red + : AppColors.darkPrimary; + + final iconBgColor = isError + ? const Color(0xFF7F1D1D).withValues(alpha: 0.3) + : AppColors.darkPrimary.withValues(alpha: 0.15); + + return Positioned( + top: topPadding + 8, + left: 12, + right: 12, + child: SlideTransition( + position: _slideAnimation, + child: FadeTransition( + opacity: _fadeAnimation, + child: MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) { + setState(() => _isHovered = false); + _startAutoClose(); + }, + child: GestureDetector( + onTap: _dismiss, + onVerticalDragEnd: (details) { + if (details.primaryVelocity != null && + details.primaryVelocity! < -100) { + _dismiss(); + } + }, + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxHeight), + child: Material( + color: Colors.transparent, + child: Container( + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: borderColor, width: 1), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 24, + offset: const Offset(0, 8), + spreadRadius: 0, + ), + BoxShadow( + color: accentColor.withValues(alpha: 0.08), + blurRadius: 40, + offset: const Offset(0, 4), + spreadRadius: 0, + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(14), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Accent line at top + Container( + height: 3, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + accentColor.withValues(alpha: 0.8), + accentColor.withValues(alpha: 0.4), + accentColor.withValues(alpha: 0.1), + ], + ), + ), + ), + // Content + Flexible( + child: Padding( + padding: const EdgeInsets.all(16), + child: _ToastContent( + icon: widget.icon, + iconBgColor: iconBgColor, + accentColor: accentColor, + title: widget.title, + description: widget.description, + actions: widget.actions, + onDismiss: _dismiss, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), ), ), - for (final (text, fn) in actions) - ElevatedButton( - onPressed: () async { - await fn.call(); - toastification.dismissAll(delayForAnimation: false); - }, - child: Text(text), + ), + ); + } +} + +class _ToastContent extends StatelessWidget { + final IconData icon; + final Color iconBgColor; + final Color accentColor; + final String title; + final String? description; + final List<(String, Future Function())> actions; + final VoidCallback onDismiss; + + const _ToastContent({ + required this.icon, + required this.iconBgColor, + required this.accentColor, + required this.title, + this.description, + required this.actions, + required this.onDismiss, + }); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + 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, + children: [ + // Title + Text( + title, + style: const TextStyle( + fontFamily: kFontFamily, + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + height: 1.35, + ), + ), + // Description + if (description != null) ...[ + const SizedBox(height: 6), + Flexible( + child: SingleChildScrollView( + child: 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(), + ), + ], + ], + ), + ), + // 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), + ), + ), + ), ], ); } } + +class _ToastActionButton extends StatelessWidget { + final String label; + final VoidCallback onPressed; + final Color accentColor; + + const _ToastActionButton({ + required this.label, + required this.onPressed, + required this.accentColor, + }); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(6), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: accentColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: accentColor.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Text( + label, + style: TextStyle( + fontFamily: kFontFamily, + fontSize: 13, + fontWeight: FontWeight.w500, + color: accentColor, + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/auth_widgets.dart b/lib/widgets/auth_widgets.dart index c9ce3b7..d3eb2b4 100644 --- a/lib/widgets/auth_widgets.dart +++ b/lib/widgets/auth_widgets.dart @@ -41,7 +41,11 @@ class SignInPromptDialog extends HookConsumerWidget { } } catch (e) { if (context.mounted) { - context.showError('Sign-in failed: $e'); + context.showError( + 'Sign-in failed', + description: + 'Amber could not complete the sign-in. Make sure Amber is installed and try again.\n\n$e', + ); } } }, diff --git a/lib/widgets/bookmark_widgets.dart b/lib/widgets/bookmark_widgets.dart index ee6789d..6e2f3d5 100644 --- a/lib/widgets/bookmark_widgets.dart +++ b/lib/widgets/bookmark_widgets.dart @@ -101,7 +101,10 @@ class BookmarkDialog extends HookConsumerWidget { if (signer == null || signedInPubkey == null) { if (context.mounted) { - context.showError('You must be signed in to save apps'); + context.showError( + 'Sign in required', + description: 'You need to sign in to save apps to your bookmarks.', + ); } return; } @@ -165,7 +168,7 @@ class BookmarkDialog extends HookConsumerWidget { } } catch (e) { if (context.mounted) { - context.showError('Failed to update bookmark: $e'); + context.showError('Failed to update bookmark', description: '$e'); } } } @@ -448,7 +451,10 @@ class AddToPackDialog extends HookConsumerWidget { final signer = ref.read(Signer.activeSignerProvider); if (signer == null) { if (context.mounted) { - context.showError('You must be signed in'); + context.showError( + 'Sign in required', + description: 'You need to sign in to manage app packs.', + ); } return; } @@ -548,7 +554,7 @@ class AddToPackDialog extends HookConsumerWidget { } } catch (e) { if (context.mounted) { - context.showError('Failed to save: $e'); + context.showError('Failed to save', description: '$e'); } } } diff --git a/lib/widgets/comments_section.dart b/lib/widgets/comments_section.dart index a2221eb..8cd8710 100644 --- a/lib/widgets/comments_section.dart +++ b/lib/widgets/comments_section.dart @@ -397,7 +397,10 @@ class _CommentComposer extends HookConsumerWidget { final signer = ref.read(Signer.activeSignerProvider); if (signer == null) { if (context.mounted) { - context.showError('Please sign in to comment'); + context.showError( + 'Sign in required', + description: 'You need to sign in with Amber to post comments.', + ); } return; } @@ -405,7 +408,10 @@ class _CommentComposer extends HookConsumerWidget { final app = fileMetadata.release.value?.app.value; if (app == null) { if (context.mounted) { - context.showError('App information not available'); + context.showError( + 'Unable to comment', + description: 'App information is not available. Try again later.', + ); } return; } @@ -434,7 +440,7 @@ class _CommentComposer extends HookConsumerWidget { } } catch (e) { if (context.mounted) { - context.showError('Failed to post comment: $e'); + context.showError('Failed to post comment', description: '$e'); } } } diff --git a/lib/widgets/install_button.dart b/lib/widgets/install_button.dart index 9a544d6..d811a13 100644 --- a/lib/widgets/install_button.dart +++ b/lib/widgets/install_button.dart @@ -43,10 +43,18 @@ class InstallButton extends ConsumerWidget { next.errorDetails != null && previous?.errorDetails != next.errorDetails) { if (context.mounted) { - final errorMessage = next.errorDetails == 'CERTIFICATE_MISMATCH' - ? 'Installation failed: Certificate mismatch detected' - : 'Installation failed: ${next.errorDetails}'; - context.showError(errorMessage); + if (next.errorDetails == 'CERTIFICATE_MISMATCH') { + context.showError( + 'Installation failed', + description: + 'Certificate mismatch detected. The app signature does not match the expected developer.', + ); + } else { + context.showError( + 'Installation failed', + description: next.errorDetails, + ); + } } } }); @@ -211,7 +219,11 @@ class InstallButton extends ConsumerWidget { ), onError: () { if (context.mounted) { - context.showError('Installation failed. Please try again.'); + context.showError( + 'Installation failed', + description: + 'The package could not be installed. Check storage space and try again.', + ); } }, ), @@ -579,7 +591,11 @@ class InstallButton extends ConsumerWidget { await packageManager.launchApp(app.identifier); } catch (e) { if (!context.mounted) return; - context.showError('Failed to launch ${app.name ?? app.identifier}: $e'); + context.showError( + 'Failed to launch ${app.name ?? app.identifier}', + description: + 'The app may have been uninstalled or moved. Try reinstalling.\n\n$e', + ); } } @@ -653,7 +669,7 @@ class InstallButton extends ConsumerWidget { // Don't show error for user cancellation final message = e.toString(); if (!message.contains('cancelled')) { - context.showError('Uninstall failed: $e'); + context.showError('Uninstall failed', description: '$e'); } } } @@ -767,7 +783,7 @@ class InstallButton extends ConsumerWidget { } catch (e) { final message = e.toString(); if (context.mounted && !message.contains('cancelled')) { - context.showError('Force update failed: $e'); + context.showError('Force update failed', description: '$e'); } } } diff --git a/lib/widgets/nwc_widgets.dart b/lib/widgets/nwc_widgets.dart index f286467..610b91b 100644 --- a/lib/widgets/nwc_widgets.dart +++ b/lib/widgets/nwc_widgets.dart @@ -222,11 +222,17 @@ class NWCConnectionCard extends HookConsumerWidget { await secureStorage.clearNWCString(); nwcStatus.value = NWCStatus.disconnected; if (context.mounted) { - context.showInfo('Wallet connection removed'); + context.showInfo( + 'Wallet disconnected', + description: 'You can reconnect anytime from Settings.', + ); } } catch (e) { if (context.mounted) { - context.showError('Failed to remove connection: $e'); + context.showError( + 'Failed to disconnect wallet', + description: '$e', + ); } } } @@ -320,13 +326,19 @@ class NWCConnectionDialog extends HookWidget { ValueNotifier isLoading, ) async { if (nwcString.trim().isEmpty) { - context.showError('Please enter a valid NWC connection string'); + context.showError( + 'Missing connection string', + description: + 'Get a NWC connection string from your Lightning wallet (e.g., Alby, Zeus, Coinos).', + ); return; } if (!nwcString.trim().startsWith('nostr+walletconnect://')) { context.showError( - 'Invalid NWC format. Should start with nostr+walletconnect://', + 'Invalid NWC format', + description: + 'Connection string should start with nostr+walletconnect://', ); return; } @@ -343,7 +355,11 @@ class NWCConnectionDialog extends HookWidget { } } catch (e) { if (context.mounted) { - context.showError('Connection failed: $e'); + context.showError( + 'Wallet connection failed', + description: + 'Could not connect to the wallet. Verify the connection string and try again.\n\n$e', + ); } } finally { isLoading.value = false; diff --git a/lib/widgets/zap_widgets.dart b/lib/widgets/zap_widgets.dart index 0009016..8801b9c 100644 --- a/lib/widgets/zap_widgets.dart +++ b/lib/widgets/zap_widgets.dart @@ -323,7 +323,9 @@ class ZapAmountDialog extends HookConsumerWidget { // Navigate to Amber app page if (context.mounted) { context.showInfo( - 'Install Amber to sign in and unlock zapping features', + 'Amber required', + description: + 'Install Amber to sign in and unlock zapping features.', ); context.push( '/profile/apps/naddr1qqdkxmmd9enhyet9deshyaphvvejumn0wd68yumfvahx2uszyp6hjpmdntls5n8aa7n7ypzlyjrv0ewch33ml3452wtjx0smhl93jqcyqqq8uzcgpp6ky', @@ -336,7 +338,11 @@ class ZapAmountDialog extends HookConsumerWidget { // because pubkey will no longer be null } catch (e) { if (context.mounted) { - context.showError('Sign-in failed: $e'); + context.showError( + 'Sign-in failed', + description: + 'Amber could not complete the sign-in. Make sure Amber is installed and try again.\n\n$e', + ); } } } @@ -550,7 +556,7 @@ class ZapAmountDialog extends HookConsumerWidget { } } catch (e) { if (context.mounted) { - context.showError('Zap failed: $e'); + context.showError('Zap failed', description: '$e'); Navigator.of(context).pop(false); } } @@ -705,13 +711,17 @@ class NWCConnectionDialogInline extends HookConsumerWidget { final nwcString = controller.text; if (nwcString.trim().isEmpty) { context.showError( - 'Please enter a valid NWC connection string', + 'Missing connection string', + description: + 'Get a NWC connection string from your Lightning wallet (e.g., Alby, Zeus, Coinos).', ); return; } if (!nwcString.trim().startsWith('nostr+walletconnect://')) { context.showError( - 'Invalid NWC format. Should start with nostr+walletconnect://', + 'Invalid NWC format', + description: + 'Connection string should start with nostr+walletconnect://', ); return; } @@ -726,7 +736,11 @@ class NWCConnectionDialogInline extends HookConsumerWidget { } } catch (e) { if (context.mounted) { - context.showError('Connection failed: $e'); + context.showError( + 'Wallet connection failed', + description: + 'Could not connect to the wallet. Verify the connection string and try again.\n\n$e', + ); } } finally { isLoading.value = false; diff --git a/pubspec.lock b/pubspec.lock index a39eec7..1dd021d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -553,14 +553,6 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.2" - iconsax_flutter: - dependency: transitive - description: - name: iconsax_flutter - sha256: d14b4cec8586025ac15276bdd40f6eea308cb85748135965bb6255f14beb2564 - url: "https://pub.dev" - source: hosted - version: "1.0.1" image: dependency: transitive description: @@ -776,14 +768,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.0" - pausable_timer: - dependency: transitive - description: - name: pausable_timer - sha256: "6ef1a95441ec3439de6fb63f39a011b67e693198e7dae14e20675c3c00e86074" - url: "https://pub.dev" - source: hosted - version: "3.1.0+3" percent_indicator: dependency: "direct main" description: @@ -1189,14 +1173,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.10.1" - toastification: - dependency: "direct main" - description: - name: toastification - sha256: "69db2bff425b484007409650d8bcd5ed1ce2e9666293ece74dcd917dacf23112" - url: "https://pub.dev" - source: hosted - version: "3.0.3" typed_data: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 8d2ddf3..be29e7d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -47,7 +47,6 @@ dependencies: intl: ^0.20.2 collection: ^1.19.1 qr_flutter: ^4.1.0 - toastification: ^3.0.3 share_plus: ^12.0.0 background_downloader: ^9.2.6 connectivity_plus: ^7.0.0