diff --git a/lib/widgets/common_widgets/app_toast.dart b/lib/widgets/common_widgets/app_toast.dart new file mode 100644 index 0000000..1d89da7 --- /dev/null +++ b/lib/widgets/common_widgets/app_toast.dart @@ -0,0 +1,149 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:mc_common_app/config/routes.dart'; +import 'package:mc_common_app/theme/colors.dart'; + +/// Self-dismissing toast with a visible countdown, shown on top of whatever view is +/// currently on screen. Mounts via the root navigator's Overlay, so callers don't need +/// to pass a BuildContext (avoids the stale-context issues a dialog-based approach has, +/// e.g. when shown from a VM/repo layer that doesn't hold one). +class AppToast { + static OverlayEntry? _entry; + + // Single place to retune how long an action-performing toast waits before firing its + // onComplete (either naturally, or immediately if the user taps it early -- see _dismiss). + static const int actionSeconds = 4; + + // Duration for the plain toast with no onComplete callback -- change this single value + // to retune it everywhere that uses it. + static const int plainSeconds = 3; + + static const Color errorBgColor = Color(0xFFF1CECE); + static const Color errorTextColor = MyColors.redColor; + static const Color successBgColor = Color(0xFFECFDF5); + static const Color successTextColor = MyColors.greenColor; + + static void show(String message, {int seconds = 5, VoidCallback? onComplete, bool showIcon = true, bool showCountdownText = true, Color? bgColor, Color? textColor}) { + OverlayState? overlay = navigatorKey.currentState?.overlay; + if (overlay == null) return; + + // Only one toast on screen at a time -- a new call replaces whatever is currently + // showing. Its onComplete (if any) never fires in that case -- _dismiss only acts on + // the entry that's still current. + _entry?.remove(); + _entry = null; + + late OverlayEntry entry; + entry = OverlayEntry( + builder: (BuildContext context) => _CountdownToast( + message: message, + seconds: seconds, + showIcon: showIcon, + showCountdownText: showCountdownText, + bgColor: bgColor, + textColor: textColor, + onDismiss: () => _dismiss(entry, onComplete), + ), + ); + _entry = entry; + overlay.insert(entry); + } + + // Fires on both the timer completing and an early tap -- a tap just means "don't make + // me wait", not "cancel this". Guarded so a toast that's already been replaced never + // fires its (now stale) action. + static void _dismiss(OverlayEntry entry, VoidCallback? onComplete) { + if (_entry == entry) { + entry.remove(); + _entry = null; + onComplete?.call(); + } + } +} + +class _CountdownToast extends StatefulWidget { + final String message; + final int seconds; + final VoidCallback onDismiss; + final bool showIcon; + final bool showCountdownText; + final Color? bgColor; + final Color? textColor; + + const _CountdownToast({required this.message, required this.seconds, required this.onDismiss, this.showIcon = true, this.showCountdownText = true, this.bgColor, this.textColor}); + + @override + State<_CountdownToast> createState() => _CountdownToastState(); +} + +class _CountdownToastState extends State<_CountdownToast> { + late int _remaining = widget.seconds; + Timer? _timer; + + @override + void initState() { + super.initState(); + _timer = Timer.periodic(const Duration(seconds: 1), (Timer t) { + setState(() => _remaining--); + if (_remaining <= 0) { + t.cancel(); + widget.onDismiss(); + } + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + Color bgColor = widget.bgColor ?? MyColors.darkTextColor; + Color textColor = widget.textColor ?? Colors.white; + + return Positioned( + left: 16, + right: 16, + top: 24, + child: SafeArea( + bottom: false, + child: Material( + color: Colors.transparent, + child: GestureDetector( + onTap: widget.onDismiss, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(10), + boxShadow: const [BoxShadow(color: Color(0x33000000), blurRadius: 12, offset: Offset(0, 4))], + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (widget.showIcon) ...[Icon(Icons.error_outline, color: textColor, size: 20), const SizedBox(width: 10)], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(widget.message, style: TextStyle(color: textColor, fontSize: 14, height: 1.3, fontWeight: FontWeight.w600)), + if (widget.showCountdownText) ...[ + const SizedBox(height: 4), + Text("Dismissing in ${_remaining}s", style: TextStyle(color: textColor.withOpacity(0.7), fontSize: 12, fontWeight: FontWeight.w500)), + ], + ], + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +}