feat(ui): Phase 8 실시간 피드백 시스템 구현

- StatsPanel: 스탯 변화 애니메이션 (증감 표시)
- CombatLog: 전투 이벤트 로그 위젯
- NotificationService: 큐 기반 알림 관리
- NotificationOverlay: 레벨업/퀘스트 완료 팝업 알림
- GamePlayScreen: 새 위젯 통합
This commit is contained in:
JiWoong Sul
2025-12-17 18:33:21 +09:00
parent bfcec44ac7
commit 8cbef3475b
5 changed files with 760 additions and 48 deletions

View File

@@ -0,0 +1,150 @@
import 'package:flutter/material.dart';
/// 전투 로그 엔트리 (Combat Log Entry)
class CombatLogEntry {
const CombatLogEntry({
required this.message,
required this.timestamp,
this.type = CombatLogType.normal,
});
final String message;
final DateTime timestamp;
final CombatLogType type;
}
/// 로그 타입에 따른 스타일 구분
enum CombatLogType {
normal, // 일반 메시지
damage, // 피해 입힘
heal, // 회복
levelUp, // 레벨업
questComplete, // 퀘스트 완료
loot, // 전리품 획득
spell, // 주문 습득
}
/// 전투 로그 위젯 (Phase 8: 실시간 전투 이벤트 표시)
///
/// 최근 전투 이벤트를 스크롤 가능한 리스트로 표시
class CombatLog extends StatefulWidget {
const CombatLog({super.key, required this.entries, this.maxEntries = 50});
final List<CombatLogEntry> entries;
final int maxEntries;
@override
State<CombatLog> createState() => _CombatLogState();
}
class _CombatLogState extends State<CombatLog> {
final ScrollController _scrollController = ScrollController();
int _previousLength = 0;
@override
void didUpdateWidget(CombatLog oldWidget) {
super.didUpdateWidget(oldWidget);
// 새 로그 추가 시 자동 스크롤
if (widget.entries.length > _previousLength) {
_scrollToBottom();
}
_previousLength = widget.entries.length;
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListView.builder(
controller: _scrollController,
itemCount: widget.entries.length,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
itemBuilder: (context, index) {
final entry = widget.entries[index];
return _LogEntryTile(entry: entry);
},
);
}
}
/// 개별 로그 엔트리 타일
class _LogEntryTile extends StatelessWidget {
const _LogEntryTile({required this.entry});
final CombatLogEntry entry;
@override
Widget build(BuildContext context) {
final (color, icon) = _getStyleForType(entry.type);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 타임스탬프(timestamp)
Text(
_formatTime(entry.timestamp),
style: TextStyle(
fontSize: 10,
color: Theme.of(context).colorScheme.outline,
fontFamily: 'monospace',
),
),
const SizedBox(width: 4),
// 아이콘
if (icon != null)
Padding(
padding: const EdgeInsets.only(right: 4),
child: Icon(icon, size: 12, color: color),
),
// 메시지
Expanded(
child: Text(
entry.message,
style: TextStyle(
fontSize: 11,
color: color ?? Theme.of(context).colorScheme.onSurface,
),
),
),
],
),
);
}
String _formatTime(DateTime time) {
return '${time.hour.toString().padLeft(2, '0')}:'
'${time.minute.toString().padLeft(2, '0')}:'
'${time.second.toString().padLeft(2, '0')}';
}
(Color?, IconData?) _getStyleForType(CombatLogType type) {
return switch (type) {
CombatLogType.normal => (null, null),
CombatLogType.damage => (Colors.red.shade300, Icons.local_fire_department),
CombatLogType.heal => (Colors.green.shade300, Icons.healing),
CombatLogType.levelUp => (Colors.amber, Icons.arrow_upward),
CombatLogType.questComplete => (Colors.blue.shade300, Icons.check_circle),
CombatLogType.loot => (Colors.orange.shade300, Icons.inventory_2),
CombatLogType.spell => (Colors.purple.shade300, Icons.auto_fix_high),
};
}
}

View File

@@ -0,0 +1,218 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:askiineverdie/src/core/notification/notification_service.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(
top: MediaQuery.of(context).padding.top + 16,
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 (bgColor, icon, iconColor) = _getStyleForType(notification.type);
return Material(
elevation: 8,
borderRadius: BorderRadius.circular(12),
color: bgColor,
child: InkWell(
onTap: onDismiss,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// 아이콘
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child: Icon(icon, color: iconColor, size: 24),
),
const SizedBox(width: 12),
// 텍스트
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
notification.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
if (notification.subtitle != null) ...[
const SizedBox(height: 2),
Text(
notification.subtitle!,
style: TextStyle(
fontSize: 13,
color: Colors.white.withValues(alpha: 0.8),
),
),
],
],
),
),
// 닫기 버튼
IconButton(
icon: const Icon(Icons.close, color: Colors.white70, size: 20),
onPressed: onDismiss,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
),
],
),
),
),
);
}
(Color, IconData, Color) _getStyleForType(NotificationType type) {
return switch (type) {
NotificationType.levelUp => (
const Color(0xFF1565C0),
Icons.trending_up,
Colors.amber,
),
NotificationType.questComplete => (
const Color(0xFF2E7D32),
Icons.check_circle,
Colors.lightGreen,
),
NotificationType.actComplete => (
const Color(0xFF6A1B9A),
Icons.flag,
Colors.purpleAccent,
),
NotificationType.newSpell => (
const Color(0xFF4527A0),
Icons.auto_fix_high,
Colors.deepPurpleAccent,
),
NotificationType.newEquipment => (
const Color(0xFFE65100),
Icons.shield,
Colors.orange,
),
NotificationType.bossDefeat => (
const Color(0xFFC62828),
Icons.whatshot,
Colors.redAccent,
),
};
}
}

View File

@@ -0,0 +1,209 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/l10n/app_localizations.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
/// 스탯 표시 패널 (Phase 8: 실시간 변화 표시)
///
/// 장비 변경이나 버프 시 스탯 변화량을 애니메이션으로 표시
class StatsPanel extends StatefulWidget {
const StatsPanel({super.key, required this.stats});
final Stats stats;
@override
State<StatsPanel> createState() => _StatsPanelState();
}
class _StatsPanelState extends State<StatsPanel>
with SingleTickerProviderStateMixin {
// 변화량 맵 (스탯 이름 -> 변화량)
final Map<String, int> _statChanges = {};
// 변화 애니메이션 컨트롤러
late AnimationController _animationController;
late Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: const Duration(milliseconds: 2000),
vsync: this,
);
_fadeAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
CurvedAnimation(
parent: _animationController,
curve: const Interval(0.5, 1.0, curve: Curves.easeOut),
),
);
}
@override
void didUpdateWidget(StatsPanel oldWidget) {
super.didUpdateWidget(oldWidget);
// 스탯 변화 감지
_detectChanges(oldWidget.stats, widget.stats);
}
void _detectChanges(Stats oldStats, Stats newStats) {
final changes = <String, int>{};
if (newStats.str != oldStats.str) {
changes['str'] = newStats.str - oldStats.str;
}
if (newStats.con != oldStats.con) {
changes['con'] = newStats.con - oldStats.con;
}
if (newStats.dex != oldStats.dex) {
changes['dex'] = newStats.dex - oldStats.dex;
}
if (newStats.intelligence != oldStats.intelligence) {
changes['int'] = newStats.intelligence - oldStats.intelligence;
}
if (newStats.wis != oldStats.wis) {
changes['wis'] = newStats.wis - oldStats.wis;
}
if (newStats.cha != oldStats.cha) {
changes['cha'] = newStats.cha - oldStats.cha;
}
if (newStats.hpMax != oldStats.hpMax) {
changes['hpMax'] = newStats.hpMax - oldStats.hpMax;
}
if (newStats.mpMax != oldStats.mpMax) {
changes['mpMax'] = newStats.mpMax - oldStats.mpMax;
}
if (changes.isNotEmpty) {
setState(() {
_statChanges
..clear()
..addAll(changes);
});
// 애니메이션 재시작
_animationController
..reset()
..forward().then((_) {
if (mounted) {
setState(() {
_statChanges.clear();
});
}
});
}
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context);
final stats = [
('str', l10n.statStr, widget.stats.str),
('con', l10n.statCon, widget.stats.con),
('dex', l10n.statDex, widget.stats.dex),
('int', l10n.statInt, widget.stats.intelligence),
('wis', l10n.statWis, widget.stats.wis),
('cha', l10n.statCha, widget.stats.cha),
('hpMax', l10n.statHpMax, widget.stats.hpMax),
('mpMax', l10n.statMpMax, widget.stats.mpMax),
];
return ListView.builder(
itemCount: stats.length,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemBuilder: (context, index) {
final stat = stats[index];
final change = _statChanges[stat.$1];
return _StatRow(
label: stat.$2,
value: stat.$3,
change: change,
fadeAnimation: _fadeAnimation,
);
},
);
}
}
/// 개별 스탯 행 위젯
class _StatRow extends StatelessWidget {
const _StatRow({
required this.label,
required this.value,
this.change,
required this.fadeAnimation,
});
final String label;
final int value;
final int? change;
final Animation<double> fadeAnimation;
@override
Widget build(BuildContext context) {
return Row(
children: [
SizedBox(
width: 50,
child: Text(label, style: const TextStyle(fontSize: 11)),
),
Text(
'$value',
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold),
),
if (change != null) ...[
const SizedBox(width: 4),
AnimatedBuilder(
animation: fadeAnimation,
builder: (context, child) {
return Opacity(
opacity: fadeAnimation.value,
child: _ChangeIndicator(change: change!),
);
},
),
],
],
);
}
}
/// 변화량 표시 위젯
class _ChangeIndicator extends StatelessWidget {
const _ChangeIndicator({required this.change});
final int change;
@override
Widget build(BuildContext context) {
final isPositive = change > 0;
final color = isPositive ? Colors.green : Colors.red;
final icon = isPositive ? Icons.arrow_upward : Icons.arrow_downward;
final text = isPositive ? '+$change' : '$change';
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 10, color: color),
Text(
text,
style: TextStyle(
fontSize: 10,
color: color,
fontWeight: FontWeight.bold,
),
),
],
);
}
}