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,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);
}
}