Improve toast notifications/actions

This commit is contained in:
franzap
2025-12-15 13:16:21 -03:00
parent 07c9001d84
commit 7c96d8b4b2
10 changed files with 484 additions and 120 deletions
+7 -4
View File
@@ -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()}');
}
}
}
+388 -64
View File
@@ -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<void> 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<void> 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<void> 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<double> _fadeAnimation;
late Animation<Offset> _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<Offset>(
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<void> 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,
),
),
),
),
);
}
}
+5 -1
View File
@@ -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',
);
}
}
},
+10 -4
View File
@@ -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');
}
}
}
+9 -3
View File
@@ -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');
}
}
}
+24 -8
View File
@@ -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');
}
}
}
+21 -5
View File
@@ -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<bool> 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;
+20 -6
View File
@@ -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;
-24
View File
@@ -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:
-1
View File
@@ -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