// πŸ”” μŠ€λ‚΅λ°” λŒ€μ‹  μ“°λŠ” μ•Œμ•½(pill) λͺ¨μ–‘ μ•Œλ¦Ό. κΈ°λŠ₯ 타일을 λˆ„λ₯Ό λ•Œ λœ¨λŠ” // "OO둜 μ΄λ™ν•©λ‹ˆλ‹€" 같은 짧은 μ•ˆλ‚΄μš©. ν‘Έμ‹œ μ•Œλ¦Ό(FCM)κ³ΌλŠ” 무관 β€” 그건 κ·ΈλŒ€λ‘œ λ™μž‘ν•œλ‹€. // μ›Ήμ—μ„œλŠ” μ™Όμͺ½ μœ„(μ•Œλ¦Όμ°½ 자리)에, 폰 μ•±μ—μ„œλŠ” κΈ°μ‘΄ μŠ€λ‚΅λ°”μ²˜λŸΌ μ•„λž˜μͺ½μ— λœ¬λ‹€. import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import '../theme/app_palette.dart'; class AppNotice { static final List<_QueuedNotice> _queue = []; static bool _showing = false; static void show( BuildContext context, String message, { IconData? icon, Color? color, }) { _queue.add(_QueuedNotice(context, message, icon, color)); _tryShowNext(); } static void _tryShowNext() { if (_showing || _queue.isEmpty) return; final next = _queue.removeAt(0); if (!next.context.mounted) { _tryShowNext(); return; } _showing = true; final overlay = Overlay.of(next.context); late OverlayEntry entry; entry = OverlayEntry( builder: (context) => _NoticeBanner( message: next.message, icon: next.icon, color: next.color, onDone: () { entry.remove(); _showing = false; _tryShowNext(); }, ), ); overlay.insert(entry); } } class _QueuedNotice { final BuildContext context; final String message; final IconData? icon; final Color? color; _QueuedNotice(this.context, this.message, this.icon, this.color); } class _NoticeBanner extends StatefulWidget { final String message; final IconData? icon; final Color? color; final VoidCallback onDone; const _NoticeBanner({ required this.message, required this.icon, required this.color, required this.onDone, }); @override State<_NoticeBanner> createState() => _NoticeBannerState(); } class _NoticeBannerState extends State<_NoticeBanner> with SingleTickerProviderStateMixin { late final AnimationController _controller; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 220), reverseDuration: const Duration(milliseconds: 180), ); _controller.forward(); Future.delayed(const Duration(milliseconds: 2600), () async { if (!mounted) return; await _controller.reverse(); widget.onDone(); }); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final curved = CurvedAnimation( parent: _controller, curve: Curves.easeOutCubic, reverseCurve: Curves.easeInCubic, ); final media = MediaQuery.of(context); final pill = Material( color: Colors.transparent, child: Container( constraints: const BoxConstraints(maxWidth: 360), padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), decoration: BoxDecoration( color: widget.color ?? AppPalette.ink, borderRadius: BorderRadius.circular(999), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.25), blurRadius: 16, offset: const Offset(0, 6), ), ], ), child: Row( mainAxisSize: MainAxisSize.min, children: [ if (widget.icon != null) ...[ Icon(widget.icon, color: AppPalette.paper, size: 18), const SizedBox(width: 8), ], Flexible( child: Text( widget.message, style: const TextStyle( color: AppPalette.paper, fontSize: 13.5, fontWeight: FontWeight.w600, ), ), ), ], ), ), ); final animatedPill = FadeTransition( opacity: curved, child: SlideTransition( position: Tween( begin: kIsWeb ? const Offset(0, -0.3) : const Offset(0, 0.3), end: Offset.zero, ).animate(curved), child: pill, ), ); if (kIsWeb) { return Positioned( top: media.padding.top + 16, left: 16, child: IgnorePointer(child: animatedPill), ); } return Positioned( left: 16, right: 16, bottom: media.padding.bottom + 24, child: IgnorePointer(child: Center(child: animatedPill)), ); } }