feat(l10n): 국제화(L10n) 시스템 도입 및 하드코딩 텍스트 변환

- flutter_localizations 및 intl 패키지 추가
- l10n.yaml 설정 파일 및 app_ko.arb 메시지 파일 생성
- 모든 화면(app, front, game_play, new_character, save_picker)의 하드코딩 텍스트를 L10n 키로 변환
- 테스트 파일에 localizationsDelegates 추가하여 L10n 지원
This commit is contained in:
JiWoong Sul
2025-12-11 17:50:34 +09:00
parent 2b10deba5d
commit 35e3d92316
20 changed files with 2155 additions and 113 deletions

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/l10n/app_localizations.dart';
import 'package:askiineverdie/src/core/engine/game_mutations.dart';
import 'package:askiineverdie/src/core/engine/progress_service.dart';
import 'package:askiineverdie/src/core/engine/reward_service.dart';
@@ -51,6 +52,8 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
return MaterialApp(
title: 'Ascii Never Die',
debugShowCheckedModeBanner: false,
localizationsDelegates: L10n.localizationsDelegates,
supportedLocales: L10n.supportedLocales,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF234361)),
scaffoldBackgroundColor: const Color(0xFFF4F5F7),
@@ -85,9 +88,9 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
if (saves.isEmpty) {
// 저장 파일이 없으면 안내 메시지
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('저장된 게임이 없습니다.')));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(L10n.of(context).noSavedGames)),
);
return;
} else if (saves.length == 1) {
// 파일이 하나면 바로 선택
@@ -114,7 +117,7 @@ class _AskiiNeverDieAppState extends State<AskiiNeverDieApp> {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'저장 파일을 불러올 수 없습니다: ${_controller.error ?? "알 수 없는 오류"}',
L10n.of(context).loadError(_controller.error ?? 'Unknown error'),
),
),
);

View File

@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/l10n/app_localizations.dart';
class FrontScreen extends StatelessWidget {
const FrontScreen({super.key, this.onNewCharacter, this.onLoadSave});
@@ -107,7 +109,7 @@ class _HeroHeader extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Ascii Never Die',
L10n.of(context).appTitle,
style: theme.textTheme.headlineSmall?.copyWith(
color: colorScheme.onPrimary,
fontWeight: FontWeight.w700,
@@ -126,14 +128,19 @@ class _HeroHeader extends StatelessWidget {
],
),
const SizedBox(height: 14),
Wrap(
spacing: 8,
runSpacing: 8,
children: const [
_Tag(icon: Icons.cloud_off_outlined, label: 'No network'),
_Tag(icon: Icons.timer_outlined, label: 'Idle RPG loop'),
_Tag(icon: Icons.storage_rounded, label: 'Local saves'),
],
Builder(
builder: (context) {
final l10n = L10n.of(context);
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
_Tag(icon: Icons.cloud_off_outlined, label: l10n.tagNoNetwork),
_Tag(icon: Icons.timer_outlined, label: l10n.tagIdleRpg),
_Tag(icon: Icons.storage_rounded, label: l10n.tagLocalSaves),
],
);
},
),
],
),
@@ -151,6 +158,7 @@ class _ActionRow extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = L10n.of(context);
return Wrap(
spacing: 12,
@@ -159,7 +167,7 @@ class _ActionRow extends StatelessWidget {
FilledButton.icon(
onPressed: onNewCharacter,
icon: const Icon(Icons.casino_outlined),
label: const Text('New character'),
label: Text(l10n.newCharacter),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
textStyle: theme.textTheme.titleMedium,
@@ -168,7 +176,7 @@ class _ActionRow extends StatelessWidget {
OutlinedButton.icon(
onPressed: onLoadSave,
icon: const Icon(Icons.folder_open),
label: const Text('Load save'),
label: Text(l10n.loadSave),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
textStyle: theme.textTheme.titleMedium,
@@ -177,7 +185,7 @@ class _ActionRow extends StatelessWidget {
TextButton.icon(
onPressed: () => _showPlaceholder(context),
icon: const Icon(Icons.menu_book_outlined),
label: const Text('View build plan'),
label: Text(l10n.viewBuildPlan),
),
],
);
@@ -189,11 +197,12 @@ class _StatusCards extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = L10n.of(context);
return Column(
children: const [
children: [
_InfoCard(
icon: Icons.route_outlined,
title: 'Build roadmap',
title: l10n.buildRoadmap,
points: [
'Port PQ 6.4 data set (Config.dfm) into Dart constants.',
'Recreate quest/task loop with deterministic RNG + saves.',
@@ -203,7 +212,7 @@ class _StatusCards extends StatelessWidget {
SizedBox(height: 16),
_InfoCard(
icon: Icons.auto_fix_high_outlined,
title: 'Tech stack',
title: l10n.techStack,
points: [
'Flutter (Material 3) with multiplatform targets enabled.',
'path_provider + shared_preferences for local storage hooks.',

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:askiineverdie/l10n/app_localizations.dart';
import 'package:askiineverdie/src/core/storage/save_service.dart'
show SaveFileInfo;
@@ -20,7 +21,7 @@ class SavePickerDialog extends StatelessWidget {
// 저장 파일이 없으면 안내 메시지
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('저장된 게임이 없습니다.')));
).showSnackBar(SnackBar(content: Text(L10n.of(context).noSavedGames)));
return null;
}
@@ -35,12 +36,13 @@ class SavePickerDialog extends StatelessWidget {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final l10n = L10n.of(context);
return AlertDialog(
title: Row(
children: [
Icon(Icons.folder_open, color: colorScheme.primary),
const SizedBox(width: 12),
const Text('Load Game'),
Text(l10n.loadGame),
],
),
content: SizedBox(
@@ -64,7 +66,7 @@ class SavePickerDialog extends StatelessWidget {
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(null),
child: const Text('Cancel'),
child: Text(l10n.cancel),
),
],
);

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/l10n/app_localizations.dart';
import 'package:askiineverdie/src/core/animation/ascii_animation_data.dart';
import 'package:askiineverdie/src/core/animation/ascii_animation_type.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
@@ -129,21 +130,22 @@ class _GamePlayScreenState extends State<GamePlayScreen>
/// 뒤로가기 시 저장 확인 다이얼로그
Future<bool> _onPopInvoked() async {
final l10n = L10n.of(context);
final shouldPop = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Exit Game'),
content: const Text('Save your progress before leaving?'),
title: Text(l10n.exitGame),
content: Text(l10n.saveProgressQuestion),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
child: Text(l10n.cancel),
),
TextButton(
onPressed: () {
Navigator.of(context).pop(true);
},
child: const Text('Exit without saving'),
child: Text(l10n.exitWithoutSaving),
),
FilledButton(
onPressed: () async {
@@ -152,7 +154,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
Navigator.of(context).pop(true);
}
},
child: const Text('Save and Exit'),
child: Text(l10n.saveAndExit),
),
],
),
@@ -189,23 +191,23 @@ class _GamePlayScreenState extends State<GamePlayScreen>
},
child: Scaffold(
appBar: AppBar(
title: Text('Progress Quest - ${state.traits.name}'),
title: Text(L10n.of(context).progressQuestTitle(state.traits.name)),
actions: [
// 치트 버튼 (디버그용)
if (widget.controller.cheatsEnabled) ...[
IconButton(
icon: const Text('L+1'),
tooltip: 'Level Up',
tooltip: L10n.of(context).levelUp,
onPressed: () => widget.controller.loop?.cheatCompleteTask(),
),
IconButton(
icon: const Text('Q!'),
tooltip: 'Complete Quest',
tooltip: L10n.of(context).completeQuest,
onPressed: () => widget.controller.loop?.cheatCompleteQuest(),
),
IconButton(
icon: const Text('P!'),
tooltip: 'Complete Plot',
tooltip: L10n.of(context).completePlot,
onPressed: () => widget.controller.loop?.cheatCompletePlot(),
),
],
@@ -250,34 +252,35 @@ class _GamePlayScreenState extends State<GamePlayScreen>
/// 좌측 패널: Character Sheet (Traits, Stats, Experience, Spells)
Widget _buildCharacterPanel(GameState state) {
final l10n = L10n.of(context);
return Card(
margin: const EdgeInsets.all(4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildPanelHeader('Character Sheet'),
_buildPanelHeader(l10n.characterSheet),
// Traits 목록
_buildSectionHeader('Traits'),
_buildSectionHeader(l10n.traits),
_buildTraitsList(state),
// Stats 목록
_buildSectionHeader('Stats'),
_buildSectionHeader(l10n.stats),
Expanded(flex: 2, child: _buildStatsList(state)),
// Experience 바
_buildSectionHeader('Experience'),
_buildSectionHeader(l10n.experience),
_buildProgressBar(
state.progress.exp.position,
state.progress.exp.max,
Colors.blue,
tooltip:
'${state.progress.exp.max - state.progress.exp.position} '
'XP needed for next level',
'${l10n.xpNeededForNextLevel}',
),
// Spell Book
_buildSectionHeader('Spell Book'),
_buildSectionHeader(l10n.spellBook),
Expanded(flex: 2, child: _buildSpellsList(state)),
],
),
@@ -286,22 +289,23 @@ class _GamePlayScreenState extends State<GamePlayScreen>
/// 중앙 패널: Equipment/Inventory
Widget _buildEquipmentPanel(GameState state) {
final l10n = L10n.of(context);
return Card(
margin: const EdgeInsets.all(4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildPanelHeader('Equipment'),
_buildPanelHeader(l10n.equipment),
// Equipment 목록
Expanded(flex: 2, child: _buildEquipmentList(state)),
// Inventory
_buildPanelHeader('Inventory'),
_buildPanelHeader(l10n.inventory),
Expanded(flex: 3, child: _buildInventoryList(state)),
// Encumbrance 바
_buildSectionHeader('Encumbrance'),
_buildSectionHeader(l10n.encumbrance),
_buildProgressBar(
state.progress.encumbrance.position,
state.progress.encumbrance.max,
@@ -314,12 +318,13 @@ class _GamePlayScreenState extends State<GamePlayScreen>
/// 우측 패널: Plot/Quest
Widget _buildQuestPanel(GameState state) {
final l10n = L10n.of(context);
return Card(
margin: const EdgeInsets.all(4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildPanelHeader('Plot Development'),
_buildPanelHeader(l10n.plotDevelopment),
// Plot 목록
Expanded(child: _buildPlotList(state)),
@@ -334,7 +339,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
: null,
),
_buildPanelHeader('Quests'),
_buildPanelHeader(l10n.quests),
// Quest 목록
Expanded(child: _buildQuestList(state)),
@@ -345,7 +350,10 @@ class _GamePlayScreenState extends State<GamePlayScreen>
state.progress.quest.max,
Colors.green,
tooltip: state.progress.quest.max > 0
? '${(100 * state.progress.quest.position ~/ state.progress.quest.max)}% complete'
? l10n.percentComplete(
100 * state.progress.quest.position ~/
state.progress.quest.max,
)
: null,
),
],
@@ -398,11 +406,12 @@ class _GamePlayScreenState extends State<GamePlayScreen>
}
Widget _buildTraitsList(GameState state) {
final l10n = L10n.of(context);
final traits = [
('Name', state.traits.name),
('Race', state.traits.race),
('Class', state.traits.klass),
('Level', '${state.traits.level}'),
(l10n.traitName, state.traits.name),
(l10n.traitRace, state.traits.race),
(l10n.traitClass, state.traits.klass),
(l10n.traitLevel, '${state.traits.level}'),
];
return Padding(
@@ -433,15 +442,16 @@ class _GamePlayScreenState extends State<GamePlayScreen>
}
Widget _buildStatsList(GameState state) {
final l10n = L10n.of(context);
final stats = [
('STR', state.stats.str),
('CON', state.stats.con),
('DEX', state.stats.dex),
('INT', state.stats.intelligence),
('WIS', state.stats.wis),
('CHA', state.stats.cha),
('HP Max', state.stats.hpMax),
('MP Max', state.stats.mpMax),
(l10n.statStr, state.stats.str),
(l10n.statCon, state.stats.con),
(l10n.statDex, state.stats.dex),
(l10n.statInt, state.stats.intelligence),
(l10n.statWis, state.stats.wis),
(l10n.statCha, state.stats.cha),
(l10n.statHpMax, state.stats.hpMax),
(l10n.statMpMax, state.stats.mpMax),
];
return ListView.builder(
@@ -467,8 +477,8 @@ class _GamePlayScreenState extends State<GamePlayScreen>
Widget _buildSpellsList(GameState state) {
if (state.spellBook.spells.isEmpty) {
return const Center(
child: Text('No spells yet', style: TextStyle(fontSize: 11)),
return Center(
child: Text(L10n.of(context).noSpellsYet, style: const TextStyle(fontSize: 11)),
);
}
@@ -498,18 +508,19 @@ class _GamePlayScreenState extends State<GamePlayScreen>
Widget _buildEquipmentList(GameState state) {
// 원본 Main.dfm Equips ListView - 11개 슬롯
final l10n = L10n.of(context);
final equipment = [
('Weapon', state.equipment.weapon),
('Shield', state.equipment.shield),
('Helm', state.equipment.helm),
('Hauberk', state.equipment.hauberk),
('Brassairts', state.equipment.brassairts),
('Vambraces', state.equipment.vambraces),
('Gauntlets', state.equipment.gauntlets),
('Gambeson', state.equipment.gambeson),
('Cuisses', state.equipment.cuisses),
('Greaves', state.equipment.greaves),
('Sollerets', state.equipment.sollerets),
(l10n.equipWeapon, state.equipment.weapon),
(l10n.equipShield, state.equipment.shield),
(l10n.equipHelm, state.equipment.helm),
(l10n.equipHauberk, state.equipment.hauberk),
(l10n.equipBrassairts, state.equipment.brassairts),
(l10n.equipVambraces, state.equipment.vambraces),
(l10n.equipGauntlets, state.equipment.gauntlets),
(l10n.equipGambeson, state.equipment.gambeson),
(l10n.equipCuisses, state.equipment.cuisses),
(l10n.equipGreaves, state.equipment.greaves),
(l10n.equipSollerets, state.equipment.sollerets),
];
return ListView.builder(
@@ -543,10 +554,11 @@ class _GamePlayScreenState extends State<GamePlayScreen>
}
Widget _buildInventoryList(GameState state) {
final l10n = L10n.of(context);
if (state.inventory.items.isEmpty) {
return Center(
child: Text(
'Gold: ${state.inventory.gold}',
l10n.goldAmount(state.inventory.gold),
style: const TextStyle(fontSize: 11),
),
);
@@ -559,8 +571,8 @@ class _GamePlayScreenState extends State<GamePlayScreen>
if (index == 0) {
return Row(
children: [
const Expanded(
child: Text('Gold', style: TextStyle(fontSize: 11)),
Expanded(
child: Text(l10n.gold, style: const TextStyle(fontSize: 11)),
),
Text(
'${state.inventory.gold}',
@@ -594,10 +606,11 @@ class _GamePlayScreenState extends State<GamePlayScreen>
Widget _buildPlotList(GameState state) {
// 플롯 단계를 표시 (Act I, Act II, ...)
final l10n = L10n.of(context);
final plotCount = state.progress.plotStageCount;
if (plotCount == 0) {
return const Center(
child: Text('Prologue', style: TextStyle(fontSize: 11)),
return Center(
child: Text(l10n.prologue, style: const TextStyle(fontSize: 11)),
);
}
@@ -615,7 +628,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
),
const SizedBox(width: 4),
Text(
index == 0 ? 'Prologue' : 'Act ${_toRoman(index)}',
index == 0 ? l10n.prologue : l10n.actNumber(_toRoman(index)),
style: TextStyle(
fontSize: 11,
decoration: isCompleted
@@ -630,10 +643,11 @@ class _GamePlayScreenState extends State<GamePlayScreen>
}
Widget _buildQuestList(GameState state) {
final l10n = L10n.of(context);
final questCount = state.progress.questCount;
if (questCount == 0) {
return const Center(
child: Text('No active quests', style: TextStyle(fontSize: 11)),
return Center(
child: Text(l10n.noActiveQuests, style: const TextStyle(fontSize: 11)),
);
}
@@ -649,7 +663,7 @@ class _GamePlayScreenState extends State<GamePlayScreen>
child: Text(
currentTask.caption.isNotEmpty
? currentTask.caption
: 'Quest #$questCount',
: l10n.questNumber(questCount),
style: const TextStyle(fontSize: 11),
overflow: TextOverflow.ellipsis,
),

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:askiineverdie/l10n/app_localizations.dart';
import 'package:askiineverdie/src/core/animation/ascii_animation_data.dart';
import 'package:askiineverdie/src/core/animation/ascii_animation_type.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
@@ -60,7 +61,7 @@ class TaskProgressPanel extends StatelessWidget {
child: Text(
progress.currentTask.caption.isNotEmpty
? progress.currentTask.caption
: 'Welcome to Progress Quest!',
: L10n.of(context).welcomeMessage,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),

View File

@@ -2,6 +2,7 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:askiineverdie/l10n/app_localizations.dart';
import 'package:askiineverdie/src/core/model/game_state.dart';
import 'package:askiineverdie/src/core/model/pq_config.dart';
import 'package:askiineverdie/src/core/util/deterministic_random.dart';
@@ -241,6 +242,7 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
}
Widget _buildNameSection() {
final l10n = L10n.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
@@ -249,9 +251,9 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
Expanded(
child: TextField(
controller: _nameController,
decoration: const InputDecoration(
labelText: 'Name',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: l10n.name,
border: const OutlineInputBorder(),
),
maxLength: 30,
),
@@ -260,7 +262,7 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
IconButton.filled(
onPressed: _onGenerateName,
icon: const Icon(Icons.casino),
tooltip: 'Generate Name',
tooltip: l10n.generateName,
),
],
),
@@ -269,29 +271,30 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
}
Widget _buildStatsSection() {
final l10n = L10n.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Stats', style: Theme.of(context).textTheme.titleMedium),
Text(l10n.stats, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
// 스탯 그리드
Row(
children: [
Expanded(child: _buildStatTile('STR', _str)),
Expanded(child: _buildStatTile('CON', _con)),
Expanded(child: _buildStatTile('DEX', _dex)),
Expanded(child: _buildStatTile(l10n.statStr, _str)),
Expanded(child: _buildStatTile(l10n.statCon, _con)),
Expanded(child: _buildStatTile(l10n.statDex, _dex)),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(child: _buildStatTile('INT', _int)),
Expanded(child: _buildStatTile('WIS', _wis)),
Expanded(child: _buildStatTile('CHA', _cha)),
Expanded(child: _buildStatTile(l10n.statInt, _int)),
Expanded(child: _buildStatTile(l10n.statWis, _wis)),
Expanded(child: _buildStatTile(l10n.statCha, _cha)),
],
),
const SizedBox(height: 12),
@@ -307,9 +310,9 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Total',
style: TextStyle(fontWeight: FontWeight.bold),
Text(
l10n.total,
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text(
'$_total',
@@ -333,7 +336,7 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
OutlinedButton.icon(
onPressed: _onUnroll,
icon: const Icon(Icons.undo),
label: const Text('Unroll'),
label: Text(l10n.unroll),
style: OutlinedButton.styleFrom(
foregroundColor: _rollHistory.isEmpty ? Colors.grey : null,
),
@@ -342,7 +345,7 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
FilledButton.icon(
onPressed: _onReroll,
icon: const Icon(Icons.casino),
label: const Text('Roll'),
label: Text(l10n.roll),
),
],
),
@@ -389,7 +392,7 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Race', style: Theme.of(context).textTheme.titleMedium),
Text(L10n.of(context).race, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
SizedBox(
height: 300,
@@ -434,7 +437,7 @@ class _NewCharacterScreenState extends State<NewCharacterScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Class', style: Theme.of(context).textTheme.titleMedium),
Text(L10n.of(context).classTitle, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
SizedBox(
height: 300,