...
This commit is contained in:
@@ -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/
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
TODO: Add your license here.
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user