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