Files
asciinevrdie/lib/src/features/game/layouts/mobile_carousel_layout.dart
JiWoong Sul 19faa9ea39 feat(ui): 게임 화면 및 UI 컴포넌트 개선
- front_screen: 프론트 화면 UI 업데이트
- game_play_screen: 게임 플레이 화면 수정
- game_session_controller: 세션 관리 로직 개선
- mobile_carousel_layout: 모바일 캐러셀 레이아웃 개선
- enhanced_animation_panel: 애니메이션 패널 업데이트
- help_dialog: 도움말 다이얼로그 수정
- return_rewards_dialog: 복귀 보상 다이얼로그 개선
- new_character_screen: 새 캐릭터 화면 수정
- settings_screen: 설정 화면 업데이트
2026-01-19 15:50:35 +09:00

1242 lines
40 KiB
Dart

import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:flutter/material.dart';
import 'package:asciineverdie/src/core/notification/notification_service.dart';
import 'package:asciineverdie/data/game_text_l10n.dart' as l10n;
import 'package:asciineverdie/l10n/app_localizations.dart';
import 'package:asciineverdie/src/core/animation/ascii_animation_type.dart';
import 'package:asciineverdie/src/core/model/game_state.dart';
import 'package:asciineverdie/src/features/game/pages/character_sheet_page.dart';
import 'package:asciineverdie/src/features/game/pages/combat_log_page.dart';
import 'package:asciineverdie/src/features/game/pages/equipment_page.dart';
import 'package:asciineverdie/src/features/game/pages/inventory_page.dart';
import 'package:asciineverdie/src/features/game/pages/quest_page.dart';
import 'package:asciineverdie/src/features/game/pages/skills_page.dart';
import 'package:asciineverdie/src/features/game/pages/story_page.dart';
import 'package:asciineverdie/src/features/game/widgets/carousel_nav_bar.dart';
import 'package:asciineverdie/src/features/game/widgets/combat_log.dart';
import 'package:asciineverdie/src/features/game/widgets/enhanced_animation_panel.dart';
import 'package:asciineverdie/src/shared/retro_colors.dart';
import 'package:asciineverdie/src/shared/widgets/retro_widgets.dart';
/// 모바일 캐로셀 레이아웃
///
/// 모바일 앱용 레이아웃:
/// - 상단: 확장 애니메이션 패널 (ASCII 애니메이션, HP/MP, 버프, 몬스터 HP)
/// - 중앙: 캐로셀 (7개 페이지: 스킬, 인벤토리, 장비, 캐릭터시트, 전투로그, 스토리, 퀘스트)
/// - 하단: 네비게이션 바 (7개 버튼)
class MobileCarouselLayout extends StatefulWidget {
const MobileCarouselLayout({
super.key,
required this.state,
required this.combatLogEntries,
required this.speedMultiplier,
required this.onSpeedCycle,
required this.isPaused,
required this.onPauseToggle,
required this.onSave,
required this.onExit,
required this.notificationService,
required this.onLanguageChange,
required this.onDeleteSaveAndNewGame,
this.specialAnimation,
this.bgmVolume = 0.7,
this.sfxVolume = 0.8,
this.onBgmVolumeChange,
this.onSfxVolumeChange,
this.onShowStatistics,
this.onShowHelp,
this.cheatsEnabled = false,
this.onCheatTask,
this.onCheatQuest,
this.onCheatPlot,
this.onCreateTestCharacter,
this.autoReviveEndMs,
this.speedBoostEndMs,
this.isPaidUser = false,
this.onSpeedBoostActivate,
this.onSetSpeed,
this.adSpeedMultiplier = 5,
this.has2xUnlocked = false,
});
final GameState state;
final List<CombatLogEntry> combatLogEntries;
final int speedMultiplier;
final VoidCallback onSpeedCycle;
/// 특정 속도로 직접 설정 (옵션 메뉴용)
final void Function(int speed)? onSetSpeed;
final bool isPaused;
final VoidCallback onPauseToggle;
final VoidCallback onSave;
final VoidCallback onExit;
final NotificationService notificationService;
final void Function(String locale) onLanguageChange;
final VoidCallback onDeleteSaveAndNewGame;
final AsciiAnimationType? specialAnimation;
/// BGM 볼륨 (0.0 ~ 1.0)
final double bgmVolume;
/// SFX 볼륨 (0.0 ~ 1.0)
final double sfxVolume;
/// BGM 볼륨 변경 콜백
final void Function(double volume)? onBgmVolumeChange;
/// SFX 볼륨 변경 콜백
final void Function(double volume)? onSfxVolumeChange;
/// 통계 표시 콜백
final VoidCallback? onShowStatistics;
/// 도움말 표시 콜백
final VoidCallback? onShowHelp;
/// 치트 모드 활성화 여부
final bool cheatsEnabled;
/// 치트: 태스크 완료
final VoidCallback? onCheatTask;
/// 치트: 퀘스트 완료
final VoidCallback? onCheatQuest;
/// 치트: 액트(플롯) 완료
final VoidCallback? onCheatPlot;
/// 테스트 캐릭터 생성 콜백 (디버그 모드 전용)
final Future<void> Function()? onCreateTestCharacter;
/// 자동부활 버프 종료 시점 (elapsedMs 기준)
final int? autoReviveEndMs;
/// 5배속 버프 종료 시점 (elapsedMs 기준)
final int? speedBoostEndMs;
/// 유료 유저 여부
final bool isPaidUser;
/// 광고 배속 활성화 콜백 (광고 시청)
final VoidCallback? onSpeedBoostActivate;
/// 광고 배속 배율 (릴리즈: 5x, 디버그빌드+디버그모드: 20x)
final int adSpeedMultiplier;
/// 2x 배속 해금 여부 (명예의 전당에 캐릭터가 있으면 true)
final bool has2xUnlocked;
@override
State<MobileCarouselLayout> createState() => _MobileCarouselLayoutState();
}
class _MobileCarouselLayoutState extends State<MobileCarouselLayout> {
late PageController _pageController;
int _currentPage = CarouselPage.character.index; // 기본: 캐릭터시트
@override
void initState() {
super.initState();
_pageController = PageController(initialPage: _currentPage);
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _onPageChanged(int page) {
setState(() {
_currentPage = page;
});
}
void _onNavPageSelected(int page) {
_pageController.animateToPage(
page,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
/// 현재 언어명 가져오기
String _getCurrentLanguageName() {
final locale = l10n.currentGameLocale;
if (locale == 'ko') return l10n.languageKorean;
if (locale == 'ja') return l10n.languageJapanese;
return l10n.languageEnglish;
}
/// 언어 선택 다이얼로그 표시
void _showLanguageDialog(BuildContext context) {
showDialog<void>(
context: context,
builder: (context) => _RetroSelectDialog(
title: l10n.menuLanguage.toUpperCase(),
children: [
_buildLanguageOption(context, 'en', l10n.languageEnglish, '🇺🇸'),
_buildLanguageOption(context, 'ko', l10n.languageKorean, '🇰🇷'),
_buildLanguageOption(context, 'ja', l10n.languageJapanese, '🇯🇵'),
],
),
);
}
Widget _buildLanguageOption(
BuildContext context,
String locale,
String label,
String flag,
) {
final isSelected = l10n.currentGameLocale == locale;
return _RetroOptionItem(
label: label.toUpperCase(),
prefix: flag,
isSelected: isSelected,
onTap: () {
Navigator.pop(context);
widget.onLanguageChange(locale);
},
);
}
/// 사운드 상태 텍스트 가져오기
String _getSoundStatus() {
final bgmPercent = (widget.bgmVolume * 100).round();
final sfxPercent = (widget.sfxVolume * 100).round();
if (bgmPercent == 0 && sfxPercent == 0) {
return l10n.uiSoundOff;
}
return 'BGM $bgmPercent% / SFX $sfxPercent%';
}
/// 사운드 설정 다이얼로그 표시
void _showSoundDialog(BuildContext context) {
var bgmVolume = widget.bgmVolume;
var sfxVolume = widget.sfxVolume;
showDialog<void>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => _RetroSoundDialog(
bgmVolume: bgmVolume,
sfxVolume: sfxVolume,
onBgmChanged: (double value) {
setDialogState(() => bgmVolume = value);
widget.onBgmVolumeChange?.call(value);
},
onSfxChanged: (double value) {
setDialogState(() => sfxVolume = value);
widget.onSfxVolumeChange?.call(value);
},
),
),
);
}
/// 세이브 삭제 확인 다이얼로그 표시
void _showDeleteConfirmDialog(BuildContext context) {
showDialog<void>(
context: context,
builder: (context) => _RetroConfirmDialog(
title: l10n.confirmDeleteTitle.toUpperCase(),
message: l10n.confirmDeleteMessage,
confirmText: l10n.buttonConfirm.toUpperCase(),
cancelText: l10n.buttonCancel.toUpperCase(),
onConfirm: () {
Navigator.pop(context);
widget.onDeleteSaveAndNewGame();
},
onCancel: () => Navigator.pop(context),
),
);
}
/// 테스트 캐릭터 생성 확인 다이얼로그
Future<void> _showTestCharacterDialog(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => _RetroConfirmDialog(
title: 'CREATE TEST CHARACTER?',
message: '현재 캐릭터가 레벨 100으로 변환되어\n'
'명예의 전당에 등록됩니다.\n\n'
'⚠️ 현재 세이브 파일이 삭제됩니다.\n'
'이 작업은 되돌릴 수 없습니다.',
confirmText: 'CREATE',
cancelText: 'CANCEL',
onConfirm: () => Navigator.of(context).pop(true),
onCancel: () => Navigator.of(context).pop(false),
),
);
if (confirmed == true && mounted) {
await widget.onCreateTestCharacter?.call();
}
}
/// 옵션 메뉴 표시
void _showOptionsMenu(BuildContext context) {
final localizations = L10n.of(context);
final background = RetroColors.backgroundOf(context);
final gold = RetroColors.goldOf(context);
final border = RetroColors.borderOf(context);
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.75,
),
builder: (context) => Container(
decoration: BoxDecoration(
color: background,
border: Border.all(color: border, width: 2),
borderRadius: const BorderRadius.vertical(top: Radius.circular(4)),
),
child: SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 핸들 바
Padding(
padding: const EdgeInsets.only(top: 8, bottom: 4),
child: Container(
width: 60,
height: 4,
color: border,
),
),
// 헤더
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
width: double.infinity,
decoration: BoxDecoration(
color: RetroColors.panelBgOf(context),
border: Border(bottom: BorderSide(color: gold, width: 2)),
),
child: Row(
children: [
Icon(Icons.settings, color: gold, size: 18),
const SizedBox(width: 8),
Text(
'OPTIONS',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: gold,
),
),
const Spacer(),
RetroIconButton(
icon: Icons.close,
onPressed: () => Navigator.pop(context),
size: 28,
),
],
),
),
// 메뉴 목록
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// === 게임 제어 ===
const _RetroMenuSection(title: 'CONTROL'),
const SizedBox(height: 8),
// 일시정지/재개
_RetroMenuItem(
icon: widget.isPaused ? Icons.play_arrow : Icons.pause,
iconColor: widget.isPaused
? RetroColors.expOf(context)
: RetroColors.warningOf(context),
label: widget.isPaused
? l10n.menuResume.toUpperCase()
: l10n.menuPause.toUpperCase(),
onTap: () {
Navigator.pop(context);
widget.onPauseToggle();
},
),
const SizedBox(height: 8),
// 속도 조절
_RetroMenuItem(
icon: Icons.speed,
iconColor: gold,
label: l10n.menuSpeed.toUpperCase(),
trailing: _buildRetroSpeedSelector(context),
),
const SizedBox(height: 16),
// === 정보 ===
const _RetroMenuSection(title: 'INFO'),
const SizedBox(height: 8),
if (widget.onShowStatistics != null)
_RetroMenuItem(
icon: Icons.bar_chart,
iconColor: RetroColors.mpOf(context),
label: l10n.uiStatistics.toUpperCase(),
onTap: () {
Navigator.pop(context);
widget.onShowStatistics?.call();
},
),
if (widget.onShowHelp != null) ...[
const SizedBox(height: 8),
_RetroMenuItem(
icon: Icons.help_outline,
iconColor: RetroColors.expOf(context),
label: l10n.uiHelp.toUpperCase(),
onTap: () {
Navigator.pop(context);
widget.onShowHelp?.call();
},
),
],
const SizedBox(height: 16),
// === 설정 ===
const _RetroMenuSection(title: 'SETTINGS'),
const SizedBox(height: 8),
_RetroMenuItem(
icon: Icons.language,
iconColor: RetroColors.mpOf(context),
label: l10n.menuLanguage.toUpperCase(),
value: _getCurrentLanguageName(),
onTap: () {
Navigator.pop(context);
_showLanguageDialog(context);
},
),
if (widget.onBgmVolumeChange != null ||
widget.onSfxVolumeChange != null) ...[
const SizedBox(height: 8),
_RetroMenuItem(
icon: widget.bgmVolume == 0 && widget.sfxVolume == 0
? Icons.volume_off
: Icons.volume_up,
iconColor: RetroColors.textMutedOf(context),
label: l10n.uiSound.toUpperCase(),
value: _getSoundStatus(),
onTap: () {
Navigator.pop(context);
_showSoundDialog(context);
},
),
],
const SizedBox(height: 16),
// === 저장/종료 ===
const _RetroMenuSection(title: 'SAVE / EXIT'),
const SizedBox(height: 8),
_RetroMenuItem(
icon: Icons.save,
iconColor: RetroColors.mpOf(context),
label: l10n.menuSave.toUpperCase(),
onTap: () {
Navigator.pop(context);
widget.onSave();
widget.notificationService.showGameSaved(
l10n.menuSaved,
);
},
),
const SizedBox(height: 8),
_RetroMenuItem(
icon: Icons.refresh,
iconColor: RetroColors.warningOf(context),
label: l10n.menuNewGame.toUpperCase(),
subtitle: l10n.menuDeleteSave,
onTap: () {
Navigator.pop(context);
_showDeleteConfirmDialog(context);
},
),
const SizedBox(height: 8),
_RetroMenuItem(
icon: Icons.exit_to_app,
iconColor: RetroColors.hpOf(context),
label: localizations.exitGame.toUpperCase(),
onTap: () {
Navigator.pop(context);
widget.onExit();
},
),
// === 치트 섹션 (디버그 모드에서만) ===
if (widget.cheatsEnabled) ...[
const SizedBox(height: 16),
_RetroMenuSection(
title: 'DEBUG CHEATS',
color: RetroColors.hpOf(context),
),
const SizedBox(height: 8),
_RetroMenuItem(
icon: Icons.fast_forward,
iconColor: RetroColors.hpOf(context),
label: 'SKIP TASK (L+1)',
subtitle: '태스크 즉시 완료',
onTap: () {
Navigator.pop(context);
widget.onCheatTask?.call();
},
),
const SizedBox(height: 8),
_RetroMenuItem(
icon: Icons.skip_next,
iconColor: RetroColors.hpOf(context),
label: 'SKIP QUEST (Q!)',
subtitle: '퀘스트 즉시 완료',
onTap: () {
Navigator.pop(context);
widget.onCheatQuest?.call();
},
),
const SizedBox(height: 8),
_RetroMenuItem(
icon: Icons.double_arrow,
iconColor: RetroColors.hpOf(context),
label: 'SKIP ACT (P!)',
subtitle: '액트 즉시 완료',
onTap: () {
Navigator.pop(context);
widget.onCheatPlot?.call();
},
),
],
// === 디버그 도구 섹션 ===
if (kDebugMode && widget.onCreateTestCharacter != null) ...[
const SizedBox(height: 16),
_RetroMenuSection(
title: 'DEBUG TOOLS',
color: RetroColors.warningOf(context),
),
const SizedBox(height: 8),
_RetroMenuItem(
icon: Icons.science,
iconColor: RetroColors.warningOf(context),
label: 'CREATE TEST CHARACTER',
subtitle: '레벨 100 캐릭터를 명예의 전당에 등록',
onTap: () {
Navigator.pop(context);
_showTestCharacterDialog(context);
},
),
],
const SizedBox(height: 16),
],
),
),
),
],
),
),
),
);
}
/// 레트로 스타일 속도 선택기
Widget _buildRetroSpeedSelector(BuildContext context) {
final currentElapsedMs = widget.state.skillSystem.elapsedMs;
final speedBoostEndMs = widget.speedBoostEndMs ?? 0;
final isSpeedBoostActive =
speedBoostEndMs > currentElapsedMs || widget.isPaidUser;
final adSpeed = widget.adSpeedMultiplier;
void setSpeed(int speed) {
if (widget.onSetSpeed != null) {
widget.onSetSpeed!(speed);
} else {
widget.onSpeedCycle();
}
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
// 1x 버튼
_RetroSpeedChip(
speed: 1,
isSelected: widget.speedMultiplier == 1 && !isSpeedBoostActive,
onTap: () {
setSpeed(1);
Navigator.pop(context);
},
),
// 2x 버튼 (명예의 전당 해금 시)
if (widget.has2xUnlocked) ...[
const SizedBox(width: 4),
_RetroSpeedChip(
speed: 2,
isSelected: widget.speedMultiplier == 2 && !isSpeedBoostActive,
onTap: () {
setSpeed(2);
Navigator.pop(context);
},
),
],
const SizedBox(width: 4),
// 광고배속 버튼
_RetroSpeedChip(
speed: adSpeed,
isSelected: isSpeedBoostActive,
isAdBased: !isSpeedBoostActive && !widget.isPaidUser,
onTap: () {
if (!isSpeedBoostActive) {
widget.onSpeedBoostActivate?.call();
}
Navigator.pop(context);
},
),
],
);
}
@override
Widget build(BuildContext context) {
final state = widget.state;
final background = RetroColors.backgroundOf(context);
final panelBg = RetroColors.panelBgOf(context);
final gold = RetroColors.goldOf(context);
return Scaffold(
backgroundColor: background,
appBar: AppBar(
backgroundColor: panelBg,
title: Text(
L10n.of(context).progressQuestTitle(state.traits.name),
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: gold,
),
),
actions: [
// 옵션 버튼
IconButton(
icon: Icon(Icons.settings, color: gold),
onPressed: () => _showOptionsMenu(context),
tooltip: l10n.menuOptions,
),
],
),
body: SafeArea(
top: false, // AppBar가 상단 처리
child: Column(
children: [
// 상단: 확장 애니메이션 패널
EnhancedAnimationPanel(
progress: state.progress,
stats: state.stats,
skillSystem: state.skillSystem,
speedMultiplier: widget.speedMultiplier,
onSpeedCycle: widget.onSpeedCycle,
isPaused: widget.isPaused,
onPauseToggle: widget.onPauseToggle,
specialAnimation: widget.specialAnimation,
weaponName: state.equipment.weapon,
shieldName: state.equipment.shield,
characterLevel: state.traits.level,
monsterLevel: state.progress.currentTask.monsterLevel,
monsterGrade: state.progress.currentTask.monsterGrade,
monsterSize: state.progress.currentTask.monsterSize,
latestCombatEvent:
state.progress.currentCombat?.recentEvents.lastOrNull,
raceId: state.traits.raceId,
weaponRarity: state.equipment.weaponItem.rarity,
autoReviveEndMs: widget.autoReviveEndMs,
speedBoostEndMs: widget.speedBoostEndMs,
isPaidUser: widget.isPaidUser,
onSpeedBoostActivate: widget.onSpeedBoostActivate,
adSpeedMultiplier: widget.adSpeedMultiplier,
),
// 중앙: 캐로셀 (PageView)
Expanded(
child: PageView(
controller: _pageController,
onPageChanged: _onPageChanged,
children: [
// 0: 스킬
SkillsPage(
skillBook: state.skillBook,
skillSystem: state.skillSystem,
),
// 1: 인벤토리
InventoryPage(
inventory: state.inventory,
potionInventory: state.potionInventory,
encumbrance: state.progress.encumbrance,
),
// 2: 장비
EquipmentPage(equipment: state.equipment),
// 3: 캐릭터시트 (기본)
CharacterSheetPage(
traits: state.traits,
stats: state.stats,
exp: state.progress.exp,
),
// 4: 전투로그
CombatLogPage(entries: widget.combatLogEntries),
// 5: 퀘스트
QuestPage(
questHistory: state.progress.questHistory,
quest: state.progress.quest,
),
// 6: 스토리
StoryPage(
plotStageCount: state.progress.plotStageCount,
plot: state.progress.plot,
),
],
),
),
// 하단: 네비게이션 바
CarouselNavBar(
currentPage: _currentPage,
onPageSelected: _onNavPageSelected,
),
],
),
),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// 레트로 스타일 옵션 메뉴 위젯들
// ═══════════════════════════════════════════════════════════════════════════
/// 메뉴 섹션 타이틀
class _RetroMenuSection extends StatelessWidget {
const _RetroMenuSection({required this.title, this.color});
final String title;
final Color? color;
@override
Widget build(BuildContext context) {
final gold = color ?? RetroColors.goldOf(context);
return Row(
children: [
Container(width: 4, height: 14, color: gold),
const SizedBox(width: 8),
Text(
title,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 9,
color: gold,
letterSpacing: 1,
),
),
],
);
}
}
/// 메뉴 아이템
class _RetroMenuItem extends StatelessWidget {
const _RetroMenuItem({
required this.icon,
required this.iconColor,
required this.label,
this.value,
this.subtitle,
this.trailing,
this.onTap,
});
final IconData icon;
final Color iconColor;
final String label;
final String? value;
final String? subtitle;
final Widget? trailing;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final border = RetroColors.borderOf(context);
final panelBg = RetroColors.panelBgOf(context);
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: panelBg,
border: Border.all(color: border, width: 1),
),
child: Row(
children: [
Icon(icon, size: 16, color: iconColor),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 16,
color: RetroColors.textPrimaryOf(context),
),
),
if (subtitle != null) ...[
const SizedBox(height: 4),
Text(
subtitle!,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 12,
color: RetroColors.textMutedOf(context),
),
),
],
],
),
),
if (value != null)
Text(
value!,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 14,
color: RetroColors.goldOf(context),
),
),
if (trailing != null) trailing!,
],
),
),
);
}
}
/// 속도 선택 칩
class _RetroSpeedChip extends StatelessWidget {
const _RetroSpeedChip({
required this.speed,
required this.isSelected,
required this.onTap,
this.isAdBased = false,
});
final int speed;
final bool isSelected;
final VoidCallback onTap;
final bool isAdBased;
@override
Widget build(BuildContext context) {
final gold = RetroColors.goldOf(context);
final warning = RetroColors.warningOf(context);
final border = RetroColors.borderOf(context);
final Color bgColor;
final Color textColor;
final Color borderColor;
if (isSelected) {
bgColor = isAdBased ? warning.withValues(alpha: 0.3) : gold.withValues(alpha: 0.3);
textColor = isAdBased ? warning : gold;
borderColor = isAdBased ? warning : gold;
} else if (isAdBased) {
bgColor = Colors.transparent;
textColor = warning;
borderColor = warning;
} else {
bgColor = Colors.transparent;
textColor = RetroColors.textMutedOf(context);
borderColor = border;
}
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: bgColor,
border: Border.all(color: borderColor, width: isSelected ? 2 : 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isAdBased && !isSelected)
Padding(
padding: const EdgeInsets.only(right: 2),
child: Text(
'',
style: TextStyle(fontSize: 7, color: warning),
),
),
Text(
'${speed}x',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 8,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: textColor,
),
),
],
),
),
);
}
}
/// 선택 다이얼로그
class _RetroSelectDialog extends StatelessWidget {
const _RetroSelectDialog({required this.title, required this.children});
final String title;
final List<Widget> children;
@override
Widget build(BuildContext context) {
final background = RetroColors.backgroundOf(context);
final gold = RetroColors.goldOf(context);
return Dialog(
backgroundColor: Colors.transparent,
child: Container(
constraints: const BoxConstraints(maxWidth: 320),
decoration: BoxDecoration(
color: background,
border: Border.all(color: gold, width: 2),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 타이틀
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
color: gold.withValues(alpha: 0.2),
child: Text(
title,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 10,
color: gold,
),
textAlign: TextAlign.center,
),
),
// 옵션 목록
Padding(
padding: const EdgeInsets.all(12),
child: Column(children: children),
),
],
),
),
);
}
}
/// 선택 옵션 아이템
class _RetroOptionItem extends StatelessWidget {
const _RetroOptionItem({
required this.label,
required this.isSelected,
required this.onTap,
this.prefix,
});
final String label;
final bool isSelected;
final VoidCallback onTap;
final String? prefix;
@override
Widget build(BuildContext context) {
final gold = RetroColors.goldOf(context);
final border = RetroColors.borderOf(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: isSelected ? gold.withValues(alpha: 0.15) : Colors.transparent,
border: Border.all(
color: isSelected ? gold : border,
width: isSelected ? 2 : 1,
),
),
child: Row(
children: [
if (prefix != null) ...[
Text(prefix!, style: const TextStyle(fontSize: 16)),
const SizedBox(width: 12),
],
Expanded(
child: Text(
label,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 18,
color: isSelected ? gold : RetroColors.textPrimaryOf(context),
),
),
),
if (isSelected) Icon(Icons.check, size: 16, color: gold),
],
),
),
),
);
}
}
/// 사운드 설정 다이얼로그
class _RetroSoundDialog extends StatelessWidget {
const _RetroSoundDialog({
required this.bgmVolume,
required this.sfxVolume,
required this.onBgmChanged,
required this.onSfxChanged,
});
final double bgmVolume;
final double sfxVolume;
final ValueChanged<double> onBgmChanged;
final ValueChanged<double> onSfxChanged;
@override
Widget build(BuildContext context) {
final background = RetroColors.backgroundOf(context);
final gold = RetroColors.goldOf(context);
return Dialog(
backgroundColor: Colors.transparent,
child: Container(
constraints: const BoxConstraints(maxWidth: 360),
decoration: BoxDecoration(
color: background,
border: Border.all(color: gold, width: 2),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 타이틀
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
color: gold.withValues(alpha: 0.2),
child: Text(
'SOUND',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 10,
color: gold,
),
textAlign: TextAlign.center,
),
),
// 슬라이더
Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
_buildVolumeSlider(
context,
icon: bgmVolume == 0 ? Icons.music_off : Icons.music_note,
label: 'BGM',
value: bgmVolume,
onChanged: onBgmChanged,
),
const SizedBox(height: 16),
_buildVolumeSlider(
context,
icon: sfxVolume == 0 ? Icons.volume_off : Icons.volume_up,
label: 'SFX',
value: sfxVolume,
onChanged: onSfxChanged,
),
],
),
),
// 확인 버튼
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: SizedBox(
width: double.infinity,
child: RetroTextButton(
text: 'OK',
onPressed: () => Navigator.pop(context),
),
),
),
],
),
),
);
}
Widget _buildVolumeSlider(
BuildContext context, {
required IconData icon,
required String label,
required double value,
required ValueChanged<double> onChanged,
}) {
final gold = RetroColors.goldOf(context);
final border = RetroColors.borderOf(context);
final percentage = (value * 100).round();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 18, color: gold),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 16,
color: RetroColors.textPrimaryOf(context),
),
),
const Spacer(),
Text(
'$percentage%',
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 16,
color: gold,
),
),
],
),
const SizedBox(height: 8),
SliderTheme(
data: SliderThemeData(
trackHeight: 8,
activeTrackColor: gold,
inactiveTrackColor: border,
thumbColor: RetroColors.goldLightOf(context),
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8),
overlayColor: gold.withValues(alpha: 0.2),
trackShape: const RectangularSliderTrackShape(),
),
child: Slider(value: value, onChanged: onChanged, divisions: 10),
),
],
);
}
}
/// 확인 다이얼로그
class _RetroConfirmDialog extends StatelessWidget {
const _RetroConfirmDialog({
required this.title,
required this.message,
required this.confirmText,
required this.cancelText,
required this.onConfirm,
required this.onCancel,
});
final String title;
final String message;
final String confirmText;
final String cancelText;
final VoidCallback onConfirm;
final VoidCallback onCancel;
@override
Widget build(BuildContext context) {
final background = RetroColors.backgroundOf(context);
final gold = RetroColors.goldOf(context);
return Dialog(
backgroundColor: Colors.transparent,
child: Container(
constraints: const BoxConstraints(maxWidth: 360),
decoration: BoxDecoration(
color: background,
border: Border.all(color: gold, width: 3),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 타이틀
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
color: gold.withValues(alpha: 0.2),
child: Text(
title,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 10,
color: gold,
),
textAlign: TextAlign.center,
),
),
// 메시지
Padding(
padding: const EdgeInsets.all(16),
child: Text(
message,
style: TextStyle(
fontFamily: 'PressStart2P',
fontSize: 16,
color: RetroColors.textPrimaryOf(context),
height: 1.8,
),
textAlign: TextAlign.center,
),
),
// 버튼
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: Row(
children: [
Expanded(
child: RetroTextButton(
text: cancelText,
isPrimary: false,
onPressed: onCancel,
),
),
const SizedBox(width: 8),
Expanded(
child: RetroTextButton(
text: confirmText,
onPressed: onConfirm,
),
),
],
),
),
],
),
),
);
}
}