- GamePlayScreen 개선 - GameSessionController 확장 - MobileCarouselLayout 기능 추가 - SettingsScreen 테스트 기능 추가
520 lines
16 KiB
Dart
520 lines
16 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'package:asciineverdie/data/game_text_l10n.dart' as game_l10n;
|
|
import 'package:asciineverdie/src/core/storage/settings_repository.dart';
|
|
|
|
/// 통합 설정 화면
|
|
///
|
|
/// 언어, 테마, 사운드, 애니메이션 속도 등 모든 설정을 한 곳에서 관리
|
|
class SettingsScreen extends StatefulWidget {
|
|
const SettingsScreen({
|
|
super.key,
|
|
required this.settingsRepository,
|
|
required this.currentThemeMode,
|
|
required this.onThemeModeChange,
|
|
this.onLocaleChange,
|
|
this.onBgmVolumeChange,
|
|
this.onSfxVolumeChange,
|
|
this.onCreateTestCharacter,
|
|
});
|
|
|
|
final SettingsRepository settingsRepository;
|
|
final ThemeMode currentThemeMode;
|
|
final void Function(ThemeMode mode) onThemeModeChange;
|
|
final void Function(String locale)? onLocaleChange;
|
|
|
|
/// BGM 볼륨 변경 콜백 (AudioService 연동용)
|
|
final void Function(double volume)? onBgmVolumeChange;
|
|
|
|
/// SFX 볼륨 변경 콜백 (AudioService 연동용)
|
|
final void Function(double volume)? onSfxVolumeChange;
|
|
|
|
/// 테스트 캐릭터 생성 콜백 (디버그 모드 전용)
|
|
///
|
|
/// 현재 캐릭터를 레벨 100으로 만들어 명예의 전당에 등록하고 세이브 삭제
|
|
final Future<void> Function()? onCreateTestCharacter;
|
|
|
|
@override
|
|
State<SettingsScreen> createState() => _SettingsScreenState();
|
|
|
|
/// 설정 화면을 모달 바텀시트로 표시
|
|
static Future<void> show(
|
|
BuildContext context, {
|
|
required SettingsRepository settingsRepository,
|
|
required ThemeMode currentThemeMode,
|
|
required void Function(ThemeMode mode) onThemeModeChange,
|
|
void Function(String locale)? onLocaleChange,
|
|
void Function(double volume)? onBgmVolumeChange,
|
|
void Function(double volume)? onSfxVolumeChange,
|
|
Future<void> Function()? onCreateTestCharacter,
|
|
}) {
|
|
return showModalBottomSheet<void>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
useSafeArea: true,
|
|
builder: (context) => DraggableScrollableSheet(
|
|
initialChildSize: 0.7,
|
|
minChildSize: 0.5,
|
|
maxChildSize: 0.95,
|
|
expand: false,
|
|
builder: (context, scrollController) => SettingsScreen(
|
|
settingsRepository: settingsRepository,
|
|
currentThemeMode: currentThemeMode,
|
|
onThemeModeChange: onThemeModeChange,
|
|
onLocaleChange: onLocaleChange,
|
|
onBgmVolumeChange: onBgmVolumeChange,
|
|
onSfxVolumeChange: onSfxVolumeChange,
|
|
onCreateTestCharacter: onCreateTestCharacter,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SettingsScreenState extends State<SettingsScreen> {
|
|
double _bgmVolume = 0.7;
|
|
double _sfxVolume = 0.8;
|
|
double _animationSpeed = 1.0;
|
|
bool _isLoading = true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadSettings();
|
|
}
|
|
|
|
Future<void> _loadSettings() async {
|
|
final bgm = await widget.settingsRepository.loadBgmVolume();
|
|
final sfx = await widget.settingsRepository.loadSfxVolume();
|
|
final speed = await widget.settingsRepository.loadAnimationSpeed();
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_bgmVolume = bgm;
|
|
_sfxVolume = sfx;
|
|
_animationSpeed = speed;
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
|
|
if (_isLoading) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: theme.scaffoldBackgroundColor,
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
// 핸들 바
|
|
Container(
|
|
margin: const EdgeInsets.only(top: 8),
|
|
width: 40,
|
|
height: 4,
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.onSurface.withValues(alpha: 0.3),
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
// 헤더
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.settings, color: theme.colorScheme.primary),
|
|
const SizedBox(width: 8),
|
|
Text(game_l10n.uiSettings, style: theme.textTheme.titleLarge),
|
|
const Spacer(),
|
|
IconButton(
|
|
icon: const Icon(Icons.close),
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const Divider(height: 1),
|
|
// 설정 목록
|
|
Expanded(
|
|
child: ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
// 테마 설정
|
|
_buildSectionTitle(game_l10n.uiTheme),
|
|
_buildThemeSelector(),
|
|
const SizedBox(height: 24),
|
|
|
|
// 언어 설정
|
|
_buildSectionTitle(game_l10n.uiLanguage),
|
|
_buildLanguageSelector(),
|
|
const SizedBox(height: 24),
|
|
|
|
// 사운드 설정
|
|
_buildSectionTitle(game_l10n.uiSound),
|
|
_buildVolumeSlider(
|
|
label: game_l10n.uiBgmVolume,
|
|
value: _bgmVolume,
|
|
icon: Icons.music_note,
|
|
onChanged: (value) {
|
|
setState(() => _bgmVolume = value);
|
|
widget.settingsRepository.saveBgmVolume(value);
|
|
widget.onBgmVolumeChange?.call(value);
|
|
},
|
|
),
|
|
const SizedBox(height: 8),
|
|
_buildVolumeSlider(
|
|
label: game_l10n.uiSfxVolume,
|
|
value: _sfxVolume,
|
|
icon: Icons.volume_up,
|
|
onChanged: (value) {
|
|
setState(() => _sfxVolume = value);
|
|
widget.settingsRepository.saveSfxVolume(value);
|
|
widget.onSfxVolumeChange?.call(value);
|
|
},
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
// 애니메이션 속도
|
|
_buildSectionTitle(game_l10n.uiAnimationSpeed),
|
|
_buildAnimationSpeedSlider(),
|
|
const SizedBox(height: 24),
|
|
|
|
// 정보
|
|
_buildSectionTitle(game_l10n.uiAbout),
|
|
_buildAboutCard(),
|
|
|
|
// 디버그 섹션 (디버그 모드에서만 표시)
|
|
if (kDebugMode && widget.onCreateTestCharacter != null) ...[
|
|
const SizedBox(height: 24),
|
|
_buildSectionTitle('Debug'),
|
|
_buildDebugSection(),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDebugSection() {
|
|
return Card(
|
|
color: Theme.of(context).colorScheme.errorContainer.withValues(alpha: 0.3),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.bug_report,
|
|
color: Theme.of(context).colorScheme.error,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'Developer Tools',
|
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
color: Theme.of(context).colorScheme.error,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
'현재 캐릭터를 레벨 100으로 수정하여 명예의 전당에 등록합니다. '
|
|
'등록 후 현재 세이브 파일이 삭제됩니다.',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
const SizedBox(height: 12),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton.icon(
|
|
onPressed: _handleCreateTestCharacter,
|
|
icon: const Icon(Icons.science),
|
|
label: const Text('Create Test Character'),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Theme.of(context).colorScheme.error,
|
|
foregroundColor: Theme.of(context).colorScheme.onError,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _handleCreateTestCharacter() async {
|
|
// 확인 다이얼로그 표시
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Create Test Character?'),
|
|
content: const Text(
|
|
'현재 캐릭터가 레벨 100으로 변환되어 명예의 전당에 등록됩니다.\n\n'
|
|
'⚠️ 현재 세이브 파일이 삭제됩니다.\n'
|
|
'이 작업은 되돌릴 수 없습니다.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () => Navigator.of(context).pop(true),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Theme.of(context).colorScheme.error,
|
|
foregroundColor: Theme.of(context).colorScheme.onError,
|
|
),
|
|
child: const Text('Create'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (confirmed == true && mounted) {
|
|
await widget.onCreateTestCharacter?.call();
|
|
if (mounted) {
|
|
Navigator.of(context).pop(); // 설정 화면 닫기
|
|
}
|
|
}
|
|
}
|
|
|
|
Widget _buildSectionTitle(String title) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: Text(
|
|
title,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildThemeSelector() {
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(8),
|
|
child: Row(
|
|
children: [
|
|
_buildThemeOption(
|
|
icon: Icons.light_mode,
|
|
label: game_l10n.uiThemeLight,
|
|
mode: ThemeMode.light,
|
|
),
|
|
_buildThemeOption(
|
|
icon: Icons.dark_mode,
|
|
label: game_l10n.uiThemeDark,
|
|
mode: ThemeMode.dark,
|
|
),
|
|
_buildThemeOption(
|
|
icon: Icons.brightness_auto,
|
|
label: game_l10n.uiThemeSystem,
|
|
mode: ThemeMode.system,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildThemeOption({
|
|
required IconData icon,
|
|
required String label,
|
|
required ThemeMode mode,
|
|
}) {
|
|
final isSelected = widget.currentThemeMode == mode;
|
|
final theme = Theme.of(context);
|
|
|
|
return Expanded(
|
|
child: InkWell(
|
|
onTap: () => widget.onThemeModeChange(mode),
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
decoration: BoxDecoration(
|
|
color: isSelected
|
|
? theme.colorScheme.primaryContainer
|
|
: Colors.transparent,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Icon(
|
|
icon,
|
|
color: isSelected
|
|
? theme.colorScheme.onPrimaryContainer
|
|
: theme.colorScheme.onSurface,
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
|
color: isSelected
|
|
? theme.colorScheme.onPrimaryContainer
|
|
: theme.colorScheme.onSurface,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildLanguageSelector() {
|
|
final currentLocale = game_l10n.currentGameLocale;
|
|
final languages = [
|
|
('en', 'English', '🇺🇸'),
|
|
('ko', '한국어', '🇰🇷'),
|
|
('ja', '日本語', '🇯🇵'),
|
|
];
|
|
|
|
return Card(
|
|
child: Column(
|
|
children: languages.map((lang) {
|
|
final isSelected = currentLocale == lang.$1;
|
|
return ListTile(
|
|
leading: Text(lang.$3, style: const TextStyle(fontSize: 24)),
|
|
title: Text(lang.$2),
|
|
trailing: isSelected
|
|
? Icon(
|
|
Icons.check,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
)
|
|
: null,
|
|
onTap: () {
|
|
game_l10n.setGameLocale(lang.$1);
|
|
widget.settingsRepository.saveLocale(lang.$1);
|
|
widget.onLocaleChange?.call(lang.$1);
|
|
setState(() {});
|
|
},
|
|
);
|
|
}).toList(),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildVolumeSlider({
|
|
required String label,
|
|
required double value,
|
|
required IconData icon,
|
|
required void Function(double) onChanged,
|
|
}) {
|
|
final theme = Theme.of(context);
|
|
final percentage = (value * 100).round();
|
|
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
value == 0 ? Icons.volume_off : icon,
|
|
color: theme.colorScheme.primary,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [Text(label), Text('$percentage%')],
|
|
),
|
|
Slider(value: value, onChanged: onChanged, divisions: 10),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAnimationSpeedSlider() {
|
|
final theme = Theme.of(context);
|
|
final speedLabel = switch (_animationSpeed) {
|
|
<= 0.6 => game_l10n.uiSpeedSlow,
|
|
>= 1.4 => game_l10n.uiSpeedFast,
|
|
_ => game_l10n.uiSpeedNormal,
|
|
};
|
|
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.speed, color: theme.colorScheme.primary),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(game_l10n.uiAnimationSpeed),
|
|
Text(speedLabel),
|
|
],
|
|
),
|
|
Slider(
|
|
value: _animationSpeed,
|
|
min: 0.5,
|
|
max: 2.0,
|
|
divisions: 6,
|
|
onChanged: (value) {
|
|
setState(() => _animationSpeed = value);
|
|
widget.settingsRepository.saveAnimationSpeed(value);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAboutCard() {
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'ASCII NEVER DIE',
|
|
style: Theme.of(context).textTheme.titleMedium,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
game_l10n.uiAboutDescription,
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'v1.0.0',
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: Theme.of(context).colorScheme.outline,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|