feat(settings): 설정 화면 및 저장소 추가
- SettingsScreen 구현 (테마, 언어, 사운드, 애니메이션 속도) - SettingsRepository에 BGM/SFX 볼륨, 애니메이션 속도 저장 추가 - 설정 관련 l10n 텍스트 추가 (한/영/일)
This commit is contained in:
@@ -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.';
|
||||
}
|
||||
|
||||
@@ -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<void> saveBgmVolume(double volume) async {
|
||||
await init();
|
||||
await _prefs!.setDouble(_keyBgmVolume, volume.clamp(0.0, 1.0));
|
||||
}
|
||||
|
||||
/// BGM 볼륨 불러오기 (기본값: 0.7)
|
||||
Future<double> loadBgmVolume() async {
|
||||
await init();
|
||||
return _prefs!.getDouble(_keyBgmVolume) ?? 0.7;
|
||||
}
|
||||
|
||||
/// SFX 볼륨 저장 (0.0 ~ 1.0)
|
||||
Future<void> saveSfxVolume(double volume) async {
|
||||
await init();
|
||||
await _prefs!.setDouble(_keySfxVolume, volume.clamp(0.0, 1.0));
|
||||
}
|
||||
|
||||
/// SFX 볼륨 불러오기 (기본값: 0.8)
|
||||
Future<double> loadSfxVolume() async {
|
||||
await init();
|
||||
return _prefs!.getDouble(_keySfxVolume) ?? 0.8;
|
||||
}
|
||||
|
||||
/// 애니메이션 속도 저장 (0.5 ~ 2.0, 1.0이 기본)
|
||||
Future<void> saveAnimationSpeed(double speed) async {
|
||||
await init();
|
||||
await _prefs!.setDouble(_keyAnimationSpeed, speed.clamp(0.5, 2.0));
|
||||
}
|
||||
|
||||
/// 애니메이션 속도 불러오기 (기본값: 1.0)
|
||||
Future<double> loadAnimationSpeed() async {
|
||||
await init();
|
||||
return _prefs!.getDouble(_keyAnimationSpeed) ?? 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
412
lib/src/features/settings/settings_screen.dart
Normal file
412
lib/src/features/settings/settings_screen.dart
Normal file
@@ -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<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,
|
||||
}) {
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user