287 lines
9.2 KiB
Dart
287 lines
9.2 KiB
Dart
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<NotificationOverlay> createState() => _NotificationOverlayState();
|
||
}
|
||
|
||
class _NotificationOverlayState extends State<NotificationOverlay>
|
||
with SingleTickerProviderStateMixin {
|
||
GameNotification? _currentNotification;
|
||
late AnimationController _animationController;
|
||
late Animation<Offset> _slideAnimation;
|
||
late Animation<double> _fadeAnimation;
|
||
|
||
StreamSubscription<GameNotification>? _notificationSub;
|
||
StreamSubscription<void>? _dismissSub;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
|
||
_animationController = AnimationController(
|
||
duration: const Duration(milliseconds: 300),
|
||
vsync: this,
|
||
);
|
||
|
||
// 하단에서 슬라이드 인/아웃
|
||
_slideAnimation = Tween<Offset>(begin: const Offset(0, 1), end: Offset.zero)
|
||
.animate(
|
||
CurvedAnimation(
|
||
parent: _animationController,
|
||
curve: Curves.easeOutBack,
|
||
),
|
||
);
|
||
|
||
_fadeAnimation = Tween<double>(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',
|
||
};
|
||
}
|
||
}
|