This commit is contained in:
2025-11-14 18:03:50 +09:00
parent 1f5cea9a96
commit 13ed537b23
342 changed files with 18293 additions and 0 deletions
@@ -0,0 +1,195 @@
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:uuid/uuid.dart';
// 🔽 [신규] 현재 로그인 세션을 담을 모델
class UserSession {
final String userId;
final String? userName;
final String loginProvider; // "guest", "google", "apple"
final String? email;
UserSession({
required this.userId,
this.userName,
this.loginProvider = "guest",
this.email,
});
bool get isGuest => loginProvider == "guest";
}
// 앱-고유 ID와 사용자 이름, 레벨 진행 상황을 관리하는 서비스
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';
final _storage = const FlutterSecureStorage();
/// 🔽 [신규] iOS 앱 간 데이터 공유를 위한 옵션
IOSOptions _getIOSOptions() => const IOSOptions(
// 🔽 [수정] Xcode 설정 전까지 'groupId'를 주석 처리하여 크래시 방지
// groupId: 'group.com.lunaticbum.mygamecenter',
);
AndroidOptions _getAndroidOptions() => const AndroidOptions(
encryptedSharedPreferences: true,
);
// 🔽 [신규] 1. 현재 세션 정보를 '객체'로 가져오기
Future<UserSession> getUserSession() async {
final userId = await getOrCreateUserId(); // 게스트 ID는 항상 보장
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,
);
}
// 2. 앱-고유 ID 가져오기 (없으면 생성)
Future<String> getOrCreateUserId() async {
String? userId = await _storage.read(
key: _userIdKey,
iOptions: _getIOSOptions(),
aOptions: _getAndroidOptions(),
);
if (userId == null) {
userId = const Uuid().v4();
await _storage.write(
key: _userIdKey,
value: userId,
iOptions: _getIOSOptions(),
aOptions: _getAndroidOptions(),
);
}
return userId;
}
// 3. 랭킹에 등록한 사용자 이름 가져오기
Future<String?> getSavedUserName() async {
return await _storage.read(
key: _userNameKey,
iOptions: _getIOSOptions(),
aOptions: _getAndroidOptions(),
);
}
// 4. 랭킹 등록 성공 시, 사용자 이름 저장하기
Future<void> saveUserName(String name) async {
await _storage.write(
key: _userNameKey,
value: name,
iOptions: _getIOSOptions(),
aOptions: _getAndroidOptions(),
);
}
// 🔽 [신규] 5. 소셜 로그인 성공 시 호출 (계정 연결)
Future<UserSession> saveSocialLogin({
required String newUserId, // 서버가 발급한 마스터 계정 ID
required String newUserName,
required String newEmail,
required String provider, // "google" 또는 "apple"
}) 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,
);
}
// 🔽 [신규] 6. 로그아웃 (게스트 계정으로 되돌리기)
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 {
final key = gameType == 'SPIDER' ? _spiderMaxLevelKey : _sudokuMaxLevelKey;
String? levelString = await _storage.read(
key: key,
iOptions: _getIOSOptions(),
aOptions: _getAndroidOptions(),
);
return int.parse(levelString ?? '1'); // 기본값 1
}
// 8. 최대 레벨 저장하기
Future<void> saveMaxUnlockedLevel(int level, {String gameType = 'SUDOKU'}) async {
final key = gameType == 'SPIDER' ? _spiderMaxLevelKey : _sudokuMaxLevelKey;
await _storage.write(
key: key,
value: level.toString(),
iOptions: _getIOSOptions(),
aOptions: _getAndroidOptions(),
);
}
// 9. 마지막 랭킹 맵 가져오기
Future<Map<int, int>> getLastSavedRankMap({String gameType = 'SUDOKU'}) async {
final key = gameType == 'SPIDER' ? _spiderRankMapKey : _sudokuRankMapKey;
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));
} catch (e) {
return {};
}
}
// 10. 마지막 랭킹 맵 저장하기
Future<void> saveLastRankMap(Map<int, int> rankMap, {String gameType = 'SUDOKU'}) async {
final key = gameType == 'SPIDER' ? _spiderRankMapKey : _sudokuRankMapKey;
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(),
);
}
}
@@ -0,0 +1,102 @@
import 'dart:convert';
import 'dart:developer';
import 'package:http/http.dart' as http;
import 'package:service_api/models/sudoku_game_dto.dart';
import 'package:service_api/models/unified_rank_dto.dart';
import 'package:service_api/models/game_rank_dto.dart';
class PuzzleService {
final String _baseUrl = "https://lunaticbum.kr";
// 🔽 [수정] 'difficulty' 파라미터 1개만 받음 (1~11)
Future<SudokuGameDto> startGame(String difficulty) async {
final response = await http.get(
// 🔽 [수정] 'difficulty' 파라미터만 전달
Uri.parse('$_baseUrl/puzzle/sudoku/start?difficulty=$difficulty'),
);
if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes));
return SudokuGameDto.fromJson(data);
} else {
throw Exception('게임 로딩 실패: ${response.statusCode}');
}
}
// 'puzzleId'를 받아 검증 (서버 DTO와 일치)
Future<bool> validateSolution(int puzzleId, String answer) async {
final response = await http.post(
Uri.parse('$_baseUrl/puzzle/sudoku/validate'),
headers: {'Content-Type': 'application/json'},
body: jsonEncode({
'puzzleId': puzzleId,
'answer': answer,
}),
);
if (response.statusCode == 200) {
return jsonDecode(response.body)['correct'] ?? false;
} else {
log("정답 확인 실패: ${response.statusCode}");
log("응답 본문: ${response.body}");
throw Exception('정답 확인 실패: ${response.statusCode}');
}
}
// 🔽 [전체 수정] submitRank 함수
// 반환 타입이 Future<RankSubmissionResult>로 변경되었습니다.
Future<RankSubmissionResult> submitRank(UnifiedRankDto rankDto) async {
final requestBody = jsonEncode(rankDto.toJson());
log(">>> 랭킹 등록 요청: $requestBody");
final response = await http.post(
// 🔽 [수정] API 경로가 /api/ranks/submit
Uri.parse('$_baseUrl/api/ranks/submit'),
headers: {'Content-Type': 'application/json'},
body: requestBody,
);
// 🔽 [수정] 성공(200) 시, 서버가 반환한 RankSubmissionResult 객체를 파싱
if (response.statusCode == 200) {
log("<<< 랭킹 등록 성공: 200 OK (RankSubmissionResult 반환됨)");
try {
final Map<String, dynamic> data = jsonDecode(utf8.decode(response.bodyBytes));
// 🔽 [수정] RankSubmissionResult.fromJson으로 파싱
return RankSubmissionResult.fromJson(data);
} catch (e) {
log("<<< 랭킹 등록 성공했으나, 반환된 랭킹 목록 파싱 실패: $e");
throw Exception('랭킹 목록 파싱 실패: $e');
}
}
// 🔽 [수정] 실패 시, 기존 로직과 동일하게 에러 처리
else {
log("<<< 랭킹 등록 실패: ${response.statusCode}");
try {
final errorBody = utf8.decode(response.bodyBytes);
log("<<< 서버 에러 메시지: $errorBody");
throw Exception(errorBody);
} catch (e) {
throw Exception('랭킹 등록 실패: ${response.reasonPhrase}');
}
}
}
// 랭킹 조회
Future<List<GameRankDto>> fetchRanks(String gameType, String? contextId) async {
final queryParams = {
'gameType': gameType,
if (contextId != null) 'contextId': contextId,
};
final uri = Uri.parse('$_baseUrl/api/ranks/list').replace(queryParameters: queryParams);
final response = await http.get(uri);
if (response.statusCode == 200) {
final List<dynamic> data = jsonDecode(utf8.decode(response.bodyBytes));
return data.map((json) => GameRankDto.fromJson(json)).toList();
} else {
throw Exception('랭킹 로딩 실패');
}
}
}
@@ -0,0 +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 'identity_service.dart';
import 'puzzle_service.dart';
class SessionNotifier with ChangeNotifier {
final IdentityService _identityService = IdentityService();
final PuzzleService _puzzleService = PuzzleService();
UserSession? _session;
UserSession? get session => _session;
bool get isLoading => _session == null;
bool get isGuest => _session?.isGuest ?? true;
// 🔽 [수정] '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;
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)해야 합니다.
// --- 백엔드 응답 (임시 시뮬레이션) ---
// 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();
} catch (e) {
debugPrint("$provider 로그인 오류: $e");
// [TODO] 유저에게 "로그인에 실패했습니다." 스낵바 표시
}
}
/// 로그아웃
Future<void> logout() async {
await _googleSignIn.signOut();
_session = await _identityService.logout();
notifyListeners();
}
}
@@ -0,0 +1,94 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
// 1. 앱에서 사용할 색상표 정의
final Map<String, MaterialColor> appColors = {
'Blue': Colors.blue,
'Green': Colors.green,
'Red': Colors.red,
'Purple': Colors.purple,
'Orange': Colors.orange,
'Teal': Colors.teal,
};
class ThemeNotifier with ChangeNotifier {
final String _themeKey = 'selected_theme';
final String _darkModeKey = 'is_dark_mode'; // 다크 모드 저장 키
MaterialColor _currentColor = Colors.blue; // 기본값
bool _isDarkMode = false; // 다크 모드 상태 변수
// --- Getters ---
// 라이트 모드용 테마
ThemeData get currentTheme => ThemeData(
// 🔽 [수정] M3의 권장 방식인 ColorScheme.fromSeed 사용
colorScheme: ColorScheme.fromSeed(
seedColor: _currentColor, // 👈 _currentColor를 '씨앗색'으로 사용
brightness: Brightness.light,
),
useMaterial3: true,
brightness: Brightness.light,
// 🔺 'primarySwatch' 속성 제거
);
// 다크 모드용 테마
ThemeData get currentDarkTheme => ThemeData(
// 🔽 [수정] 다크 모드에도 동일하게 적용
colorScheme: ColorScheme.fromSeed(
seedColor: _currentColor, // 👈 _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 ---
ThemeNotifier() {
_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;
notifyListeners(); // 설정 로드 후 UI 갱신
}
// 새 테마 색상 설정
void setTheme(String themeName) async {
final newColor = appColors[themeName];
if (newColor == null) return;
_currentColor = newColor;
notifyListeners(); // 테마 변경을 앱 전체에 알림
final prefs = await SharedPreferences.getInstance();
prefs.setString(_themeKey, themeName); // 선택한 테마 이름 저장
}
// 다크 모드 토글
void toggleTheme(bool isDark) async {
_isDarkMode = isDark;
notifyListeners(); // 모드 변경을 앱 전체에 알림
final prefs = await SharedPreferences.getInstance();
prefs.setBool(_darkModeKey, isDark); // 다크 모드 상태 저장
}
}