...
This commit is contained in:
@@ -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); // 다크 모드 상태 저장
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user