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

View File

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

View File

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

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

View File

@ -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,