diff --git a/lib/data/game_text_l10n.dart b/lib/data/game_text_l10n.dart index 0c7f439..1050f64 100644 --- a/lib/data/game_text_l10n.dart +++ b/lib/data/game_text_l10n.dart @@ -1792,3 +1792,97 @@ String get uiLoading { if (isJapaneseLocale) return '読み込み中...'; return 'Loading...'; } + +// ============================================================================ +// 설정 화면 텍스트 +// ============================================================================ + +String get uiSettings { + if (isKoreanLocale) return '설정'; + if (isJapaneseLocale) return '設定'; + return 'Settings'; +} + +String get uiTheme { + if (isKoreanLocale) return '테마'; + if (isJapaneseLocale) return 'テーマ'; + return 'Theme'; +} + +String get uiThemeLight { + if (isKoreanLocale) return '라이트'; + if (isJapaneseLocale) return 'ライト'; + return 'Light'; +} + +String get uiThemeDark { + if (isKoreanLocale) return '다크'; + if (isJapaneseLocale) return 'ダーク'; + return 'Dark'; +} + +String get uiThemeSystem { + if (isKoreanLocale) return '시스템'; + if (isJapaneseLocale) return 'システム'; + return 'System'; +} + +String get uiLanguage { + if (isKoreanLocale) return '언어'; + if (isJapaneseLocale) return '言語'; + return 'Language'; +} + +String get uiSound { + if (isKoreanLocale) return '사운드'; + if (isJapaneseLocale) return 'サウンド'; + return 'Sound'; +} + +String get uiBgmVolume { + if (isKoreanLocale) return 'BGM 볼륨'; + if (isJapaneseLocale) return 'BGM音量'; + return 'BGM Volume'; +} + +String get uiSfxVolume { + if (isKoreanLocale) return '효과음 볼륨'; + if (isJapaneseLocale) return '効果音音量'; + return 'SFX Volume'; +} + +String get uiAnimationSpeed { + if (isKoreanLocale) return '애니메이션 속도'; + if (isJapaneseLocale) return 'アニメーション速度'; + return 'Animation Speed'; +} + +String get uiSpeedSlow { + if (isKoreanLocale) return '느림'; + if (isJapaneseLocale) return '遅い'; + return 'Slow'; +} + +String get uiSpeedNormal { + if (isKoreanLocale) return '보통'; + if (isJapaneseLocale) return '普通'; + return 'Normal'; +} + +String get uiSpeedFast { + if (isKoreanLocale) return '빠름'; + if (isJapaneseLocale) return '速い'; + return 'Fast'; +} + +String get uiAbout { + if (isKoreanLocale) return '정보'; + if (isJapaneseLocale) return '情報'; + return 'About'; +} + +String get uiAboutDescription { + if (isKoreanLocale) return 'Progress Quest 6.4를 Flutter로 재구현한 오프라인 싱글플레이어 RPG입니다.'; + if (isJapaneseLocale) return 'Progress Quest 6.4をFlutterで再実装したオフラインシングルプレイヤーRPGです。'; + return 'An offline single-player RPG reimplementation of Progress Quest 6.4 in Flutter.'; +} diff --git a/lib/src/core/storage/settings_repository.dart b/lib/src/core/storage/settings_repository.dart index 2f9fe94..d1bf935 100644 --- a/lib/src/core/storage/settings_repository.dart +++ b/lib/src/core/storage/settings_repository.dart @@ -3,10 +3,13 @@ import 'package:shared_preferences/shared_preferences.dart'; /// 앱 설정 저장소 (SharedPreferences 기반) /// -/// 테마, 언어 등 사용자 설정을 로컬에 저장 +/// 테마, 언어, 사운드 등 사용자 설정을 로컬에 저장 class SettingsRepository { static const _keyThemeMode = 'theme_mode'; static const _keyLocale = 'locale'; + static const _keyBgmVolume = 'bgm_volume'; + static const _keySfxVolume = 'sfx_volume'; + static const _keyAnimationSpeed = 'animation_speed'; SharedPreferences? _prefs; @@ -49,4 +52,40 @@ class SettingsRepository { await init(); return _prefs!.getString(_keyLocale); } + + /// BGM 볼륨 저장 (0.0 ~ 1.0) + Future saveBgmVolume(double volume) async { + await init(); + await _prefs!.setDouble(_keyBgmVolume, volume.clamp(0.0, 1.0)); + } + + /// BGM 볼륨 불러오기 (기본값: 0.7) + Future loadBgmVolume() async { + await init(); + return _prefs!.getDouble(_keyBgmVolume) ?? 0.7; + } + + /// SFX 볼륨 저장 (0.0 ~ 1.0) + Future saveSfxVolume(double volume) async { + await init(); + await _prefs!.setDouble(_keySfxVolume, volume.clamp(0.0, 1.0)); + } + + /// SFX 볼륨 불러오기 (기본값: 0.8) + Future loadSfxVolume() async { + await init(); + return _prefs!.getDouble(_keySfxVolume) ?? 0.8; + } + + /// 애니메이션 속도 저장 (0.5 ~ 2.0, 1.0이 기본) + Future saveAnimationSpeed(double speed) async { + await init(); + await _prefs!.setDouble(_keyAnimationSpeed, speed.clamp(0.5, 2.0)); + } + + /// 애니메이션 속도 불러오기 (기본값: 1.0) + Future loadAnimationSpeed() async { + await init(); + return _prefs!.getDouble(_keyAnimationSpeed) ?? 1.0; + } } diff --git a/lib/src/features/settings/settings_screen.dart b/lib/src/features/settings/settings_screen.dart new file mode 100644 index 0000000..e20f364 --- /dev/null +++ b/lib/src/features/settings/settings_screen.dart @@ -0,0 +1,412 @@ +import 'package:flutter/material.dart'; + +import 'package:askiineverdie/data/game_text_l10n.dart' as game_l10n; +import 'package:askiineverdie/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, + }); + + final SettingsRepository settingsRepository; + final ThemeMode currentThemeMode; + final void Function(ThemeMode mode) onThemeModeChange; + final void Function(String locale)? onLocaleChange; + + @override + State createState() => _SettingsScreenState(); + + /// 설정 화면을 모달 바텀시트로 표시 + static Future show( + BuildContext context, { + required SettingsRepository settingsRepository, + required ThemeMode currentThemeMode, + required void Function(ThemeMode mode) onThemeModeChange, + void Function(String locale)? onLocaleChange, + }) { + return showModalBottomSheet( + 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, + ), + ), + ); + } +} + +class _SettingsScreenState extends State { + double _bgmVolume = 0.7; + double _sfxVolume = 0.8; + double _animationSpeed = 1.0; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadSettings(); + } + + Future _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); + }, + ), + const SizedBox(height: 8), + _buildVolumeSlider( + label: game_l10n.uiSfxVolume, + value: _sfxVolume, + icon: Icons.volume_up, + onChanged: (value) { + setState(() => _sfxVolume = value); + widget.settingsRepository.saveSfxVolume(value); + }, + ), + const SizedBox(height: 24), + + // 애니메이션 속도 + _buildSectionTitle(game_l10n.uiAnimationSpeed), + _buildAnimationSpeedSlider(), + const SizedBox(height: 24), + + // 정보 + _buildSectionTitle(game_l10n.uiAbout), + _buildAboutCard(), + ], + ), + ), + ], + ), + ); + } + + 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.primary + : theme.colorScheme.onSurface, + ), + const SizedBox(height: 4), + Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + color: isSelected + ? theme.colorScheme.primary + : 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, + ), + ), + ], + ), + ), + ); + } +}