feat(ui): 반응형 레이아웃 및 저장 시스템 개선

## 반응형 레이아웃
- app.dart: 화면 크기별 레이아웃 분기 로직 추가 (+173 라인)
- game_play_screen.dart: 반응형 UI 구조 개선
- layouts/, pages/ 디렉토리 추가 (새 레이아웃 시스템)
- carousel_nav_bar.dart: 캐러셀 네비게이션 바 추가
- enhanced_animation_panel.dart: 향상된 애니메이션 패널

## 저장 시스템
- save_manager.dart: 저장 관리 기능 확장
- save_repository.dart: 저장소 인터페이스 개선
- save_service.dart: 저장 서비스 로직 추가

## UI 개선
- notification_service.dart: 알림 시스템 기능 확장
- notification_overlay.dart: 오버레이 UI 개선
- equipment_stats_panel.dart: 장비 스탯 패널 개선
- cinematic_view.dart: 시네마틱 뷰 개선
- new_character_screen.dart: 캐릭터 생성 화면 개선

## 다국어
- game_text_l10n.dart: 텍스트 추가 (+182 라인)

## 테스트
- 관련 테스트 파일 업데이트
This commit is contained in:
JiWoong Sul
2025-12-23 17:52:43 +09:00
parent 1da6fa7a2b
commit e6af7dd91a
28 changed files with 2734 additions and 73 deletions

View File

@@ -0,0 +1,121 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/data/game_text_l10n.dart' as l10n;
/// 캐로셀 페이지 인덱스
enum CarouselPage {
skills, // 0: 스킬
inventory, // 1: 인벤토리
equipment, // 2: 장비
character, // 3: 캐릭터시트 (기본)
combatLog, // 4: 전투로그
quest, // 5: 퀘스트
story, // 6: 스토리
}
/// 캐로셀 네비게이션 바
///
/// 7개의 페이지 버튼을 표시하고 현재 페이지를 하이라이트.
/// 버튼 탭 시 해당 페이지로 이동.
class CarouselNavBar extends StatelessWidget {
const CarouselNavBar({
super.key,
required this.currentPage,
required this.onPageSelected,
});
final int currentPage;
final ValueChanged<int> onPageSelected;
@override
Widget build(BuildContext context) {
return Container(
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
border: Border(top: BorderSide(color: Theme.of(context).dividerColor)),
),
child: Row(
children: CarouselPage.values.map((page) {
final isSelected = page.index == currentPage;
return Expanded(
child: _NavButton(
page: page,
isSelected: isSelected,
onTap: () => onPageSelected(page.index),
),
);
}).toList(),
),
);
}
}
/// 개별 네비게이션 버튼
class _NavButton extends StatelessWidget {
const _NavButton({
required this.page,
required this.isSelected,
required this.onTap,
});
final CarouselPage page;
final bool isSelected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final (icon, label) = _getIconAndLabel(page);
final theme = Theme.of(context);
final color = isSelected
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 4),
decoration: isSelected
? BoxDecoration(
color: theme.colorScheme.primaryContainer.withValues(
alpha: 0.5,
),
borderRadius: BorderRadius.circular(8),
)
: null,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 20, color: color),
const SizedBox(height: 2),
Text(
label,
style: TextStyle(
fontSize: 9,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: color,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
);
}
/// 페이지별 아이콘과 라벨
(IconData, String) _getIconAndLabel(CarouselPage page) {
return switch (page) {
CarouselPage.skills => (Icons.auto_fix_high, l10n.navSkills),
CarouselPage.inventory => (Icons.inventory_2, l10n.navInventory),
CarouselPage.equipment => (Icons.shield, l10n.navEquipment),
CarouselPage.character => (Icons.person, l10n.navCharacter),
CarouselPage.combatLog => (Icons.list_alt, l10n.navCombatLog),
CarouselPage.story => (Icons.auto_stories, l10n.navStory),
CarouselPage.quest => (Icons.flag, l10n.navQuest),
};
}
}

View File

@@ -165,7 +165,10 @@ class _CinematicViewState extends State<CinematicView>
onPressed: _skip,
child: Text(
l10n.uiSkip,
style: const TextStyle(color: Colors.white54, fontSize: 14),
style: const TextStyle(
color: Colors.white54,
fontSize: 14,
),
),
),
),

View File

@@ -0,0 +1,642 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/data/game_text_l10n.dart' as l10n;
import 'package:askiineverdie/src/core/animation/ascii_animation_type.dart';
import 'package:askiineverdie/src/core/model/combat_event.dart';
import 'package:askiineverdie/src/core/model/combat_state.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
import 'package:askiineverdie/src/features/game/widgets/ascii_animation_card.dart';
/// 모바일용 확장 애니메이션 패널
///
/// 캐로셀 레이아웃에서 상단 영역에 표시되는 통합 패널:
/// - ASCII 애니메이션 (기존 높이 유지)
/// - 플레이어 HP/MP 컴팩트 바 (플로팅 텍스트 포함)
/// - 활성 버프 아이콘 (최대 3개)
/// - 몬스터 HP 바 (전투 중)
class EnhancedAnimationPanel extends StatefulWidget {
const EnhancedAnimationPanel({
super.key,
required this.progress,
required this.stats,
required this.skillSystem,
required this.speedMultiplier,
required this.onSpeedCycle,
required this.isPaused,
required this.onPauseToggle,
this.specialAnimation,
this.weaponName,
this.shieldName,
this.characterLevel,
this.monsterLevel,
this.latestCombatEvent,
});
final ProgressState progress;
final Stats stats;
final SkillSystemState skillSystem;
final int speedMultiplier;
final VoidCallback onSpeedCycle;
final bool isPaused;
final VoidCallback onPauseToggle;
final AsciiAnimationType? specialAnimation;
final String? weaponName;
final String? shieldName;
final int? characterLevel;
final int? monsterLevel;
final CombatEvent? latestCombatEvent;
@override
State<EnhancedAnimationPanel> createState() => _EnhancedAnimationPanelState();
}
class _EnhancedAnimationPanelState extends State<EnhancedAnimationPanel>
with TickerProviderStateMixin {
// HP/MP 변화 애니메이션
late AnimationController _hpFlashController;
late AnimationController _mpFlashController;
late AnimationController _monsterFlashController;
late Animation<double> _hpFlashAnimation;
late Animation<double> _mpFlashAnimation;
late Animation<double> _monsterFlashAnimation;
int _hpChange = 0;
int _mpChange = 0;
int _monsterHpChange = 0;
// 이전 값 추적
int _lastHp = 0;
int _lastMp = 0;
int _lastMonsterHp = 0;
@override
void initState() {
super.initState();
_hpFlashController = AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
);
_mpFlashController = AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
);
_monsterFlashController = AnimationController(
duration: const Duration(milliseconds: 500),
vsync: this,
);
_hpFlashAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
CurvedAnimation(parent: _hpFlashController, curve: Curves.easeOut),
);
_mpFlashAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
CurvedAnimation(parent: _mpFlashController, curve: Curves.easeOut),
);
_monsterFlashAnimation = Tween<double>(begin: 1.0, end: 0.0).animate(
CurvedAnimation(parent: _monsterFlashController, curve: Curves.easeOut),
);
// 초기값 설정
_lastHp = _currentHp;
_lastMp = _currentMp;
_lastMonsterHp = _currentMonsterHp ?? 0;
}
@override
void didUpdateWidget(EnhancedAnimationPanel oldWidget) {
super.didUpdateWidget(oldWidget);
// HP 변화 감지
final newHp = _currentHp;
if (newHp != _lastHp) {
_hpChange = newHp - _lastHp;
_hpFlashController.forward(from: 0.0);
_lastHp = newHp;
}
// MP 변화 감지
final newMp = _currentMp;
if (newMp != _lastMp) {
_mpChange = newMp - _lastMp;
_mpFlashController.forward(from: 0.0);
_lastMp = newMp;
}
// 몬스터 HP 변화 감지
final newMonsterHp = _currentMonsterHp;
if (newMonsterHp != null && newMonsterHp != _lastMonsterHp) {
_monsterHpChange = newMonsterHp - _lastMonsterHp;
_monsterFlashController.forward(from: 0.0);
_lastMonsterHp = newMonsterHp;
} else if (newMonsterHp == null) {
_lastMonsterHp = 0;
}
}
int get _currentHp =>
widget.progress.currentCombat?.playerStats.hpCurrent ?? widget.stats.hp;
int get _currentHpMax =>
widget.progress.currentCombat?.playerStats.hpMax ?? widget.stats.hpMax;
int get _currentMp =>
widget.progress.currentCombat?.playerStats.mpCurrent ?? widget.stats.mp;
int get _currentMpMax =>
widget.progress.currentCombat?.playerStats.mpMax ?? widget.stats.mpMax;
int? get _currentMonsterHp =>
widget.progress.currentCombat?.monsterStats.hpCurrent;
int? get _currentMonsterHpMax =>
widget.progress.currentCombat?.monsterStats.hpMax;
@override
void dispose() {
_hpFlashController.dispose();
_mpFlashController.dispose();
_monsterFlashController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final combat = widget.progress.currentCombat;
final isInCombat = combat != null && combat.isActive;
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
border: Border(
bottom: BorderSide(color: Theme.of(context).dividerColor),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// ASCII 애니메이션 (기존 높이 120 유지)
SizedBox(
height: 120,
child: AsciiAnimationCard(
taskType: widget.progress.currentTask.type,
monsterBaseName: widget.progress.currentTask.monsterBaseName,
specialAnimation: widget.specialAnimation,
weaponName: widget.weaponName,
shieldName: widget.shieldName,
characterLevel: widget.characterLevel,
monsterLevel: widget.monsterLevel,
isPaused: widget.isPaused,
latestCombatEvent: widget.latestCombatEvent,
),
),
const SizedBox(height: 8),
// 상태 바 영역: HP/MP + 버프 아이콘 + 몬스터 HP
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 좌측: HP/MP 바
Expanded(
flex: 3,
child: Column(
children: [
_buildCompactHpBar(),
const SizedBox(height: 4),
_buildCompactMpBar(),
],
),
),
const SizedBox(width: 8),
// 중앙: 활성 버프 아이콘 (최대 3개)
_buildBuffIcons(),
const SizedBox(width: 8),
// 우측: 몬스터 HP (전투 중) 또는 컨트롤 버튼
Expanded(
flex: 2,
child: isInCombat
? _buildMonsterHpBar(combat)
: _buildControlButtons(),
),
],
),
const SizedBox(height: 6),
// 하단: 태스크 프로그레스 바 + 캡션
_buildTaskProgress(),
],
),
);
}
/// 컴팩트 HP 바
Widget _buildCompactHpBar() {
final ratio = _currentHpMax > 0 ? _currentHp / _currentHpMax : 0.0;
final isLow = ratio < 0.2 && ratio > 0;
return AnimatedBuilder(
animation: _hpFlashAnimation,
builder: (context, child) {
return Stack(
clipBehavior: Clip.none,
children: [
// HP 바
Container(
height: 14,
decoration: BoxDecoration(
color: isLow
? Colors.red.withValues(alpha: 0.2)
: Colors.grey.shade800,
borderRadius: BorderRadius.circular(3),
),
child: Row(
children: [
// 라벨
Container(
width: 28,
alignment: Alignment.center,
child: Text(
l10n.statHp,
style: const TextStyle(
fontSize: 9,
fontWeight: FontWeight.bold,
color: Colors.white70,
),
),
),
// 프로그레스
Expanded(
child: ClipRRect(
borderRadius: const BorderRadius.horizontal(
right: Radius.circular(3),
),
child: LinearProgressIndicator(
value: ratio.clamp(0.0, 1.0),
backgroundColor: Colors.red.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation(
isLow ? Colors.red : Colors.red.shade600,
),
minHeight: 14,
),
),
),
// 수치
Container(
width: 48,
alignment: Alignment.center,
child: Text(
'$_currentHp/$_currentHpMax',
style: const TextStyle(fontSize: 8, color: Colors.white),
),
),
],
),
),
// 플로팅 변화량
if (_hpChange != 0 && _hpFlashAnimation.value > 0.05)
Positioned(
right: 50,
top: -8,
child: Transform.translate(
offset: Offset(0, -10 * (1 - _hpFlashAnimation.value)),
child: Opacity(
opacity: _hpFlashAnimation.value,
child: Text(
_hpChange > 0 ? '+$_hpChange' : '$_hpChange',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: _hpChange < 0 ? Colors.red : Colors.green,
shadows: const [
Shadow(color: Colors.black, blurRadius: 3),
],
),
),
),
),
),
],
);
},
);
}
/// 컴팩트 MP 바
Widget _buildCompactMpBar() {
final ratio = _currentMpMax > 0 ? _currentMp / _currentMpMax : 0.0;
return AnimatedBuilder(
animation: _mpFlashAnimation,
builder: (context, child) {
return Stack(
clipBehavior: Clip.none,
children: [
Container(
height: 14,
decoration: BoxDecoration(
color: Colors.grey.shade800,
borderRadius: BorderRadius.circular(3),
),
child: Row(
children: [
Container(
width: 28,
alignment: Alignment.center,
child: Text(
l10n.statMp,
style: const TextStyle(
fontSize: 9,
fontWeight: FontWeight.bold,
color: Colors.white70,
),
),
),
Expanded(
child: ClipRRect(
borderRadius: const BorderRadius.horizontal(
right: Radius.circular(3),
),
child: LinearProgressIndicator(
value: ratio.clamp(0.0, 1.0),
backgroundColor: Colors.blue.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation(
Colors.blue.shade600,
),
minHeight: 14,
),
),
),
Container(
width: 48,
alignment: Alignment.center,
child: Text(
'$_currentMp/$_currentMpMax',
style: const TextStyle(fontSize: 8, color: Colors.white),
),
),
],
),
),
if (_mpChange != 0 && _mpFlashAnimation.value > 0.05)
Positioned(
right: 50,
top: -8,
child: Transform.translate(
offset: Offset(0, -10 * (1 - _mpFlashAnimation.value)),
child: Opacity(
opacity: _mpFlashAnimation.value,
child: Text(
_mpChange > 0 ? '+$_mpChange' : '$_mpChange',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: _mpChange < 0 ? Colors.orange : Colors.cyan,
shadows: const [
Shadow(color: Colors.black, blurRadius: 3),
],
),
),
),
),
),
],
);
},
);
}
/// 활성 버프 아이콘 (최대 3개)
Widget _buildBuffIcons() {
final buffs = widget.skillSystem.activeBuffs;
final currentMs = widget.skillSystem.elapsedMs;
if (buffs.isEmpty) {
return const SizedBox(width: 60);
}
// 최대 3개만 표시
final displayBuffs = buffs.take(3).toList();
return SizedBox(
width: 60,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: displayBuffs.map((buff) {
final remainingMs = buff.remainingDuration(currentMs);
final progress = remainingMs / buff.effect.durationMs;
final isExpiring = remainingMs < 3000;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Stack(
alignment: Alignment.center,
children: [
// 진행률 원형 표시
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
value: progress.clamp(0.0, 1.0),
strokeWidth: 2,
backgroundColor: Colors.grey.shade700,
valueColor: AlwaysStoppedAnimation(
isExpiring ? Colors.orange : Colors.lightBlue,
),
),
),
// 버프 아이콘
Icon(
Icons.trending_up,
size: 10,
color: isExpiring ? Colors.orange : Colors.lightBlue,
),
],
),
);
}).toList(),
),
);
}
/// 몬스터 HP 바 (전투 중)
Widget _buildMonsterHpBar(CombatState combat) {
final max = _currentMonsterHpMax ?? 1;
final current = _currentMonsterHp ?? 0;
final ratio = max > 0 ? current / max : 0.0;
return AnimatedBuilder(
animation: _monsterFlashAnimation,
builder: (context, child) {
return Stack(
clipBehavior: Clip.none,
children: [
Container(
height: 32,
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// HP 바
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: ratio.clamp(0.0, 1.0),
backgroundColor: Colors.orange.withValues(alpha: 0.2),
valueColor: const AlwaysStoppedAnimation(Colors.orange),
minHeight: 8,
),
),
),
const SizedBox(height: 2),
// 퍼센트
Text(
'${(ratio * 100).toInt()}%',
style: const TextStyle(
fontSize: 9,
color: Colors.orange,
fontWeight: FontWeight.bold,
),
),
],
),
),
// 플로팅 데미지
if (_monsterHpChange != 0 && _monsterFlashAnimation.value > 0.05)
Positioned(
right: 10,
top: -10,
child: Transform.translate(
offset: Offset(0, -10 * (1 - _monsterFlashAnimation.value)),
child: Opacity(
opacity: _monsterFlashAnimation.value,
child: Text(
_monsterHpChange > 0
? '+$_monsterHpChange'
: '$_monsterHpChange',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: _monsterHpChange < 0
? Colors.yellow
: Colors.green,
shadows: const [
Shadow(color: Colors.black, blurRadius: 3),
],
),
),
),
),
),
],
);
},
);
}
/// 컨트롤 버튼 (비전투 시)
Widget _buildControlButtons() {
return SizedBox(
height: 32,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 일시정지 버튼
SizedBox(
width: 36,
height: 28,
child: OutlinedButton(
onPressed: widget.onPauseToggle,
style: OutlinedButton.styleFrom(
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
side: BorderSide(
color: widget.isPaused
? Colors.orange.withValues(alpha: 0.7)
: Theme.of(
context,
).colorScheme.outline.withValues(alpha: 0.5),
),
),
child: Icon(
widget.isPaused ? Icons.play_arrow : Icons.pause,
size: 14,
color: widget.isPaused ? Colors.orange : null,
),
),
),
const SizedBox(width: 4),
// 속도 버튼
SizedBox(
width: 36,
height: 28,
child: OutlinedButton(
onPressed: widget.onSpeedCycle,
style: OutlinedButton.styleFrom(
padding: EdgeInsets.zero,
visualDensity: VisualDensity.compact,
),
child: Text(
'${widget.speedMultiplier}x',
style: TextStyle(
fontSize: 10,
fontWeight: widget.speedMultiplier > 1
? FontWeight.bold
: FontWeight.normal,
color: widget.speedMultiplier > 1
? Theme.of(context).colorScheme.primary
: null,
),
),
),
),
],
),
);
}
/// 태스크 프로그레스 바
Widget _buildTaskProgress() {
final task = widget.progress.task;
final progressValue = task.max > 0
? (task.position / task.max).clamp(0.0, 1.0)
: 0.0;
return Column(
children: [
// 캡션
Text(
widget.progress.currentTask.caption,
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
// 프로그레스 바
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: LinearProgressIndicator(
value: progressValue,
backgroundColor: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
minHeight: 10,
),
),
],
);
}
}

View File

@@ -308,7 +308,10 @@ class _StatsGrid extends StatelessWidget {
}
if (stats.criRate > 0) {
entries.add(
_StatEntry(l10n.statCri, '${(stats.criRate * 100).toStringAsFixed(1)}%'),
_StatEntry(
l10n.statCri,
'${(stats.criRate * 100).toStringAsFixed(1)}%',
),
);
}
if (stats.parryRate > 0) {
@@ -335,7 +338,10 @@ class _StatsGrid extends StatelessWidget {
}
if (stats.evasion > 0) {
entries.add(
_StatEntry(l10n.statEva, '${(stats.evasion * 100).toStringAsFixed(1)}%'),
_StatEntry(
l10n.statEva,
'${(stats.evasion * 100).toStringAsFixed(1)}%',
),
);
}

View File

@@ -40,8 +40,9 @@ class _NotificationOverlayState extends State<NotificationOverlay>
vsync: this,
);
_slideAnimation =
Tween<Offset>(begin: const Offset(0, -1), end: Offset.zero).animate(
// 하단에서 슬라이드 인/아웃
_slideAnimation = Tween<Offset>(begin: const Offset(0, 1), end: Offset.zero)
.animate(
CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutBack,
@@ -86,7 +87,7 @@ class _NotificationOverlayState extends State<NotificationOverlay>
widget.child,
if (_currentNotification != null)
Positioned(
top: MediaQuery.of(context).padding.top + 16,
bottom: MediaQuery.of(context).padding.bottom + 80,
left: 16,
right: 16,
child: SlideTransition(
@@ -214,6 +215,21 @@ class _NotificationCard extends StatelessWidget {
Icons.whatshot,
Colors.redAccent,
),
NotificationType.gameSaved => (
const Color(0xFF00695C),
Icons.save,
Colors.tealAccent,
),
NotificationType.info => (
const Color(0xFF0277BD),
Icons.info_outline,
Colors.lightBlueAccent,
),
NotificationType.warning => (
const Color(0xFFF57C00),
Icons.warning_amber,
Colors.amber,
),
};
}
}

View File

@@ -120,6 +120,8 @@ class _StatsPanelState extends State<StatsPanel>
return ListView.builder(
itemCount: stats.length,
padding: const EdgeInsets.symmetric(horizontal: 8),
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) {
final stat = stats[index];
final change = _statChanges[stat.$1];