This commit is contained in:
2025-12-15 18:18:17 +09:00
parent 03a7ed2ef2
commit 4c2c98de8a
216 changed files with 9831 additions and 725 deletions
@@ -0,0 +1,31 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.buildlog/
.history
.svn/
migrate_working_dir/
# IntelliJ related
*.iml
*.ipr
*.iws
.idea/
# The .vscode folder contains launch configuration and tasks you configure in
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/
# Flutter/Dart/Pub related
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
/pubspec.lock
**/doc/api/
.dart_tool/
.flutter-plugins-dependencies
/build/
/coverage/
+10
View File
@@ -0,0 +1,10 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2"
channel: "stable"
project_type: package
@@ -0,0 +1,3 @@
## 0.0.1
* TODO: Describe initial release.
+1
View File
@@ -0,0 +1 @@
TODO: Add your license here.
+39
View File
@@ -0,0 +1,39 @@
<!--
This README describes the package. If you publish this package to pub.dev,
this README's contents appear on the landing page for your package.
For information about how to write a good package README, see the guide for
[writing package pages](https://dart.dev/tools/pub/writing-package-pages).
For general information about developing packages, see the Dart guide for
[creating packages](https://dart.dev/guides/libraries/create-packages)
and the Flutter guide for
[developing packages and plugins](https://flutter.dev/to/develop-packages).
-->
TODO: Put a short description of the package here that helps potential users
know whether this package might be useful for them.
## Features
TODO: List what your package can do. Maybe include images, gifs, or videos.
## Getting started
TODO: List prerequisites and provide or point to information on how to
start using the package.
## Usage
TODO: Include short and useful examples for package users. Add longer examples
to `/example` folder.
```dart
const like = 'sample';
```
## Additional information
TODO: Tell users more about the package: where to find more information, how to
contribute to the package, how to file issues, what response they can expect
from the package authors, and more.
@@ -0,0 +1,4 @@
include: package:flutter_lints/flutter.yaml
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
@@ -0,0 +1,4 @@
library feature_game_dictation;
export 'screens/dictation_game_screen.dart';
export 'screens/dictation_lobby_screen.dart'; // 추가
@@ -0,0 +1,31 @@
import 'dart:math';
class DictationDifficultyRepository {
static final Random _random = Random();
static final Map<int, List<String>> _levels = {
// Lv 1: 2글자 쉬운 단어
1: ['구두', '바지', '모자', '가수', '나비', '포도', '사과', '우유', '지도', '치마'],
// Lv 2: 3~4글자 단어
2: ['어머니', '아버지', '강아지', '고양이', '자전거', '자동차', '비행기', '소나무', '운동화'],
// Lv 3: 받침이 있는 단어 / 복합어
3: ['학교', '병원', '경찰서', '도서관', '선생님', '냉장고', '세탁기', '박물관', '운동장'],
// Lv 4: 짧은 문장 (인사/일상)
4: ['반갑습니다', '안녕하세요', '감사합니다', '밥 먹었어요', '사랑합니다', '건강하세요'],
// Lv 5: 띄어쓰기가 있는 문장
5: ['날씨가 좋아요', '비가 옵니다', '꽃이 피었습니다', '문을 닫으세요', '손을 씻으세요'],
// Lv 6: 속담 (기억력 훈련)
6: ['가는 말이 고와야 오는 말이 곱다', '티끌 모아 태산', '소 잃고 외양간 고친다', '발 없는 말이 천 리 간다'],
};
static String getProblem(int level) {
int targetLevel = level.clamp(1, 6);
final list = _levels[targetLevel]!;
return list[_random.nextInt(list.length)];
}
}
@@ -0,0 +1,357 @@
import 'package:flutter/material.dart';
import 'package:flutter_tts/flutter_tts.dart';
import 'package:feature_common/feature_common.dart';
import '../models/dictation_difficulty.dart';
class DictationGameScreen extends BaseGameScreen {
final int levelIndex;
const DictationGameScreen({
super.key,
super.onNextGame,
this.levelIndex = 1,
});
@override
State<DictationGameScreen> createState() => _DictationGameScreenState();
}
class _DictationGameScreenState extends BaseGameScreenState<DictationGameScreen> {
late FlutterTts _flutterTts;
final TextEditingController _textController = TextEditingController();
String _targetText = "";
bool _isPlaying = false;
// 🔽 [신규] 상태 변수
int _currentRound = 1;
int _totalRounds = 1;
int _listenCount = 0; // 듣기 횟수 카운트
double _speechRate = 0.4; // 말하기 속도 (0.0 ~ 1.0)
// 한글 초성 리스트 (유니코드 순서)
final List<String> _chosungList = [
'', '', '', '', '', '', '', '', '', '',
'', '', '', '', '', '', '', '', ''
];
@override
void initState() {
super.initState();
_initTts();
_calculateTotalRounds();
_loadNewProblem();
}
void _calculateTotalRounds() {
if (widget.levelIndex <= 3) {
_totalRounds = 5;
} else {
_totalRounds = 3;
}
}
void _initTts() async {
_flutterTts = FlutterTts();
await _flutterTts.setIosAudioCategory(
IosTextToSpeechAudioCategory.playback,
[
IosTextToSpeechAudioCategoryOptions.defaultToSpeaker,
IosTextToSpeechAudioCategoryOptions.allowBluetooth,
IosTextToSpeechAudioCategoryOptions.allowBluetoothA2DP,
],
);
// 2. 언어 및 목소리 설정 (여기가 핵심! 🌟)
await _flutterTts.setLanguage("ko-KR");
// 기기에 설치된 목소리 리스트를 가져와서 한국어 목소리 찾기
try {
List<dynamic>? voices = await _flutterTts.getVoices;
if (voices != null) {
// Android/iOS에서 'ko-KR' 또는 'ko_KR'을 포함한 목소리 찾기
var koreaVoice = voices.firstWhere(
(v) => v.toString().contains("ko-KR") || v.toString().contains("ko_KR"),
orElse: () => null
);
if (koreaVoice != null) {
// 찾은 목소리로 강제 설정 (Map 형태 or String)
if (koreaVoice is Map) {
await _flutterTts.setVoice({"name": koreaVoice["name"], "locale": koreaVoice["locale"]});
} else {
// 일부 기기는 이름만 요구할 수 있음
debugPrint("Korean Voice Found: $koreaVoice");
}
}
}
} catch (e) {
debugPrint("Voice setting error: $e");
}
await _flutterTts.setPitch(1.0);
// 초기 속도 설정
await _flutterTts.setSpeechRate(_speechRate);
_flutterTts.setStartHandler(() => setState(() => _isPlaying = true));
_flutterTts.setCompletionHandler(() => setState(() => _isPlaying = false));
_flutterTts.setCancelHandler(() => setState(() => _isPlaying = false));
}
void _loadNewProblem() {
setState(() {
_targetText = DictationDifficultyRepository.getProblem(widget.levelIndex);
_textController.clear();
_listenCount = 0; // 문제 바뀔 때 횟수 초기화
});
Future.delayed(const Duration(milliseconds: 600), _speak);
}
Future<void> _speak() async {
if (_targetText.isEmpty) return;
// 재생 시 카운트 증가
setState(() {
_listenCount++;
});
await _flutterTts.setSpeechRate(_speechRate); // 현재 설정된 속도로 재생
await _flutterTts.stop();
await _flutterTts.speak(_targetText);
}
// 🔽 [신규] 초성 변환 헬퍼 함수
String _getInitialConsonants(String text) {
String result = "";
for (int i = 0; i < text.length; i++) {
int code = text.codeUnitAt(i);
// 한글 유니코드 범위: 0xAC00(가) ~ 0xD7A3(힣)
if (code >= 0xAC00 && code <= 0xD7A3) {
int chosungIndex = (code - 0xAC00) ~/ (21 * 28);
result += _chosungList[chosungIndex];
} else {
// 한글이 아니면(공백, 특수문자 등) 그대로 출력
result += text[i];
}
}
return result;
}
void _checkAnswer() {
final String input = _textController.text.trim();
if (input.replaceAll(' ', '') == _targetText.replaceAll(' ', '')) {
_handleRoundCompletion();
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('틀렸습니다. 다시 들어보세요!'),
backgroundColor: Colors.orange,
duration: Duration(seconds: 1),
),
);
_speak();
}
}
void _handleRoundCompletion() {
if (_currentRound < _totalRounds) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("정답입니다! (${_currentRound + 1}/$_totalRounds)"),
duration: const Duration(milliseconds: 1000),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.green,
),
);
setState(() {
_currentRound++;
});
Future.delayed(const Duration(milliseconds: 1000), _loadNewProblem);
} else {
showCommonGameCompletion(
GameResultArgs(
gameType: 'DICTATION',
contextId: 'Lv${widget.levelIndex}',
primaryScore: 100,
scoreFormatter: (s, _) => "훈련 완료!",
levelIndex: widget.levelIndex,
stars: 3,
)
);
}
}
@override
void dispose() {
_flutterTts.stop();
_textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('듣고 받아쓰기 ($_currentRound/$_totalRounds)'),
),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 🔽 [신규] 말하기 속도 조절 슬라이더
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
const Icon(Icons.speed, size: 20, color: Colors.grey),
const SizedBox(width: 8),
const Text("속도", style: TextStyle(fontWeight: FontWeight.bold)),
Expanded(
child: Slider(
value: _speechRate,
min: 0.1,
max: 0.8,
divisions: 7,
label: _speechRate <= 0.3 ? "느림" : (_speechRate >= 0.6 ? "빠름" : "보통"),
onChanged: (val) {
setState(() {
_speechRate = val;
});
},
),
),
Text(
_speechRate <= 0.3 ? "느림" : (_speechRate >= 0.6 ? "빠름" : "보통"),
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold),
),
],
),
),
const SizedBox(height: 24),
GestureDetector(
onTap: _speak,
child: Container(
height: 140,
decoration: BoxDecoration(
color: _isPlaying ? Colors.blue.shade100 : Colors.blue.shade50,
shape: BoxShape.circle,
border: Border.all(
color: _isPlaying ? Colors.blue : Colors.blue.shade100,
width: 4
),
boxShadow: [
if (_isPlaying)
BoxShadow(
color: Colors.blue.withOpacity(0.3),
blurRadius: 20,
spreadRadius: 5,
)
]
),
child: Icon(
_isPlaying ? Icons.volume_up : Icons.volume_down_rounded,
size: 70,
color: Colors.blue,
),
),
),
const SizedBox(height: 12),
Text(
"버튼을 눌러 다시 들을 수 있습니다.\n(현재 $_listenCount회 들음)",
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
),
const SizedBox(height: 20),
// 🔽 [신규] 힌트 표시 (3회 이상 들었을 때)
if (_listenCount >= 3)
Container(
margin: const EdgeInsets.only(bottom: 20),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.shade200),
),
child: Column(
children: [
const Text(
"💡 힌트 (초성)",
style: TextStyle(
color: Colors.orange,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
const SizedBox(height: 4),
Text(
_getInitialConsonants(_targetText),
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.black87,
letterSpacing: 4.0,
),
),
],
),
)
else
// 공간 확보용 (힌트 없을 때도 레이아웃 덜 튀게)
const SizedBox(height: 20),
TextField(
controller: _textController,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
decoration: InputDecoration(
hintText: "정답 입력",
hintStyle: TextStyle(color: Colors.grey.shade300),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: const BorderSide(width: 2),
),
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(vertical: 20),
),
onSubmitted: (_) => _checkAnswer(),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: _checkAnswer,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: Colors.blueAccent,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: const Text(
"정답 확인",
style: TextStyle(fontSize: 20, color: Colors.white, fontWeight: FontWeight.bold)
),
),
],
),
),
),
);
}
}
@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'dictation_game_screen.dart';
class DictationLobbyScreen extends StatelessWidget {
const DictationLobbyScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('듣고 받아쓰기')),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
const Icon(Icons.keyboard, size: 80, color: Colors.orange),
const SizedBox(height: 16),
const Text(
"들려주는 단어나 문장을 잘 듣고\n정확하게 입력하세요.",
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16),
),
const SizedBox(height: 32),
Expanded(
child: ListView.separated(
itemCount: 6,
separatorBuilder: (c, i) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final level = index + 1;
return ListTile(
tileColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12), side: BorderSide(color: Colors.grey.shade300)),
leading: CircleAvatar(child: Text("$level"), backgroundColor: Colors.orange.shade100, foregroundColor: Colors.orange),
title: Text("레벨 $level"),
subtitle: Text(_getLevelDesc(level)),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: () {
Navigator.push(context, MaterialPageRoute(builder: (_) => DictationGameScreen(levelIndex: level)));
},
);
},
),
),
],
),
),
);
}
String _getLevelDesc(int level) {
if (level <= 3) return "단어 받아쓰기";
return "문장 받아쓰기";
}
}
@@ -0,0 +1,25 @@
name: feature_game_dictation
description: Dictation game for auditory memory training.
version: 0.0.1
publish_to: 'none'
resolution: workspace
environment:
sdk: '^3.9.2'
flutter: '>=3.10.0'
dependencies:
flutter:
sdk: flutter
# 🗣️ 텍스트를 음성으로 변환 (TTS)
flutter_tts: ^3.8.3
# 공통 모듈
feature_common:
path: ../feature_common
service_api:
path: ../service_api
dev_dependencies:
flutter_test:
sdk: flutter
@@ -0,0 +1,12 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:feature_game_dictation/feature_game_dictation.dart';
void main() {
test('adds one to input values', () {
final calculator = Calculator();
expect(calculator.addOne(2), 3);
expect(calculator.addOne(-7), -6);
expect(calculator.addOne(0), 1);
});
}