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