...
This commit is contained in:
parent
5a0785f262
commit
3b6328cc4c
@ -1,7 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sudoku_app/models/game_level.dart'; // 👈 [추가] 레벨 모델 임포트
|
||||
import 'package:sudoku_app/models/game_level.dart';
|
||||
import 'package:sudoku_app/models/sudoku_game_dto.dart';
|
||||
import 'package:sudoku_app/models/sudoku_theme.dart';
|
||||
import 'package:sudoku_app/models/unified_rank_dto.dart';
|
||||
@ -20,7 +20,7 @@ class GameScreen extends StatefulWidget {
|
||||
final String themeName;
|
||||
final String userId;
|
||||
final String? userName;
|
||||
final int levelIndex; // 👈 HomeScreen에서 전달받은 현재 레벨
|
||||
final int levelIndex;
|
||||
|
||||
const GameScreen({
|
||||
super.key,
|
||||
@ -39,12 +39,11 @@ class _GameScreenState extends State<GameScreen> {
|
||||
final PuzzleService _puzzleService = PuzzleService();
|
||||
final IdentityService _identityService = IdentityService();
|
||||
|
||||
late final GameLevel currentLevel; // 👈 현재 레벨 정보
|
||||
late final GameLevel currentLevel;
|
||||
late final int blockSize;
|
||||
late final int gridSize;
|
||||
late final SudokuTheme activeTheme; // 👈 이번 게임의 '동적' 테마
|
||||
late final SudokuTheme activeTheme;
|
||||
|
||||
// 모든 내부 로직은 'int'로 관리
|
||||
late List<int> puzzleCells;
|
||||
late List<int> solutionCells;
|
||||
late List<int> originalCells;
|
||||
@ -60,7 +59,9 @@ class _GameScreenState extends State<GameScreen> {
|
||||
_RankSubmissionStep _rankStep = _RankSubmissionStep.enterName;
|
||||
List<GameRankDto> _rankingList = [];
|
||||
String _submittedPlayerName = "";
|
||||
|
||||
|
||||
// 줌 컨트롤러
|
||||
late final TransformationController _transformationController;
|
||||
|
||||
// "A" -> 10 (파싱용)
|
||||
int _charToInt(String char) {
|
||||
@ -85,31 +86,28 @@ class _GameScreenState extends State<GameScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 1. HomeScreen에서 받은 levelIndex로 현재 레벨 정보 로드
|
||||
currentLevel = AppLevels.getLevel(widget.levelIndex);
|
||||
blockSize = currentLevel.blockSize;
|
||||
gridSize = blockSize * blockSize;
|
||||
|
||||
// 2. 🔽 [수정] 테마 정책 적용
|
||||
String themeForThisGame = widget.themeName;
|
||||
_transformationController = TransformationController();
|
||||
|
||||
// 🔽 [수정] 'isEasyMode' 변수를 'GameLevel'의 속성들로 조합
|
||||
// 🔽 [수정] 'isEasyMode'를 올바르게 계산하여 전달
|
||||
String themeForThisGame = widget.themeName;
|
||||
bool isEasyMode = currentLevel.isSequentialNumbers || currentLevel.isSequentialLetters;
|
||||
|
||||
if (currentLevel.isSequentialNumbers) {
|
||||
themeForThisGame = AppThemes.numbers; // 👈 숫자 테마 강제
|
||||
themeForThisGame = AppThemes.numbers;
|
||||
} else if (currentLevel.isSequentialLetters) {
|
||||
themeForThisGame = AppThemes.letters; // 👈 알파벳 테마 강제
|
||||
themeForThisGame = AppThemes.letters;
|
||||
}
|
||||
|
||||
// 3. '이번 게임'의 테마 객체 생성
|
||||
|
||||
activeTheme = AppThemes.buildGameTheme(
|
||||
themeForThisGame,
|
||||
gridSize,
|
||||
isEasyMode: isEasyMode, // 👈 [수정] 조합된 bool 값 전달
|
||||
isEasyMode: isEasyMode, // 👈 수정된 bool 값
|
||||
);
|
||||
|
||||
// 4. 서버 데이터(String)를 내부 로직(int)으로 변환
|
||||
puzzleCells = widget.gameData.question.split('').map(_charToInt).toList();
|
||||
solutionCells = widget.gameData.solution.split('').map(_charToInt).toList();
|
||||
originalCells = widget.gameData.question.split('').map(_charToInt).toList();
|
||||
@ -120,6 +118,7 @@ class _GameScreenState extends State<GameScreen> {
|
||||
@override
|
||||
void dispose() {
|
||||
timer?.cancel();
|
||||
_transformationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@ -213,6 +212,7 @@ class _GameScreenState extends State<GameScreen> {
|
||||
selectedIndex = null;
|
||||
selectedNumberPad = null;
|
||||
score = 5;
|
||||
_resetBoardZoom();
|
||||
|
||||
timer?.cancel();
|
||||
secondsElapsed = 0;
|
||||
@ -224,8 +224,11 @@ class _GameScreenState extends State<GameScreen> {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
void _resetBoardZoom() {
|
||||
_transformationController.value = Matrix4.identity();
|
||||
}
|
||||
|
||||
|
||||
// 정답 확인 API 호출
|
||||
Future<void> _validateGame() async {
|
||||
if (isValidating) return;
|
||||
setState(() { isValidating = true; });
|
||||
@ -263,9 +266,9 @@ class _GameScreenState extends State<GameScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// 랭킹 등록 팝업 (2단계 UI + 레벨 잠금 해제)
|
||||
void _showRankingDialog() {
|
||||
final nameController = TextEditingController(text: widget.userName);
|
||||
// (상태 변수들은 변경 없음)
|
||||
bool isSubmitting = false;
|
||||
String? dialogErrorMessage;
|
||||
|
||||
@ -282,6 +285,7 @@ class _GameScreenState extends State<GameScreen> {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setDialogState) {
|
||||
|
||||
// (위젯 빌드 로직은 변경 없음)
|
||||
Widget closeButton = TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
@ -359,8 +363,12 @@ class _GameScreenState extends State<GameScreen> {
|
||||
secondaryScore: (5 - score),
|
||||
);
|
||||
|
||||
// 🔽 [수정] 여기가 핵심입니다.
|
||||
try {
|
||||
await _puzzleService.submitRank(rankDto);
|
||||
// 1. submitRank가 이제 랭킹 리스트를 직접 반환합니다.
|
||||
final List<GameRankDto> ranks = await _puzzleService.submitRank(rankDto);
|
||||
|
||||
// 2. 랭킹 등록 성공 후 로컬 작업 수행
|
||||
await _identityService.saveUserName(playerName);
|
||||
|
||||
final int currentMaxLevel = await _identityService.getMaxUnlockedLevel();
|
||||
@ -375,10 +383,12 @@ class _GameScreenState extends State<GameScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
final ranks = await _puzzleService.fetchRanks('SUDOKU', contextId);
|
||||
// 3. 🔽 [삭제] 별도로 랭킹을 다시 불러올 필요가 없습니다.
|
||||
// final ranks = await _puzzleService.fetchRanks('SUDOKU', contextId);
|
||||
|
||||
// 4. submitRank가 반환한 랭킹 리스트로 즉시 UI 업데이트
|
||||
setDialogState(() {
|
||||
_rankingList = ranks;
|
||||
_rankingList = ranks; // 👈 반환된 랭킹 리스트 사용
|
||||
_rankStep = _RankSubmissionStep.showList;
|
||||
});
|
||||
|
||||
@ -393,6 +403,9 @@ class _GameScreenState extends State<GameScreen> {
|
||||
child: const Text('랭킹 등록'),
|
||||
);
|
||||
|
||||
// (dialogContent, dialogActions 등 나머지 로직은 변경 없음)
|
||||
// ...
|
||||
// ...
|
||||
Widget dialogContent;
|
||||
if (_rankStep == _RankSubmissionStep.showList) {
|
||||
dialogContent = rankListWidget;
|
||||
@ -435,7 +448,6 @@ class _GameScreenState extends State<GameScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// 랭킹 ID 생성을 위해, 원본 문제의 빈칸 비율로 레벨(1~5)을 역산
|
||||
int _difficultyLevel(String question) {
|
||||
int holes = question.split('').where((c) => c == '0').length;
|
||||
double holeRatio = holes / (gridSize * gridSize);
|
||||
@ -482,6 +494,7 @@ class _GameScreenState extends State<GameScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// 세로 모드(일반 폰) 레이아웃
|
||||
Widget _buildPortraitLayout(BuildContext context, Map<int, int> numberCounts, BoxConstraints constraints, String formattedTime) {
|
||||
final double boardWidth = (constraints.maxWidth > 600) ? 600 : constraints.maxWidth;
|
||||
|
||||
@ -492,19 +505,22 @@ class _GameScreenState extends State<GameScreen> {
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 0),
|
||||
child: _buildGameInfoWidget(formattedTime),
|
||||
child: _buildGameInfoWidget(formattedTime), // 👈 상단 컨트롤 바
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0), // 👈 보드/패드 상하 패딩
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildSudokuBoardWidget(),
|
||||
_buildSudokuBoardWidget(), // 👈 줌 기능
|
||||
const SizedBox(height: 15),
|
||||
_buildNumberPadWidget(context, numberCounts, isLandscape: false, boardWidth: boardWidth),
|
||||
Padding( // 👈 하단 컨트롤 패널에만 좌우 패딩
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: _buildControlPanelWidget(context, numberCounts, isLandscape: false, boardWidth: boardWidth),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -517,29 +533,35 @@ class _GameScreenState extends State<GameScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// 가로 모드(폴더블/태블릿) 레이아웃
|
||||
Widget _buildLandscapeLayout(BuildContext context, Map<int, int> numberCounts, BoxConstraints constraints, String formattedTime) {
|
||||
|
||||
const double infoBarHeight = 60.0;
|
||||
// 🔽 [수정] 보드 너비는 (사용 가능 높이 - 상단 컨트롤바 높이 - 상하 패딩)
|
||||
double boardWidth = constraints.maxHeight - infoBarHeight - 32.0;
|
||||
|
||||
// 🔽 [수정] 컨트롤 패널 너비 계산
|
||||
double controlPanelWidth;
|
||||
const double numberPadScaleRatio = 0.6;
|
||||
double padWidth = boardWidth * numberPadScaleRatio;
|
||||
|
||||
if (padWidth < 200) padWidth = 200;
|
||||
if (padWidth > 350) padWidth = 350;
|
||||
controlPanelWidth = padWidth + 100; // 100 for buttons
|
||||
|
||||
double totalWidth = boardWidth + (padWidth + 100) + 16.0;
|
||||
if (totalWidth > (constraints.maxWidth - 32.0)) {
|
||||
// 🔽 [수정] 화면 너비에 맞게 전체 크기 동적 조절
|
||||
double totalWidth = boardWidth + controlPanelWidth + 16.0; // 16 for spacing
|
||||
if (totalWidth > (constraints.maxWidth - 32.0)) { // 32 for screen padding
|
||||
double scale = (constraints.maxWidth - 32.0) / totalWidth;
|
||||
boardWidth *= scale;
|
||||
padWidth *= scale;
|
||||
controlPanelWidth *= scale;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildGameInfoWidget(formattedTime),
|
||||
_buildGameInfoWidget(formattedTime), // 👈 상단 컨트롤 바
|
||||
Expanded(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
@ -547,15 +569,15 @@ class _GameScreenState extends State<GameScreen> {
|
||||
children: [
|
||||
SizedBox(
|
||||
width: boardWidth,
|
||||
child: _buildSudokuBoardWidget(),
|
||||
child: _buildSudokuBoardWidget(), // 👈 줌 기능
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
SizedBox(
|
||||
width: padWidth + 100,
|
||||
width: controlPanelWidth, // 👈 계산된 너비
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildNumberPadWidget(context, numberCounts, isLandscape: true, boardWidth: boardWidth),
|
||||
_buildControlPanelWidget(context, numberCounts, isLandscape: true, boardWidth: boardWidth),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -568,6 +590,7 @@ class _GameScreenState extends State<GameScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// 🔽 [수정] 상단 정보 (점수, 시간만)
|
||||
Widget _buildGameInfoWidget(String formattedTime) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
@ -575,48 +598,48 @@ class _GameScreenState extends State<GameScreen> {
|
||||
children: [
|
||||
Text('SCORE: $score', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
Text(formattedTime, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: onHintTapped,
|
||||
icon: const Icon(Icons.lightbulb_outline, color: Colors.orange),
|
||||
iconSize: 30,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: onUndoTapped,
|
||||
icon: const Icon(Icons.undo, color: Colors.red),
|
||||
iconSize: 30,
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 게임 보드 (줌 기능 + 롱프레스 리셋)
|
||||
Widget _buildSudokuBoardWidget() {
|
||||
return SudokuBoard(
|
||||
blockSize: blockSize,
|
||||
theme: activeTheme,
|
||||
cells: puzzleCells,
|
||||
originalCells: originalCells,
|
||||
selectedIndex: selectedIndex,
|
||||
selectedNumberPad: selectedNumberPad,
|
||||
incorrectCells: incorrectCells,
|
||||
onCellTapped: onCellTapped,
|
||||
return GestureDetector( // 👈 롱프레스 감지
|
||||
onLongPress: _resetBoardZoom,
|
||||
child: InteractiveViewer(
|
||||
transformationController: _transformationController, // 👈 컨트롤러 연결
|
||||
boundaryMargin: const EdgeInsets.all(20.0),
|
||||
minScale: 1.0,
|
||||
maxScale: 2.5,
|
||||
child: SudokuBoard(
|
||||
blockSize: blockSize,
|
||||
theme: activeTheme,
|
||||
cells: puzzleCells,
|
||||
originalCells: originalCells,
|
||||
selectedIndex: selectedIndex,
|
||||
selectedNumberPad: selectedNumberPad,
|
||||
incorrectCells: incorrectCells,
|
||||
onCellTapped: onCellTapped,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNumberPadWidget(BuildContext context, Map<int, int> numberCounts, {required bool isLandscape, required double boardWidth}) {
|
||||
// 🔽 [수정] 하단 컨트롤 패널 (넘버패드 + 모든 버튼)
|
||||
Widget _buildControlPanelWidget(BuildContext context, Map<int, int> numberCounts, {required bool isLandscape, required double boardWidth}) {
|
||||
const double numberPadScaleRatio = 0.6;
|
||||
double? padMaxWidth;
|
||||
|
||||
if (!isLandscape) {
|
||||
// 세로 모드: (보드 너비 * 0.6)
|
||||
padMaxWidth = boardWidth * numberPadScaleRatio;
|
||||
} else {
|
||||
padMaxWidth = null;
|
||||
// 가로 모드: (보드 너비 * 0.6) -> 가로모드에서도 비율 유지
|
||||
padMaxWidth = boardWidth * numberPadScaleRatio;
|
||||
if (padMaxWidth < 200) padMaxWidth = 200; // 최소 너비
|
||||
}
|
||||
|
||||
// 1. 숫자 그리드
|
||||
Widget numberPadGrid = ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: padMaxWidth ?? double.infinity),
|
||||
child: NumberPad(
|
||||
@ -629,19 +652,47 @@ class _GameScreenState extends State<GameScreen> {
|
||||
),
|
||||
);
|
||||
|
||||
Widget quitButton = IconButton(
|
||||
icon: Icon(Icons.close, color: Colors.red.shade700, size: 30),
|
||||
onPressed: _onQuitGameTapped,
|
||||
tooltip: "게임 종료",
|
||||
// 2. 왼쪽 버튼 (종료, 다시하기)
|
||||
Widget leftButtons = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.close, color: Colors.red.shade700, size: 30),
|
||||
onPressed: _onQuitGameTapped,
|
||||
tooltip: "게임 종료",
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.refresh, color: Colors.blue.shade700, size: 30),
|
||||
onPressed: _onRestartGameTapped,
|
||||
tooltip: "다시하기",
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget restartButton = IconButton(
|
||||
icon: Icon(Icons.refresh, color: Colors.blue.shade700, size: 30),
|
||||
onPressed: _onRestartGameTapped,
|
||||
tooltip: "다시하기",
|
||||
// 3. 오른쪽 버튼 (힌트, 되돌리기)
|
||||
Widget rightButtons = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: onHintTapped,
|
||||
icon: const Icon(Icons.lightbulb_outline, color: Colors.orange),
|
||||
iconSize: 30,
|
||||
tooltip: "힌트",
|
||||
),
|
||||
IconButton(
|
||||
onPressed: onUndoTapped,
|
||||
icon: const Icon(Icons.undo, color: Colors.red),
|
||||
iconSize: 30,
|
||||
tooltip: "되돌리기",
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// 4. 레이아웃 조립
|
||||
if (isLandscape) {
|
||||
// 가로 모드: (세로) Column -> [ NumberPad, (가로) Row [버튼 4개] ]
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@ -649,18 +700,22 @@ class _GameScreenState extends State<GameScreen> {
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [quitButton, restartButton],
|
||||
children: [
|
||||
leftButtons, // 👈 Column
|
||||
rightButtons // 👈 Column
|
||||
],
|
||||
)
|
||||
],
|
||||
);
|
||||
} else {
|
||||
// 세로 모드: (가로) Row -> [ 왼쪽 버튼, Expanded(NumberPad), 오른쪽 버튼 ]
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
quitButton,
|
||||
leftButtons,
|
||||
Expanded(child: numberPadGrid),
|
||||
restartButton,
|
||||
rightButtons,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import 'dart:developer';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sudoku_app/models/game_level.dart'; // 👈 [추가]
|
||||
import 'package:sudoku_app/models/game_level.dart';
|
||||
import 'package:sudoku_app/models/game_rank_dto.dart';
|
||||
import 'package:sudoku_app/models/sudoku_game_dto.dart';
|
||||
import 'package:sudoku_app/models/sudoku_theme.dart';
|
||||
import 'package:sudoku_app/screens/game_screen.dart';
|
||||
@ -8,6 +10,8 @@ import 'package:sudoku_app/services/puzzle_service.dart';
|
||||
import 'package:sudoku_app/services/identity_service.dart';
|
||||
import 'package:sudoku_app/widgets/ad_banner_widget.dart';
|
||||
|
||||
// 🔽 [삭제] RankChangeStatus enum은 더 이상 필요하지 않습니다.
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@ -16,8 +20,13 @@ class HomeScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen> {
|
||||
// '최대 잠금 해제 레벨' 상태
|
||||
int _maxUnlockedLevel = 1;
|
||||
// 🔽 [수정] 랭킹 변동 상태 대신 (이전 랭킹, 현재 랭킹) 튜플을 저장합니다.
|
||||
// (Key: levelIndex, Value: (oldRank, currentRank))
|
||||
// (0은 랭킹에 없음을 의미)
|
||||
Map<int, (int, int)> _rankHistory = {};
|
||||
String? _userName;
|
||||
|
||||
late String _selectedThemeName;
|
||||
bool _isLoading = false;
|
||||
|
||||
@ -28,48 +37,106 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedThemeName = AppThemes.random;
|
||||
_loadProgress(); // 👈 저장된 진행 상황 로드
|
||||
_loadProgress();
|
||||
}
|
||||
|
||||
// 로컬 저장소에서 클리어 레벨 불러오기
|
||||
// 🔽 [삭제] _calculateRankStatus 헬퍼 함수는 더 이상 필요하지 않습니다.
|
||||
|
||||
// 🔽 [수정] 랭킹 변동 맵을 (int, int) 튜플로 저장하도록 수정
|
||||
Future<void> _loadProgress() async {
|
||||
// 1. 기본 정보 로드 (레벨, 유저 이름)
|
||||
final maxLevel = await _identityService.getMaxUnlockedLevel();
|
||||
final String? myName = await _identityService.getSavedUserName();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_maxUnlockedLevel = maxLevel;
|
||||
_userName = myName;
|
||||
});
|
||||
}
|
||||
|
||||
if (myName == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 3-1. 이전에 저장된 랭킹 맵 로드
|
||||
final Map<int, int> oldRankMap = await _identityService.getLastSavedRankMap();
|
||||
|
||||
// 3-2. 모든 레벨의 랭킹을 병렬로 조회
|
||||
List<Future<List<GameRankDto>>> rankFutures = [];
|
||||
for (final level in AppLevels.allLevels) {
|
||||
rankFutures.add(_puzzleService.fetchRanks('SUDOKU', level.contextId));
|
||||
}
|
||||
|
||||
final List<List<GameRankDto>> allRankResults = await Future.wait(rankFutures);
|
||||
|
||||
// 3-4. 결과 분석
|
||||
Map<int, int> newRankMapForStorage = {}; // 👈 다음에 저장할 새로운 랭킹 맵
|
||||
Map<int, (int, int)> newRankHistoryForState = {}; // 👈 UI에 반영할 변동 맵
|
||||
|
||||
for (int i = 0; i < AppLevels.allLevels.length; i++) {
|
||||
final level = AppLevels.allLevels[i];
|
||||
final currentRanks = allRankResults[i];
|
||||
final int levelIndex = level.levelIndex;
|
||||
|
||||
// 3-5. 이 레벨의 이전 랭킹 (없으면 0)
|
||||
final int oldRank = oldRankMap[levelIndex] ?? 0;
|
||||
|
||||
// 3-6. 이 레벨의 현재 랭킹 (없으면 0)
|
||||
int currentRank = 0;
|
||||
int myRankIndex = currentRanks.indexWhere((r) => r.playerName == myName);
|
||||
if (myRankIndex != -1) {
|
||||
currentRank = myRankIndex + 1; // 1-based 순위
|
||||
}
|
||||
|
||||
// 3-7. 상태 저장 (숫자 쌍 자체를 저장)
|
||||
newRankMapForStorage[levelIndex] = currentRank;
|
||||
newRankHistoryForState[levelIndex] = (oldRank, currentRank);
|
||||
}
|
||||
|
||||
// 4. 새로운 랭킹 맵을 로컬에 저장 (다음 실행 시 비교용)
|
||||
await _identityService.saveLastRankMap(newRankMapForStorage);
|
||||
|
||||
// 5. UI 상태 업데이트
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_rankHistory = newRankHistoryForState; // 👈 튜플 맵으로 UI 상태 업데이트
|
||||
});
|
||||
}
|
||||
log("모든 레벨 랭킹 변동 확인 완료. (유저: $myName)");
|
||||
|
||||
} catch (e) {
|
||||
log("HomeScreen: 랭킹 확인 실패: $e");
|
||||
}
|
||||
}
|
||||
|
||||
// 게임 시작 로직
|
||||
|
||||
// (startGame 함수는 변경 없음)
|
||||
Future<void> _startGame(GameLevel level) async {
|
||||
setState(() { _isLoading = true; });
|
||||
|
||||
try {
|
||||
// 1. 선택한 레벨의 '인덱스'(1-11)를 문자열로 전달
|
||||
final String difficulty = level.levelIndex.toString();
|
||||
|
||||
final SudokuGameDto gameData = await _puzzleService.startGame(difficulty);
|
||||
|
||||
final String userId = await _identityService.getOrCreateUserId();
|
||||
final String? userName = await _identityService.getSavedUserName();
|
||||
final String? userName = _userName;
|
||||
|
||||
if (mounted) {
|
||||
// 2. GameScreen으로 이동 (게임 클리어 후 돌아오면 _loadProgress() 호출)
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => GameScreen(
|
||||
gameData: gameData,
|
||||
themeName: _selectedThemeName, // 👈 '랜덤' 또는 '과일' 등
|
||||
themeName: _selectedThemeName,
|
||||
userId: userId,
|
||||
userName: userName,
|
||||
levelIndex: level.levelIndex, // 👈 1~11
|
||||
levelIndex: level.levelIndex,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// 3. GameScreen에서 돌아왔을 때, 진행 상황(클리어)을 다시 로드
|
||||
_loadProgress();
|
||||
}
|
||||
} catch (e) {
|
||||
@ -85,9 +152,9 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// 🔽 [수정] build 메소드에서 랭킹 숫자(튜플)를 직접 비교하여 UI 생성
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 99 = 모든 레벨 클리어
|
||||
final bool allLevelsUnlocked = _maxUnlockedLevel >= 99;
|
||||
|
||||
return Scaffold(
|
||||
@ -95,7 +162,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
body: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const double maxContentRatio = 0.6;
|
||||
// 🔽 [수정] 태블릿 등에서 너무 커지는 것을 방지 (최대 500px)
|
||||
final double constrainedWidth = (constraints.maxHeight * maxContentRatio) > 500
|
||||
? 500 : (constraints.maxHeight * maxContentRatio);
|
||||
|
||||
@ -104,7 +170,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
constraints: BoxConstraints(maxWidth: constrainedWidth),
|
||||
child: Column(
|
||||
children: [
|
||||
// 1. 테마 선택
|
||||
// 1. 테마 선택 (변경 없음)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 10.0),
|
||||
child: Row(
|
||||
@ -129,15 +195,65 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
),
|
||||
),
|
||||
|
||||
// 2. 🔽 [수정] 레벨 선택 리스트 (Slider 대체)
|
||||
// 2. 레벨 선택 리스트
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: AppLevels.allLevels.length,
|
||||
itemBuilder: (context, index) {
|
||||
final GameLevel level = AppLevels.allLevels[index];
|
||||
// 3. 잠금 해제 로직
|
||||
final bool isUnlocked = allLevelsUnlocked || level.levelIndex <= _maxUnlockedLevel;
|
||||
|
||||
// 🔽 [수정] 랭킹 튜플(숫자 쌍)을 가져옵니다.
|
||||
final (int oldRank, int currentRank) = _rankHistory[level.levelIndex] ?? (0, 0);
|
||||
|
||||
// 1. 기본값 설정 (잠금 해제 시 플레이 아이콘, 잠겼으면 null)
|
||||
Widget? trailingWidget = isUnlocked ? const Icon(Icons.play_arrow_rounded) : null;
|
||||
String? subtitleText;
|
||||
Color? subtitleColor;
|
||||
|
||||
// 🔽 [수정] 랭킹 숫자를 직접 비교하여 UI 텍스트와 아이콘을 결정합니다.
|
||||
if (currentRank > 0) {
|
||||
// 1. 현재 랭킹이 있는 경우 (1위, 5위 등)
|
||||
String rankStr = "${currentRank}위"; // 예: "5위"
|
||||
|
||||
if (oldRank > 0) {
|
||||
// 1a. 이전 랭킹도 있는 경우 (변동 비교)
|
||||
int change = oldRank - currentRank; // (7 -> 5) = +2 (상승) | (5 -> 7) = -2 (하락)
|
||||
|
||||
if (change > 0) { // 순위 상승
|
||||
subtitleText = "$rankStr (▲ $change)";
|
||||
subtitleColor = Colors.green;
|
||||
trailingWidget = const Icon(Icons.arrow_circle_up_rounded, color: Colors.green, size: 28);
|
||||
} else if (change < 0) { // 순위 하락
|
||||
subtitleText = "$rankStr (▼ ${change.abs()})";
|
||||
subtitleColor = Colors.red;
|
||||
trailingWidget = const Icon(Icons.arrow_circle_down_rounded, color: Colors.red, size: 28);
|
||||
} else { // 순위 유지
|
||||
subtitleText = "$rankStr (유지)";
|
||||
subtitleColor = Colors.grey;
|
||||
trailingWidget = const Icon(Icons.check_circle_outline_rounded, color: Colors.grey, size: 28);
|
||||
}
|
||||
} else {
|
||||
// 1b. 신규 랭킹 진입 (currentRank > 0, oldRank == 0)
|
||||
subtitleText = "$rankStr (신규 진입)";
|
||||
subtitleColor = Colors.blue;
|
||||
trailingWidget = const Icon(Icons.new_releases_rounded, color: Colors.blue, size: 28);
|
||||
}
|
||||
|
||||
} else {
|
||||
// 2. 현재 랭킹이 없는 경우 (currentRank == 0)
|
||||
if (oldRank > 0) {
|
||||
// 2a. 랭킹에서 이탈 (currentRank == 0, oldRank > 0)
|
||||
subtitleText = "랭킹 이탈 (이전 ${oldRank}위)";
|
||||
subtitleColor = Colors.orange;
|
||||
trailingWidget = const Icon(Icons.warning_amber_rounded, color: Colors.orange, size: 28);
|
||||
} else {
|
||||
// 2b. 랭킹 기록 없음 (currentRank == 0, oldRank == 0)
|
||||
// subtitleText는 null, trailingWidget은 기본값(플레이 아이콘) 유지
|
||||
}
|
||||
}
|
||||
// 🔼 [수정] 완료
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
|
||||
child: ListTile(
|
||||
@ -150,31 +266,64 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
fontWeight: isUnlocked ? FontWeight.bold : FontWeight.normal,
|
||||
color: isUnlocked ? Colors.black : Colors.grey,
|
||||
)),
|
||||
trailing: isUnlocked ? const Icon(Icons.play_arrow_rounded) : null,
|
||||
// 🔽 서브타이틀 표시 (null이 아닐 때만)
|
||||
subtitle: subtitleText != null
|
||||
? Text(subtitleText, style: TextStyle(color: subtitleColor, fontWeight: FontWeight.bold))
|
||||
: null,
|
||||
// 🔽 최종 결정된 trailingWidget 표시
|
||||
trailing: trailingWidget,
|
||||
onTap: isUnlocked && !_isLoading
|
||||
? () => _startGame(level)
|
||||
: null, // 잠겼거나 로딩 중이면 탭 비활성화
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// 3. 랭킹 보기 버튼
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
// 현재 최고 레벨을 랭킹 화면의 기본값으로 전달
|
||||
final String currentDifficultyName = AppLevels.getLevel(_maxUnlockedLevel).name;
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RankingScreen(
|
||||
initialDifficultyName: currentDifficultyName,
|
||||
// 3. 랭킹 보기 버튼 (변경 없음 - 이전 스타일 유지)
|
||||
Container(
|
||||
margin: const EdgeInsets.fromLTRB(16.0, 0, 16.0, 8.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(12.0),
|
||||
topRight: Radius.circular(12.0),
|
||||
),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(12.0),
|
||||
topRight: Radius.circular(12.0),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
final String currentDifficultyName = AppLevels.getLevel(_maxUnlockedLevel).name;
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => RankingScreen(
|
||||
initialDifficultyName: currentDifficultyName,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14.0),
|
||||
child: const Text(
|
||||
'🏆 전체 랭킹 보기',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('전체 랭킹 보기'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -182,7 +331,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
);
|
||||
},
|
||||
),
|
||||
bottomNavigationBar: const AdBannerWidget(), // 👈 [수정] body -> bottomNavigationBar
|
||||
bottomNavigationBar: const AdBannerWidget(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
import 'dart:convert'; // 👈 [추가]
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
@ -5,7 +6,10 @@ import 'package:uuid/uuid.dart';
|
||||
class IdentityService {
|
||||
static const String _userIdKey = 'app_user_id';
|
||||
static const String _userNameKey = 'app_user_name';
|
||||
static const String _maxLevelKey = 'max_unlocked_level'; // 👈 [추가]
|
||||
static const String _maxLevelKey = 'max_unlocked_level';
|
||||
|
||||
// 🔽 [수정] 랭킹 정보를 Map<int, int> 형태로 저장하기 위한 Key
|
||||
static const String _lastRankMapKey = 'last_checked_rank_map';
|
||||
|
||||
// 1. 앱-고유 ID 가져오기 (없으면 생성)
|
||||
Future<String> getOrCreateUserId() async {
|
||||
@ -13,7 +17,6 @@ class IdentityService {
|
||||
String? userId = prefs.getString(_userIdKey);
|
||||
|
||||
if (userId == null) {
|
||||
// ID가 없으면 V4 UUID 생성
|
||||
userId = const Uuid().v4();
|
||||
await prefs.setString(_userIdKey, userId);
|
||||
}
|
||||
@ -32,16 +35,46 @@ class IdentityService {
|
||||
await prefs.setString(_userNameKey, name);
|
||||
}
|
||||
|
||||
// 4. 🔽 [추가] 현재 잠금 해제된 최고 레벨 가져오기
|
||||
// 4. 현재 잠금 해제된 최고 레벨 가져오기
|
||||
Future<int> getMaxUnlockedLevel() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
// 최초 실행 시 1 (L1) 반환, 9레벨 클리어 시 99 반환
|
||||
return prefs.getInt(_maxLevelKey) ?? 1;
|
||||
}
|
||||
|
||||
// 5. 🔽 [추가] 새 레벨 잠금 해제
|
||||
// 5. 새 레벨 잠금 해제
|
||||
Future<void> saveMaxUnlockedLevel(int level) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_maxLevelKey, level);
|
||||
}
|
||||
|
||||
// 6. 🔽 [수정] 모든 레벨의 랭킹 맵(Map<int, int>) 가져오기
|
||||
Future<Map<int, int>> getLastSavedRankMap() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
String? jsonString = prefs.getString(_lastRankMapKey);
|
||||
|
||||
if (jsonString == null) {
|
||||
return {}; // 저장된 맵이 없으면 빈 맵 반환
|
||||
}
|
||||
|
||||
try {
|
||||
// JSON은 Map<String, dynamic>이므로, 키를 int로 변환
|
||||
final Map<String, dynamic> decodedMap = jsonDecode(jsonString);
|
||||
return decodedMap.map((key, value) => MapEntry(int.parse(key), value as int));
|
||||
} catch (e) {
|
||||
// JSON 파싱 실패 시 빈 맵 반환
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// 7. 🔽 [수정] 모든 레벨의 랭킹 맵(Map<int, int>) 저장하기
|
||||
Future<void> saveLastRankMap(Map<int, int> rankMap) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
|
||||
// JSON 저장을 위해 키를 String으로 변환
|
||||
final Map<String, int> stringKeyMap =
|
||||
rankMap.map((key, value) => MapEntry(key.toString(), value));
|
||||
|
||||
String jsonString = jsonEncode(stringKeyMap);
|
||||
await prefs.setString(_lastRankMapKey, jsonString);
|
||||
}
|
||||
}
|
||||
@ -44,7 +44,7 @@ class PuzzleService {
|
||||
}
|
||||
|
||||
// 랭킹 등록
|
||||
Future<void> submitRank(UnifiedRankDto rankDto) async {
|
||||
Future<List<GameRankDto>> submitRank(UnifiedRankDto rankDto) async {
|
||||
|
||||
final requestBody = jsonEncode(rankDto.toJson());
|
||||
log(">>> 랭킹 등록 요청: $requestBody");
|
||||
@ -55,7 +55,19 @@ class PuzzleService {
|
||||
body: requestBody,
|
||||
);
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
// 🔽 [수정] 성공(200) 시, 서버가 반환한 랭킹 리스트를 파싱하여 반환
|
||||
if (response.statusCode == 200) {
|
||||
log("<<< 랭킹 등록 성공: 200 OK (랭킹 목록 반환됨)");
|
||||
try {
|
||||
final List<dynamic> data = jsonDecode(utf8.decode(response.bodyBytes));
|
||||
return data.map((json) => GameRankDto.fromJson(json)).toList();
|
||||
} catch (e) {
|
||||
log("<<< 랭킹 등록 성공했으나, 반환된 랭킹 목록 파싱 실패: $e");
|
||||
throw Exception('랭킹 목록 파싱 실패: $e');
|
||||
}
|
||||
}
|
||||
// 🔽 [수정] 실패 시, 기존 로직과 동일하게 에러 처리
|
||||
else {
|
||||
log("<<< 랭킹 등록 실패: ${response.statusCode}");
|
||||
try {
|
||||
final errorBody = utf8.decode(response.bodyBytes);
|
||||
@ -65,7 +77,6 @@ class PuzzleService {
|
||||
throw Exception('랭킹 등록 실패: ${response.reasonPhrase}');
|
||||
}
|
||||
}
|
||||
log("<<< 랭킹 등록 성공: 200 OK");
|
||||
}
|
||||
|
||||
// 랭킹 조회
|
||||
|
||||
@ -7,7 +7,7 @@ class NumberPad extends StatelessWidget {
|
||||
final Map<int, int> numberCounts;
|
||||
final int? selectedNumber;
|
||||
final Function(int) onNumberTapped;
|
||||
final bool isLandscape; // 👈 [추가] 이 파라미터가 있어야 합니다
|
||||
final bool isLandscape;
|
||||
|
||||
const NumberPad({
|
||||
super.key,
|
||||
@ -16,14 +16,13 @@ class NumberPad extends StatelessWidget {
|
||||
required this.numberCounts,
|
||||
required this.selectedNumber,
|
||||
required this.onNumberTapped,
|
||||
required this.isLandscape, // 👈 [추가] 생성자에 추가
|
||||
required this.isLandscape,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final int gridSize = blockSize * blockSize;
|
||||
|
||||
// 1. 버튼 위젯 리스트 생성
|
||||
List<Widget> numberButtons = List.generate(gridSize, (index) {
|
||||
int numberValue = index + 1;
|
||||
String numberSymbol = theme.getSymbol(numberValue);
|
||||
@ -34,38 +33,40 @@ class NumberPad extends StatelessWidget {
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isSelected ? Colors.blue.shade300 : null,
|
||||
foregroundColor: isSelected ? Colors.white : null,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 0),
|
||||
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
// 🔽 [수정] 패딩을 조절해 버튼을 채움
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
// 🔽 [수정] 기본 폰트 크기를 키움 (이모지 등)
|
||||
textStyle: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(4))
|
||||
),
|
||||
onPressed: isCompleted
|
||||
? null
|
||||
: () => onNumberTapped(numberValue),
|
||||
child: Text(numberSymbol),
|
||||
// 🔽 [수정] Text를 FittedBox로 감싸 기호가 버튼에 꽉 차게 함
|
||||
child: FittedBox(
|
||||
fit: BoxFit.contain,
|
||||
child: Text(numberSymbol),
|
||||
),
|
||||
);
|
||||
|
||||
// 가로 모드(Wrap)에서는 Flexible로 감싸고,
|
||||
// 세로 모드(Grid)에서는 감싸지 않음
|
||||
if (isLandscape) {
|
||||
// Flexible을 사용해 Wrap 내에서 버튼이 공간을 차지하도록 함
|
||||
return Flexible(child: button);
|
||||
} else {
|
||||
return button;
|
||||
}
|
||||
});
|
||||
|
||||
// 2. 가로/세로 모드에 따라 다른 레이아웃 반환
|
||||
if (isLandscape) {
|
||||
// --- 가로 모드: Wrap 사용 (버튼이 가로로 흐름) ---
|
||||
return Wrap(
|
||||
runSpacing: 4.0, // 줄(세로) 간격
|
||||
spacing: 4.0, // 버튼(가로) 간격
|
||||
runSpacing: 4.0,
|
||||
spacing: 4.0,
|
||||
children: numberButtons,
|
||||
);
|
||||
} else {
|
||||
// --- 세로 모드: GridView 사용 (블록 모양) ---
|
||||
return GridView.count(
|
||||
crossAxisCount: blockSize, // 2x2, 3x3, 4x4, 5x5
|
||||
crossAxisCount: blockSize,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: EdgeInsets.zero,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user