...
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
/pubspec.lock
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
/build/
|
||||
/coverage/
|
||||
@@ -0,0 +1,10 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "adc901062556672b4138e18a4dc62a4be8f4b3c2"
|
||||
channel: "stable"
|
||||
|
||||
project_type: package
|
||||
@@ -0,0 +1,3 @@
|
||||
## 0.0.1
|
||||
|
||||
* TODO: Describe initial release.
|
||||
@@ -0,0 +1 @@
|
||||
TODO: Add your license here.
|
||||
@@ -0,0 +1,39 @@
|
||||
<!--
|
||||
This README describes the package. If you publish this package to pub.dev,
|
||||
this README's contents appear on the landing page for your package.
|
||||
|
||||
For information about how to write a good package README, see the guide for
|
||||
[writing package pages](https://dart.dev/tools/pub/writing-package-pages).
|
||||
|
||||
For general information about developing packages, see the Dart guide for
|
||||
[creating packages](https://dart.dev/guides/libraries/create-packages)
|
||||
and the Flutter guide for
|
||||
[developing packages and plugins](https://flutter.dev/to/develop-packages).
|
||||
-->
|
||||
|
||||
TODO: Put a short description of the package here that helps potential users
|
||||
know whether this package might be useful for them.
|
||||
|
||||
## Features
|
||||
|
||||
TODO: List what your package can do. Maybe include images, gifs, or videos.
|
||||
|
||||
## Getting started
|
||||
|
||||
TODO: List prerequisites and provide or point to information on how to
|
||||
start using the package.
|
||||
|
||||
## Usage
|
||||
|
||||
TODO: Include short and useful examples for package users. Add longer examples
|
||||
to `/example` folder.
|
||||
|
||||
```dart
|
||||
const like = 'sample';
|
||||
```
|
||||
|
||||
## Additional information
|
||||
|
||||
TODO: Tell users more about the package: where to find more information, how to
|
||||
contribute to the package, how to file issues, what response they can expect
|
||||
from the package authors, and more.
|
||||
@@ -0,0 +1,4 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
@@ -0,0 +1,13 @@
|
||||
// packages/service_api/lib/models/game_difficulty.dart
|
||||
class GameDifficulty {
|
||||
/// 랭킹 Dropdown에 표시될 이름 (예: "중급 (9x9)", "1 Suit (Easy)")
|
||||
final String name;
|
||||
|
||||
/// API 조회 시 사용할 랭킹 ID (예: "SUDOKU_9x9_L2", "1_SUITS_4-3")
|
||||
final String contextId;
|
||||
|
||||
const GameDifficulty({
|
||||
required this.name,
|
||||
required this.contextId,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// lib/models/game_level.dart
|
||||
|
||||
// 11단계 레벨의 모든 속성을 정의하는 클래스
|
||||
class GameLevel {
|
||||
final int levelIndex; // 1-11
|
||||
final String name; // "입문 (4x4)"
|
||||
final int blockSize; // 2, 3, 4
|
||||
final int generatorLevel; // 서버에 요청할 생성기 난이도 (1~5)
|
||||
final String contextId; // 랭킹 ID "SUDOKU_4x4_L1"
|
||||
|
||||
// 🔽 [신규] 테마 정책
|
||||
final bool isSequentialNumbers; // L1, L4, L9 (숫자 고정)
|
||||
final bool isSequentialLetters; // L2, L5, L10 (문자 고정)
|
||||
// (둘 다 false이면 HomeScreen에서 선택한 랜덤 테마 사용)
|
||||
|
||||
const GameLevel({
|
||||
required this.levelIndex,
|
||||
required this.name,
|
||||
required this.blockSize,
|
||||
required this.generatorLevel,
|
||||
required this.contextId,
|
||||
this.isSequentialNumbers = false,
|
||||
this.isSequentialLetters = false,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// lib/models/game_rank_dto.dart
|
||||
|
||||
class GameRankDto {
|
||||
final String playerName;
|
||||
final int primaryScore; // 시간 (초)
|
||||
final int? secondaryScore; // 점수 (저장된 값, 예: 0~4)
|
||||
|
||||
GameRankDto({
|
||||
required this.playerName,
|
||||
required this.primaryScore,
|
||||
this.secondaryScore
|
||||
});
|
||||
|
||||
factory GameRankDto.fromJson(Map<String, dynamic> json) {
|
||||
return GameRankDto(
|
||||
playerName: json['playerName'],
|
||||
primaryScore: (json['primaryScore'] as num).toInt(),
|
||||
secondaryScore: (json['secondaryScore'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 🔽 [신규 추가] 나의 랭킹 + 순위(숫자)를 담는 DTO
|
||||
class GameRankWithRankNumber {
|
||||
final GameRankDto rankData;
|
||||
final int rankNumber;
|
||||
|
||||
GameRankWithRankNumber({
|
||||
required this.rankData,
|
||||
required this.rankNumber,
|
||||
});
|
||||
|
||||
factory GameRankWithRankNumber.fromJson(Map<String, dynamic> json) {
|
||||
return GameRankWithRankNumber(
|
||||
rankData: GameRankDto.fromJson(json['rankData']),
|
||||
rankNumber: (json['rankNumber'] as num).toInt(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 🔽 [신규 추가] 랭킹 등록 시 서버가 반환하는 최종 DTO
|
||||
class RankSubmissionResult {
|
||||
final List<GameRankDto> topRanks; // 상위 10개 랭킹
|
||||
final GameRankWithRankNumber? myRank; // 나의 랭킹 정보 (순위 포함)
|
||||
|
||||
RankSubmissionResult({
|
||||
required this.topRanks,
|
||||
this.myRank,
|
||||
});
|
||||
|
||||
factory RankSubmissionResult.fromJson(Map<String, dynamic> json) {
|
||||
// topRanks 파싱
|
||||
final List<dynamic> topRanksJson = json['topRanks'] ?? [];
|
||||
final List<GameRankDto> topRanksList = topRanksJson
|
||||
.map((item) => GameRankDto.fromJson(item))
|
||||
.toList();
|
||||
|
||||
// myRank 파싱 (null일 수 있음)
|
||||
final Map<String, dynamic>? myRankJson = json['myRank'];
|
||||
final GameRankWithRankNumber? myRankData =
|
||||
myRankJson != null ? GameRankWithRankNumber.fromJson(myRankJson) : null;
|
||||
|
||||
return RankSubmissionResult(
|
||||
topRanks: topRanksList,
|
||||
myRank: myRankData,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// lib/models/sudoku_game_dto.dart
|
||||
|
||||
class SudokuGameDto {
|
||||
final int puzzleId; // 👈 [추가] 서버에서 보낸 ID
|
||||
final String question;
|
||||
final String solution;
|
||||
final int blockSize;
|
||||
final int gridSize;
|
||||
|
||||
SudokuGameDto({
|
||||
required this.puzzleId, // 👈 [추가]
|
||||
required this.question,
|
||||
required this.solution,
|
||||
required this.blockSize,
|
||||
}) : gridSize = blockSize * blockSize;
|
||||
|
||||
factory SudokuGameDto.fromJson(Map<String, dynamic> json) {
|
||||
int bs = json['blockSize'] ?? 3;
|
||||
return SudokuGameDto(
|
||||
puzzleId: json['puzzleId'], // 👈 [추가] 서버의 puzzleId 매핑
|
||||
question: json['question'],
|
||||
solution: json['solution'],
|
||||
blockSize: bs,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// lib/models/sudoku_theme.dart
|
||||
|
||||
// 1. SudokuTheme 클래스
|
||||
// '게임 시작' 시점에 동적으로 생성될 객체입니다.
|
||||
class SudokuTheme {
|
||||
final String name; // "숫자", "알파벳", "과일"
|
||||
final List<String> symbols; // 👈 '게임에 실제 사용할' 무작위로 뽑힌 기호 리스트
|
||||
|
||||
const SudokuTheme({required this.name, required this.symbols});
|
||||
|
||||
// 1-based 정수(1)를 테마 기호("🍎")로 변환
|
||||
String getSymbol(int value) {
|
||||
if (value > 0 && value <= symbols.length) {
|
||||
return symbols[value - 1]; // 1 -> index 0
|
||||
}
|
||||
return '?';
|
||||
}
|
||||
|
||||
// 테마 기호("🍎")를 1-based 정수(1)로 변환
|
||||
int getValue(String symbol) {
|
||||
int index = symbols.indexOf(symbol);
|
||||
if (index != -1) {
|
||||
return index + 1; // index 0 -> 1
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. AppThemes 클래스 (테마 저장소 역할)
|
||||
class AppThemes {
|
||||
|
||||
// --- 테마 이름 정의 ---
|
||||
static const String random = "랜덤";
|
||||
static const String numbers = "숫자";
|
||||
static const String letters = "알파벳";
|
||||
static const String fruits = "과일";
|
||||
static const String korean = "한글";
|
||||
static const String animals = "동물";
|
||||
|
||||
// --- 1. 거대한 '상징 풀' 정의 (25개 이상) ---
|
||||
static const List<String> _numberPool = [
|
||||
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16",
|
||||
"17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30"
|
||||
];
|
||||
|
||||
static const List<String> _letterPool = [
|
||||
"A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P",
|
||||
"Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"
|
||||
];
|
||||
|
||||
static const List<String> _fruitPool = [
|
||||
"🍎", "🍌", "🍇", "🍓", "🍊", "🍋", "🍉", "🍑", "🍒", "🥝", "🥥", "🍍", "🥑", "🍆", "🍅", "🌽",
|
||||
"🥕", "🫑", "🌶️", "🥦", "🥬", "🥒", "🍄", "🥜", "🫘", "🍏", "🍐", "🍈", "🥭", "🫒"
|
||||
];
|
||||
|
||||
static const List<String> _koreanPool = [
|
||||
"가", "나", "다", "라", "마", "바", "사", "아", "자", "차", "카", "타", "파", "하", "고", "노",
|
||||
"도", "로", "모", "보", "소", "오", "조", "초", "코", "구", "누", "두", "루", "무"
|
||||
];
|
||||
|
||||
static const List<String> _animalPool = [
|
||||
"🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯", "🦁", "🐮", "🐷", "🐸", "🐵", "🐔",
|
||||
"🐧", "🐦", "🐤", "🦆", "🦅", "🦉", "🦇", "🐺", "🐗", "🐴", "🦄", "🐝", "🐛", "🦋"
|
||||
];
|
||||
|
||||
// --- 2. 홈 화면 '선택' 메뉴에 표시될 이름 리스트 ---
|
||||
static final List<String> selectableThemeNames = [
|
||||
random,
|
||||
numbers,
|
||||
letters,
|
||||
fruits,
|
||||
korean,
|
||||
animals
|
||||
];
|
||||
|
||||
// --- 3. 테마 이름과 실제 '상징 풀'을 매핑 ---
|
||||
static final Map<String, List<String>> _themePools = {
|
||||
numbers: _numberPool,
|
||||
letters: _letterPool,
|
||||
fruits: _fruitPool,
|
||||
korean: _koreanPool,
|
||||
animals: _animalPool,
|
||||
};
|
||||
|
||||
// --- 4. [핵심] 게임 시작 시 호출될 테마 '빌더' 함수 ---
|
||||
static SudokuTheme buildGameTheme(String themeName, int gridSize, {bool isEasyMode = false}) { // 👈 [수정]
|
||||
String effectiveThemeName = themeName;
|
||||
|
||||
if (themeName == random) {
|
||||
final actualThemes = _themePools.keys.toList();
|
||||
effectiveThemeName = (actualThemes..shuffle()).first;
|
||||
}
|
||||
|
||||
final List<String> pool = _themePools[effectiveThemeName] ?? _numberPool;
|
||||
|
||||
if (pool.length < gridSize) {
|
||||
throw Exception("$effectiveThemeName 테마의 상징이 ${pool.length}개뿐입니다. $gridSize개가 필요합니다.");
|
||||
}
|
||||
|
||||
List<String> selectedSymbols;
|
||||
|
||||
// 🔽 [수정] 'isEasyMode'가 true이면 섞지 않고 순서대로 뽑음
|
||||
if (isEasyMode) {
|
||||
// (예: 4x4 Easy -> 1,2,3,4 또는 A,B,C,D)
|
||||
selectedSymbols = pool.sublist(0, gridSize);
|
||||
} else {
|
||||
// 그 외: 거대 풀을 섞은 뒤, gridSize만큼 뽑음
|
||||
selectedSymbols = (pool.toList()..shuffle()).sublist(0, gridSize);
|
||||
}
|
||||
|
||||
return SudokuTheme(
|
||||
name: effectiveThemeName,
|
||||
symbols: selectedSymbols,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// lib/models/unified_rank_dto.dart
|
||||
|
||||
class UnifiedRankDto {
|
||||
final String userId; // 👈 [수정] 앱-고유 ID
|
||||
final String gameType;
|
||||
final String? contextId;
|
||||
final String playerName;
|
||||
final int primaryScore;
|
||||
final int? secondaryScore;
|
||||
|
||||
UnifiedRankDto({
|
||||
required this.userId, // 👈 [수정] 생성자에 추가
|
||||
required this.gameType,
|
||||
this.contextId,
|
||||
required this.playerName,
|
||||
required this.primaryScore,
|
||||
this.secondaryScore,
|
||||
});
|
||||
|
||||
// Dart 객체를 JSON으로 변환 (서버 전송용)
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'userId': userId, // 👈 [수정]
|
||||
'gameType': gameType,
|
||||
'contextId': contextId,
|
||||
'playerName': playerName,
|
||||
'primaryScore': primaryScore,
|
||||
'secondaryScore': secondaryScore,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
class ValidateResultDto {
|
||||
final bool isCorrect;
|
||||
|
||||
ValidateResultDto({required this.isCorrect});
|
||||
|
||||
factory ValidateResultDto.fromJson(Map<String, dynamic> json) {
|
||||
return ValidateResultDto(
|
||||
isCorrect: json['correct'] ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
// packages/service_api/lib/service_api.dart
|
||||
|
||||
// Models
|
||||
export 'models/game_difficulty.dart'; // 👈 [추가]
|
||||
export 'models/game_rank_dto.dart';
|
||||
export 'models/sudoku_game_dto.dart';
|
||||
export 'models/sudoku_theme.dart';
|
||||
export 'models/unified_rank_dto.dart';
|
||||
export 'models/validate_result_dto.dart';
|
||||
// ❌ (game_level.dart는 여기서 삭제)
|
||||
|
||||
// Services
|
||||
export 'services/identity_service.dart';
|
||||
export 'services/puzzle_service.dart';
|
||||
export 'services/theme_notifier.dart';
|
||||
export 'services/session_notifier.dart';
|
||||
@@ -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); // 다크 모드 상태 저장
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
name: service_api
|
||||
description: All shared services, models, and API logic for the game center.
|
||||
version: 1.0.0
|
||||
publish_to: 'none' # 모노레포 내부용 패키지이므로 게시 안 함
|
||||
resolution: workspace
|
||||
environment:
|
||||
sdk: '^3.9.2'
|
||||
flutter: '>=3.10.0' # 👈 ThemeNotifier가 Flutter SDK를 필요로 함
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
# 1. API 통신용
|
||||
http: ^1.0.0 # (PuzzleService가 사용)
|
||||
|
||||
# 2. 로컬 저장소용
|
||||
shared_preferences: ^2.0.0 # (IdentityService가 사용)
|
||||
uuid: ^4.0.0 # (IdentityService가 사용)
|
||||
flutter_secure_storage: ^9.0.0 # (버전은 최신 버전을 확인하세요)
|
||||
google_sign_in: ^7.2.0
|
||||
sign_in_with_apple: ^7.0.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
lints: ^3.0.0
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:service_api/service_api.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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user