This commit is contained in:
lunaticbum 2025-11-11 17:45:02 +09:00
parent 5a0785f262
commit 3b6328cc4c
5 changed files with 373 additions and 124 deletions

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;
@ -60,7 +59,9 @@ class _GameScreenState extends State<GameScreen> {
_RankSubmissionStep _rankStep = _RankSubmissionStep.enterName; _RankSubmissionStep _rankStep = _RankSubmissionStep.enterName;
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,
], ],
); );
} }

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(),
); );
} }
} }

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);
}
} }

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");
} }
// //

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,