feat(settings): 설정 화면 및 저장소 추가

- SettingsScreen 구현 (테마, 언어, 사운드, 애니메이션 속도)
- SettingsRepository에 BGM/SFX 볼륨, 애니메이션 속도 저장 추가
- 설정 관련 l10n 텍스트 추가 (한/영/일)
This commit is contained in:
JiWoong Sul
2025-12-30 14:22:35 +09:00
parent 7d19905c01
commit 0ccd1bd007
3 changed files with 546 additions and 1 deletions

View File

@@ -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;
}
}

View 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,
),
),
],
),
),
);
}
}