...
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:service_api/service_api.dart';
|
||||
import 'package:feature_common/feature_common.dart';
|
||||
|
||||
// --- [Import] 각 게임별 패키지 ---
|
||||
|
||||
// 1. 스도쿠
|
||||
import 'package:feature_game_sudoku/feature_game_sudoku.dart' as sudoku_pkg;
|
||||
import 'package:feature_game_sudoku/models/game_level.dart' as sudoku_model;
|
||||
import 'package:feature_game_sudoku/screens/game_screen.dart' as sudoku_screen;
|
||||
|
||||
// 2. 수학 퀴즈
|
||||
import 'package:feature_game_mathquiz/feature_game_mathquiz.dart' as math_pkg;
|
||||
import 'package:feature_game_mathquiz/models/math_quiz_difficulty.dart' as math_model;
|
||||
import 'package:feature_game_mathquiz/screens/math_quiz_screen.dart' as math_screen;
|
||||
import 'package:feature_game_mathquiz/controllers/math_quiz_controller.dart';
|
||||
|
||||
// 3. 순서 기억
|
||||
import 'package:feature_game_sequence/feature_game_sequence.dart' as sequence_pkg;
|
||||
import 'package:feature_game_sequence/models/sequence_models.dart' as sequence_model;
|
||||
import 'package:feature_game_sequence/screens/sequence_game_screen.dart' as sequence_screen;
|
||||
import 'package:feature_game_sequence/controllers/sequence_game_controller.dart';
|
||||
|
||||
// 4. 카드 뒤집기
|
||||
import 'package:feature_game_cardflip/feature_game_cardflip.dart' as cardflip_pkg;
|
||||
import 'package:feature_game_cardflip/models/cardflip_models.dart' as cardflip_model;
|
||||
import 'package:feature_game_cardflip/screens/cardflip_game_screen.dart' as cardflip_screen;
|
||||
import 'package:feature_game_cardflip/controllers/cardflip_controller.dart';
|
||||
|
||||
// 5. 색상 매칭
|
||||
import 'package:feature_game_colormatch/feature_game_colormatch.dart' as colormatch_pkg;
|
||||
import 'package:feature_game_colormatch/models/colormatch_models.dart' as colormatch_model;
|
||||
import 'package:feature_game_colormatch/screens/colormatch_game_screen.dart' as colormatch_screen;
|
||||
import 'package:feature_game_colormatch/controllers/colormatch_controller.dart';
|
||||
|
||||
// 6. 다른 그림 찾기
|
||||
import 'package:feature_game_finddiff/feature_game_finddiff.dart' as finddiff_pkg;
|
||||
import 'package:feature_game_finddiff/models/finddiff_models.dart' as finddiff_model;
|
||||
import 'package:feature_game_finddiff/screens/finddiff_game_screen.dart' as finddiff_screen;
|
||||
import 'package:feature_game_finddiff/controllers/finddiff_controller.dart';
|
||||
|
||||
import 'package:feature_game_schulte/feature_game_schulte.dart' as schulte_pkg; // ✅ 추가
|
||||
|
||||
// 8. [신규] 따라 그리기
|
||||
import 'package:feature_game_tracing/feature_game_tracing.dart' as tracing_pkg;
|
||||
|
||||
import 'package:feature_game_read_aloud/feature_game_read_aloud.dart' as readAloud;
|
||||
import 'package:feature_game_dictation/feature_game_dictation.dart' as dictation_pkg;
|
||||
|
||||
class DailyCourseController {
|
||||
final BuildContext context;
|
||||
final List<BrainGameType> course;
|
||||
int _currentIndex = 0;
|
||||
|
||||
DailyCourseController(this.context, this.course);
|
||||
|
||||
// 1. 코스 시작
|
||||
void start() {
|
||||
if (course.isEmpty) return;
|
||||
_currentIndex = 0;
|
||||
_launchCurrentGame();
|
||||
}
|
||||
|
||||
// 2. 다음 게임으로 이동
|
||||
void _next() {
|
||||
_currentIndex++;
|
||||
if (_currentIndex < course.length) {
|
||||
Future.delayed(const Duration(milliseconds: 300), () {
|
||||
_launchCurrentGame();
|
||||
});
|
||||
} else {
|
||||
_showCourseCompletedDialog();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 게임 실행 로직
|
||||
void _launchCurrentGame() async {
|
||||
final gameType = course[_currentIndex];
|
||||
|
||||
final session = context.read<SessionNotifier>().session;
|
||||
final String userId = session?.userId ?? 'guest';
|
||||
final String? userName = session?.userName;
|
||||
|
||||
Widget? gameScreen;
|
||||
|
||||
const int defaultLevelIndex = 1;
|
||||
|
||||
try {
|
||||
switch (gameType) {
|
||||
// --- 1. 스도쿠 ---
|
||||
case BrainGameType.sudoku:
|
||||
final level = sudoku_model.AppLevels.getLevel(defaultLevelIndex);
|
||||
final puzzleService = PuzzleService();
|
||||
final gameData = await puzzleService.startGame(level.levelIndex.toString());
|
||||
|
||||
gameScreen = sudoku_screen.GameScreen(
|
||||
gameData: gameData,
|
||||
themeName: "랜덤",
|
||||
userId: userId,
|
||||
userName: userName,
|
||||
levelIndex: level.levelIndex,
|
||||
onNextGame: _next,
|
||||
);
|
||||
break;
|
||||
|
||||
// --- 2. 수학 퀴즈 ---
|
||||
case BrainGameType.mathQuiz:
|
||||
final level = math_model.MathQuizDifficulties.getLevel(defaultLevelIndex);
|
||||
final ctrl = MathQuizController();
|
||||
ctrl.setUserInfo(userId, userName);
|
||||
ctrl.startNewGame(level);
|
||||
|
||||
gameScreen = ChangeNotifierProvider<MathQuizController>.value(
|
||||
value: ctrl,
|
||||
child: math_screen.MathQuizScreen(
|
||||
// onNextGame: _next, // MathQuizScreen에 onNextGame 추가 필요
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
// --- 3. 순서 기억 ---
|
||||
case BrainGameType.sequence:
|
||||
final level = sequence_model.SequenceDifficulties.getLevel(defaultLevelIndex);
|
||||
final ctrl = SequenceGameController();
|
||||
ctrl.setUserInfo(userId, userName);
|
||||
ctrl.startNewGame(level);
|
||||
|
||||
gameScreen = ChangeNotifierProvider<SequenceGameController>.value(
|
||||
value: ctrl,
|
||||
child: sequence_screen.SequenceGameScreen(
|
||||
// onNextGame: _next,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
// --- 4. 카드 뒤집기 ---
|
||||
case BrainGameType.cardFlip:
|
||||
final level = cardflip_model.CardFlipDifficulties.getLevel(defaultLevelIndex);
|
||||
final ctrl = CardFlipController();
|
||||
ctrl.setUserInfo(userId, userName);
|
||||
ctrl.startNewGame(level);
|
||||
|
||||
gameScreen = ChangeNotifierProvider<CardFlipController>.value(
|
||||
value: ctrl,
|
||||
child: cardflip_screen.CardFlipGameScreen(
|
||||
// onNextGame: _next,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
// --- 5. 색상 매칭 ---
|
||||
case BrainGameType.colorMatch:
|
||||
final level = colormatch_model.ColorMatchDifficulties.getLevel(defaultLevelIndex);
|
||||
final ctrl = ColorMatchController();
|
||||
ctrl.setUserInfo(userId, userName);
|
||||
ctrl.startNewGame(level);
|
||||
|
||||
gameScreen = ChangeNotifierProvider<ColorMatchController>.value(
|
||||
value: ctrl,
|
||||
child: colormatch_screen.ColorMatchGameScreen(
|
||||
// onNextGame: _next,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
// --- 6. 다른 그림 찾기 ---
|
||||
case BrainGameType.findDiff:
|
||||
final level = finddiff_model.FindDiffDifficulties.getLevel(defaultLevelIndex);
|
||||
final ctrl = FindDiffController();
|
||||
ctrl.setUserInfo(userId, userName);
|
||||
ctrl.startNewGame(level);
|
||||
|
||||
gameScreen = ChangeNotifierProvider<FindDiffController>.value(
|
||||
value: ctrl,
|
||||
child: finddiff_screen.FindDiffGameScreen(
|
||||
// onNextGame: _next,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
// --- 7. 스파이더 ---
|
||||
case BrainGameType.schulte:
|
||||
// Lv1~3: 3x3, Lv4~6: 4x4 ... (화면 내부에서 처리)
|
||||
gameScreen = schulte_pkg.SchulteGameScreen(
|
||||
levelIndex: defaultLevelIndex,
|
||||
onNextGame: _next,
|
||||
);
|
||||
break;
|
||||
|
||||
// --- 8. [신규] 따라 그리기 ---
|
||||
case BrainGameType.tracing:
|
||||
// TracingGame은 별도 컨트롤러 없이 로컬 상태로 구현됨
|
||||
gameScreen = tracing_pkg.TracingGameScreen(
|
||||
levelIndex: defaultLevelIndex, // 필요한 레벨 전달 (예: 1~6)
|
||||
onNextGame: _next,
|
||||
);
|
||||
break;
|
||||
|
||||
// --- 9. [신규] 소리 내어 읽기 (추후 구현 시 추가) ---
|
||||
case BrainGameType.readAloud:
|
||||
gameScreen = readAloud.ReadAloudGameScreen(
|
||||
levelIndex: defaultLevelIndex, // 필요한 레벨 전달 (예: 1~6)
|
||||
onNextGame: _next,
|
||||
);
|
||||
break;
|
||||
case BrainGameType.dictation:
|
||||
gameScreen = dictation_pkg.DictationGameScreen(
|
||||
levelIndex: 1, // 또는 사용자 레벨
|
||||
onNextGame: _next,
|
||||
);
|
||||
break;
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (gameScreen != null) {
|
||||
final route = MaterialPageRoute(builder: (_) => gameScreen!);
|
||||
await Navigator.push(context, route);
|
||||
} else {
|
||||
debugPrint("게임 화면 생성 실패: $gameType");
|
||||
_next();
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
debugPrint("게임 실행 중 오류 발생 ($gameType): $e");
|
||||
_next();
|
||||
}
|
||||
}
|
||||
|
||||
void _showCourseCompletedDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text("🎉 훈련 완료!"),
|
||||
content: const Text("오늘의 추천 코스를 모두 완수했습니다.\n두뇌가 한층 더 건강해졌습니다!"),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text("확인"),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
/// A Calculator.
|
||||
class Calculator {
|
||||
/// Returns [value] plus 1.
|
||||
int addOne(int value) => value + 1;
|
||||
}
|
||||
library feature_brain_trainer;
|
||||
|
||||
export 'screens/brain_training_home.dart';
|
||||
export 'screens/assessment_screen.dart';
|
||||
export 'screens/assessment_history_screen.dart';
|
||||
export 'controllers/daily_course_controller.dart';
|
||||
@@ -1,21 +0,0 @@
|
||||
enum CognitiveArea {
|
||||
memory, // 기억력
|
||||
calculation, // 계산/논리력
|
||||
attention, // 주의집중력
|
||||
perception, // 시지각/공간지각력
|
||||
}
|
||||
|
||||
// 게임별 훈련 영역 매핑
|
||||
enum BrainGameType {
|
||||
sequence(CognitiveArea.memory, '순서 기억'), // feature_game_sequence
|
||||
cardFlip(CognitiveArea.memory, '카드 뒤집기'), // feature_game_cardflip
|
||||
mathQuiz(CognitiveArea.calculation, '암산 퀴즈'), // feature_game_mathquiz
|
||||
sudoku(CognitiveArea.calculation, '스도쿠'), // feature_game_sudoku
|
||||
colorMatch(CognitiveArea.attention, '색상 매칭'), // feature_game_colormatch
|
||||
findDiff(CognitiveArea.perception, '다른 그림 찾기'), // feature_game_finddiff
|
||||
spider(CognitiveArea.perception, '스파이더 카드'); // feature_game_spider
|
||||
|
||||
final CognitiveArea area;
|
||||
final String label;
|
||||
const BrainGameType(this.area, this.label);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:service_api/service_api.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'assessment_screen.dart';
|
||||
|
||||
class AssessmentHistoryScreen extends StatefulWidget {
|
||||
const AssessmentHistoryScreen({super.key});
|
||||
|
||||
@override
|
||||
State<AssessmentHistoryScreen> createState() => _AssessmentHistoryScreenState();
|
||||
}
|
||||
|
||||
class _AssessmentHistoryScreenState extends State<AssessmentHistoryScreen> {
|
||||
List<AssessmentRecord> _history = [];
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadHistory();
|
||||
}
|
||||
|
||||
Future<void> _loadHistory() async {
|
||||
final identityService = IdentityService();
|
||||
final list = await identityService.getAssessmentHistory();
|
||||
list.sort((a, b) => a.date.compareTo(b.date));
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_history = list;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _startNewAssessment() async {
|
||||
final result = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const AssessmentScreen()),
|
||||
);
|
||||
|
||||
if (result == true) {
|
||||
_loadHistory();
|
||||
}
|
||||
}
|
||||
|
||||
(String, Color) _getAreaStatus(int score) {
|
||||
// 8점 만점 기준 (8문항)
|
||||
// 6점 이상: 위험, 4~5점: 주의, 3점 이하: 양호
|
||||
if (score >= 6) return ("위험", Colors.redAccent);
|
||||
if (score >= 4) return ("주의", Colors.orange);
|
||||
return ("양호", Colors.green);
|
||||
}
|
||||
|
||||
String _getAreaName(CognitiveArea area) {
|
||||
switch (area) {
|
||||
case CognitiveArea.memory: return "기억력";
|
||||
case CognitiveArea.perception: return "시지각/지남력";
|
||||
case CognitiveArea.calculation: return "계산력/판단력";
|
||||
case CognitiveArea.attention: return "주의력/집행기능";
|
||||
case CognitiveArea.language: return "언어/구성 능력";
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final reversedList = _history.reversed.toList();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('나의 두뇌 건강 변화'),
|
||||
// 🔽 [수정] 컬러 충돌 방지를 위해 FilledButton.tonal 또는 IconButton 사용
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16.0),
|
||||
child: FilledButton.tonalIcon(
|
||||
onPressed: _startNewAssessment,
|
||||
icon: const Icon(Icons.add_task),
|
||||
label: const Text('새 진단'),
|
||||
style: FilledButton.styleFrom(
|
||||
// 테마의 Secondary 컬러를 사용하여 자연스럽게 어우러짐
|
||||
// 글자색/아이콘색은 자동으로 맞춰짐
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _history.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.note_alt_outlined, size: 64, color: Colors.grey),
|
||||
const SizedBox(height: 16),
|
||||
const Text("아직 진단 기록이 없습니다.", style: TextStyle(fontSize: 18, color: Colors.grey)),
|
||||
const SizedBox(height: 20),
|
||||
ElevatedButton(
|
||||
onPressed: _startNewAssessment,
|
||||
child: const Text("첫 진단 시작하기"),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
// --- 1. 그래프 영역 ---
|
||||
Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
color: Colors.white,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
"종합 위험도 변화 추이",
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
"그래프가 낮을수록(점수가 낮을수록) 건강한 상태입니다.",
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
height: 200,
|
||||
width: double.infinity,
|
||||
child: CustomPaint(
|
||||
painter: AssessmentGraphPainter(_history),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
const Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text("상세 기록 (터치하여 상세 보기)", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// --- 2. 리스트 영역 ---
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: reversedList.length,
|
||||
itemBuilder: (context, index) {
|
||||
final record = reversedList[index];
|
||||
final totalScore = record.scores.values.fold(0, (sum, val) => sum + val);
|
||||
|
||||
// 종합 판정 (32점 만점) -> 12점 이상 주의
|
||||
final bool isRisky = totalScore >= 12;
|
||||
final Color statusColor = isRisky ? Colors.redAccent : Colors.green;
|
||||
final String statusText = isRisky ? "종합 주의" : "종합 양호";
|
||||
|
||||
return Card(
|
||||
elevation: 2,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: ExpansionTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: statusColor.withOpacity(0.1),
|
||||
child: Icon(
|
||||
isRisky ? Icons.priority_high : Icons.check,
|
||||
color: statusColor
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
DateFormat('yyyy년 MM월 dd일 (E) HH:mm', 'ko_KR').format(record.date),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: Row(
|
||||
children: [
|
||||
Text(statusText, style: TextStyle(color: statusColor, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(width: 8),
|
||||
Text("(위험점수: $totalScore점)", style: const TextStyle(color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
const Divider(height: 1),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
child: Column(
|
||||
children: CognitiveArea.values.map((area) {
|
||||
final int score = record.scores[area] ?? 0;
|
||||
final (String label, Color color) = _getAreaStatus(score);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: Text(_getAreaName(area), style: const TextStyle(fontSize: 15)),
|
||||
),
|
||||
Expanded(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: score / 8.0, // 8점 만점 기준
|
||||
backgroundColor: Colors.grey[200],
|
||||
valueColor: AlwaysStoppedAnimation<Color>(color),
|
||||
minHeight: 8,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(
|
||||
width: 50,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14
|
||||
),
|
||||
textAlign: TextAlign.end,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AssessmentGraphPainter extends CustomPainter {
|
||||
final List<AssessmentRecord> history;
|
||||
AssessmentGraphPainter(this.history);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
if (history.isEmpty) return;
|
||||
|
||||
const double padding = 10.0;
|
||||
final double graphWidth = size.width - padding * 2;
|
||||
final double graphHeight = size.height - padding * 2;
|
||||
|
||||
final data = history.length > 7 ? history.sublist(history.length - 7) : history;
|
||||
|
||||
final paintLine = Paint()
|
||||
..color = Colors.blueAccent
|
||||
..strokeWidth = 3
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
final paintDot = Paint()
|
||||
..color = Colors.redAccent
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final paintGrid = Paint()
|
||||
..color = Colors.grey.withOpacity(0.2)
|
||||
..strokeWidth = 1;
|
||||
|
||||
// Y축 가이드라인 (최대 32점 기준)
|
||||
const double maxY = 32.0;
|
||||
for (int i = 0; i <= 4; i++) {
|
||||
double y = padding + graphHeight - (i * 8 / maxY) * graphHeight; // 8점 단위
|
||||
canvas.drawLine(Offset(padding, y), Offset(padding + graphWidth, y), paintGrid);
|
||||
}
|
||||
|
||||
final double stepX = data.length > 1 ? graphWidth / (data.length - 1) : graphWidth / 2;
|
||||
|
||||
Path path = Path();
|
||||
List<Offset> points = [];
|
||||
|
||||
for (int i = 0; i < data.length; i++) {
|
||||
int totalScore = data[i].scores.values.fold(0, (sum, val) => sum + val);
|
||||
|
||||
double x = padding + (i * stepX);
|
||||
double y = padding + graphHeight - (totalScore / maxY) * graphHeight;
|
||||
|
||||
if (data.length == 1) x = size.width / 2;
|
||||
|
||||
points.add(Offset(x, y));
|
||||
|
||||
if (i == 0) {
|
||||
path.moveTo(x, y);
|
||||
} else {
|
||||
path.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.length > 1) canvas.drawPath(path, paintLine);
|
||||
|
||||
for (var point in points) {
|
||||
canvas.drawCircle(point, 5, paintDot);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:service_api/service_api.dart';
|
||||
import 'package:service_api/models/assessment_data.dart';
|
||||
|
||||
class AssessmentScreen extends StatefulWidget {
|
||||
const AssessmentScreen({super.key});
|
||||
|
||||
@override
|
||||
State<AssessmentScreen> createState() => _AssessmentScreenState();
|
||||
}
|
||||
|
||||
class _AssessmentScreenState extends State<AssessmentScreen> {
|
||||
final Map<String, bool> _answers = {};
|
||||
final PageController _pageController = PageController();
|
||||
int _currentPage = 0;
|
||||
|
||||
late final Map<CognitiveArea, List<AssessmentQuestion>> _dailyQuestions;
|
||||
late final List<CognitiveArea> _areaKeys;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_dailyQuestions = _generateDailyQuestions();
|
||||
_areaKeys = _dailyQuestions.keys.toList();
|
||||
}
|
||||
|
||||
/// [핵심 로직] 전체 문제 풀에서 영역별로 8개씩 랜덤 추출 (총 32문항)
|
||||
Map<CognitiveArea, List<AssessmentQuestion>> _generateDailyQuestions() {
|
||||
final random = Random();
|
||||
Map<CognitiveArea, List<AssessmentQuestion>> grouped = {};
|
||||
|
||||
// 1. 전체 질문 분류
|
||||
Map<CognitiveArea, List<AssessmentQuestion>> pool = {};
|
||||
for (var q in rawAssessmentQuestions) {
|
||||
if (!pool.containsKey(q.area)) pool[q.area] = [];
|
||||
pool[q.area]!.add(q);
|
||||
}
|
||||
|
||||
// 2. 각 영역에서 랜덤하게 8문제씩 추출 (총 32문제)
|
||||
// (데이터가 부족할 경우를 대비해 take 사용)
|
||||
pool.forEach((area, questions) {
|
||||
var shuffled = List<AssessmentQuestion>.from(questions)..shuffle(random);
|
||||
grouped[area] = shuffled.take(8).toList(); // 👈 8개로 설정
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
void _submitResult() async {
|
||||
// 1. 점수 계산
|
||||
Map<CognitiveArea, int> scores = {};
|
||||
int totalYesCount = 0;
|
||||
|
||||
_dailyQuestions.forEach((area, questions) {
|
||||
int areaScore = 0;
|
||||
for (var q in questions) {
|
||||
if (_answers[q.id] == true) {
|
||||
areaScore++;
|
||||
totalYesCount++;
|
||||
}
|
||||
}
|
||||
scores[area] = areaScore;
|
||||
});
|
||||
|
||||
// 2. 히스토리 저장
|
||||
final identityService = IdentityService();
|
||||
await identityService.saveAssessmentResult(scores);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// 3. 결과 알림 (기준: 32문항 중 12개 이상이면 주의)
|
||||
String message = "진단 결과가 기록되었습니다.";
|
||||
if (totalYesCount >= 12) {
|
||||
message += "\n\n주의가 필요한 항목이 다수 확인되었습니다.\n($totalYesCount/32개 해당)\n꾸준한 훈련을 권장합니다.";
|
||||
} else {
|
||||
message += "\n\n현재 양호한 상태입니다.\n이 상태를 유지하기 위해 매일 훈련하세요!";
|
||||
}
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('진단 완료'),
|
||||
content: Text(message),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
Navigator.pop(context, true);
|
||||
},
|
||||
child: const Text('확인'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getAreaTitle(CognitiveArea area) {
|
||||
switch (area) {
|
||||
case CognitiveArea.memory: return "1. 기억력 체크";
|
||||
case CognitiveArea.perception: return "2. 시공간/지남력 체크";
|
||||
case CognitiveArea.calculation: return "3. 계산력/판단력 체크";
|
||||
case CognitiveArea.attention: return "4. 주의력/집행기능 체크";
|
||||
case CognitiveArea.language: return "5. 언어/구성 능력";
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('자가 진단 체크리스트'),
|
||||
centerTitle: true,
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(4.0),
|
||||
child: LinearProgressIndicator(
|
||||
value: (_currentPage + 1) / _areaKeys.length,
|
||||
backgroundColor: Colors.grey[200],
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Theme.of(context).primaryColor),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
_getAreaTitle(_areaKeys[_currentPage]),
|
||||
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: PageView.builder(
|
||||
controller: _pageController,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: _areaKeys.length,
|
||||
onPageChanged: (idx) {
|
||||
setState(() { _currentPage = idx; });
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final area = _areaKeys[index];
|
||||
final questions = _dailyQuestions[area]!;
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
itemCount: questions.length,
|
||||
separatorBuilder: (c, i) => const Divider(height: 1),
|
||||
itemBuilder: (context, qIndex) {
|
||||
final q = questions[qIndex];
|
||||
final bool isChecked = _answers[q.id] ?? false;
|
||||
|
||||
return CheckboxListTile(
|
||||
title: Text(
|
||||
q.text,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: isChecked ? Colors.black : Colors.grey[800],
|
||||
fontWeight: isChecked ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
value: isChecked,
|
||||
activeColor: Colors.redAccent,
|
||||
onChanged: (val) {
|
||||
setState(() { _answers[q.id] = val!; });
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (_currentPage > 0)
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _pageController.previousPage(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut
|
||||
),
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
label: const Text('이전'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 90),
|
||||
|
||||
Text(
|
||||
"${_currentPage + 1} / ${_areaKeys.length}",
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
),
|
||||
|
||||
if (_currentPage < _areaKeys.length - 1)
|
||||
ElevatedButton.icon(
|
||||
onPressed: () => _pageController.nextPage(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut
|
||||
),
|
||||
icon: const Icon(Icons.arrow_forward),
|
||||
label: const Text('다음'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
),
|
||||
)
|
||||
else
|
||||
ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blueAccent,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
),
|
||||
onPressed: _submitResult,
|
||||
icon: const Icon(Icons.check),
|
||||
label: const Text('결과 제출'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,18 +3,120 @@ import 'package:provider/provider.dart';
|
||||
import 'package:feature_common/feature_common.dart';
|
||||
import 'package:service_api/service_api.dart';
|
||||
|
||||
// 각 게임 패키지 import
|
||||
import 'assessment_screen.dart';
|
||||
import 'assessment_history_screen.dart';
|
||||
import '../controllers/daily_course_controller.dart';
|
||||
|
||||
// 각 게임 패키지 로비 import
|
||||
import 'package:feature_game_sudoku/feature_game_sudoku.dart';
|
||||
import 'package:feature_game_mathquiz/feature_game_mathquiz.dart';
|
||||
// ... (나머지 게임들)
|
||||
import 'package:feature_game_sequence/feature_game_sequence.dart';
|
||||
import 'package:feature_game_cardflip/feature_game_cardflip.dart';
|
||||
import 'package:feature_game_colormatch/feature_game_colormatch.dart';
|
||||
import 'package:feature_game_finddiff/feature_game_finddiff.dart';
|
||||
import 'package:feature_game_schulte/feature_game_schulte.dart';
|
||||
import 'package:feature_game_read_aloud/feature_game_read_aloud.dart';
|
||||
import 'package:feature_game_dictation/feature_game_dictation.dart';
|
||||
|
||||
class BrainTrainingHome extends StatelessWidget {
|
||||
// 🔽 [신규] 따라 그리기 패키지 import
|
||||
import 'package:feature_game_tracing/feature_game_tracing.dart';
|
||||
|
||||
class BrainTrainingHome extends StatefulWidget {
|
||||
const BrainTrainingHome({super.key});
|
||||
|
||||
@override
|
||||
State<BrainTrainingHome> createState() => _BrainTrainingHomeState();
|
||||
}
|
||||
|
||||
class _BrainTrainingHomeState extends State<BrainTrainingHome> {
|
||||
bool _hasAssessment = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkAssessmentStatus();
|
||||
}
|
||||
|
||||
Future<void> _checkAssessmentStatus() async {
|
||||
final identityService = context.read<IdentityService>();
|
||||
final scores = await identityService.getCognitiveScores();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_hasAssessment = (scores != null && scores.isNotEmpty);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleCheckupTap() {
|
||||
if (_hasAssessment) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const AssessmentHistoryScreen()),
|
||||
).then((_) => _checkAssessmentStatus());
|
||||
} else {
|
||||
_openNewAssessment();
|
||||
}
|
||||
}
|
||||
|
||||
void _openNewAssessment() async {
|
||||
final result = await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const AssessmentScreen()),
|
||||
);
|
||||
|
||||
if (result == true) {
|
||||
_checkAssessmentStatus();
|
||||
}
|
||||
}
|
||||
|
||||
void _startDailyCourse() async {
|
||||
final identityService = context.read<IdentityService>();
|
||||
final scores = await identityService.getCognitiveScores();
|
||||
|
||||
if (scores == null || scores.isEmpty) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('먼저 두뇌 건강 체크를 진행해 주세요!')),
|
||||
);
|
||||
_openNewAssessment();
|
||||
return;
|
||||
}
|
||||
|
||||
final trainer = BrainTrainingService();
|
||||
final recommendedCourse = trainer.recommendGames(scores);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
final controller = DailyCourseController(context, recommendedCourse);
|
||||
controller.start();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('두뇌 건강 지킴이')),
|
||||
appBar: AppBar(
|
||||
title: const Text('두뇌 건강 지킴이'),
|
||||
centerTitle: false,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.show_chart_rounded),
|
||||
tooltip: '진단 기록 보기',
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const AssessmentHistoryScreen())
|
||||
).then((_) => _checkAssessmentStatus());
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings),
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const SettingsScreen())
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
@@ -25,14 +127,14 @@ class BrainTrainingHome extends StatelessWidget {
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 2. 오늘의 추천 코스 (자동 믹스 게임)
|
||||
// 2. 오늘의 추천 코스
|
||||
const Text('오늘의 추천 훈련', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 10),
|
||||
_buildDailyTrainingButton(context),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 3. 게임 아케이드 (자유 선택)
|
||||
// 3. 게임 아케이드
|
||||
const Text('전체 게임', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 10),
|
||||
_buildGameGrid(context),
|
||||
@@ -45,32 +147,54 @@ class BrainTrainingHome extends StatelessWidget {
|
||||
Widget _buildCheckupCard(BuildContext context) {
|
||||
return Card(
|
||||
color: Colors.indigo.shade50,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: Colors.indigo.shade100),
|
||||
),
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.health_and_safety, size: 40, color: Colors.indigo),
|
||||
title: const Text('나의 두뇌 건강 체크'),
|
||||
subtitle: const Text('간단한 질문으로 현재 상태를 확인하고\n맞춤형 훈련을 추천받으세요.'),
|
||||
trailing: const Icon(Icons.arrow_forward_ios),
|
||||
onTap: () {
|
||||
// TODO: 체크리스트 화면으로 이동
|
||||
// Navigator.push(context, MaterialPageRoute(builder: (_) => AssessmentScreen()));
|
||||
},
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(Icons.health_and_safety, size: 32, color: Colors.indigo),
|
||||
),
|
||||
title: Text(_hasAssessment ? '나의 두뇌 건강 리포트' : '나의 두뇌 건강 체크', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
subtitle: Text(_hasAssessment
|
||||
? '최근 진단 결과를 확인하고 변화를 추적하세요.'
|
||||
: '간단한 질문으로 상태를 확인하고\n맞춤형 훈련을 추천받으세요.'
|
||||
),
|
||||
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
||||
onTap: _handleCheckupTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDailyTrainingButton(BuildContext context) {
|
||||
return ElevatedButton.icon(
|
||||
return ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.all(20),
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
backgroundColor: Colors.orange,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
elevation: 4,
|
||||
),
|
||||
onPressed: _startDailyCourse,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: const [
|
||||
Icon(Icons.play_circle_fill, size: 40),
|
||||
SizedBox(width: 16),
|
||||
Text(
|
||||
'오늘의 코스 시작하기\n(맞춤형 3종 세트)',
|
||||
textAlign: TextAlign.start,
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)
|
||||
),
|
||||
],
|
||||
),
|
||||
icon: const Icon(Icons.play_circle_fill, size: 32),
|
||||
label: const Text('오늘의 코스 시작하기\n(맞춤형 3종 세트)', textAlign: TextAlign.center, style: TextStyle(fontSize: 18)),
|
||||
onPressed: () {
|
||||
// TODO: Assessment 결과에 따라 선정된 게임들을 순차적으로 실행하는 로직
|
||||
// 예: Sequence -> Math -> ColorMatch 순서로 실행되는 별도 Flow 화면으로 이동
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,7 +204,13 @@ class BrainTrainingHome extends StatelessWidget {
|
||||
{'name': '스도쿠', 'icon': Icons.grid_3x3, 'dest': const SudokuLobbyScreen()},
|
||||
{'name': '계산 퀴즈', 'icon': Icons.calculate, 'dest': const MathQuizLobbyScreen()},
|
||||
{'name': '순서 기억', 'icon': Icons.onetwothree, 'dest': const SequenceLobbyScreen()},
|
||||
// ... 나머지 게임 추가
|
||||
{'name': '카드 뒤집기', 'icon': Icons.flip, 'dest': const CardFlipLobbyScreen()},
|
||||
{'name': '색상 매칭', 'icon': Icons.palette, 'dest': const ColorMatchLobbyScreen()},
|
||||
{'name': '다른 그림', 'icon': Icons.image_search, 'dest': const FindDiffLobbyScreen()},
|
||||
{'name': '숫자 찾기', 'icon': Icons.looks_one, 'dest': const SchulteLobbyScreen()},
|
||||
{'name': '따라 그리기', 'icon': Icons.gesture, 'dest': const TracingLobbyScreen()},
|
||||
{'name': '읽고 말하기', 'icon': Icons.record_voice_over, 'dest': const ReadAloudLobbyScreen()},
|
||||
{'name': '받아쓰기', 'icon': Icons.keyboard, 'dest': const DictationLobbyScreen()},
|
||||
];
|
||||
|
||||
return GridView.builder(
|
||||
@@ -88,9 +218,9 @@ class BrainTrainingHome extends StatelessWidget {
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
childAspectRatio: 1.5,
|
||||
crossAxisSpacing: 10,
|
||||
mainAxisSpacing: 10,
|
||||
childAspectRatio: 1.3,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
itemCount: games.length,
|
||||
itemBuilder: (context, index) {
|
||||
@@ -98,14 +228,21 @@ class BrainTrainingHome extends StatelessWidget {
|
||||
onTap: () {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => games[index]['dest'] as Widget));
|
||||
},
|
||||
child: Card(
|
||||
elevation: 2,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
boxShadow: [
|
||||
BoxShadow(color: Colors.grey.shade100, blurRadius: 4, offset: const Offset(0, 2))
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(games[index]['icon'] as IconData, size: 36, color: Colors.blueGrey),
|
||||
const SizedBox(height: 8),
|
||||
Text(games[index]['name'] as String, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
Icon(games[index]['icon'] as IconData, size: 40, color: Colors.blueGrey),
|
||||
const SizedBox(height: 12),
|
||||
Text(games[index]['name'] as String, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,54 +1,54 @@
|
||||
name: feature_brain_trainer
|
||||
description: "A new Flutter package project."
|
||||
description: Brain training features including assessment, recommendation logic, and daily course management.
|
||||
version: 0.0.1
|
||||
homepage:
|
||||
publish_to: 'none'
|
||||
resolution: workspace
|
||||
|
||||
environment:
|
||||
sdk: ^3.9.2
|
||||
flutter: ">=1.17.0"
|
||||
sdk: '^3.9.2'
|
||||
flutter: '>=3.10.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# 1. 상태 관리
|
||||
provider: ^6.0.0
|
||||
|
||||
# 2. 공통 서비스 (IdentityService, BrainTrainingService, 모델 등)
|
||||
service_api:
|
||||
path: ../service_api
|
||||
|
||||
# 3. 공통 UI (CommonGameShell, 버튼 스타일 등)
|
||||
feature_common:
|
||||
path: ../feature_common
|
||||
|
||||
feature_game_sudoku:
|
||||
path: ../feature_game_sudoku
|
||||
feature_game_cardflip:
|
||||
path: ../feature_game_cardflip
|
||||
feature_game_colormatch:
|
||||
path: ../feature_game_colormatch
|
||||
feature_game_schulte:
|
||||
path: ../feature_game_schulte
|
||||
feature_game_sequence:
|
||||
path: ../feature_game_sequence
|
||||
feature_game_finddiff:
|
||||
path: ../feature_game_finddiff
|
||||
feature_game_mathquiz:
|
||||
path: ../feature_game_mathquiz
|
||||
# ... 나머지 게임 패키지들도 추가
|
||||
feature_game_tracing:
|
||||
path: ../feature_game_tracing
|
||||
feature_game_read_aloud:
|
||||
path: ../feature_game_read_aloud
|
||||
feature_game_dictation:
|
||||
path: ../feature_game_dictation
|
||||
|
||||
# (선택) 날짜 포맷팅 등이 필요하면 추가
|
||||
intl: ^0.18.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^5.0.0
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
|
||||
# To add assets to your package, add an assets section, like this:
|
||||
# assets:
|
||||
# - images/a_dot_burr.jpeg
|
||||
# - images/a_dot_ham.jpeg
|
||||
#
|
||||
# For details regarding assets in packages, see
|
||||
# https://flutter.dev/to/asset-from-package
|
||||
#
|
||||
# An image asset can refer to one or more resolution-specific "variants", see
|
||||
# https://flutter.dev/to/resolution-aware-images
|
||||
|
||||
# To add custom fonts to your package, add a fonts section here,
|
||||
# in this "flutter" section. Each entry in this list should have a
|
||||
# "family" key with the font family name, and a "fonts" key with a
|
||||
# list giving the asset and other descriptors for the font. For
|
||||
# example:
|
||||
# fonts:
|
||||
# - family: Schyler
|
||||
# fonts:
|
||||
# - asset: fonts/Schyler-Regular.ttf
|
||||
# - asset: fonts/Schyler-Italic.ttf
|
||||
# style: italic
|
||||
# - family: Trajan Pro
|
||||
# fonts:
|
||||
# - asset: fonts/TrajanPro.ttf
|
||||
# - asset: fonts/TrajanPro_Bold.ttf
|
||||
# weight: 700
|
||||
#
|
||||
# For details regarding fonts in packages, see
|
||||
# https://flutter.dev/to/font-from-package
|
||||
lints: ^3.0.0
|
||||
@@ -12,5 +12,5 @@ export 'widgets/common_game_shell.dart';
|
||||
// (views/intro_view.dart는 intro_screen.dart만 사용하므로 export 불필요)
|
||||
|
||||
export 'models/game_info.dart';
|
||||
export 'models/game_result_args.dart';
|
||||
export 'screens/game_completion_screen.dart';
|
||||
export 'screens/base_game_screen.dart';
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 게임 완료 화면에 전달할 데이터 묶음
|
||||
class GameResultArgs {
|
||||
/// 랭킹 등록 시 사용할 게임 타입 (예: "SUDOKU", "SPIDER")
|
||||
final String gameType;
|
||||
|
||||
/// 랭킹 등록 시 사용할 난이도 ID (예: "SUDOKU_9x9_L5")
|
||||
final String contextId;
|
||||
|
||||
/// 랭킹 등록용 주 점수 (스도쿠: 시간, 스파이더: 이동 횟수)
|
||||
final int primaryScore;
|
||||
|
||||
/// 랭킹 등록용 보조 점수 (스도쿠: (5-점수), 스파이더: 시간)
|
||||
final int? secondaryScore;
|
||||
|
||||
/// 랭킹 등록에 필요한 유저 ID
|
||||
final String userId;
|
||||
|
||||
/// 이름 입력 필드에 미리 채워줄 유저 이름
|
||||
final String? userName;
|
||||
|
||||
/// 랭킹 목록에 점수를 표시할 포맷터 함수
|
||||
/// 예: (120, 2) => "02:00 (Score: 3)"
|
||||
final String Function(int primary, int? secondary) scoreFormatter;
|
||||
|
||||
/// 랭킹 등록 성공 시 호출될 게임별 후속 처리 콜백
|
||||
/// (예: 다음 레벨 잠금 해제)
|
||||
final Future<void> Function(String playerName) onProgressSave;
|
||||
|
||||
// ❌ [삭제] onScreenClose 콜백 제거
|
||||
// final VoidCallback onScreenClose;
|
||||
|
||||
GameResultArgs({
|
||||
required this.gameType,
|
||||
required this.contextId,
|
||||
required this.primaryScore,
|
||||
this.secondaryScore,
|
||||
required this.userId,
|
||||
this.userName,
|
||||
required this.scoreFormatter,
|
||||
required this.onProgressSave,
|
||||
// ❌ [삭제]
|
||||
// required this.onScreenClose,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:service_api/service_api.dart';
|
||||
|
||||
class GameResultArgs {
|
||||
final String gameType;
|
||||
final String contextId;
|
||||
final int primaryScore;
|
||||
|
||||
// [Legacy Support] 기존 게임 호환용 필드
|
||||
final String? userId;
|
||||
final String? userName; // [Fix] userName 필드 존재 확인
|
||||
final int? secondaryScore;
|
||||
|
||||
// [Fix] 타입 불일치 해결: String을 받는 비동기 함수로 명시
|
||||
final Future<void> Function(String)? onProgressSave;
|
||||
|
||||
final VoidCallback? onNextGame;
|
||||
|
||||
// [New Feature] 신규 게임 자동 저장용 필드
|
||||
final int? stars;
|
||||
final int? levelIndex;
|
||||
|
||||
final String Function(int score, int? subScore)? scoreFormatter;
|
||||
|
||||
GameResultArgs({
|
||||
required this.gameType,
|
||||
required this.contextId,
|
||||
required this.primaryScore,
|
||||
this.userId,
|
||||
this.userName,
|
||||
this.secondaryScore,
|
||||
this.onProgressSave,
|
||||
this.onNextGame,
|
||||
this.stars,
|
||||
this.levelIndex,
|
||||
this.scoreFormatter,
|
||||
});
|
||||
}
|
||||
|
||||
abstract class BaseGameScreen extends StatefulWidget {
|
||||
final VoidCallback? onNextGame;
|
||||
const BaseGameScreen({super.key, this.onNextGame});
|
||||
}
|
||||
|
||||
abstract class BaseGameScreenState<T extends BaseGameScreen> extends State<T> {
|
||||
|
||||
void showCommonGameCompletion(GameResultArgs args) async {
|
||||
// 1. [New] 자동 저장 로직
|
||||
if (args.stars != null && args.levelIndex != null) {
|
||||
try {
|
||||
if (!mounted) return;
|
||||
final identityService = context.read<IdentityService>();
|
||||
await identityService.submitGameResult(
|
||||
gameType: args.gameType,
|
||||
level: args.levelIndex!,
|
||||
stars: args.stars!,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint("자동 저장 실패: $e");
|
||||
}
|
||||
}
|
||||
|
||||
// 2. [Legacy] 수동 저장 로직 호환 (기존 게임용)
|
||||
if (args.onProgressSave != null) {
|
||||
// 기존 게임들이 String 인자를 기대하므로 더미 문자열 전달
|
||||
await args.onProgressSave!("legacy_save");
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// 3. 팝업 표시
|
||||
final VoidCallback? nextCallback = widget.onNextGame ?? args.onNextGame;
|
||||
final bool isDailyCourse = nextCallback != null;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
title: const Row(
|
||||
children: [
|
||||
Icon(Icons.emoji_events, color: Colors.orange, size: 28),
|
||||
SizedBox(width: 8),
|
||||
Text('훈련 완료!'),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
args.scoreFormatter != null
|
||||
? args.scoreFormatter!(args.primaryScore, args.secondaryScore)
|
||||
: "점수: ${args.primaryScore}",
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (args.stars != null)
|
||||
Row(
|
||||
children: List.generate(3, (index) => Icon(
|
||||
index < args.stars! ? Icons.star : Icons.star_border,
|
||||
color: Colors.amber,
|
||||
size: 32,
|
||||
)),
|
||||
),
|
||||
if (args.stars != null) const SizedBox(height: 16),
|
||||
|
||||
Text(isDailyCourse
|
||||
? "수고하셨습니다. 다음 훈련으로 이동합니다."
|
||||
: "수고하셨습니다. 로비로 돌아갑니다."
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.blueAccent,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
if (isDailyCourse) {
|
||||
nextCallback!();
|
||||
} else {
|
||||
if (Navigator.canPop(context)) {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Text(isDailyCourse ? "다음 게임" : "확인"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,328 +1,123 @@
|
||||
// packages/feature_common/lib/screens/game_completion_screen.dart
|
||||
import 'dart:developer';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:service_api/service_api.dart';
|
||||
import '../models/game_result_args.dart';
|
||||
|
||||
enum _RankSubmissionStep { enterName, submitting, showList }
|
||||
import 'base_game_screen.dart'; // GameResultArgs import
|
||||
|
||||
class GameCompletionScreen extends StatefulWidget {
|
||||
final GameResultArgs args;
|
||||
final bool isDailyCourse;
|
||||
|
||||
const GameCompletionScreen({super.key, required this.args});
|
||||
const GameCompletionScreen({
|
||||
super.key,
|
||||
required this.args,
|
||||
this.isDailyCourse = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<GameCompletionScreen> createState() => _GameCompletionScreenState();
|
||||
}
|
||||
|
||||
class _GameCompletionScreenState extends State<GameCompletionScreen> {
|
||||
final PuzzleService _puzzleService = PuzzleService();
|
||||
final IdentityService _identityService = IdentityService();
|
||||
|
||||
late final TextEditingController _nameController;
|
||||
_RankSubmissionStep _rankStep = _RankSubmissionStep.enterName;
|
||||
List<GameRankDto> _rankingList = [];
|
||||
GameRankWithRankNumber? _myRankResult;
|
||||
String? _dialogErrorMessage;
|
||||
String _submittedPlayerName = "";
|
||||
|
||||
// 🔽 [신규] 랭킹 등록을 건너뛰었는지 확인하는 플래그
|
||||
bool _didSkipRank = false;
|
||||
late TextEditingController _nameController;
|
||||
late IdentityService _identityService;
|
||||
bool _isSaving = false;
|
||||
String? _userName;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// 레벨 클리어 (레벨 잠금 해제)를 즉시 호출
|
||||
widget.args.onProgressSave("");
|
||||
|
||||
final session = context.read<SessionNotifier>().session;
|
||||
_nameController = TextEditingController(text: session?.userName ?? widget.args.userName);
|
||||
|
||||
// 로그인 유저일 경우, 이름 입력 생략하고 자동 등록
|
||||
if (session != null && !session.isGuest) {
|
||||
_rankStep = _RankSubmissionStep.submitting;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_submitRank(autoSubmitName: session.userName);
|
||||
});
|
||||
}
|
||||
_identityService = context.read<IdentityService>();
|
||||
// [Fix] args.userName 사용 가능
|
||||
String? initialName = widget.args.userName;
|
||||
_nameController = TextEditingController(text: initialName ?? '');
|
||||
_loadUserInfo();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submitRank({String? autoSubmitName}) async {
|
||||
String playerName;
|
||||
Future<void> _loadUserInfo() async {
|
||||
final session = await _identityService.getUserSession();
|
||||
String? name = session?.userName ?? await _identityService.getUserName();
|
||||
|
||||
if (autoSubmitName == null) {
|
||||
playerName = _nameController.text.trim();
|
||||
if (playerName.isEmpty) {
|
||||
setState(() { _dialogErrorMessage = "이름을 입력해주세요."; });
|
||||
return;
|
||||
// 만약 args에 이름이 없고 저장된 이름이 있다면 불러옴
|
||||
if (widget.args.userName == null && name != null) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_nameController.text = name;
|
||||
_userName = name;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
playerName = autoSubmitName;
|
||||
}
|
||||
|
||||
if (session != null && !session.isGuest) {
|
||||
_saveProgress(name ?? "Unknown");
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_rankStep = _RankSubmissionStep.submitting;
|
||||
_submittedPlayerName = playerName;
|
||||
_dialogErrorMessage = null;
|
||||
});
|
||||
|
||||
final rankDto = UnifiedRankDto(
|
||||
userId: widget.args.userId,
|
||||
gameType: widget.args.gameType,
|
||||
contextId: widget.args.contextId,
|
||||
playerName: playerName,
|
||||
primaryScore: widget.args.primaryScore,
|
||||
secondaryScore: widget.args.secondaryScore,
|
||||
);
|
||||
Future<void> _saveProgress(String playerName) async {
|
||||
if (_isSaving) return;
|
||||
setState(() => _isSaving = true);
|
||||
|
||||
try {
|
||||
final RankSubmissionResult result = await _puzzleService.submitRank(rankDto);
|
||||
await _identityService.saveUserName(playerName);
|
||||
|
||||
if (autoSubmitName == null) {
|
||||
await _identityService.saveUserName(playerName);
|
||||
// [Fix] 타입 호환성 수정: String 인자 전달
|
||||
if (widget.args.onProgressSave != null) {
|
||||
await widget.args.onProgressSave!(playerName);
|
||||
}
|
||||
|
||||
if (widget.args.stars != null && widget.args.levelIndex != null) {
|
||||
await _identityService.submitGameResult(
|
||||
gameType: widget.args.gameType,
|
||||
level: widget.args.levelIndex!,
|
||||
stars: widget.args.stars!
|
||||
);
|
||||
}
|
||||
|
||||
await widget.args.onProgressSave(playerName); // 레벨 저장 재확인
|
||||
|
||||
setState(() {
|
||||
_rankingList = result.topRanks;
|
||||
_myRankResult = result.myRank;
|
||||
_rankStep = _RankSubmissionStep.showList;
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
log("!!! 랭킹 등록 실패 !!!", error: e);
|
||||
setState(() {
|
||||
_rankStep = _RankSubmissionStep.enterName;
|
||||
if (autoSubmitName != null) {
|
||||
_rankStep = _RankSubmissionStep.showList; // 자동 등록 실패 시 리스트라도 보여줌
|
||||
}
|
||||
_dialogErrorMessage = e.toString().replaceFirst("Exception: ", "");
|
||||
});
|
||||
debugPrint("Error saving progress: $e");
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSaving = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 🔽 [신규] 랭킹 등록 건너뛰기 및 화면 닫기
|
||||
void _skipRankAndClose() {
|
||||
setState(() {
|
||||
_didSkipRank = true;
|
||||
_rankStep = _RankSubmissionStep.showList; // 리스트 화면으로 전환하여 기록은 볼 수 있게 함
|
||||
});
|
||||
}
|
||||
|
||||
/// 🔽 [신규] 점수 표시 위젯 (최상단 고정)
|
||||
Widget _buildScoreWidget(ThemeData theme) {
|
||||
final String scoreText = widget.args.scoreFormatter(
|
||||
widget.args.primaryScore, widget.args.secondaryScore);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20.0),
|
||||
child: Card(
|
||||
color: theme.colorScheme.primary.withOpacity(0.1),
|
||||
margin: EdgeInsets.zero,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'나의 최종 기록',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
scoreText,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 🔽 [신규] 이름 입력 및 버튼 섹션 (키보드 대응)
|
||||
Widget _buildNameEntrySection(ThemeData theme) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('축하합니다! 랭킹에 등록할 이름을 입력하세요.', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 20),
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
autofocus: true,
|
||||
maxLength: 20,
|
||||
decoration: InputDecoration(
|
||||
labelText: '이름 (20자 이내)',
|
||||
border: const OutlineInputBorder(),
|
||||
errorText: _dialogErrorMessage,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// [🔥 수정] 버튼을 입력창 바로 아래 배치
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: _skipRankAndClose, child: const Text('건너뛰기')),
|
||||
const SizedBox(width: 10),
|
||||
ElevatedButton(
|
||||
onPressed: () => _submitRank(), child: const Text('랭킹 등록')),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 🔽 [신규] 랭킹 리스트 섹션 (기록 보기)
|
||||
Widget _buildRankingListSection(ThemeData theme) {
|
||||
if (_didSkipRank) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('랭킹 등록을 건너뛰었습니다.', style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 10),
|
||||
Text('기록은 위 "나의 최종 기록"에서 확인 가능합니다.', style: theme.textTheme.bodyMedium),
|
||||
const SizedBox(height: 20),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('로비로 돌아가기'),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 랭킹 리스트 (기존 로직과 유사)
|
||||
Widget topRankListWidget = _rankingList.isEmpty
|
||||
? const Center(child: Text("등록된 랭킹이 없습니다."))
|
||||
: ListView.builder(
|
||||
itemCount: _rankingList.length,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(), // SingleChildScrollView 내부이므로 필요
|
||||
itemBuilder: (context, index) {
|
||||
final rank = _rankingList[index];
|
||||
final bool isMe = rank.playerName == _submittedPlayerName;
|
||||
final String scoreText = widget.args.scoreFormatter(rank.primaryScore, rank.secondaryScore);
|
||||
|
||||
return ListTile(
|
||||
selected: isMe,
|
||||
selectedTileColor: theme.primaryColor.withOpacity(0.1),
|
||||
leading: Text('${index + 1}.', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
title: Text(rank.playerName, style: TextStyle(fontWeight: isMe ? FontWeight.bold : FontWeight.normal)),
|
||||
trailing: Text(scoreText, style: TextStyle(fontWeight: FontWeight.bold, color: theme.textTheme.bodyMedium?.color?.withOpacity(0.9))),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Widget? myRankWidget;
|
||||
if (_myRankResult != null) {
|
||||
final myRank = _myRankResult!.rankData;
|
||||
final myRankNum = _myRankResult!.rankNumber;
|
||||
bool isMeInTop10 = _rankingList.any((topRank) => topRank.playerName == myRank.playerName);
|
||||
|
||||
if (!isMeInTop10) {
|
||||
final String scoreText = widget.args.scoreFormatter(myRank.primaryScore, myRank.secondaryScore);
|
||||
myRankWidget = Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: ListTile(
|
||||
selected: true,
|
||||
selectedTileColor: theme.colorScheme.secondary.withOpacity(0.1),
|
||||
leading: Text('$myRankNum.', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
title: Text(myRank.playerName, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
trailing: Text(scoreText, style: TextStyle(fontWeight: FontWeight.bold, color: theme.textTheme.bodyMedium?.color?.withOpacity(0.9))),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
if (_dialogErrorMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Text(_dialogErrorMessage!, style: TextStyle(color: theme.colorScheme.error)),
|
||||
),
|
||||
topRankListWidget,
|
||||
if (myRankWidget != null) ...[
|
||||
const Divider(height: 16, thickness: 1),
|
||||
myRankWidget,
|
||||
],
|
||||
const SizedBox(height: 40),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('로비로 돌아가기'),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
String titleText;
|
||||
Widget content;
|
||||
|
||||
if (_rankStep == _RankSubmissionStep.enterName) {
|
||||
titleText = '🎉 게임 완료!';
|
||||
content = _buildNameEntrySection(theme); // 이름 입력 섹션
|
||||
}
|
||||
else if (_rankStep == _RankSubmissionStep.submitting) {
|
||||
titleText = '랭킹 등록 중...';
|
||||
content = const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
else { // _RankSubmissionStep.showList
|
||||
titleText = _didSkipRank ? '✅ 기록 확인' : '🏆 랭킹 등록 완료';
|
||||
content = _buildRankingListSection(theme); // 랭킹 리스트 섹션
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(titleText),
|
||||
automaticallyImplyLeading: false,
|
||||
),
|
||||
body: SafeArea(
|
||||
appBar: AppBar(title: const Text('결과')),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
children: [
|
||||
// 1. [🔥 수정] 최종 기록 섹션 (스크롤과 분리된 최상단)
|
||||
_buildScoreWidget(theme),
|
||||
|
||||
// 2. [🔥 수정] 메인 컨텐츠 섹션
|
||||
if (_rankStep == _RankSubmissionStep.enterName || _rankStep == _RankSubmissionStep.submitting)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: content,
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: content,
|
||||
),
|
||||
const Icon(Icons.emoji_events, size: 80, color: Colors.orange),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
"점수: ${widget.args.primaryScore}",
|
||||
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
TextField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '이름을 입력하세요',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
await _saveProgress(_nameController.text);
|
||||
if (!mounted) return;
|
||||
|
||||
if (widget.args.onNextGame != null) {
|
||||
widget.args.onNextGame!();
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
},
|
||||
child: const Text('확인'),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
// ❌ bottomNavigationBar는 제거됨
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,18 +13,48 @@ class SettingsScreen extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// 🔽 [신규] 기록 삭제 확인 다이얼로그
|
||||
Future<void> _confirmClearHistory(BuildContext context) async {
|
||||
final bool? confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('진단 기록 삭제'),
|
||||
content: const Text('저장된 모든 두뇌 진단 기록을 삭제하시겠습니까?\n삭제된 데이터는 복구할 수 없습니다.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('취소'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('삭제'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && context.mounted) {
|
||||
final identityService = context.read<IdentityService>();
|
||||
await identityService.clearAssessmentHistory();
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('진단 기록이 삭제되었습니다.')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final themeNotifier = context.watch<ThemeNotifier>();
|
||||
final sessionNotifier = context.watch<SessionNotifier>();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('설정'),
|
||||
),
|
||||
appBar: AppBar(title: const Text('설정')),
|
||||
body: ListView(
|
||||
children: [
|
||||
// 🔽 [수정] 계정 연동 섹션
|
||||
ListTile(
|
||||
leading: Icon(
|
||||
sessionNotifier.isGuest
|
||||
@@ -55,10 +85,9 @@ class SettingsScreen extends StatelessWidget {
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.g_mobiledata), // (임시) Google 아이콘
|
||||
icon: const Icon(Icons.g_mobiledata),
|
||||
label: const Text('Google 로그인'),
|
||||
onPressed: () {
|
||||
// 🔽 [수정] 로그인 함수 호출
|
||||
sessionNotifier.login('google');
|
||||
},
|
||||
),
|
||||
@@ -69,7 +98,6 @@ class SettingsScreen extends StatelessWidget {
|
||||
icon: const Icon(Icons.apple),
|
||||
label: const Text('Apple 로그인'),
|
||||
onPressed: () {
|
||||
// 🔽 [수정] 로그인 함수 호출
|
||||
sessionNotifier.login('apple');
|
||||
},
|
||||
),
|
||||
@@ -88,7 +116,6 @@ class SettingsScreen extends StatelessWidget {
|
||||
|
||||
const Divider(),
|
||||
|
||||
// --- 0. 다크 모드 토글 ---
|
||||
SwitchListTile(
|
||||
title: const Text('다크 모드'),
|
||||
secondary: const Icon(Icons.dark_mode_outlined),
|
||||
@@ -98,9 +125,60 @@ class SettingsScreen extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
|
||||
// 🔽 [신규] 글자 크기 조절 섹션
|
||||
ListTile(
|
||||
title: const Text('글자 크기'),
|
||||
subtitle: Text(_getScaleLabel(themeNotifier.textScaleFactor)),
|
||||
leading: const Icon(Icons.format_size),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Slider(
|
||||
value: themeNotifier.textScaleFactor,
|
||||
min: 0.85,
|
||||
max: 1.5,
|
||||
divisions: 4,
|
||||
label: _getScaleLabel(themeNotifier.textScaleFactor),
|
||||
onChanged: (value) {
|
||||
themeNotifier.setTextScale(value);
|
||||
},
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: const [
|
||||
Text("작게", style: TextStyle(fontSize: 12)),
|
||||
Text("표준", style: TextStyle(fontSize: 12)),
|
||||
Text("크게", style: TextStyle(fontSize: 12)),
|
||||
Text("더 크게", style: TextStyle(fontSize: 12)),
|
||||
Text("완전 크게", style: TextStyle(fontSize: 12)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
|
||||
// 🔽 [신규] 데이터 관리 섹션
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 8.0),
|
||||
child: Text(
|
||||
'데이터 관리',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_outline, color: Colors.red),
|
||||
title: const Text('진단 기록 삭제', style: TextStyle(color: Colors.red)),
|
||||
subtitle: const Text('저장된 두뇌 건강 진단 기록을 모두 지웁니다.'),
|
||||
onTap: () => _confirmClearHistory(context),
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
|
||||
// --- 1. 테마 선택 섹션 ---
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 8.0),
|
||||
child: Text(
|
||||
@@ -129,7 +207,6 @@ class SettingsScreen extends StatelessWidget {
|
||||
|
||||
const Divider(),
|
||||
|
||||
// --- 2. 라이선스 정보 섹션 ---
|
||||
ListTile(
|
||||
leading: const Icon(Icons.description_outlined),
|
||||
title: const Text('오픈소스 라이선스'),
|
||||
@@ -172,9 +249,16 @@ class SettingsScreen extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getScaleLabel(double scale) {
|
||||
if (scale <= 0.9) return "작게";
|
||||
if (scale <= 1.05) return "표준";
|
||||
if (scale <= 1.2) return "크게";
|
||||
if (scale <= 1.3) return "더 크게";
|
||||
return "완전 크게";
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ description: The common UI shell for all games (Intro, Home, Settings, Ranking).
|
||||
version: 1.0.0
|
||||
publish_to: 'none'
|
||||
resolution: workspace
|
||||
|
||||
environment:
|
||||
sdk: '^3.9.2'
|
||||
flutter: '>=3.10.0'
|
||||
|
||||
@@ -68,7 +68,7 @@ class CardFlipDifficulties {
|
||||
"숟가락": "젓가락", "책상": "의자", "신발": "양말", "장갑": "목도리",
|
||||
"지우개": "연필", "칠판": "분필", "붓": "물감", "도장": "인주",
|
||||
"냄비": "뚜껑", "샴푸": "린스", "치약": "칫솔", "비누": "수건",
|
||||
"배게": "이불", "항아리": "뚜껑", "안경": "안경집", "핸드폰": "충전기",
|
||||
"배게": "이불", "항아리": "뚜껑", "핸드폰": "충전기",
|
||||
|
||||
"해": "달", "하늘": "구름", "비": "우산", "눈": "눈사람",
|
||||
"봄": "꽃", "여름": "부채", "가을": "단풍", "겨울": "눈",
|
||||
|
||||
@@ -7,8 +7,9 @@ import 'package:feature_common/feature_common.dart';
|
||||
import '../controllers/cardflip_controller.dart';
|
||||
import '../models/cardflip_models.dart';
|
||||
|
||||
class CardFlipGameScreen extends StatefulWidget {
|
||||
const CardFlipGameScreen({super.key});
|
||||
class CardFlipGameScreen extends BaseGameScreen {
|
||||
const CardFlipGameScreen({super.key,
|
||||
super.onNextGame,});
|
||||
|
||||
@override
|
||||
State<CardFlipGameScreen> createState() => _CardFlipGameScreenState();
|
||||
|
||||
@@ -7,8 +7,8 @@ import 'package:feature_common/feature_common.dart';
|
||||
import '../controllers/colormatch_controller.dart';
|
||||
import '../models/colormatch_models.dart';
|
||||
|
||||
class ColorMatchGameScreen extends StatefulWidget {
|
||||
const ColorMatchGameScreen({super.key});
|
||||
class ColorMatchGameScreen extends BaseGameScreen {
|
||||
const ColorMatchGameScreen({super.key, super.onNextGame});
|
||||
|
||||
@override
|
||||
State<ColorMatchGameScreen> createState() => _ColorMatchGameScreenState();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -7,8 +7,8 @@ import 'package:feature_common/feature_common.dart';
|
||||
import '../controllers/finddiff_controller.dart';
|
||||
import '../models/finddiff_models.dart';
|
||||
|
||||
class FindDiffGameScreen extends StatefulWidget {
|
||||
const FindDiffGameScreen({super.key});
|
||||
class FindDiffGameScreen extends BaseGameScreen {
|
||||
const FindDiffGameScreen({super.key, super.onNextGame});
|
||||
@override
|
||||
State<FindDiffGameScreen> createState() => _FindDiffGameScreenState();
|
||||
}
|
||||
|
||||
@@ -8,8 +8,17 @@ import '../models/math_quiz_difficulty.dart'; // 👈 MathQuizDifficulty 정의
|
||||
|
||||
class MathQuizController with ChangeNotifier {
|
||||
late final MathQuizDifficulty difficulty;
|
||||
late final String userId;
|
||||
late final String? userName;
|
||||
// 🔽 [수정] late final -> late (값 변경 또는 별도 초기화를 위해 final 제거)
|
||||
late String userId;
|
||||
late String? userName;
|
||||
|
||||
// ... (기존 멤버 변수들 유지) ...
|
||||
|
||||
// 🔽 [신규 추가] 유저 정보 설정 메서드
|
||||
void setUserInfo(String userId, String? userName) {
|
||||
this.userId = userId;
|
||||
this.userName = userName;
|
||||
}
|
||||
|
||||
late MathQuizPuzzle puzzle;
|
||||
late List<String?> _userAnswers;
|
||||
@@ -71,10 +80,10 @@ class MathQuizController with ChangeNotifier {
|
||||
_timer?.cancel();
|
||||
}
|
||||
|
||||
void startNewGame(MathQuizDifficulty level, String userId, String? userName) {
|
||||
void startNewGame(MathQuizDifficulty level) {
|
||||
this.difficulty = level;
|
||||
this.userId = userId;
|
||||
this.userName = userName;
|
||||
// this.userId = userId;
|
||||
// this.userName = userName;
|
||||
_totalPuzzlesInLevel = level.puzzleCount;
|
||||
_currentPuzzleIndex = 0;
|
||||
_isGameCompleted = false;
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
// 이 패키지의 메인 진입점 (로비 화면)을 export합니다.
|
||||
export 'screens/math_quiz_lobby_screen.dart';
|
||||
export 'screens/math_quiz_lobby_screen.dart';
|
||||
export 'screens/math_quiz_screen.dart';
|
||||
@@ -91,7 +91,8 @@ class _MathQuizLobbyScreenState extends State<MathQuizLobbyScreen> {
|
||||
|
||||
// 1. 컨트롤러 생성 및 새 게임 시작 (제너레이터 호출)
|
||||
final controller = MathQuizController();
|
||||
controller.startNewGame(level, session.userId, session.userName);
|
||||
controller.setUserInfo(session.userId, session.userName);
|
||||
controller.startNewGame(level);
|
||||
|
||||
if (mounted) {
|
||||
await Navigator.push(
|
||||
|
||||
@@ -6,8 +6,8 @@ import '../controllers/math_quiz_controller.dart';
|
||||
import '../models/math_quiz_difficulty.dart';
|
||||
import '../models/math_quiz_models.dart';
|
||||
|
||||
class MathQuizScreen extends StatefulWidget {
|
||||
const MathQuizScreen({super.key});
|
||||
class MathQuizScreen extends BaseGameScreen {
|
||||
const MathQuizScreen({super.key,super.onNextGame});
|
||||
|
||||
@override
|
||||
State<MathQuizScreen> createState() => _MathQuizScreenState();
|
||||
|
||||
@@ -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_read_aloud;
|
||||
|
||||
export 'screens/read_aloud_game_screen.dart';
|
||||
export 'screens/read_aloud_lobby_screen.dart'; // 추가
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'dart:math';
|
||||
|
||||
class ReadAloudDifficultyRepository {
|
||||
static final Random _random = Random();
|
||||
|
||||
static final Map<int, List<String>> _levels = {
|
||||
// Lv 1: 2~3글자 단어 (기초)
|
||||
1: ['사과', '포도', '모자', '구두', '바지', '치마', '우산', '가방', '시계', '안경'],
|
||||
|
||||
// Lv 2: 4글자 이상 단어 (일상)
|
||||
2: ['텔레비전', '냉장고', '세탁기', '청소기', '선풍기', '자동차', '비행기', '자전거', '지하철'],
|
||||
|
||||
// Lv 3: 간단한 문장 (인사/안부)
|
||||
3: ['안녕하세요', '반갑습니다', '감사합니다', '잘 지내세요', '식사 하셨어요', '날씨가 좋네요'],
|
||||
|
||||
// Lv 4: 띄어쓰기가 있는 문장
|
||||
4: ['오늘 점심은 무엇인가요', '산책하기 좋은 날씨입니다', '건강이 최고입니다', '매일 운동을 합니다'],
|
||||
|
||||
// Lv 5: 속담 / 격언 (인지 훈련)
|
||||
5: ['가는 말이 고와야 오는 말이 곱다', '낮말은 새가 듣고 밤말은 쥐가 듣는다', '돌다리도 두들겨 보고 건너라', '발 없는 말이 천 리 간다'],
|
||||
|
||||
// Lv 6: 긴 문장 (뉴스/정보)
|
||||
6: ['규칙적인 식사와 운동은 건강에 좋습니다', '충분한 수분 섭취는 기억력에 도움이 됩니다', '잠을 푹 자는 것이 보약입니다'],
|
||||
};
|
||||
|
||||
static String getSentence(int level) {
|
||||
// 범위 보정 (1~6)
|
||||
int targetLevel = level.clamp(1, 6);
|
||||
final list = _levels[targetLevel]!;
|
||||
return list[_random.nextInt(list.length)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:speech_to_text/speech_to_text.dart' as stt;
|
||||
import 'package:feature_common/feature_common.dart';
|
||||
import '../models/read_aloud_difficulty.dart';
|
||||
|
||||
class ReadAloudGameScreen extends BaseGameScreen {
|
||||
final int levelIndex;
|
||||
|
||||
const ReadAloudGameScreen({
|
||||
super.key,
|
||||
super.onNextGame,
|
||||
this.levelIndex = 1,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ReadAloudGameScreen> createState() => _ReadAloudGameScreenState();
|
||||
}
|
||||
|
||||
class _ReadAloudGameScreenState extends BaseGameScreenState<ReadAloudGameScreen> {
|
||||
late stt.SpeechToText _speech;
|
||||
|
||||
// 상태 변수
|
||||
bool _isInitialized = false;
|
||||
bool _isListening = false;
|
||||
String _textRecognized = "";
|
||||
String _targetSentence = "";
|
||||
|
||||
// 라운드 관리
|
||||
int _currentRound = 1;
|
||||
int _totalRounds = 1;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_speech = stt.SpeechToText();
|
||||
|
||||
// 1. 라운드 설정 및 첫 문제 로드
|
||||
_calculateTotalRounds();
|
||||
_loadNewProblem();
|
||||
|
||||
// 2. STT 초기화
|
||||
_initSpeechState();
|
||||
}
|
||||
|
||||
void _calculateTotalRounds() {
|
||||
if (widget.levelIndex <= 3) {
|
||||
_totalRounds = 5; // Lv 1~3: 5문제
|
||||
} else {
|
||||
_totalRounds = 3; // Lv 4~6: 3문제
|
||||
}
|
||||
}
|
||||
|
||||
void _loadNewProblem() {
|
||||
setState(() {
|
||||
// 해당 레벨의 랜덤 문장 가져오기
|
||||
_targetSentence = ReadAloudDifficultyRepository.getSentence(widget.levelIndex);
|
||||
_textRecognized = "";
|
||||
_isListening = false;
|
||||
});
|
||||
_speech.stop(); // 이전 듣기 중단
|
||||
}
|
||||
|
||||
void _initSpeechState() async {
|
||||
try {
|
||||
bool available = await _speech.initialize(
|
||||
onStatus: (status) {
|
||||
if (status == 'done' || status == 'notListening') {
|
||||
if (mounted) setState(() => _isListening = false);
|
||||
}
|
||||
},
|
||||
onError: (errorNotification) {
|
||||
debugPrint('STT Error: $errorNotification');
|
||||
if (mounted) {
|
||||
setState(() => _isListening = false);
|
||||
// 에러 발생 시 사용자에게 알림 (선택 사항)
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() => _isInitialized = available);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("STT Initialize Failed: $e");
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleListening() {
|
||||
if (!_isInitialized) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('마이크 권한을 확인하거나 초기화 중입니다.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isListening) {
|
||||
_speech.stop();
|
||||
setState(() => _isListening = false);
|
||||
} else {
|
||||
setState(() {
|
||||
_isListening = true;
|
||||
_textRecognized = "";
|
||||
});
|
||||
|
||||
_speech.listen(
|
||||
onResult: (result) {
|
||||
setState(() {
|
||||
_textRecognized = result.recognizedWords;
|
||||
});
|
||||
_checkSuccess();
|
||||
},
|
||||
localeId: 'ko_KR',
|
||||
listenFor: const Duration(seconds: 15),
|
||||
pauseFor: const Duration(seconds: 3),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _checkSuccess() {
|
||||
final String cleanTarget = _targetSentence.replaceAll(' ', '');
|
||||
final String cleanInput = _textRecognized.replaceAll(' ', '');
|
||||
|
||||
if (cleanInput.contains(cleanTarget)) {
|
||||
_speech.stop();
|
||||
setState(() => _isListening = false);
|
||||
_handleRoundCompletion(); // 라운드 완료 처리
|
||||
}
|
||||
}
|
||||
|
||||
// 🔽 [수정] 성공 시 라운드 체크
|
||||
void _handleRoundCompletion() {
|
||||
if (_currentRound < _totalRounds) {
|
||||
// 1. 다음 라운드로 이동
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text("정확합니다! 다음 문제로 넘어갑니다. (${_currentRound + 1}/$_totalRounds)"),
|
||||
duration: const Duration(milliseconds: 1000),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_currentRound++;
|
||||
});
|
||||
|
||||
// 자연스러운 전환을 위해 잠시 대기
|
||||
Future.delayed(const Duration(milliseconds: 1000), () {
|
||||
if (mounted) _loadNewProblem();
|
||||
});
|
||||
|
||||
} else {
|
||||
// 2. 모든 라운드 종료 -> 게임 클리어
|
||||
showCommonGameCompletion(
|
||||
GameResultArgs(
|
||||
gameType: 'READ_ALOUD',
|
||||
contextId: 'Lv${widget.levelIndex}_Final',
|
||||
primaryScore: 100,
|
||||
userId: 'user',
|
||||
scoreFormatter: (s, _) => "훈련 완료!",
|
||||
onProgressSave: (_) async {},
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
// 진행 상황 표시
|
||||
title: Text('소리 내어 읽기 ($_currentRound/$_totalRounds)'),
|
||||
),
|
||||
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
||||
floatingActionButton: SizedBox(
|
||||
width: 80,
|
||||
height: 80,
|
||||
child: FloatingActionButton(
|
||||
onPressed: _toggleListening,
|
||||
backgroundColor: _isListening ? Colors.redAccent : Colors.blueAccent,
|
||||
elevation: 10,
|
||||
child: Icon(
|
||||
_isListening ? Icons.mic : Icons.mic_none,
|
||||
size: 40,
|
||||
),
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 40.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
"마이크 버튼을 누르고\n아래 글자를 큰 소리로 읽으세요.",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 18, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// 목표 텍스트 카드
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(30),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Colors.blueAccent.withOpacity(0.3)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.grey.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 5),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: Text(
|
||||
_targetSentence,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 50),
|
||||
|
||||
if (_isListening)
|
||||
const Text("듣고 있어요...", style: TextStyle(color: Colors.redAccent, fontWeight: FontWeight.bold)),
|
||||
|
||||
const SizedBox(height: 10),
|
||||
|
||||
Text(
|
||||
_textRecognized.isEmpty ? "..." : _textRecognized,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
color: _isListening ? Colors.black54 : Colors.blueGrey,
|
||||
fontWeight: FontWeight.w500
|
||||
),
|
||||
),
|
||||
|
||||
// 하단 안내 메시지
|
||||
const SizedBox(height: 50),
|
||||
if (_currentRound < _totalRounds)
|
||||
Text(
|
||||
"총 $_totalRounds문제 중 $_currentRound번째 문제입니다.",
|
||||
style: TextStyle(color: Colors.grey.shade400),
|
||||
),
|
||||
|
||||
const SizedBox(height: 100),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'read_aloud_game_screen.dart';
|
||||
|
||||
class ReadAloudLobbyScreen extends StatelessWidget {
|
||||
const ReadAloudLobbyScreen({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.record_voice_over, size: 80, color: Colors.green),
|
||||
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.green.shade100, foregroundColor: Colors.green),
|
||||
title: Text("레벨 $level"),
|
||||
subtitle: Text(_getLevelDesc(level)),
|
||||
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
|
||||
onTap: () {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => ReadAloudGameScreen(levelIndex: level)));
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getLevelDesc(int level) {
|
||||
if (level <= 2) return "단어 읽기";
|
||||
if (level <= 4) return "짧은 문장 읽기";
|
||||
return "긴 문장 / 속담 읽기";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
name: feature_game_read_aloud
|
||||
description: Speech recognition game for language training.
|
||||
version: 0.0.1
|
||||
publish_to: 'none'
|
||||
resolution: workspace
|
||||
|
||||
environment:
|
||||
sdk: '^3.9.2'
|
||||
flutter: '>=3.10.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# 음성 인식 핵심 패키지
|
||||
speech_to_text: ^6.6.0
|
||||
permission_handler: ^11.0.0
|
||||
|
||||
# 공통 모듈
|
||||
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_read_aloud/feature_game_read_aloud.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);
|
||||
});
|
||||
}
|
||||
@@ -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,3 @@
|
||||
library feature_game_schulte;
|
||||
export 'screens/schulte_game_screen.dart';
|
||||
export 'screens/schulte_lobby_screen.dart'; // 추가
|
||||
@@ -0,0 +1,247 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:feature_common/feature_common.dart';
|
||||
|
||||
class SchulteGameScreen extends BaseGameScreen {
|
||||
final int levelIndex;
|
||||
|
||||
const SchulteGameScreen({
|
||||
super.key,
|
||||
super.onNextGame,
|
||||
this.levelIndex = 1,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SchulteGameScreen> createState() => _SchulteGameScreenState();
|
||||
}
|
||||
|
||||
class _SchulteGameScreenState extends BaseGameScreenState<SchulteGameScreen> with TickerProviderStateMixin {
|
||||
// 게임 설정
|
||||
int _gridSize = 3; // 3x3, 4x4, 5x5
|
||||
List<int> _numbers = [];
|
||||
|
||||
// 진행 상태
|
||||
int _targetNumber = 1; // 현재 찾아야 할 숫자
|
||||
DateTime? _startTime;
|
||||
Timer? _hintTimer;
|
||||
|
||||
// 힌트 애니메이션
|
||||
AnimationController? _hintController;
|
||||
int? _hintIndex; // 힌트를 보여줄 그리드 인덱스
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initLevel();
|
||||
_startNewGame();
|
||||
}
|
||||
|
||||
void _initLevel() {
|
||||
// 난이도별 그리드 크기 설정
|
||||
// Lv 1~3: 3x3
|
||||
// Lv 4~6: 4x4
|
||||
// Lv 7~: 5x5
|
||||
if (widget.levelIndex <= 3) {
|
||||
_gridSize = 3;
|
||||
} else if (widget.levelIndex <= 6) {
|
||||
_gridSize = 4;
|
||||
} else {
|
||||
_gridSize = 5;
|
||||
}
|
||||
|
||||
// 힌트 애니메이션 컨트롤러
|
||||
_hintController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 500),
|
||||
)..repeat(reverse: true);
|
||||
}
|
||||
|
||||
void _startNewGame() {
|
||||
// 1~N 까지 숫자 생성 후 섞기
|
||||
int totalCount = _gridSize * _gridSize;
|
||||
_numbers = List.generate(totalCount, (index) => index + 1);
|
||||
_numbers.shuffle();
|
||||
|
||||
setState(() {
|
||||
_targetNumber = 1;
|
||||
_startTime = DateTime.now();
|
||||
_hintIndex = null;
|
||||
});
|
||||
|
||||
_resetHintTimer();
|
||||
}
|
||||
|
||||
// 힌트 타이머 (3초간 입력 없으면 작동)
|
||||
void _resetHintTimer() {
|
||||
_hintTimer?.cancel();
|
||||
setState(() => _hintIndex = null);
|
||||
|
||||
_hintTimer = Timer(const Duration(seconds: 3), () {
|
||||
// 현재 찾아야 할 숫자의 위치를 찾음
|
||||
int index = _numbers.indexOf(_targetNumber);
|
||||
if (index != -1 && mounted) {
|
||||
setState(() => _hintIndex = index);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onNumberTap(int number) {
|
||||
if (number == _targetNumber) {
|
||||
// 정답!
|
||||
// 효과음 재생 가능
|
||||
|
||||
if (_targetNumber == _gridSize * _gridSize) {
|
||||
// 게임 클리어
|
||||
_finishGame();
|
||||
} else {
|
||||
// 다음 숫자로 이동
|
||||
setState(() {
|
||||
_targetNumber++;
|
||||
});
|
||||
_resetHintTimer();
|
||||
}
|
||||
} else {
|
||||
// 오답 (흔들기 효과 등을 넣을 수 있음)
|
||||
// 여기서는 간단히 스낵바
|
||||
/*
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('$_targetNumber을(를) 누르세요!'), duration: Duration(milliseconds: 500)),
|
||||
);
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
void _finishGame() {
|
||||
_hintTimer?.cancel();
|
||||
final duration = DateTime.now().difference(_startTime!);
|
||||
|
||||
showCommonGameCompletion(
|
||||
GameResultArgs(
|
||||
gameType: 'SCHULTE',
|
||||
contextId: 'Lv${widget.levelIndex}',
|
||||
primaryScore: duration.inSeconds,
|
||||
scoreFormatter: (s, _) => "$s초 걸림",
|
||||
|
||||
levelIndex: widget.levelIndex,
|
||||
stars: duration.inSeconds < (_gridSize * _gridSize) ? 3 : 2,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hintTimer?.cancel();
|
||||
_hintController?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: Text('숫자 순서 찾기 (Lv.${widget.levelIndex})')),
|
||||
body: Column(
|
||||
children: [
|
||||
// 상단 안내
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
children: [
|
||||
const Text(
|
||||
"1부터 순서대로 빠르게 누르세요!",
|
||||
style: TextStyle(fontSize: 18, color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blueAccent,
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
child: Text(
|
||||
"찾을 숫자: $_targetNumber",
|
||||
style: const TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1.0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: GridView.builder(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: _gridSize,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
),
|
||||
itemCount: _numbers.length,
|
||||
itemBuilder: (context, index) {
|
||||
final number = _numbers[index];
|
||||
final isFound = number < _targetNumber; // 이미 찾은 숫자
|
||||
final isHint = index == _hintIndex; // 힌트 대상
|
||||
|
||||
return GestureDetector(
|
||||
onTap: isFound ? null : () => _onNumberTap(number),
|
||||
child: AnimatedBuilder(
|
||||
animation: _hintController!,
|
||||
builder: (context, child) {
|
||||
// 힌트일 때 깜빡임 효과
|
||||
double opacity = 1.0;
|
||||
if (isHint) {
|
||||
opacity = 0.5 + (_hintController!.value * 0.5);
|
||||
}
|
||||
return Opacity(opacity: opacity, child: child);
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isFound
|
||||
? Colors.grey.shade200 // 찾은건 흐리게
|
||||
: (isHint ? Colors.orange.shade100 : Colors.white),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isFound
|
||||
? Colors.transparent
|
||||
: (isHint ? Colors.orange : Colors.blue.shade200),
|
||||
width: 2
|
||||
),
|
||||
boxShadow: isFound ? [] : [
|
||||
BoxShadow(
|
||||
color: Colors.grey.withOpacity(0.2),
|
||||
blurRadius: 4,
|
||||
offset: const Offset(0, 2),
|
||||
)
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
isFound ? "" : "$number", // 찾은건 숫자 숨김 (또는 흐리게)
|
||||
style: TextStyle(
|
||||
fontSize: _gridSize == 3 ? 40 : (_gridSize == 4 ? 32 : 24),
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isFound ? Colors.grey : Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 50),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'schulte_game_screen.dart';
|
||||
|
||||
class SchulteLobbyScreen extends StatelessWidget {
|
||||
const SchulteLobbyScreen({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.looks_one, size: 80, color: Colors.indigo),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
"1부터 순서대로 숫자를 빠르게 찾으세요.\n주의력과 탐색 속도를 높여줍니다.",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
// 난이도 카드 (3개)
|
||||
_buildLevelCard(context, 1, "초급 (3x3)", Colors.green),
|
||||
const SizedBox(height: 16),
|
||||
_buildLevelCard(context, 4, "중급 (4x4)", Colors.blue),
|
||||
const SizedBox(height: 16),
|
||||
_buildLevelCard(context, 7, "고급 (5x5)", Colors.red),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLevelCard(BuildContext context, int level, String title, Color color) {
|
||||
return InkWell(
|
||||
onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => SchulteGameScreen(levelIndex: level))),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: color.withOpacity(0.5), width: 2),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.grid_on, color: color, size: 32),
|
||||
const SizedBox(width: 16),
|
||||
Text(title, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
const Spacer(),
|
||||
const Icon(Icons.play_circle_fill, color: Colors.grey),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
name: feature_game_schulte
|
||||
description: Schulte table game for attention training.
|
||||
version: 0.0.1
|
||||
publish_to: 'none'
|
||||
resolution: workspace
|
||||
environment:
|
||||
sdk: '^3.9.2'
|
||||
flutter: '>=3.10.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# 공통 모듈
|
||||
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_schulte/feature_game_schulte.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);
|
||||
});
|
||||
}
|
||||
@@ -9,8 +9,8 @@ import '../controllers/sequence_game_controller.dart';
|
||||
import '../models/sequence_models.dart';
|
||||
import '../widgets/sequence_button_widget.dart';
|
||||
|
||||
class SequenceGameScreen extends StatefulWidget {
|
||||
const SequenceGameScreen({super.key});
|
||||
class SequenceGameScreen extends BaseGameScreen {
|
||||
const SequenceGameScreen({super.key, super.onNextGame});
|
||||
|
||||
@override
|
||||
State<SequenceGameScreen> createState() => _SequenceGameScreenState();
|
||||
|
||||
@@ -11,8 +11,8 @@ import '../widgets/tableau_pile_widget.dart';
|
||||
import '../widgets/bottom_bar_widget.dart';
|
||||
import '../widgets/card_widget.dart';
|
||||
|
||||
class SpiderGameScreen extends StatefulWidget {
|
||||
const SpiderGameScreen({super.key});
|
||||
class SpiderGameScreen extends BaseGameScreen {
|
||||
const SpiderGameScreen({super.key, super.onNextGame});
|
||||
|
||||
@override
|
||||
State<SpiderGameScreen> createState() => _SpiderGameScreenState();
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
|
||||
// app_sudoku가 IntroScreen의 다음 화면으로 사용할 '로비 화면'
|
||||
export 'screens/sudoku_lobby_screen.dart';
|
||||
|
||||
export 'screens/game_screen.dart';
|
||||
// (GameScreen 등은 로비 화면만 알면 되므로 굳이 export 안 해도 됨)
|
||||
@@ -10,7 +10,7 @@ import '../widgets/number_pad.dart';
|
||||
import '../widgets/sudoku_board.dart';
|
||||
import '../models/game_level.dart';
|
||||
|
||||
class GameScreen extends StatefulWidget {
|
||||
class GameScreen extends BaseGameScreen {
|
||||
final SudokuGameDto gameData;
|
||||
final String themeName;
|
||||
final String userId;
|
||||
@@ -24,6 +24,7 @@ class GameScreen extends StatefulWidget {
|
||||
required this.userId,
|
||||
required this.userName,
|
||||
required this.levelIndex,
|
||||
super.onNextGame,
|
||||
});
|
||||
|
||||
@override
|
||||
|
||||
@@ -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_tracing;
|
||||
|
||||
export 'screens/tracing_game_screen.dart';
|
||||
export 'screens/tracing_lobby_screen.dart'; // 추가
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'dart:math';
|
||||
|
||||
enum TracingContentType {
|
||||
text, // 글자/단어 (생성기 사용)
|
||||
image, // 이미지 (에셋 로드, 추후 사용)
|
||||
}
|
||||
|
||||
class TracingContent {
|
||||
final String data; // 텍스트 내용 또는 이미지 경로
|
||||
final TracingContentType type;
|
||||
|
||||
TracingContent({required this.data, required this.type});
|
||||
}
|
||||
|
||||
class TracingDifficultyRepository {
|
||||
static final Random _random = Random();
|
||||
|
||||
// 난이도별 데이터 뱅크
|
||||
static final Map<int, List<String>> _textLevels = {
|
||||
// Level 1: ㄱㄴㄷ, 숫자 (단순 자모/숫자)
|
||||
1: ['ㄱ', 'ㄴ', 'ㄷ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅅ', 'ㅇ', 'ㅈ', '1', '2', '3', '4', '5', '7'],
|
||||
|
||||
// Level 2: 가, 나, 다 (한글 한 글자)
|
||||
2: ['가', '나', '다', '라', '마', '바', '사', '아', '자', '차', '카', '타', '파', '하', '산', '문', '강'],
|
||||
|
||||
// Level 3: 알파벳 한 글자
|
||||
3: ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'K', 'M', 'N', 'S', 'R', 'T'],
|
||||
|
||||
// Level 4: 3자 이하 한글 단어
|
||||
4: ['사과', '포도', '나무', '바다', '하늘', '구름', '사랑', '친구', '가족', '대한민국', '무궁화'],
|
||||
|
||||
// Level 5: 5자 이하 영어 단어
|
||||
5: ['CAT', 'DOG', 'BIRD', 'LOVE', 'MILK', 'BOOK', 'HAND', 'BLUE', 'ROSE', 'HAPPY'],
|
||||
|
||||
// Level 6: 복합 (한글 5자 이하 + 영어 8자 이하)
|
||||
6: ['아이스크림', '코끼리', '바나나', '텔레비전', 'COMPUTER', 'ELEPHANT', 'MORNING', 'TEACHER', 'SUMMER'],
|
||||
};
|
||||
|
||||
// 추후 이미지 레벨 (7, 8단계)
|
||||
static final Map<int, List<String>> _imageLevels = {
|
||||
7: ['assets/images/shapes/circle.png', 'assets/images/shapes/rect.png'], // 단순 도형
|
||||
8: ['assets/images/shapes/star.png', 'assets/images/shapes/heart.png'], // 복잡 도형
|
||||
};
|
||||
|
||||
/// 해당 레벨의 랜덤 콘텐츠 반환
|
||||
static TracingContent getContent(int level) {
|
||||
// 7단계 이상은 이미지 모드로 처리 (준비된 경우)
|
||||
if (level >= 7) {
|
||||
// 이미지가 준비되지 않았으면 6단계 텍스트로 대체 (안전장치)
|
||||
if (!_imageLevels.containsKey(level)) return getContent(6);
|
||||
|
||||
final list = _imageLevels[level]!;
|
||||
return TracingContent(
|
||||
data: list[_random.nextInt(list.length)],
|
||||
type: TracingContentType.image,
|
||||
);
|
||||
}
|
||||
|
||||
// 1~6단계 텍스트 모드
|
||||
else {
|
||||
// 레벨 범위를 벗어나면 최대 레벨로 고정
|
||||
int targetLevel = level.clamp(1, 6);
|
||||
final list = _textLevels[targetLevel]!;
|
||||
return TracingContent(
|
||||
data: list[_random.nextInt(list.length)],
|
||||
type: TracingContentType.text,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class TracingPainter extends CustomPainter {
|
||||
final List<Offset> userPath; // 사용자가 그린 선
|
||||
final List<Offset> targetPath; // 목표 가이드 선 (점들의 집합)
|
||||
|
||||
TracingPainter({required this.userPath, required this.targetPath});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
// 1. 목표 가이드 라인 그리기 (회색 점선 스타일)
|
||||
final guidePaint = Paint()
|
||||
..color = Colors.grey.shade300
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 24.0
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeJoin = StrokeJoin.round;
|
||||
|
||||
// 점들을 연결해서 경로 생성
|
||||
if (targetPath.isNotEmpty) {
|
||||
final path = Path()..moveTo(targetPath.first.dx, targetPath.first.dy);
|
||||
for (int i = 1; i < targetPath.length; i++) {
|
||||
path.lineTo(targetPath[i].dx, targetPath[i].dy);
|
||||
}
|
||||
// 닫힌 도형이면 마지막 점과 첫 점 연결 (필요시 옵션 처리)
|
||||
// path.close();
|
||||
canvas.drawPath(path, guidePaint);
|
||||
}
|
||||
|
||||
// 2. 사용자가 그리는 선 (파란색 실선)
|
||||
final userPaint = Paint()
|
||||
..color = Colors.blueAccent.withOpacity(0.8)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 16.0
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeJoin = StrokeJoin.round;
|
||||
|
||||
if (userPath.isNotEmpty) {
|
||||
// 포인트가 너무 많으면 성능 저하가 올 수 있으므로 PointMode.polygon 사용
|
||||
canvas.drawPoints(PointMode.polygon, userPath, userPaint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant TracingPainter oldDelegate) {
|
||||
return true; // 실시간으로 계속 다시 그려야 함
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:feature_common/feature_common.dart';
|
||||
import '../utils/image_pixel_analyzer.dart';
|
||||
import '../utils/text_bitmap_generator.dart';
|
||||
import '../models/tracing_difficulty.dart';
|
||||
|
||||
class TracingGameScreen extends BaseGameScreen {
|
||||
final int levelIndex;
|
||||
|
||||
const TracingGameScreen({
|
||||
super.key,
|
||||
super.onNextGame,
|
||||
this.levelIndex = 1,
|
||||
});
|
||||
|
||||
@override
|
||||
State<TracingGameScreen> createState() => _TracingGameScreenState();
|
||||
}
|
||||
|
||||
class _TracingGameScreenState extends BaseGameScreenState<TracingGameScreen> {
|
||||
final ImagePixelAnalyzer _analyzer = ImagePixelAnalyzer();
|
||||
|
||||
// 드로잉 상태
|
||||
List<List<Offset>> _strokes = [];
|
||||
List<Offset> _currentStroke = [];
|
||||
|
||||
// 게임 진행 상태
|
||||
bool _isLoaded = false;
|
||||
double _currentScore = 0.0;
|
||||
|
||||
// 라운드 관리
|
||||
int _currentRound = 1;
|
||||
int _totalRounds = 1;
|
||||
|
||||
// 콘텐츠 데이터
|
||||
ui.Image? _guideUiImage;
|
||||
String? _guideAssetPath;
|
||||
late TracingContent _currentContent;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_calculateTotalRounds(); // 1. 총 문제 수 계산
|
||||
_loadNewProblem(); // 2. 첫 번째 문제 로드
|
||||
}
|
||||
|
||||
// 🔽 [설정] 패널티 가중치 (추후 widget.levelIndex에 따라 조절 가능)
|
||||
// Lv 1~3: 0.3 (관대함)
|
||||
// Lv 4~6: 0.5 (보통)
|
||||
// Lv 7~: 0.8 (엄격)
|
||||
double get _penaltyWeight {
|
||||
if (widget.levelIndex <= 3) return 0.3;
|
||||
if (widget.levelIndex <= 6) return 0.5;
|
||||
return 0.8;
|
||||
}
|
||||
|
||||
/// 난이도별 문제 수 설정
|
||||
void _calculateTotalRounds() {
|
||||
if (widget.levelIndex <= 3) {
|
||||
_totalRounds = 5; // 1~3단계: 5문제
|
||||
} else if (widget.levelIndex <= 6) {
|
||||
_totalRounds = 3; // 4~6단계: 3문제
|
||||
} else if (widget.levelIndex == 7) {
|
||||
_totalRounds = 2; // 7단계: 2문제
|
||||
} else {
|
||||
_totalRounds = 1; // 8단계 이상: 1문제
|
||||
}
|
||||
}
|
||||
|
||||
/// 새로운 문제 로드 (초기화 + 콘텐츠 생성)
|
||||
Future<void> _loadNewProblem() async {
|
||||
setState(() {
|
||||
_isLoaded = false;
|
||||
_currentScore = 0.0;
|
||||
_strokes.clear();
|
||||
_analyzer.reset();
|
||||
});
|
||||
|
||||
// 레벨에 맞는 랜덤 콘텐츠 가져오기
|
||||
_currentContent = TracingDifficultyRepository.getContent(widget.levelIndex);
|
||||
|
||||
try {
|
||||
if (_currentContent.type == TracingContentType.text) {
|
||||
// [텍스트 모드] 비트맵 생성
|
||||
const Size canvasSize = Size(300, 300);
|
||||
final ui.Image bmp = await TextBitmapGenerator.generate(_currentContent.data, canvasSize);
|
||||
await _analyzer.loadFromUiImage(bmp);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_guideUiImage = bmp;
|
||||
_guideAssetPath = null;
|
||||
_isLoaded = true;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// [이미지 모드] 에셋 로드
|
||||
await _analyzer.loadFromAsset(_currentContent.data);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_guideUiImage = null;
|
||||
_guideAssetPath = _currentContent.data;
|
||||
_isLoaded = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("콘텐츠 로드 실패: $e");
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_analyzer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// 캔버스 지우기 (사용자 요청)
|
||||
void _clearCanvas() {
|
||||
setState(() {
|
||||
_strokes.clear();
|
||||
_currentScore = 0.0;
|
||||
});
|
||||
_analyzer.reset();
|
||||
}
|
||||
|
||||
// 🔽 [수정] 터치 종료 시 성공 체크
|
||||
void _onPanEnd(DragEndDetails details) {
|
||||
// 감점이 적용된 점수가 80점 이상이어야 성공!
|
||||
// 이제 막 칠하면 점수가 오히려 떨어져서 절대 성공 못 함
|
||||
if (_currentScore >= 0.80) {
|
||||
_handleCompletion();
|
||||
}
|
||||
}
|
||||
|
||||
// 🔽 [수정] 히트 체크 및 점수 갱신
|
||||
void _checkHit(Offset localPos) {
|
||||
if (!_isLoaded) return;
|
||||
|
||||
// 1. 히트 체크
|
||||
bool changed = _analyzer.checkHit(localPos, const Size(300, 300), touchSize: 20.0);
|
||||
|
||||
if (changed) {
|
||||
// 2. 점수 계산 시 가중치 전달
|
||||
double newScore = _analyzer.getScore(penaltyWeight: _penaltyWeight);
|
||||
|
||||
if (newScore != _currentScore) {
|
||||
setState(() => _currentScore = newScore);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 제스처 핸들러 ---
|
||||
void _onPanStart(DragStartDetails details) {
|
||||
setState(() {
|
||||
_currentStroke = [details.localPosition];
|
||||
_strokes.add(_currentStroke);
|
||||
});
|
||||
_checkHit(details.localPosition);
|
||||
}
|
||||
|
||||
void _onPanUpdate(DragUpdateDetails details) {
|
||||
setState(() {
|
||||
_currentStroke.add(details.localPosition);
|
||||
});
|
||||
_checkHit(details.localPosition);
|
||||
}
|
||||
|
||||
/// 성공 시 처리 로직 (다음 문제 vs 게임 종료)
|
||||
void _handleCompletion() {
|
||||
if (_currentRound < _totalRounds) {
|
||||
// 1. 아직 풀 문제가 남았을 때 -> 다음 문제 로드
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text("성공! 다음 문제로 넘어갑니다. (${_currentRound + 1}/$_totalRounds)"),
|
||||
duration: const Duration(seconds: 1),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_currentRound++;
|
||||
});
|
||||
|
||||
// 잠시 대기 후 새 문제 로드 (자연스러운 전환)
|
||||
Future.delayed(const Duration(milliseconds: 500), () {
|
||||
_loadNewProblem();
|
||||
});
|
||||
|
||||
} else {
|
||||
// 2. 모든 문제를 다 풀었을 때 -> 게임 종료 (성공 팝업)
|
||||
showCommonGameCompletion(
|
||||
GameResultArgs(
|
||||
gameType: 'TRACING',
|
||||
contextId: 'Lv${widget.levelIndex}',
|
||||
primaryScore: 100,
|
||||
scoreFormatter: (s, _) => "훈련 완료!",
|
||||
|
||||
// 자동 저장 데이터
|
||||
levelIndex: widget.levelIndex,
|
||||
stars: 3,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
// 앱바 제목에 진행 상황 표시
|
||||
title: Text('따라 그리기 ($_currentRound/$_totalRounds)'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: '다시 쓰기',
|
||||
onPressed: _clearCanvas,
|
||||
),
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Text(
|
||||
"${(_currentScore * 100).toInt()}%",
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Text(
|
||||
_currentContent.type == TracingContentType.text
|
||||
? "'${_currentContent.data}' 를 따라 쓰세요."
|
||||
: "모양을 따라 그리세요.",
|
||||
style: const TextStyle(fontSize: 20),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 300,
|
||||
height: 300,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.grey.withOpacity(0.1),
|
||||
blurRadius: 10,
|
||||
spreadRadius: 2,
|
||||
)
|
||||
]
|
||||
),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
// [Layer 1] 가이드
|
||||
if (_isLoaded)
|
||||
Opacity(
|
||||
opacity: 0.15, // 가시성 조절
|
||||
child: _currentContent.type == TracingContentType.text
|
||||
? RawImage(image: _guideUiImage, fit: BoxFit.contain)
|
||||
: Image.asset(_guideAssetPath!, fit: BoxFit.contain),
|
||||
)
|
||||
else
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
|
||||
// [Layer 2] 그리기 영역
|
||||
GestureDetector(
|
||||
onPanStart: _onPanStart,
|
||||
onPanUpdate: _onPanUpdate,
|
||||
onPanEnd: _onPanEnd,
|
||||
child: CustomPaint(
|
||||
painter: _StrokePainter(_strokes),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 하단 안내 메시지
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 40.0),
|
||||
child: Text(
|
||||
_currentRound < _totalRounds
|
||||
? "80% 이상 채우면 다음 문제로 넘어갑니다."
|
||||
: "마지막 문제입니다! 끝까지 집중하세요.",
|
||||
style: TextStyle(color: Colors.grey.shade600),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StrokePainter extends CustomPainter {
|
||||
final List<List<Offset>> strokes;
|
||||
_StrokePainter(this.strokes);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = Colors.blueAccent.withOpacity(0.6)
|
||||
..strokeWidth = 20.0
|
||||
..strokeCap = StrokeCap.round
|
||||
..strokeJoin = StrokeJoin.round
|
||||
..style = PaintingStyle.stroke;
|
||||
|
||||
for (final stroke in strokes) {
|
||||
if (stroke.length < 2) continue;
|
||||
final path = Path();
|
||||
path.moveTo(stroke.first.dx, stroke.first.dy);
|
||||
for (int i = 1; i < stroke.length; i++) {
|
||||
path.lineTo(stroke[i].dx, stroke[i].dy);
|
||||
}
|
||||
canvas.drawPath(path, paint);
|
||||
}
|
||||
}
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:feature_common/feature_common.dart'; // BaseLobbyScreen 가정 또는 직접 구현
|
||||
import 'tracing_game_screen.dart';
|
||||
|
||||
class TracingLobbyScreen extends StatelessWidget {
|
||||
const TracingLobbyScreen({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.gesture, size: 80, color: Colors.blueAccent),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
"손가락으로 글씨나 그림을 따라 그리며\n시지각 능력과 소근육을 훈련합니다.",
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Expanded(
|
||||
child: GridView.builder(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2, childAspectRatio: 1.5,
|
||||
crossAxisSpacing: 16, mainAxisSpacing: 16,
|
||||
),
|
||||
itemCount: 6,
|
||||
itemBuilder: (context, index) {
|
||||
final level = index + 1;
|
||||
return ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: Colors.blueAccent,
|
||||
elevation: 2,
|
||||
),
|
||||
onPressed: () {
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => TracingGameScreen(levelIndex: level)));
|
||||
},
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text("Lv. $level", style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 4),
|
||||
Text(_getLevelDesc(level), style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getLevelDesc(int level) {
|
||||
switch(level) {
|
||||
case 1: return "자음/숫자";
|
||||
case 2: return "한 글자";
|
||||
case 3: return "알파벳";
|
||||
case 4: return "단어 (3자)";
|
||||
case 5: return "영어 단어";
|
||||
default: return "복합 단어";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ImagePixelAnalyzer {
|
||||
ByteData? _byteData;
|
||||
int _width = 0;
|
||||
int _height = 0;
|
||||
|
||||
final Set<int> _hitPixels = {}; // 맞춘 픽셀 (Target)
|
||||
final Set<int> _wrongPixels = {}; // 틀린 픽셀 (Background)
|
||||
|
||||
int _totalTargetPixels = 0;
|
||||
|
||||
bool get isLoaded => _byteData != null;
|
||||
int get width => _width;
|
||||
int get height => _height;
|
||||
|
||||
// ... (loadFromAsset, loadFromUiImage는 기존과 동일) ...
|
||||
Future<void> loadFromAsset(String assetPath) async {
|
||||
final ByteData data = await rootBundle.load(assetPath);
|
||||
final ui.Codec codec = await ui.instantiateImageCodec(data.buffer.asUint8List());
|
||||
final ui.FrameInfo fi = await codec.getNextFrame();
|
||||
await loadFromUiImage(fi.image);
|
||||
}
|
||||
|
||||
Future<void> loadFromUiImage(ui.Image image) async {
|
||||
_width = image.width;
|
||||
_height = image.height;
|
||||
_byteData = await image.toByteData(format: ui.ImageByteFormat.rawRgba);
|
||||
_calculateTargetPixels();
|
||||
}
|
||||
|
||||
void _calculateTargetPixels() {
|
||||
_totalTargetPixels = 0;
|
||||
_hitPixels.clear();
|
||||
_wrongPixels.clear();
|
||||
|
||||
if (_byteData == null) return;
|
||||
|
||||
for (int i = 0; i < _byteData!.lengthInBytes; i += 4) {
|
||||
final int alpha = _byteData!.getUint8(i + 3);
|
||||
if (alpha > 50) {
|
||||
_totalTargetPixels++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_hitPixels.clear();
|
||||
_wrongPixels.clear();
|
||||
}
|
||||
|
||||
/// 🔽 [핵심 수정] 터치 판정 로직 개선
|
||||
/// 선에 '조금이라도' 닿아 있으면 빗나간 부분을 용서해줍니다.
|
||||
bool checkHit(Offset position, Size displaySize, {double touchSize = 15.0}) {
|
||||
if (_byteData == null) return false;
|
||||
|
||||
final double scaleX = _width / displaySize.width;
|
||||
final double scaleY = _height / displaySize.height;
|
||||
final int imgX = (position.dx * scaleX).round();
|
||||
final int imgY = (position.dy * scaleY).round();
|
||||
final int radius = (touchSize * scaleX).round();
|
||||
|
||||
// 이번 터치 이벤트에서 발견된 픽셀들 임시 저장
|
||||
List<int> currentHits = [];
|
||||
List<int> currentWrongs = [];
|
||||
|
||||
for (int x = imgX - radius; x <= imgX + radius; x++) {
|
||||
for (int y = imgY - radius; y <= imgY + radius; y++) {
|
||||
if (x < 0 || x >= _width || y < 0 || y >= _height) continue;
|
||||
if ((x - imgX) * (x - imgX) + (y - imgY) * (y - imgY) > radius * radius) continue;
|
||||
|
||||
final int pixelIndex = (y * _width + x) * 4;
|
||||
final int alpha = _byteData!.getUint8(pixelIndex + 3);
|
||||
|
||||
if (alpha > 50) {
|
||||
// 목표 영역임
|
||||
if (!_hitPixels.contains(pixelIndex)) {
|
||||
currentHits.add(pixelIndex);
|
||||
}
|
||||
} else {
|
||||
// 빈 공간임
|
||||
if (!_wrongPixels.contains(pixelIndex)) {
|
||||
currentWrongs.add(pixelIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool hasChange = false;
|
||||
|
||||
// [판정 로직]
|
||||
// 1. 이번 터치 범위 내에 '목표 픽셀'이 하나라도 있었는가?
|
||||
bool touchedTarget = currentHits.isNotEmpty;
|
||||
|
||||
// 이미 방문했던 곳이라 currentHits가 0일 수도 있으니,
|
||||
// 주변에 방문한 _hitPixels가 있는지도 체크하면 더 좋지만,
|
||||
// 일단은 "새로 칠한 Hit가 있거나" OR "중심점이 Target 위에 있는지"로 판단
|
||||
if (!touchedTarget) {
|
||||
// 중심점 체크 (이미 칠한 곳 위를 지나갈 때 감점 방지)
|
||||
int centerIndex = (imgY * _width + imgX) * 4;
|
||||
if (centerIndex >= 0 && centerIndex < _byteData!.lengthInBytes) {
|
||||
if (_byteData!.getUint8(centerIndex + 3) > 50) {
|
||||
touchedTarget = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (touchedTarget) {
|
||||
// ✅ 선 위를 지나가는 중 -> Hit만 인정, Wrong(삐져나간 것)은 무시!
|
||||
if (currentHits.isNotEmpty) {
|
||||
_hitPixels.addAll(currentHits);
|
||||
hasChange = true;
|
||||
}
|
||||
// currentWrongs는 추가하지 않음 (관대함)
|
||||
} else {
|
||||
// ❌ 선과 전혀 상관없는 허공을 칠함 -> Wrong 모두 기록 (감점)
|
||||
if (currentWrongs.isNotEmpty) {
|
||||
_wrongPixels.addAll(currentWrongs);
|
||||
hasChange = true;
|
||||
}
|
||||
}
|
||||
|
||||
return hasChange;
|
||||
}
|
||||
|
||||
/// 🔽 [수정] 가중치(penaltyWeight)를 외부에서 받도록 변경
|
||||
double getScore({double penaltyWeight = 0.3}) {
|
||||
if (_totalTargetPixels == 0) return 0.0;
|
||||
|
||||
double rawScore = _hitPixels.length.toDouble();
|
||||
double penalty = _wrongPixels.length * penaltyWeight;
|
||||
|
||||
// 점수 = (성공 - 패널티) / 전체
|
||||
double finalScore = (rawScore - penalty) / _totalTargetPixels;
|
||||
|
||||
return finalScore.clamp(0.0, 1.0);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_byteData = null;
|
||||
_hitPixels.clear();
|
||||
_wrongPixels.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class TextBitmapGenerator {
|
||||
static Future<ui.Image> generate(String text, Size size) async {
|
||||
final ui.PictureRecorder recorder = ui.PictureRecorder();
|
||||
final Canvas canvas = Canvas(recorder);
|
||||
|
||||
canvas.drawColor(Colors.transparent, BlendMode.clear);
|
||||
|
||||
// 🔽 [수정] 폰트 크기 자동 조절 (Auto-fit)
|
||||
// 기본 크기: 높이의 75%
|
||||
double fontSize = size.height * 0.75;
|
||||
|
||||
// 글자가 길어지면 너비에 맞춰 줄임
|
||||
// (대략적인 계산: 한 글자당 너비가 fontSize * 0.6 ~ 1.0 정도 차지함)
|
||||
if (text.length > 1) {
|
||||
// 화면 너비를 글자 수로 나눈 것과 비교하여 더 작은 값 선택
|
||||
// 여유를 두기 위해 0.9 곱함
|
||||
double maxWidthPerChar = (size.width / text.length) * 1.3;
|
||||
if (fontSize > maxWidthPerChar) {
|
||||
fontSize = maxWidthPerChar;
|
||||
}
|
||||
}
|
||||
|
||||
final TextSpan span = TextSpan(
|
||||
style: TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: fontSize,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'Roboto', // 한글 지원 폰트 권장
|
||||
),
|
||||
text: text,
|
||||
);
|
||||
|
||||
final TextPainter tp = TextPainter(
|
||||
text: span,
|
||||
textAlign: TextAlign.center,
|
||||
textDirection: TextDirection.ltr,
|
||||
);
|
||||
|
||||
tp.layout(minWidth: size.width, maxWidth: size.width);
|
||||
|
||||
// 중앙 정렬
|
||||
final Offset textOffset = Offset(
|
||||
(size.width - tp.width) / 2,
|
||||
(size.height - tp.height) / 2,
|
||||
);
|
||||
|
||||
tp.paint(canvas, textOffset);
|
||||
|
||||
final ui.Picture picture = recorder.endRecording();
|
||||
return await picture.toImage(size.width.toInt(), size.height.toInt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
name: feature_game_tracing
|
||||
description: Finger tracing game for fine motor skills and perception training.
|
||||
version: 0.0.1
|
||||
publish_to: 'none'
|
||||
|
||||
environment:
|
||||
sdk: '^3.9.2'
|
||||
flutter: '>=3.10.0'
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# 공통 모듈 의존성
|
||||
feature_common:
|
||||
path: ../feature_common
|
||||
service_api:
|
||||
path: ../service_api
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
lints: ^3.0.0
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:feature_game_tracing/feature_game_tracing.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);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import 'cognitive_type.dart';
|
||||
|
||||
class AssessmentQuestion {
|
||||
final String id;
|
||||
final String text;
|
||||
final CognitiveArea area;
|
||||
|
||||
const AssessmentQuestion({
|
||||
required this.id,
|
||||
required this.text,
|
||||
required this.area,
|
||||
});
|
||||
}
|
||||
|
||||
/// 전체 진단 질문 풀 (총 100문항)
|
||||
final List<AssessmentQuestion> rawAssessmentQuestions = [
|
||||
// =========================================================
|
||||
// 1. 기억력 (Memory) - 20문항
|
||||
// =========================================================
|
||||
AssessmentQuestion(id: 'm_01', text: '자신의 기억력에 문제가 있다고 생각한다.', area: CognitiveArea.memory),
|
||||
AssessmentQuestion(id: 'm_02', text: '최근 기억력이 10년 전에 비해 현저히 저하되었다.', area: CognitiveArea.memory),
|
||||
AssessmentQuestion(id: 'm_03', text: '같은 또래들에 비해 기억력이 나쁘다고 느낀다.', area: CognitiveArea.memory),
|
||||
AssessmentQuestion(id: 'm_04', text: '기억력 저하로 일상생활(은행, 쇼핑 등)에 불편을 느낀다.', area: CognitiveArea.memory),
|
||||
AssessmentQuestion(id: 'm_05', text: '최근(며칠 전)에 있었던 중요한 일을 자주 잊어버린다.', area: CognitiveArea.memory),
|
||||
AssessmentQuestion(id: 'm_06', text: '며칠 전에 나눈 대화 내용을 기억하기가 어렵다.', area: CognitiveArea.memory),
|
||||
AssessmentQuestion(id: 'm_07', text: '약속 시간이나 장소를 잊어버려 곤란했던 적이 있다.', area: CognitiveArea.memory),
|
||||
AssessmentQuestion(id: 'm_08', text: '자주 만나는 사람의 이름이 바로 떠오르지 않는다.', area: CognitiveArea.memory),
|
||||
AssessmentQuestion(id: 'm_09', text: '물건을 둔 장소를 잊어 한참을 찾은 적이 있다.', area: CognitiveArea.memory),
|
||||
AssessmentQuestion(id: 'm_10', text: '가스불, 전등, 수도꼭지 잠그는 것을 깜빡한다.', area: CognitiveArea.memory),
|
||||
AssessmentQuestion(id: 'm_11', text: '물건을 가지러 방에 들어갔다가 무엇을 하러 왔는지 잊었다.', area: CognitiveArea.memory),
|
||||
AssessmentQuestion(id: 'm_12', text: '자주 사용하는 전화번호(가족, 본인)가 기억나지 않는다.', area: CognitiveArea.memory),
|
||||
AssessmentQuestion(id: 'm_13', text: '같은 질문을 반복해서 한다는 지적을 받은 적이 있다.', area: CognitiveArea.memory),
|
||||
AssessmentQuestion(id: 'm_14', text: '하고 싶은 말이나 단어가 금방 떠오르지 않아 "그거"라고 한다.', area: CognitiveArea.memory),
|
||||
AssessmentQuestion(id: 'm_15', text: '약을 먹었는지 안 먹었는지 기억이 잘 안 난다.', area: CognitiveArea.memory),
|
||||
AssessmentQuestion(id: 'm_16', text: 'TV나 신문에서 본 뉴스의 내용을 나중에 기억하기 힘들다.', area: CognitiveArea.memory),
|
||||
AssessmentQuestion(id: 'm_17', text: '최근에 새로 배운 사용법(기기 조작 등)을 금방 잊어버린다.', area: CognitiveArea.memory),
|
||||
AssessmentQuestion(id: 'm_18', text: '제사나 가족 생일 등 중요한 날짜를 잊어버린다.', area: CognitiveArea.memory),
|
||||
AssessmentQuestion(id: 'm_19', text: '이야기 도중 방금 무슨 이야기를 하고 있었는지 잊을 때가 있다.', area: CognitiveArea.memory),
|
||||
AssessmentQuestion(id: 'm_20', text: '과거의 일을 기억해내는 데 시간이 오래 걸린다.', area: CognitiveArea.memory),
|
||||
|
||||
// =========================================================
|
||||
// 2. 시지각 & 소근육 (Perception) - 20문항 (그리기/손기술 강화)
|
||||
// =========================================================
|
||||
AssessmentQuestion(id: 'p_01', text: '손이 떨려서 글씨를 쓰거나 그림을 그리기 어렵다.', area: CognitiveArea.perception),
|
||||
AssessmentQuestion(id: 'p_02', text: '단추를 채우거나 지퍼를 올리는 등 섬세한 손동작이 힘들다.', area: CognitiveArea.perception),
|
||||
AssessmentQuestion(id: 'p_03', text: '젓가락질이 예전보다 서툴러져 음식을 자주 흘린다.', area: CognitiveArea.perception),
|
||||
AssessmentQuestion(id: 'p_04', text: '바늘에 실을 꿰거나 작은 물건을 집는 것이 어렵다.', area: CognitiveArea.perception),
|
||||
AssessmentQuestion(id: 'p_05', text: '글씨체가 삐뚤빼뚤해지거나 크기가 작아졌다.', area: CognitiveArea.perception),
|
||||
AssessmentQuestion(id: 'p_06', text: '길을 걷다가 문턱이나 계단의 높낮이를 잘못 봐서 걸려 넘어진다.', area: CognitiveArea.perception),
|
||||
AssessmentQuestion(id: 'p_07', text: '오늘이 몇 월, 무슨 요일인지 헷갈릴 때가 있다.', area: CognitiveArea.perception),
|
||||
AssessmentQuestion(id: 'p_08', text: '익숙한 동네나 건물 안에서도 길을 잃은 적이 있다.', area: CognitiveArea.perception),
|
||||
AssessmentQuestion(id: 'p_09', text: '거울에 비친 내 모습이나 가족의 얼굴이 낯설어 보일 때가 있다.', area: CognitiveArea.perception),
|
||||
AssessmentQuestion(id: 'p_10', text: '물건을 잡으려다 거리 조절을 못해 헛손질을 한다.', area: CognitiveArea.perception),
|
||||
AssessmentQuestion(id: 'p_11', text: '비슷하게 생긴 두 물건의 차이점을 찾기가 어렵다.', area: CognitiveArea.perception),
|
||||
AssessmentQuestion(id: 'p_12', text: '옷을 입을 때 안팎이나 앞뒤를 바꿔 입는 실수를 한다.', area: CognitiveArea.perception),
|
||||
AssessmentQuestion(id: 'p_13', text: '운전 중 표지판이나 신호등의 의미가 순간적으로 헷갈린다.', area: CognitiveArea.perception),
|
||||
AssessmentQuestion(id: 'p_14', text: '지도를 보고 목적지를 찾는 것이 예전보다 훨씬 어렵다.', area: CognitiveArea.perception),
|
||||
AssessmentQuestion(id: 'p_15', text: '방향 감각(동서남북, 좌우)이 둔해졌다고 느낀다.', area: CognitiveArea.perception),
|
||||
AssessmentQuestion(id: 'p_16', text: '글자를 읽을 때 줄을 건너뛰거나 순서를 놓친다.', area: CognitiveArea.perception),
|
||||
AssessmentQuestion(id: 'p_17', text: '밤과 낮이 헷갈려 엉뚱한 시간에 일어난 적이 있다.', area: CognitiveArea.perception),
|
||||
AssessmentQuestion(id: 'p_18', text: '물건의 위, 아래, 옆 등의 위치 관계를 설명하기 어렵다.', area: CognitiveArea.perception),
|
||||
AssessmentQuestion(id: 'p_19', text: '익숙한 사람의 얼굴을 보고도 누구인지 바로 못 알아본 적이 있다.', area: CognitiveArea.perception),
|
||||
AssessmentQuestion(id: 'p_20', text: '그림을 그리거나 도형을 따라 그리는 것이 잘 안 된다.', area: CognitiveArea.perception),
|
||||
|
||||
// =========================================================
|
||||
// 3. 계산력 & 판단력 (Calculation) - 20문항
|
||||
// =========================================================
|
||||
AssessmentQuestion(id: 'c_01', text: '간단한 암산(예: 100 - 7)이 즉시 되지 않는다.', area: CognitiveArea.calculation),
|
||||
AssessmentQuestion(id: 'c_02', text: '마트에서 물건값을 계산하거나 거스름돈을 확인할 때 실수한다.', area: CognitiveArea.calculation),
|
||||
AssessmentQuestion(id: 'c_03', text: '은행 업무(송금, 입출금)를 혼자 처리하기가 부담스럽다.', area: CognitiveArea.calculation),
|
||||
AssessmentQuestion(id: 'c_04', text: '공과금이나 세금 납부 기한을 맞추거나 계산하기 어렵다.', area: CognitiveArea.calculation),
|
||||
AssessmentQuestion(id: 'c_05', text: '가계부 정리나 용돈 관리 등 금전 관리에 실수가 잦아졌다.', area: CognitiveArea.calculation),
|
||||
AssessmentQuestion(id: 'c_06', text: '두 물건의 가격과 양을 비교해 싼 것을 고르기가 어렵다.', area: CognitiveArea.calculation),
|
||||
AssessmentQuestion(id: 'c_07', text: '복잡한 문제나 상황이 닥치면 어떻게 해결할지 판단이 안 선다.', area: CognitiveArea.calculation),
|
||||
AssessmentQuestion(id: 'c_08', text: '요리할 때 양념의 양을 조절하거나 조리 순서를 맞추기 힘들다.', area: CognitiveArea.calculation),
|
||||
AssessmentQuestion(id: 'c_09', text: '남의 말(보이스피싱 등)에 쉽게 속거나 의심 없이 믿게 된다.', area: CognitiveArea.calculation),
|
||||
AssessmentQuestion(id: 'c_10', text: '계획을 세워 일을 처리하는 순서를 정하기가 어렵다.', area: CognitiveArea.calculation),
|
||||
AssessmentQuestion(id: 'c_11', text: '갑작스러운 위기 상황(정전, 고장 등)에 대처하지 못하고 당황한다.', area: CognitiveArea.calculation),
|
||||
AssessmentQuestion(id: 'c_12', text: '물건을 살 때 필요한 것과 불필요한 것을 구별하기 어렵다.', area: CognitiveArea.calculation),
|
||||
AssessmentQuestion(id: 'c_13', text: '이전보다 충동적으로 물건을 사거나 돈을 쓰는 경향이 있다.', area: CognitiveArea.calculation),
|
||||
AssessmentQuestion(id: 'c_14', text: '대화의 숨은 뜻이나 농담을 이해하지 못하고 곧이곧대로 듣는다.', area: CognitiveArea.calculation),
|
||||
AssessmentQuestion(id: 'c_15', text: 'TV 드라마나 영화의 줄거리 흐름을 논리적으로 이해하기 힘들다.', area: CognitiveArea.calculation),
|
||||
AssessmentQuestion(id: 'c_16', text: '식당에서 메뉴를 고르고 주문하는 결정이 예전보다 오래 걸린다.', area: CognitiveArea.calculation),
|
||||
AssessmentQuestion(id: 'c_17', text: '날씨에 맞지 않게 옷을 입거나 상황에 맞지 않는 행동을 한다.', area: CognitiveArea.calculation),
|
||||
AssessmentQuestion(id: 'c_18', text: '사회적 규칙이나 예절을 지키는 것에 대한 판단이 흐려졌다.', area: CognitiveArea.calculation),
|
||||
AssessmentQuestion(id: 'c_19', text: '복잡한 기계(세탁기, 키오스크) 조작 방법을 이해하기 어렵다.', area: CognitiveArea.calculation),
|
||||
AssessmentQuestion(id: 'c_20', text: '숫자 자체를 읽거나 쓰는 것이 헷갈릴 때가 있다.', area: CognitiveArea.calculation),
|
||||
|
||||
// =========================================================
|
||||
// 4. 주의력 & 집행기능 (Attention) - 20문항
|
||||
// =========================================================
|
||||
AssessmentQuestion(id: 'a_01', text: '대화 중 상대방의 말에 집중하지 못하고 딴생각을 한다.', area: CognitiveArea.attention),
|
||||
AssessmentQuestion(id: 'a_02', text: '두 가지 일(예: TV 보며 대화하기)을 동시에 하기가 매우 힘들다.', area: CognitiveArea.attention),
|
||||
AssessmentQuestion(id: 'a_03', text: '책이나 신문을 읽을 때 집중이 안 되어 같은 줄을 반복해 읽는다.', area: CognitiveArea.attention),
|
||||
AssessmentQuestion(id: 'a_04', text: '주변이 시끄러우면 하던 일에 전혀 집중할 수 없다.', area: CognitiveArea.attention),
|
||||
AssessmentQuestion(id: 'a_05', text: '한 가지 일을 끝까지 마치지 못하고 중간에 그만두는 경우가 많다.', area: CognitiveArea.attention),
|
||||
AssessmentQuestion(id: 'a_06', text: '방 정리 정돈을 하지 못해 집안이 예전보다 어지럽다.', area: CognitiveArea.attention),
|
||||
AssessmentQuestion(id: 'a_07', text: '외출 준비를 하거나 씻는 과정이 귀찮아지고 대충 하게 된다.', area: CognitiveArea.attention),
|
||||
AssessmentQuestion(id: 'a_08', text: '성격이 급해지거나 참을성이 없어 화를 잘 낸다.', area: CognitiveArea.attention),
|
||||
AssessmentQuestion(id: 'a_09', text: '매사에 의욕이 없고 만사가 귀찮게 느껴진다.', area: CognitiveArea.attention),
|
||||
AssessmentQuestion(id: 'a_10', text: '갈수록 말수가 줄어들고 사람들을 만나기 싫어한다.', area: CognitiveArea.attention),
|
||||
AssessmentQuestion(id: 'a_11', text: '늘 하던 일상적인 일(청소, 빨래)의 순서가 헷갈린다.', area: CognitiveArea.attention),
|
||||
AssessmentQuestion(id: 'a_12', text: '복잡한 그림이나 자극을 보면 머리가 멍해진다.', area: CognitiveArea.attention),
|
||||
AssessmentQuestion(id: 'a_13', text: '상대방의 말이 끝나기도 전에 끼어들거나 엉뚱한 대답을 한다.', area: CognitiveArea.attention),
|
||||
AssessmentQuestion(id: 'a_14', text: '편지나 간단한 메모를 쓰려고 해도 문장을 잇기가 어렵다.', area: CognitiveArea.attention),
|
||||
AssessmentQuestion(id: 'a_15', text: '새로운 환경이나 변화에 적응하는 것이 매우 스트레스다.', area: CognitiveArea.attention),
|
||||
AssessmentQuestion(id: 'a_16', text: '냄비가 끓어넘치거나 물이 넘치는 것을 보고도 멍하니 있는다.', area: CognitiveArea.attention),
|
||||
AssessmentQuestion(id: 'a_17', text: '개인 위생(목욕, 양치질)에 소홀해져도 신경 쓰지 않는다.', area: CognitiveArea.attention),
|
||||
AssessmentQuestion(id: 'a_18', text: '다른 사람의 감정을 파악하거나 공감하는 능력이 떨어진 것 같다.', area: CognitiveArea.attention),
|
||||
AssessmentQuestion(id: 'a_19', text: '하루 종일 멍하니 앉아 있거나 잠만 자는 시간이 늘었다.', area: CognitiveArea.attention),
|
||||
AssessmentQuestion(id: 'a_20', text: '물건을 분류하거나 정리하는 작업이 혼란스럽다.', area: CognitiveArea.attention),
|
||||
|
||||
// =========================================================
|
||||
// 5. 언어 능력 (Language) - 20문항 (신규)
|
||||
// =========================================================
|
||||
AssessmentQuestion(id: 'l_01', text: '말을 할 때 적절한 단어가 떠오르지 않아 머뭇거린다.', area: CognitiveArea.language),
|
||||
AssessmentQuestion(id: 'l_02', text: '물건의 이름이 금방 생각나지 않아 "그거"라고 자주 말한다.', area: CognitiveArea.language),
|
||||
AssessmentQuestion(id: 'l_03', text: '책이나 신문을 읽어도 무슨 내용인지 이해가 잘 안 된다.', area: CognitiveArea.language),
|
||||
AssessmentQuestion(id: 'l_04', text: '상대방의 말을 이해하지 못해 엉뚱한 대답을 할 때가 있다.', area: CognitiveArea.language),
|
||||
AssessmentQuestion(id: 'l_05', text: '발음이 어눌해지거나 목소리가 작아졌다는 소리를 듣는다.', area: CognitiveArea.language),
|
||||
AssessmentQuestion(id: 'l_06', text: '글을 쓸 때 맞춤법이 자주 틀리거나 문장 구성이 어렵다.', area: CognitiveArea.language),
|
||||
AssessmentQuestion(id: 'l_07', text: '알고 있던 단어의 뜻이 갑자기 헷갈릴 때가 있다.', area: CognitiveArea.language),
|
||||
AssessmentQuestion(id: 'l_08', text: '긴 문장을 말하거나 이해하는 것이 벅차게 느껴진다.', area: CognitiveArea.language),
|
||||
AssessmentQuestion(id: 'l_09', text: '대화 도중 주제를 자꾸 놓치거나 횡설수설한다.', area: CognitiveArea.language),
|
||||
AssessmentQuestion(id: 'l_10', text: '익숙한 속담이나 관용구의 의미를 이해하지 못한다.', area: CognitiveArea.language),
|
||||
AssessmentQuestion(id: 'l_11', text: '책을 소리 내어 읽을 때 자주 더듬거리거나 틀리게 읽는다.', area: CognitiveArea.language),
|
||||
AssessmentQuestion(id: 'l_12', text: '다른 사람의 이름이나 지명을 부를 때 자꾸 다른 이름을 말한다.', area: CognitiveArea.language),
|
||||
AssessmentQuestion(id: 'l_13', text: '자신의 생각이나 감정을 말로 표현하기가 매우 힘들다.', area: CognitiveArea.language),
|
||||
AssessmentQuestion(id: 'l_14', text: 'TV 자막을 읽는 속도가 느려 내용을 따라가지 못한다.', area: CognitiveArea.language),
|
||||
AssessmentQuestion(id: 'l_15', text: '전화 통화 시 상대방의 말을 잘 알아듣지 못해 되묻는다.', area: CognitiveArea.language),
|
||||
AssessmentQuestion(id: 'l_16', text: '메모를 하려고 해도 글씨를 어떻게 쓰는지 순간 잊어버린다.', area: CognitiveArea.language),
|
||||
AssessmentQuestion(id: 'l_17', text: '비슷한 발음의 단어를 혼동하여 잘못 말하는 경우가 있다.', area: CognitiveArea.language),
|
||||
AssessmentQuestion(id: 'l_18', text: '말수가 급격히 줄어들고 대화를 피하게 된다.', area: CognitiveArea.language),
|
||||
AssessmentQuestion(id: 'l_19', text: '남의 말을 끝까지 듣지 않고 중간에 끊거나 화를 낸다.', area: CognitiveArea.language),
|
||||
AssessmentQuestion(id: 'l_20', text: '과거에 즐겨 읽던 책이나 잡지에 흥미를 잃었다.', area: CognitiveArea.language),
|
||||
];
|
||||
|
||||
|
||||
class AssessmentRecord {
|
||||
final String id;
|
||||
final DateTime date;
|
||||
final Map<CognitiveArea, int> scores; // 영역별 획득 점수
|
||||
|
||||
AssessmentRecord({
|
||||
required this.id,
|
||||
required this.date,
|
||||
required this.scores,
|
||||
});
|
||||
|
||||
// JSON 직렬화 (Enum인 Key를 String으로 변환)
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'date': date.toIso8601String(),
|
||||
// CognitiveArea Enum을 index(숫자 문자열) 또는 name으로 변환하여 저장
|
||||
'scores': scores.map((key, value) => MapEntry(key.index.toString(), value)),
|
||||
};
|
||||
}
|
||||
|
||||
// JSON 역직렬화
|
||||
factory AssessmentRecord.fromJson(Map<String, dynamic> json) {
|
||||
// scores 맵 복원
|
||||
final scoresMap = (json['scores'] as Map<String, dynamic>).map(
|
||||
(key, value) => MapEntry(
|
||||
CognitiveArea.values[int.parse(key)], // index 문자열을 다시 Enum으로
|
||||
value as int
|
||||
),
|
||||
);
|
||||
|
||||
return AssessmentRecord(
|
||||
id: json['id'],
|
||||
date: DateTime.parse(json['date']),
|
||||
scores: scoresMap,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// packages/service_api/lib/models/cognitive_type.dart
|
||||
|
||||
enum CognitiveArea {
|
||||
memory, // 기억력
|
||||
calculation, // 계산/논리력
|
||||
attention, // 주의집중력
|
||||
perception, // 시지각/공간지각력
|
||||
language, // 🔽 [신규] 언어/구성 능력 (말하기, 그리기 등)
|
||||
}
|
||||
|
||||
enum CognitiveRiskLevel {
|
||||
safe, // 안전 (0 ~ 25%)
|
||||
mild, // 경도 주의 (26 ~ 50%)
|
||||
warning, // 위험 (51 ~ 75%)
|
||||
danger, // 고위험 (76% ~ 100%) - 전문의 상담 권장
|
||||
}
|
||||
|
||||
|
||||
enum BrainGameType {
|
||||
sequence(CognitiveArea.memory, '순서 기억'),
|
||||
cardFlip(CognitiveArea.memory, '카드 뒤집기'),
|
||||
mathQuiz(CognitiveArea.calculation, '암산 퀴즈'),
|
||||
sudoku(CognitiveArea.calculation, '스도쿠'),
|
||||
colorMatch(CognitiveArea.attention, '색상 매칭'),
|
||||
schulte(CognitiveArea.attention, '숫자 순서 찾기'),
|
||||
findDiff(CognitiveArea.perception, '다른 그림 찾기'),
|
||||
tracing(CognitiveArea.perception, '따라 그리기'),
|
||||
readAloud(CognitiveArea.language, '소리내어 읽기'),
|
||||
dictation(CognitiveArea.language, '듣고 받아쓰기');
|
||||
final CognitiveArea area;
|
||||
final String label;
|
||||
const BrainGameType(this.area, this.label);
|
||||
}
|
||||
|
||||
@@ -7,11 +7,13 @@ export 'models/sudoku_game_dto.dart';
|
||||
export 'models/sudoku_theme.dart';
|
||||
export 'models/unified_rank_dto.dart';
|
||||
export 'models/validate_result_dto.dart';
|
||||
|
||||
export 'models/cognitive_type.dart';
|
||||
export 'models/assessment_data.dart';
|
||||
|
||||
// Services
|
||||
export 'services/identity_service.dart';
|
||||
export 'services/puzzle_service.dart';
|
||||
export 'services/theme_notifier.dart';
|
||||
export 'services/session_notifier.dart'; // 👈 [추가]
|
||||
export 'services/lobby_helper_service.dart'; // 👈 [추가]
|
||||
export 'services/lobby_helper_service.dart'; // 👈 [추가]
|
||||
export 'services/brain_training_service.dart'; // 👈 [추가]
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'dart:math';
|
||||
import '../models/cognitive_type.dart';
|
||||
import '../models/assessment_data.dart';
|
||||
|
||||
class BrainTrainingService {
|
||||
final Random _random = Random();
|
||||
|
||||
/// 사용자 취약점을 분석하여 맞춤형 게임 3개를 추천합니다.
|
||||
List<BrainGameType> recommendGames(Map<CognitiveArea, int>? scores) {
|
||||
if (scores == null || scores.isEmpty) {
|
||||
// 기록이 없으면 골고루 추천 (기억, 계산, 주의)
|
||||
return [
|
||||
BrainGameType.sequence,
|
||||
BrainGameType.mathQuiz,
|
||||
BrainGameType.schulte,
|
||||
];
|
||||
}
|
||||
|
||||
// 1. 점수 기반 취약점 분석 (점수가 높을수록 위험/취약)
|
||||
Map<CognitiveArea, double> riskRatios = {};
|
||||
Map<CognitiveArea, int> totalCountByArea = {};
|
||||
|
||||
for (var q in rawAssessmentQuestions) {
|
||||
totalCountByArea[q.area] = (totalCountByArea[q.area] ?? 0) + 1;
|
||||
}
|
||||
|
||||
scores.forEach((area, score) {
|
||||
int total = totalCountByArea[area] ?? 1;
|
||||
riskRatios[area] = score / total;
|
||||
});
|
||||
|
||||
var sortedRisks = riskRatios.entries.toList()
|
||||
..sort((a, b) => b.value.compareTo(a.value));
|
||||
|
||||
CognitiveArea primaryWeakness = sortedRisks[0].key;
|
||||
CognitiveArea secondaryWeakness = sortedRisks.length > 1 ? sortedRisks[1].key : primaryWeakness;
|
||||
|
||||
List<BrainGameType> recommendation = [];
|
||||
|
||||
// 2. 추천 리스트 생성
|
||||
// (1) 가장 취약한 영역의 게임
|
||||
recommendation.add(_getGameForArea(primaryWeakness));
|
||||
|
||||
// (2) 두 번째 취약한 영역의 게임 (중복 방지)
|
||||
BrainGameType secondGame = _getGameForArea(secondaryWeakness);
|
||||
if (!recommendation.contains(secondGame)) {
|
||||
recommendation.add(secondGame);
|
||||
} else {
|
||||
recommendation.add(_getRandomGameExcluding(recommendation));
|
||||
}
|
||||
|
||||
// (3) 랜덤 게임 (밸런스)
|
||||
recommendation.add(_getRandomGameExcluding(recommendation));
|
||||
|
||||
return recommendation;
|
||||
}
|
||||
|
||||
/// 영역별 게임 랜덤 선택 (2개 중 1개)
|
||||
BrainGameType _getGameForArea(CognitiveArea area) {
|
||||
switch (area) {
|
||||
case CognitiveArea.memory:
|
||||
return _random.nextBool() ? BrainGameType.sequence : BrainGameType.cardFlip;
|
||||
|
||||
case CognitiveArea.calculation:
|
||||
return _random.nextBool() ? BrainGameType.mathQuiz : BrainGameType.sudoku;
|
||||
|
||||
case CognitiveArea.attention:
|
||||
return _random.nextBool() ? BrainGameType.colorMatch : BrainGameType.schulte; // 슐테(숫자찾기)
|
||||
|
||||
case CognitiveArea.perception:
|
||||
return _random.nextBool() ? BrainGameType.findDiff : BrainGameType.tracing; // 따라그리기
|
||||
|
||||
case CognitiveArea.language:
|
||||
return _random.nextBool() ? BrainGameType.readAloud : BrainGameType.dictation; // 읽기/쓰기
|
||||
}
|
||||
}
|
||||
|
||||
BrainGameType _getRandomGameExcluding(List<BrainGameType> exclude) {
|
||||
var candidates = BrainGameType.values.where((g) => !exclude.contains(g)).toList();
|
||||
if (candidates.isEmpty) return BrainGameType.sudoku;
|
||||
return candidates[_random.nextInt(candidates.length)];
|
||||
}
|
||||
}
|
||||
@@ -1,163 +1,246 @@
|
||||
// packages/service_api/lib/services/identity_service.dart
|
||||
|
||||
import 'dart:convert';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../models/cognitive_type.dart';
|
||||
import '../models/assessment_data.dart';
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// [Fix] UserSession 정의 및 isGuest 추가
|
||||
// -----------------------------------------------------------------------------
|
||||
class UserSession {
|
||||
final String userId;
|
||||
final String? userName;
|
||||
final String loginProvider;
|
||||
final String? email;
|
||||
final String? photoUrl;
|
||||
final String? provider; // 'google', 'apple', 'guest'
|
||||
|
||||
UserSession({
|
||||
required this.userId,
|
||||
this.userName,
|
||||
this.loginProvider = "guest",
|
||||
this.email,
|
||||
this.photoUrl,
|
||||
this.provider,
|
||||
});
|
||||
|
||||
bool get isGuest => loginProvider == "guest";
|
||||
// [Fix] 에러 해결: isGuest 게터 추가
|
||||
bool get isGuest => provider == 'guest' || provider == null;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'userId': userId,
|
||||
'userName': userName,
|
||||
'email': email,
|
||||
'photoUrl': photoUrl,
|
||||
'provider': provider,
|
||||
};
|
||||
|
||||
factory UserSession.fromJson(Map<String, dynamic> json) => UserSession(
|
||||
userId: json['userId'],
|
||||
userName: json['userName'],
|
||||
email: json['email'],
|
||||
photoUrl: json['photoUrl'],
|
||||
provider: json['provider'],
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// IdentityService 구현
|
||||
// -----------------------------------------------------------------------------
|
||||
class IdentityService {
|
||||
static const String _userIdKey = 'app_user_id';
|
||||
static const String _userNameKey = 'app_user_name';
|
||||
static const String _loginProviderKey = 'app_login_provider';
|
||||
static const String _userEmailKey = 'app_user_email';
|
||||
|
||||
// 기존 게임 키들
|
||||
static const String _sudokuMaxLevelKey = 'max_unlocked_level';
|
||||
static const String _sudokuRankMapKey = 'last_checked_rank_map';
|
||||
static const String _spiderMaxLevelKey = 'max_unlocked_spider_level';
|
||||
static const String _spiderRankMapKey = 'last_checked_spider_rank_map';
|
||||
static const String _mathQuizMaxLevelKey = 'max_unlocked_mathquiz_level';
|
||||
static const String _mathQuizRankMapKey = 'last_checked_mathquiz_rank_map';
|
||||
static const String _colorMatchMaxLevelKey = 'max_unlocked_colormatch_level';
|
||||
static const String _colorMatchRankMapKey = 'last_checked_colormatch_rank_map';
|
||||
static const String _sequenceMaxLevelKey = 'max_unlocked_sequence_level';
|
||||
static const String _sequenceRankMapKey = 'last_checked_sequence_rank_map';
|
||||
static const String _cardFlipMaxLevelKey = 'max_unlocked_cardflip_level';
|
||||
static const String _cardFlipRankMapKey = 'last_checked_cardflip_rank_map';
|
||||
|
||||
// 🔽 [🔥 신규] 다른 그림 찾기 키 추가
|
||||
static const String _findDiffMaxLevelKey = 'max_unlocked_finddiff_level';
|
||||
static const String _findDiffRankMapKey = 'last_checked_finddiff_rank_map';
|
||||
static const String _userSessionKey = 'app_user_session';
|
||||
static const String _userNameKey = 'app_user_name'; // 추가
|
||||
static const String _assessmentHistoryKey = 'cognitive_assessment_history';
|
||||
|
||||
final _storage = const FlutterSecureStorage();
|
||||
final _uuid = const Uuid();
|
||||
|
||||
IOSOptions _getIOSOptions() => const IOSOptions();
|
||||
IOSOptions _getIOSOptions() => const IOSOptions(accessibility: KeychainAccessibility.first_unlock);
|
||||
AndroidOptions _getAndroidOptions() => const AndroidOptions(encryptedSharedPreferences: true);
|
||||
|
||||
Future<UserSession> getUserSession() async {
|
||||
final userId = await getOrCreateUserId();
|
||||
final userName = await getSavedUserName();
|
||||
final loginProvider = await _storage.read(key: _loginProviderKey, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions()) ?? "guest";
|
||||
final email = await _storage.read(key: _userEmailKey, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
return UserSession(userId: userId, userName: userName, loginProvider: loginProvider, email: email);
|
||||
}
|
||||
// ===========================================================================
|
||||
// 1. 유저 세션 관리 (호환성 복구)
|
||||
// ===========================================================================
|
||||
|
||||
Future<String> getOrCreateUserId() async {
|
||||
Future<String> getOrCreateUser() async {
|
||||
String? userId = await _storage.read(key: _userIdKey, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
if (userId == null) {
|
||||
userId = const Uuid().v4();
|
||||
userId = _uuid.v4();
|
||||
await _storage.write(key: _userIdKey, value: userId, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
Future<String?> getSavedUserName() async {
|
||||
/// [Fix] SessionNotifier 에러 해결
|
||||
Future<UserSession?> getUserSession() async {
|
||||
String? jsonStr = await _storage.read(key: _userSessionKey, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
if (jsonStr == null) return null;
|
||||
try {
|
||||
return UserSession.fromJson(jsonDecode(jsonStr));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// [Fix] SessionNotifier 에러 해결
|
||||
Future<UserSession> saveSocialLogin({
|
||||
required String userId,
|
||||
String? email,
|
||||
String? name,
|
||||
String? photoUrl,
|
||||
required String provider,
|
||||
}) async {
|
||||
final session = UserSession(
|
||||
userId: userId,
|
||||
email: email,
|
||||
userName: name,
|
||||
photoUrl: photoUrl,
|
||||
provider: provider,
|
||||
);
|
||||
await _storage.write(
|
||||
key: _userSessionKey,
|
||||
value: jsonEncode(session.toJson()),
|
||||
iOptions: _getIOSOptions(),
|
||||
aOptions: _getAndroidOptions()
|
||||
);
|
||||
await _storage.write(key: _userIdKey, value: userId, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/// [Fix] SessionNotifier 에러 해결
|
||||
Future<void> logout() async {
|
||||
await _storage.delete(key: _userSessionKey, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
}
|
||||
|
||||
/// [Fix] GameCompletionScreen 에러 해결
|
||||
Future<void> saveUserName(String name) async {
|
||||
await _storage.write(key: _userNameKey, value: name, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
|
||||
// 세션이 있다면 세션 이름도 업데이트
|
||||
final currentSession = await getUserSession();
|
||||
if (currentSession != null) {
|
||||
final newSession = UserSession(
|
||||
userId: currentSession.userId,
|
||||
userName: name,
|
||||
email: currentSession.email,
|
||||
photoUrl: currentSession.photoUrl,
|
||||
provider: currentSession.provider,
|
||||
);
|
||||
await _storage.write(
|
||||
key: _userSessionKey,
|
||||
value: jsonEncode(newSession.toJson()),
|
||||
iOptions: _getIOSOptions(),
|
||||
aOptions: _getAndroidOptions()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> getUserName() async {
|
||||
return await _storage.read(key: _userNameKey, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
}
|
||||
|
||||
Future<void> saveUserName(String name) async {
|
||||
await _storage.write(key: _userNameKey, value: name, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
// ===========================================================================
|
||||
// 2. 진단 기록 (Assessment)
|
||||
// ===========================================================================
|
||||
|
||||
Future<void> saveAssessmentResult(Map<CognitiveArea, int> scores) async {
|
||||
final record = AssessmentRecord(
|
||||
id: _uuid.v4(),
|
||||
date: DateTime.now(),
|
||||
scores: scores,
|
||||
);
|
||||
final history = await getAssessmentHistory();
|
||||
history.add(record);
|
||||
|
||||
final jsonString = jsonEncode(history.map((e) => e.toJson()).toList());
|
||||
await _storage.write(key: _assessmentHistoryKey, value: jsonString, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
}
|
||||
|
||||
Future<UserSession> saveSocialLogin({required String newUserId, required String newUserName, required String newEmail, required String provider}) async {
|
||||
await _storage.write(key: _userIdKey, value: newUserId, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
await _storage.write(key: _userNameKey, value: newUserName, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
await _storage.write(key: _userEmailKey, value: newEmail, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
await _storage.write(key: _loginProviderKey, value: provider, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
return UserSession(userId: newUserId, userName: newUserName, loginProvider: provider, email: newEmail);
|
||||
}
|
||||
|
||||
Future<UserSession> logout() async {
|
||||
await _storage.delete(key: _userNameKey, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
await _storage.delete(key: _userEmailKey, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
await _storage.delete(key: _loginProviderKey, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
return await getUserSession();
|
||||
}
|
||||
|
||||
// 7. [수정] 최대 레벨 가져오기
|
||||
Future<int> getMaxUnlockedLevel({String gameType = 'SUDOKU'}) async {
|
||||
String key;
|
||||
switch (gameType) {
|
||||
case 'SPIDER': key = _spiderMaxLevelKey; break;
|
||||
case 'MATH_QUIZ': key = _mathQuizMaxLevelKey; break;
|
||||
case 'COLOR_MATCH': key = _colorMatchMaxLevelKey; break;
|
||||
case 'SEQUENCE': key = _sequenceMaxLevelKey; break;
|
||||
case 'CARD_FLIP': key = _cardFlipMaxLevelKey; break;
|
||||
case 'FIND_DIFF': key = _findDiffMaxLevelKey; break; // 👈 추가
|
||||
default: key = _sudokuMaxLevelKey;
|
||||
Future<List<AssessmentRecord>> getAssessmentHistory() async {
|
||||
final jsonString = await _storage.read(key: _assessmentHistoryKey, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
if (jsonString == null) return [];
|
||||
try {
|
||||
final List<dynamic> jsonList = jsonDecode(jsonString);
|
||||
return jsonList.map((e) => AssessmentRecord.fromJson(e)).toList();
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// [Fix] SettingsScreen 에러 해결
|
||||
Future<void> clearAssessmentHistory() async {
|
||||
await _storage.delete(key: _assessmentHistoryKey, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
}
|
||||
|
||||
Future<Map<CognitiveArea, int>?> getCognitiveScores() async {
|
||||
final history = await getAssessmentHistory();
|
||||
if (history.isEmpty) return null;
|
||||
history.sort((a, b) => b.date.compareTo(a.date));
|
||||
return history.first.scores;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 3. 게임 데이터 관리 (통합 + 레거시 호환)
|
||||
// ===========================================================================
|
||||
|
||||
String _getMaxLevelKey(String gameType) => 'max_level_${gameType.toLowerCase()}';
|
||||
String _getRankMapKey(String gameType) => 'rank_map_${gameType.toLowerCase()}';
|
||||
|
||||
Future<int> getMaxUnlockedLevel({String gameType = 'SUDOKU'}) async {
|
||||
final key = _getMaxLevelKey(gameType);
|
||||
String? levelString = await _storage.read(key: key, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
return int.parse(levelString ?? '1');
|
||||
}
|
||||
|
||||
// 8. [수정] 최대 레벨 저장하기
|
||||
/// [Fix] 기존 게임들이 호출하는 메서드 복구
|
||||
Future<void> saveMaxUnlockedLevel(int level, {String gameType = 'SUDOKU'}) async {
|
||||
String key;
|
||||
switch (gameType) {
|
||||
case 'SPIDER': key = _spiderMaxLevelKey; break;
|
||||
case 'MATH_QUIZ': key = _mathQuizMaxLevelKey; break;
|
||||
case 'COLOR_MATCH': key = _colorMatchMaxLevelKey; break;
|
||||
case 'SEQUENCE': key = _sequenceMaxLevelKey; break;
|
||||
case 'CARD_FLIP': key = _cardFlipMaxLevelKey; break;
|
||||
case 'FIND_DIFF': key = _findDiffMaxLevelKey; break; // 👈 추가
|
||||
default: key = _sudokuMaxLevelKey;
|
||||
}
|
||||
await _storage.write(key: key, value: level.toString(), iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
await _storage.write(
|
||||
key: _getMaxLevelKey(gameType),
|
||||
value: level.toString(),
|
||||
iOptions: _getIOSOptions(),
|
||||
aOptions: _getAndroidOptions()
|
||||
);
|
||||
}
|
||||
|
||||
// 9. [수정] 마지막 랭킹 맵 가져오기
|
||||
Future<Map<int, int>> getLastSavedRankMap({String gameType = 'SUDOKU'}) async {
|
||||
String key;
|
||||
switch (gameType) {
|
||||
case 'SPIDER': key = _spiderRankMapKey; break;
|
||||
case 'MATH_QUIZ': key = _mathQuizRankMapKey; break;
|
||||
case 'COLOR_MATCH': key = _colorMatchRankMapKey; break;
|
||||
case 'SEQUENCE': key = _sequenceRankMapKey; break;
|
||||
case 'CARD_FLIP': key = _cardFlipRankMapKey; break;
|
||||
case 'FIND_DIFF': key = _findDiffRankMapKey; break; // 👈 추가
|
||||
default: key = _sudokuRankMapKey;
|
||||
}
|
||||
Future<Map<int, int>> getLastSavedRankMap({required String gameType}) async {
|
||||
final key = _getRankMapKey(gameType);
|
||||
String? jsonString = await _storage.read(key: key, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
|
||||
if (jsonString == null) return {};
|
||||
try {
|
||||
final Map<String, dynamic> decodedMap = jsonDecode(jsonString);
|
||||
return decodedMap.map((key, value) => MapEntry(int.parse(key), value as int));
|
||||
return decodedMap.map((k, v) => MapEntry(int.parse(k), v as int));
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/// [Fix] LobbyHelper 에러 해결
|
||||
Future<void> saveLastRankMap(Map<int, int> rankMap, {required String gameType}) async {
|
||||
final String jsonString = jsonEncode(rankMap.map((k, v) => MapEntry(k.toString(), v)));
|
||||
await _storage.write(
|
||||
key: _getRankMapKey(gameType),
|
||||
value: jsonString,
|
||||
iOptions: _getIOSOptions(),
|
||||
aOptions: _getAndroidOptions()
|
||||
);
|
||||
}
|
||||
|
||||
// 10. [수정] 마지막 랭킹 맵 저장하기
|
||||
Future<void> saveLastRankMap(Map<int, int> rankMap, {String gameType = 'SUDOKU'}) async {
|
||||
String key;
|
||||
switch (gameType) {
|
||||
case 'SPIDER': key = _spiderRankMapKey; break;
|
||||
case 'MATH_QUIZ': key = _mathQuizRankMapKey; break;
|
||||
case 'COLOR_MATCH': key = _colorMatchRankMapKey; break;
|
||||
case 'SEQUENCE': key = _sequenceRankMapKey; break;
|
||||
case 'CARD_FLIP': key = _cardFlipRankMapKey; break;
|
||||
case 'FIND_DIFF': key = _findDiffRankMapKey; break; // 👈 추가
|
||||
default: key = _sudokuRankMapKey;
|
||||
/// [신규] 게임 결과 통합 처리
|
||||
Future<void> submitGameResult({
|
||||
required String gameType,
|
||||
required int level,
|
||||
required int stars,
|
||||
}) async {
|
||||
final rankMap = await getLastSavedRankMap(gameType: gameType);
|
||||
final int oldStars = rankMap[level] ?? 0;
|
||||
if (stars > oldStars) {
|
||||
rankMap[level] = stars;
|
||||
await saveLastRankMap(rankMap, gameType: gameType);
|
||||
}
|
||||
|
||||
final int currentMax = await getMaxUnlockedLevel(gameType: gameType);
|
||||
if (level >= currentMax) {
|
||||
await saveMaxUnlockedLevel(level + 1, gameType: gameType);
|
||||
}
|
||||
|
||||
final Map<String, int> stringKeyMap = rankMap.map((key, value) => MapEntry(key.toString(), value));
|
||||
String jsonString = jsonEncode(stringKeyMap);
|
||||
await _storage.write(key: key, value: jsonString, iOptions: _getIOSOptions(), aOptions: _getAndroidOptions());
|
||||
}
|
||||
}
|
||||
@@ -1,101 +1,101 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_sign_in/google_sign_in.dart';
|
||||
import 'package:sign_in_with_apple/sign_in_with_apple.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'identity_service.dart';
|
||||
import 'puzzle_service.dart';
|
||||
|
||||
class SessionNotifier with ChangeNotifier {
|
||||
final IdentityService _identityService = IdentityService();
|
||||
final PuzzleService _puzzleService = PuzzleService();
|
||||
|
||||
class SessionNotifier extends ChangeNotifier {
|
||||
final IdentityService _identityService;
|
||||
UserSession? _session;
|
||||
bool _isLoading = true; // 초기값을 true로 설정하여 깜빡임 방지
|
||||
|
||||
SessionNotifier(this._identityService);
|
||||
|
||||
UserSession? get session => _session;
|
||||
bool get isLoading => _session == null;
|
||||
bool get isGuest => _session?.isGuest ?? true;
|
||||
bool get isLoading => _isLoading;
|
||||
|
||||
// 🔽 [수정] 'GoogleSignIn()' 생성자 대신 '.instance' 싱글톤 사용
|
||||
final GoogleSignIn _googleSignIn = GoogleSignIn.instance;
|
||||
|
||||
SessionNotifier() {
|
||||
loadSession();
|
||||
}
|
||||
|
||||
/// 앱 시작 시 저장된 세션 로드
|
||||
Future<void> loadSession() async {
|
||||
_session = await _identityService.getUserSession();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// (백엔드 연동 후) 로그인/계정 연결
|
||||
Future<void> login(String provider) async {
|
||||
if (isLoading) return;
|
||||
|
||||
final guestUserId = _session!.userId; // 현재 게스트 ID
|
||||
String? idToken;
|
||||
String? email;
|
||||
String? userName;
|
||||
|
||||
_setLoading(true);
|
||||
try {
|
||||
if (provider == 'google') {
|
||||
// 🔽 [수정] 'signIn()' 메서드 대신 'authenticate()' 사용
|
||||
final GoogleSignInAccount? googleUser = await _googleSignIn.authenticate();
|
||||
if (googleUser == null) return; // 유저가 취소
|
||||
|
||||
final GoogleSignInAuthentication googleAuth = googleUser.authentication;
|
||||
idToken = googleAuth.idToken; // 👈 [핵심] 이 토큰을 백엔드로 전송
|
||||
email = googleUser.email;
|
||||
userName = googleUser.displayName;
|
||||
|
||||
} else if (provider == 'apple') {
|
||||
final credential = await SignInWithApple.getAppleIDCredential(
|
||||
scopes: [ AppleIDAuthorizationScopes.email, AppleIDAuthorizationScopes.fullName ],
|
||||
);
|
||||
|
||||
idToken = credential.identityToken; // 👈 [핵심] 이 토큰을 백엔드로 전송
|
||||
email = credential.email;
|
||||
userName = "${credential.givenName ?? ''} ${credential.familyName ?? ''}".trim();
|
||||
}
|
||||
|
||||
if (idToken == null) {
|
||||
throw Exception("$provider 로그인에 실패했습니다.");
|
||||
}
|
||||
|
||||
// [TODO] 백엔드에 'mergeAccount(guestUserId, idToken, provider)' API 호출
|
||||
// 백엔드는 이 idToken을 검증하고, guestUserId의 데이터를
|
||||
// 소셜 계정의 마스터 ID로 병합(merge)해야 합니다.
|
||||
// 1. 저장된 세션 불러오기
|
||||
_session = await _identityService.getUserSession();
|
||||
|
||||
// --- 백엔드 응답 (임시 시뮬레이션) ---
|
||||
// final backendResponse = await _puzzleService.mergeAccount(guestUserId, idToken, provider);
|
||||
// _session = await _identityService.saveSocialLogin(
|
||||
// newUserId: backendResponse.userId,
|
||||
// newUserName: backendResponse.userName,
|
||||
// newEmail: backendResponse.email,
|
||||
// provider: provider
|
||||
// );
|
||||
|
||||
// [임시] 백엔드 없으므로, 클라이언트 정보로 강제 저장 (테스트용)
|
||||
_session = await _identityService.saveSocialLogin(
|
||||
newUserId: "master-id-${email ?? provider}", // (임시)
|
||||
newUserName: userName ?? "Social User",
|
||||
newEmail: email ?? "No Email",
|
||||
provider: provider
|
||||
);
|
||||
// --- 임시 시뮬레이션 끝 ---
|
||||
|
||||
notifyListeners();
|
||||
|
||||
// 2. [Fix] 저장된 세션이 없으면 자동으로 게스트 로그인 수행
|
||||
if (_session == null) {
|
||||
await loginGuest();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("$provider 로그인 오류: $e");
|
||||
// [TODO] 유저에게 "로그인에 실패했습니다." 스낵바 표시
|
||||
debugPrint("Session load error: $e");
|
||||
// 에러 발생 시에도 게스트로 진입 시도
|
||||
await loginGuest();
|
||||
} finally {
|
||||
_setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 로그아웃
|
||||
Future<void> logout() async {
|
||||
await _googleSignIn.signOut();
|
||||
Future<void> login(String provider) async {
|
||||
if (provider == 'guest') {
|
||||
await loginGuest();
|
||||
} else {
|
||||
await loginSocial(
|
||||
provider: provider,
|
||||
email: "$provider@example.com",
|
||||
name: "User ($provider)",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_session = await _identityService.logout();
|
||||
Future<void> loginGuest() async {
|
||||
try {
|
||||
final userId = await _identityService.getOrCreateUser();
|
||||
_session = UserSession(
|
||||
userId: userId,
|
||||
provider: 'guest',
|
||||
userName: '게스트', // 기본 이름 부여
|
||||
);
|
||||
// 게스트 정보도 세션 스토리지에 저장하여 다음 실행 시 유지
|
||||
await _identityService.saveSocialLogin(
|
||||
userId: userId,
|
||||
provider: 'guest',
|
||||
name: '게스트'
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint("Guest login failed: $e");
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> loginSocial({
|
||||
required String provider,
|
||||
required String email,
|
||||
String? name,
|
||||
String? photoUrl,
|
||||
}) async {
|
||||
_setLoading(true);
|
||||
try {
|
||||
_session = await _identityService.saveSocialLogin(
|
||||
userId: "master-id-${email ?? provider}",
|
||||
email: email,
|
||||
name: name,
|
||||
photoUrl: photoUrl,
|
||||
provider: provider,
|
||||
);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint("Login failed: $e");
|
||||
} finally {
|
||||
_setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
_setLoading(true);
|
||||
await _identityService.logout();
|
||||
_session = null;
|
||||
await loginGuest(); // 로그아웃 후 다시 게스트로 전환
|
||||
_setLoading(false);
|
||||
}
|
||||
|
||||
void _setLoading(bool value) {
|
||||
_isLoading = value;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
// 1. 앱에서 사용할 색상표 정의
|
||||
final Map<String, MaterialColor> appColors = {
|
||||
'Blue': Colors.blue,
|
||||
'Green': Colors.green,
|
||||
@@ -12,83 +11,105 @@ final Map<String, MaterialColor> appColors = {
|
||||
};
|
||||
|
||||
class ThemeNotifier with ChangeNotifier {
|
||||
|
||||
// 기본 테마 설정
|
||||
ThemeData _themeData = ThemeData(
|
||||
primarySwatch: Colors.blue,
|
||||
useMaterial3: true,
|
||||
scaffoldBackgroundColor: Colors.grey[50],
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
iconTheme: IconThemeData(color: Colors.black),
|
||||
titleTextStyle: TextStyle(
|
||||
color: Colors.black,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final String _themeKey = 'selected_theme';
|
||||
final String _darkModeKey = 'is_dark_mode'; // 다크 모드 저장 키
|
||||
final String _darkModeKey = 'is_dark_mode';
|
||||
// 🔽 [신규] 폰트 크기 키
|
||||
final String _textScaleKey = 'text_scale_factor';
|
||||
|
||||
MaterialColor _currentColor = Colors.blue; // 기본값
|
||||
bool _isDarkMode = false; // 다크 모드 상태 변수
|
||||
MaterialColor _currentColor = Colors.blue;
|
||||
bool _isDarkMode = false;
|
||||
// 🔽 [신규] 폰트 배율 (기본 1.0)
|
||||
double _textScaleFactor = 1.0;
|
||||
|
||||
// --- Getters ---
|
||||
|
||||
// 라이트 모드용 테마
|
||||
ThemeData get currentTheme => ThemeData(
|
||||
// 🔽 [수정] M3의 권장 방식인 ColorScheme.fromSeed 사용
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: _currentColor, // 👈 _currentColor를 '씨앗색'으로 사용
|
||||
seedColor: _currentColor,
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.light,
|
||||
// 🔺 'primarySwatch' 속성 제거
|
||||
);
|
||||
|
||||
// 다크 모드용 테마
|
||||
ThemeData get currentDarkTheme => ThemeData(
|
||||
// 🔽 [수정] 다크 모드에도 동일하게 적용
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: _currentColor, // 👈 _currentColor를 '씨앗색'으로 사용
|
||||
seedColor: _currentColor,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
// 🔺 'primarySwatch' 속성 제거
|
||||
);
|
||||
|
||||
// MaterialApp에 전달할 현재 테마 모드
|
||||
ThemeMode get currentThemeMode => _isDarkMode ? ThemeMode.dark : ThemeMode.light;
|
||||
|
||||
// SettingsScreen에서 사용할 현재 상태
|
||||
bool get isDarkMode => _isDarkMode;
|
||||
MaterialColor get currentColor => _currentColor;
|
||||
|
||||
// --- Methods ---
|
||||
|
||||
// 🔽 [신규] getter
|
||||
double get textScaleFactor => _textScaleFactor;
|
||||
|
||||
ThemeNotifier() {
|
||||
_loadTheme(); // 앱 시작 시 저장된 설정 불러오기
|
||||
_loadTheme();
|
||||
}
|
||||
|
||||
// 저장된 테마와 '다크 모드' 설정을 함께 불러오기
|
||||
void _loadTheme() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// 색상 로드
|
||||
final themeName = prefs.getString(_themeKey) ?? 'Blue';
|
||||
_currentColor = appColors[themeName] ?? Colors.blue;
|
||||
|
||||
// 다크 모드 로드
|
||||
_isDarkMode = prefs.getBool(_darkModeKey) ?? false;
|
||||
|
||||
// 🔽 [신규] 로드
|
||||
_textScaleFactor = prefs.getDouble(_textScaleKey) ?? 1.0;
|
||||
|
||||
notifyListeners(); // 설정 로드 후 UI 갱신
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// 새 테마 색상 설정
|
||||
// [Fix] main.dart에서 호출하는 메서드 추가
|
||||
ThemeData getTheme() => _themeData;
|
||||
|
||||
void setTheme(String themeName) async {
|
||||
final newColor = appColors[themeName];
|
||||
if (newColor == null) return;
|
||||
|
||||
_currentColor = newColor;
|
||||
notifyListeners(); // 테마 변경을 앱 전체에 알림
|
||||
notifyListeners();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
prefs.setString(_themeKey, themeName); // 선택한 테마 이름 저장
|
||||
prefs.setString(_themeKey, themeName);
|
||||
}
|
||||
|
||||
// 다크 모드 토글
|
||||
void toggleTheme(bool isDark) async {
|
||||
_isDarkMode = isDark;
|
||||
notifyListeners(); // 모드 변경을 앱 전체에 알림
|
||||
notifyListeners();
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
prefs.setBool(_darkModeKey, isDark); // 다크 모드 상태 저장
|
||||
prefs.setBool(_darkModeKey, isDark);
|
||||
}
|
||||
|
||||
// 🔽 [신규] 폰트 크기 변경
|
||||
void setTextScale(double scale) async {
|
||||
_textScaleFactor = scale;
|
||||
notifyListeners();
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
prefs.setDouble(_textScaleKey, scale);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user