import 'dart:async'; import 'package:flutter/material.dart'; import 'package:asciineverdie/src/core/notification/notification_service.dart'; import 'package:asciineverdie/src/shared/retro_colors.dart'; /// 알림 오버레이 위젯 (Phase 8: 팝업/토스트 알림) /// /// 화면 상단에 알림을 슬라이드 인/아웃 애니메이션으로 표시 class NotificationOverlay extends StatefulWidget { const NotificationOverlay({ super.key, required this.notificationService, required this.child, }); final NotificationService notificationService; final Widget child; @override State createState() => _NotificationOverlayState(); } class _NotificationOverlayState extends State with SingleTickerProviderStateMixin { GameNotification? _currentNotification; late AnimationController _animationController; late Animation _slideAnimation; late Animation _fadeAnimation; StreamSubscription? _notificationSub; StreamSubscription? _dismissSub; @override void initState() { super.initState(); _animationController = AnimationController( duration: const Duration(milliseconds: 300), vsync: this, ); // 하단에서 슬라이드 인/아웃 _slideAnimation = Tween(begin: const Offset(0, 1), end: Offset.zero) .animate( CurvedAnimation( parent: _animationController, curve: Curves.easeOutBack, ), ); _fadeAnimation = Tween(begin: 0, end: 1).animate( CurvedAnimation(parent: _animationController, curve: Curves.easeIn), ); _notificationSub = widget.notificationService.notifications.listen( _onNotification, ); _dismissSub = widget.notificationService.dismissals.listen(_onDismiss); } void _onNotification(GameNotification notification) { setState(() => _currentNotification = notification); _animationController.forward(); } void _onDismiss(void _) { _animationController.reverse().then((_) { if (mounted) { setState(() => _currentNotification = null); } }); } @override void dispose() { _notificationSub?.cancel(); _dismissSub?.cancel(); _animationController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Stack( children: [ widget.child, if (_currentNotification != null) Positioned( bottom: MediaQuery.of(context).padding.bottom + 80, left: 16, right: 16, child: SlideTransition( position: _slideAnimation, child: FadeTransition( opacity: _fadeAnimation, child: _NotificationCard( notification: _currentNotification!, onDismiss: widget.notificationService.dismiss, ), ), ), ), ], ); } } /// 레트로 스타일 알림 카드 위젯 class _NotificationCard extends StatelessWidget { const _NotificationCard({ required this.notification, required this.onDismiss, }); final GameNotification notification; final VoidCallback onDismiss; @override Widget build(BuildContext context) { final (accentColor, icon, asciiIcon) = _getStyleForType(context, notification.type); final panelBg = RetroColors.panelBgOf(context); final borderColor = RetroColors.borderOf(context); final surface = RetroColors.surfaceOf(context); final textPrimary = RetroColors.textPrimaryOf(context); final textMuted = RetroColors.textMutedOf(context); return GestureDetector( onTap: onDismiss, child: Container( decoration: BoxDecoration( color: panelBg, border: Border( top: BorderSide(color: accentColor, width: 3), left: BorderSide(color: accentColor, width: 3), bottom: BorderSide(color: borderColor, width: 3), right: BorderSide(color: borderColor, width: 3), ), boxShadow: [ BoxShadow( color: accentColor.withValues(alpha: 0.4), blurRadius: 12, spreadRadius: 2, ), ], ), child: Column( mainAxisSize: MainAxisSize.min, children: [ // 헤더 바 Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), color: accentColor.withValues(alpha: 0.3), child: Row( children: [ // ASCII 아이콘 Text( asciiIcon, style: TextStyle( fontFamily: 'JetBrainsMono', fontSize: 12, color: accentColor, fontWeight: FontWeight.bold, ), ), const SizedBox(width: 8), // 타입 표시 Expanded( child: Text( _getTypeLabel(notification.type), style: TextStyle( fontFamily: 'PressStart2P', fontSize: 9, color: accentColor, letterSpacing: 1, ), ), ), // 닫기 버튼 GestureDetector( onTap: onDismiss, child: Text( '[X]', style: TextStyle( fontFamily: 'PressStart2P', fontSize: 9, color: textMuted, ), ), ), ], ), ), // 본문 Padding( padding: const EdgeInsets.all(12), child: Row( children: [ // 아이콘 박스 Container( width: 36, height: 36, decoration: BoxDecoration( color: surface, border: Border.all(color: accentColor, width: 2), ), child: Icon(icon, color: accentColor, size: 20), ), const SizedBox(width: 12), // 텍스트 Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( notification.title, style: TextStyle( fontFamily: 'PressStart2P', fontSize: 9, color: textPrimary, ), ), if (notification.subtitle != null) ...[ const SizedBox(height: 4), Text( notification.subtitle!, style: TextStyle( fontFamily: 'PressStart2P', fontSize: 9, color: textMuted, ), ), ], ], ), ), ], ), ), ], ), ), ); } /// 알림 타입별 레트로 스타일 (강조 색상, 아이콘, ASCII 아이콘) (Color, IconData, String) _getStyleForType( BuildContext context, NotificationType type, ) { final gold = RetroColors.goldOf(context); final exp = RetroColors.expOf(context); final mp = RetroColors.mpOf(context); final hp = RetroColors.hpOf(context); return switch (type) { NotificationType.levelUp => (gold, Icons.arrow_upward, '★'), NotificationType.questComplete => (exp, Icons.check, '☑'), NotificationType.actComplete => (mp, Icons.flag, '⚑'), NotificationType.newSpell => (const Color(0xFF9966FF), Icons.auto_fix_high, '✧'), NotificationType.newEquipment => (const Color(0xFFFF9933), Icons.shield, '⚔'), NotificationType.bossDefeat => (hp, Icons.whatshot, '☠'), NotificationType.gameSaved => (exp, Icons.save, '💾'), NotificationType.info => (mp, Icons.info_outline, 'ℹ'), NotificationType.warning => (const Color(0xFFFFCC00), Icons.warning, '⚠'), }; } /// 알림 타입 라벨 String _getTypeLabel(NotificationType type) { return switch (type) { NotificationType.levelUp => 'LEVEL UP', NotificationType.questComplete => 'QUEST DONE', NotificationType.actComplete => 'ACT CLEAR', NotificationType.newSpell => 'NEW SPELL', NotificationType.newEquipment => 'NEW ITEM', NotificationType.bossDefeat => 'BOSS SLAIN', NotificationType.gameSaved => 'SAVED', NotificationType.info => 'INFO', NotificationType.warning => 'WARNING', }; } }