This commit is contained in:
lunaticbum 2025-11-11 14:38:15 +09:00
parent 1883fef583
commit 5a0785f262
9 changed files with 358 additions and 208 deletions

View File

@ -0,0 +1,94 @@
// lib/models/game_level.dart
// 11
class GameLevel {
final int levelIndex; // 1-11
final String name; // "입문 (4x4)"
final int blockSize; // 2, 3, 4
final int generatorLevel; // (1~5)
final String contextId; // ID "SUDOKU_4x4_L1"
// 🔽 []
final bool isSequentialNumbers; // L1, L4, L9 ( )
final bool isSequentialLetters; // L2, L5, L10 ( )
// ( false이면 HomeScreen에서 )
const GameLevel({
required this.levelIndex,
required this.name,
required this.blockSize,
required this.generatorLevel,
required this.contextId,
this.isSequentialNumbers = false,
this.isSequentialLetters = false,
});
}
// 11
class AppLevels {
static final List<GameLevel> allLevels = [
// --- 2x2 (blockSize = 2) ---
const GameLevel(
levelIndex: 1, name: "입문 (4x4)", blockSize: 2, generatorLevel: 1,
contextId: "SUDOKU_4x4_L1", isSequentialNumbers: true // 👈
),
const GameLevel(
levelIndex: 2, name: "초급 (4x4)", blockSize: 2, generatorLevel: 3,
contextId: "SUDOKU_4x4_L3", isSequentialLetters: true // 👈
),
const GameLevel(
levelIndex: 3, name: "숙련 (4x4)", blockSize: 2, generatorLevel: 5,
contextId: "SUDOKU_4x4_L5" // 👈
),
// --- 3x3 (blockSize = 3) ---
const GameLevel(
levelIndex: 4, name: "쉬움 (9x9)", blockSize: 3, generatorLevel: 1,
contextId: "SUDOKU_9x9_L1", isSequentialNumbers: true // 👈
),
const GameLevel(
levelIndex: 5, name: "중급 (9x9)", blockSize: 3, generatorLevel: 2,
contextId: "SUDOKU_9x9_L2", isSequentialLetters: true // 👈
),
const GameLevel(
levelIndex: 6, name: "상급 (9x9)", blockSize: 3, generatorLevel: 3,
contextId: "SUDOKU_9x9_L3" // 👈
),
const GameLevel(
levelIndex: 7, name: "어려움 (9x9)", blockSize: 3, generatorLevel: 4,
contextId: "SUDOKU_9x9_L4" // 👈
),
const GameLevel(
levelIndex: 8, name: "최상급 (9x9)", blockSize: 3, generatorLevel: 5,
contextId: "SUDOKU_9x9_L5" // 👈
),
// --- 4x4 (blockSize = 4) ---
const GameLevel(
levelIndex: 9, name: "전문가 (16x16)", blockSize: 4, generatorLevel: 1,
contextId: "SUDOKU_16x16_L1", isSequentialNumbers: true // 👈
),
const GameLevel(
levelIndex: 10, name: "마스터 (16x16)", blockSize: 4, generatorLevel: 3,
contextId: "SUDOKU_16x16_L3", isSequentialLetters: true // 👈
),
const GameLevel(
levelIndex: 11, name: "지옥 (16x16)", blockSize: 4, generatorLevel: 5,
contextId: "SUDOKU_16x16_L5" // 👈
),
];
// (1-11)
static GameLevel getLevel(int levelIndex) {
if (levelIndex < 1) levelIndex = 1;
if (levelIndex > allLevels.length) levelIndex = allLevels.length;
return allLevels.firstWhere((level) => level.levelIndex == levelIndex,
orElse: () => allLevels[0] // L1
);
}
// (ContextId -> )
static Map<String, String> get contextIdToNameMap {
return { for (var level in allLevels) level.contextId : level.name };
}
}

View File

@ -84,29 +84,34 @@ class AppThemes {
}; };
// --- 4. [] '빌더' --- // --- 4. [] '빌더' ---
static SudokuTheme buildGameTheme(String themeName, int gridSize) { static SudokuTheme buildGameTheme(String themeName, int gridSize, {bool isEasyMode = false}) { // 👈 []
String effectiveThemeName = themeName; String effectiveThemeName = themeName;
// 1. "랜덤"
if (themeName == random) { if (themeName == random) {
// "랜덤"
final actualThemes = _themePools.keys.toList(); final actualThemes = _themePools.keys.toList();
effectiveThemeName = (actualThemes..shuffle()).first; effectiveThemeName = (actualThemes..shuffle()).first;
} }
// 2. '거대 풀' ( )
final List<String> pool = _themePools[effectiveThemeName] ?? _numberPool; final List<String> pool = _themePools[effectiveThemeName] ?? _numberPool;
// 3. , (gridSize)
if (pool.length < gridSize) { if (pool.length < gridSize) {
throw Exception("$effectiveThemeName 테마의 상징이 ${pool.length}개뿐입니다. $gridSize개가 필요합니다."); throw Exception("$effectiveThemeName 테마의 상징이 ${pool.length}개뿐입니다. $gridSize개가 필요합니다.");
} }
final List<String> selectedSymbols = (pool.toList()..shuffle()).sublist(0, gridSize);
// 4. '일회용' SudokuTheme List<String> selectedSymbols;
// 🔽 [] 'isEasyMode' true이면
if (isEasyMode) {
// (: 4x4 Easy -> 1,2,3,4 A,B,C,D)
selectedSymbols = pool.sublist(0, gridSize);
} else {
// : , gridSize만큼
selectedSymbols = (pool.toList()..shuffle()).sublist(0, gridSize);
}
return SudokuTheme( return SudokuTheme(
name: effectiveThemeName, // (: "과일") name: effectiveThemeName,
symbols: selectedSymbols, // symbols: selectedSymbols,
); );
} }
} }

View File

@ -1,7 +1,7 @@
// lib/models/unified_rank_dto.dart // lib/models/unified_rank_dto.dart
class UnifiedRankDto { class UnifiedRankDto {
final String userId; // 👈 [] - ID final String userId; // 👈 [] - ID
final String gameType; final String gameType;
final String? contextId; final String? contextId;
final String playerName; final String playerName;
@ -9,7 +9,7 @@ class UnifiedRankDto {
final int? secondaryScore; final int? secondaryScore;
UnifiedRankDto({ UnifiedRankDto({
required this.userId, // 👈 [] required this.userId, // 👈 []
required this.gameType, required this.gameType,
this.contextId, this.contextId,
required this.playerName, required this.playerName,
@ -20,7 +20,7 @@ class UnifiedRankDto {
// Dart JSON으로 ( ) // Dart JSON으로 ( )
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
'userId': userId, // 👈 [] 'userId': userId, // 👈 []
'gameType': gameType, 'gameType': gameType,
'contextId': contextId, 'contextId': contextId,
'playerName': playerName, 'playerName': playerName,

View File

@ -1,6 +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/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';
@ -19,6 +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에서
const GameScreen({ const GameScreen({
super.key, super.key,
@ -26,6 +28,7 @@ class GameScreen extends StatefulWidget {
required this.themeName, required this.themeName,
required this.userId, required this.userId,
required this.userName, required this.userName,
required this.levelIndex,
}); });
@override @override
@ -36,10 +39,12 @@ 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 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;
@ -80,10 +85,31 @@ class _GameScreenState extends State<GameScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
blockSize = widget.gameData.blockSize; // 1. HomeScreen에서 levelIndex로
gridSize = widget.gameData.gridSize; currentLevel = AppLevels.getLevel(widget.levelIndex);
activeTheme = AppThemes.buildGameTheme(widget.themeName, gridSize); blockSize = currentLevel.blockSize;
gridSize = blockSize * blockSize;
// 2. 🔽 []
String themeForThisGame = widget.themeName;
// 🔽 [] 'isEasyMode' 'GameLevel'
bool isEasyMode = currentLevel.isSequentialNumbers || currentLevel.isSequentialLetters;
if (currentLevel.isSequentialNumbers) {
themeForThisGame = AppThemes.numbers; // 👈
} else if (currentLevel.isSequentialLetters) {
themeForThisGame = AppThemes.letters; // 👈
}
// 3. '이번 게임'
activeTheme = AppThemes.buildGameTheme(
themeForThisGame,
gridSize,
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();
@ -107,21 +133,21 @@ class _GameScreenState extends State<GameScreen> {
void onCellTapped(int index) { void onCellTapped(int index) {
if (originalCells[index] == 0) { if (originalCells[index] == 0) {
if (incorrectCells.isNotEmpty && !incorrectCells.contains(index)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('틀린 값을 먼저 수정해주세요. (되돌리기 ↩)'),
duration: Duration(seconds: 1),
),
);
return;
}
setState(() { setState(() {
selectedIndex = index; selectedIndex = index;
if (selectedNumberPad != null) { if (selectedNumberPad != null) {
if (incorrectCells.isNotEmpty && !incorrectCells.contains(index)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('틀린 값을 먼저 수정해주세요. (되돌리기 ↩)'),
duration: Duration(seconds: 1),
),
);
return;
}
final int numberValue = selectedNumberPad!; final int numberValue = selectedNumberPad!;
puzzleCells[index] = numberValue; puzzleCells[index] = numberValue;
@ -159,12 +185,19 @@ class _GameScreenState extends State<GameScreen> {
} }
void onUndoTapped() { void onUndoTapped() {
setState(() { if (incorrectCells.isNotEmpty) {
if (selectedIndex != null && originalCells[selectedIndex!] == 0) { int errorIndex = incorrectCells.first;
setState(() {
puzzleCells[errorIndex] = 0;
incorrectCells.remove(errorIndex);
selectedIndex = errorIndex;
});
}
else if (selectedIndex != null && originalCells[selectedIndex!] == 0) {
setState(() {
puzzleCells[selectedIndex!] = 0; puzzleCells[selectedIndex!] = 0;
incorrectCells.remove(selectedIndex); });
} }
});
} }
void onHintTapped() { void onHintTapped() {
@ -192,6 +225,7 @@ class _GameScreenState extends State<GameScreen> {
} }
// API
Future<void> _validateGame() async { Future<void> _validateGame() async {
if (isValidating) return; if (isValidating) return;
setState(() { isValidating = true; }); setState(() { isValidating = true; });
@ -229,18 +263,17 @@ class _GameScreenState extends State<GameScreen> {
} }
} }
// (2 UI) // (2 UI + )
void _showRankingDialog() { void _showRankingDialog() {
final nameController = TextEditingController(text: widget.userName); final nameController = TextEditingController(text: widget.userName);
bool isSubmitting = false; bool isSubmitting = false;
final bool hasExistingName = widget.userName != null; String? dialogErrorMessage;
_rankStep = _RankSubmissionStep.enterName; _rankStep = _RankSubmissionStep.enterName;
_rankingList = []; _rankingList = [];
_submittedPlayerName = ""; _submittedPlayerName = "";
String? dialogErrorMessage; // 👈 []
final String contextId = "SUDOKU_${gridSize}x${gridSize}_L${_difficultyLevel(widget.gameData.question)}"; final String contextId = currentLevel.contextId;
showDialog( showDialog(
context: context, context: context,
@ -290,12 +323,11 @@ class _GameScreenState extends State<GameScreen> {
const SizedBox(height: 20), const SizedBox(height: 20),
TextField( TextField(
controller: nameController, controller: nameController,
readOnly: hasExistingName, readOnly: false,
decoration: InputDecoration( decoration: InputDecoration(
labelText: hasExistingName ? '등록된 이름' : '이름 (10자 이내)', labelText: '이름 (10자 이내)',
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
// 🔽 [] TextField에 errorText: dialogErrorMessage,
errorText: dialogErrorMessage,
), ),
maxLength: 10, maxLength: 10,
), ),
@ -306,7 +338,6 @@ class _GameScreenState extends State<GameScreen> {
onPressed: () async { onPressed: () async {
final playerName = nameController.text.trim(); final playerName = nameController.text.trim();
if (playerName.isEmpty) { if (playerName.isEmpty) {
// SnackBar
setDialogState(() { setDialogState(() {
dialogErrorMessage = "이름을 입력해주세요."; dialogErrorMessage = "이름을 입력해주세요.";
}); });
@ -316,7 +347,7 @@ class _GameScreenState extends State<GameScreen> {
setDialogState(() { setDialogState(() {
_rankStep = _RankSubmissionStep.submitting; _rankStep = _RankSubmissionStep.submitting;
_submittedPlayerName = playerName; _submittedPlayerName = playerName;
dialogErrorMessage = null; // 👈 [] dialogErrorMessage = null;
}); });
final rankDto = UnifiedRankDto( final rankDto = UnifiedRankDto(
@ -330,9 +361,18 @@ class _GameScreenState extends State<GameScreen> {
try { try {
await _puzzleService.submitRank(rankDto); await _puzzleService.submitRank(rankDto);
await _identityService.saveUserName(playerName);
if (!hasExistingName) {
await _identityService.saveUserName(playerName); final int currentMaxLevel = await _identityService.getMaxUnlockedLevel();
if (currentMaxLevel < 99) {
if (widget.levelIndex >= currentMaxLevel) {
int nextLevel = widget.levelIndex + 1;
if (nextLevel > AppLevels.allLevels.length) {
await _identityService.saveMaxUnlockedLevel(99);
} else {
await _identityService.saveMaxUnlockedLevel(nextLevel);
}
}
} }
final ranks = await _puzzleService.fetchRanks('SUDOKU', contextId); final ranks = await _puzzleService.fetchRanks('SUDOKU', contextId);
@ -343,15 +383,11 @@ class _GameScreenState extends State<GameScreen> {
}); });
} catch (e) { } catch (e) {
// 🔽 [] ( )
log("!!! 랭킹 등록 실패 !!!", error: e); log("!!! 랭킹 등록 실패 !!!", error: e);
setDialogState(() { setDialogState(() {
_rankStep = _RankSubmissionStep.enterName; // 1( ) _rankStep = _RankSubmissionStep.enterName;
// 👈 [] dialogErrorMessage = e.toString().replaceFirst("Exception: ", "");
dialogErrorMessage = e.toString().replaceFirst("Exception: ", "");
}); });
// SnackBar
} }
}, },
child: const Text('랭킹 등록'), child: const Text('랭킹 등록'),
@ -399,6 +435,7 @@ 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);

View File

@ -1,10 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.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/screens/game_screen.dart'; import 'package:sudoku_app/screens/game_screen.dart';
import 'package:sudoku_app/screens/ranking_screen.dart'; import 'package:sudoku_app/screens/ranking_screen.dart';
import 'package:sudoku_app/services/puzzle_service.dart'; import 'package:sudoku_app/services/puzzle_service.dart';
import 'package:sudoku_app/services/identity_service.dart'; // 👈 ID 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';
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
@ -15,51 +16,61 @@ class HomeScreen extends StatefulWidget {
} }
class _HomeScreenState extends State<HomeScreen> { class _HomeScreenState extends State<HomeScreen> {
// 8 // '최대 잠금 해제 레벨'
double _difficultyLevel = 4.0; // 1.0 ~ 8.0 ( Level 4: 9x9) int _maxUnlockedLevel = 1;
final List<String> levelLabels = [
"입문 (4x4)", "초급 (4x4)",
"쉬움 (9x9)", "중급 (9x9)", "어려움 (9x9)",
"전문가 (16x16)", "마스터 (16x16)", "지옥 (16x16)"
];
late String _selectedThemeName; late String _selectedThemeName;
bool isLoading = false; bool _isLoading = false;
final PuzzleService _puzzleService = PuzzleService(); final PuzzleService _puzzleService = PuzzleService();
final IdentityService _identityService = IdentityService(); // 👈 ID final IdentityService _identityService = IdentityService();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_selectedThemeName = AppThemes.random; // '랜덤' _selectedThemeName = AppThemes.random;
_loadProgress(); // 👈
} }
Future<void> _startGame() async { //
setState(() { isLoading = true; }); Future<void> _loadProgress() async {
final maxLevel = await _identityService.getMaxUnlockedLevel();
if (mounted) {
setState(() {
_maxUnlockedLevel = maxLevel;
});
}
}
//
Future<void> _startGame(GameLevel level) async {
setState(() { _isLoading = true; });
try { try {
// 1. (String) // 1. '인덱스'(1-11)
final String difficulty = _difficultyLevel.round().toString(); final String difficulty = level.levelIndex.toString();
// 2.
final SudokuGameDto gameData = await _puzzleService.startGame(difficulty); final SudokuGameDto gameData = await _puzzleService.startGame(difficulty);
// 3. - ID와
final String userId = await _identityService.getOrCreateUserId(); final String userId = await _identityService.getOrCreateUserId();
final String? userName = await _identityService.getSavedUserName(); final String? userName = await _identityService.getSavedUserName();
if (mounted) { if (mounted) {
Navigator.push( // 2. GameScreen으로 ( _loadProgress() )
await Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (context) => GameScreen( builder: (context) => GameScreen(
gameData: gameData, gameData: gameData,
themeName: _selectedThemeName, themeName: _selectedThemeName, // 👈 '랜덤' '과일'
userId: userId, // 👈 ID userId: userId,
userName: userName, // 👈 userName: userName,
levelIndex: level.levelIndex, // 👈 1~11
), ),
), ),
); );
// 3. GameScreen에서 , ()
_loadProgress();
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
@ -69,107 +80,109 @@ class _HomeScreenState extends State<HomeScreen> {
} }
} finally { } finally {
if (mounted) { if (mounted) {
setState(() { isLoading = false; }); setState(() { _isLoading = false; });
} }
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// 99 =
final bool allLevelsUnlocked = _maxUnlockedLevel >= 99;
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('스도쿠 게임')), appBar: AppBar(title: const Text('스도쿠 게임')),
body: LayoutBuilder( // body: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
// / (0.6 = 60% )
const double maxContentRatio = 0.6; const double maxContentRatio = 0.6;
final double constrainedWidth = constraints.maxHeight * maxContentRatio; // 🔽 [] 릿 ( 500px)
final double constrainedWidth = (constraints.maxHeight * maxContentRatio) > 500
? 500 : (constraints.maxHeight * maxContentRatio);
return Center( return Center(
child: ConstrainedBox( child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: constrainedWidth), constraints: BoxConstraints(maxWidth: constrainedWidth),
child: Column( child: Column(
children: [ children: [
Expanded( // 1.
child: Center( Padding(
child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20.0, vertical: 10.0),
padding: const EdgeInsets.symmetric(horizontal: 20.0), child: Row(
child: Column( mainAxisAlignment: MainAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center, children: [
children: [ const Text("테마: ", style: TextStyle(fontSize: 18)),
// 1. (8) DropdownButton<String>(
const Text("난이도", style: TextStyle(fontSize: 18)), value: _selectedThemeName,
Text( items: AppThemes.selectableThemeNames.map((themeName) {
levelLabels[_difficultyLevel.round() - 1], return DropdownMenuItem<String>(
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.blue), value: themeName,
), child: Text(themeName, style: const TextStyle(fontSize: 20)),
Slider( );
value: _difficultyLevel, }).toList(),
min: 1.0, max: 8.0, divisions: 7, // 8 onChanged: (themeName) {
label: levelLabels[_difficultyLevel.round() - 1], if (themeName != null) {
onChanged: (newValue) => setState(() { _difficultyLevel = newValue; }), setState(() { _selectedThemeName = themeName; });
), }
},
const SizedBox(height: 20),
// 2. (String )
const Text("테마", style: TextStyle(fontSize: 18)),
DropdownButton<String>(
value: _selectedThemeName,
items: AppThemes.selectableThemeNames.map((themeName) {
return DropdownMenuItem<String>(
value: themeName,
child: Text(themeName, style: const TextStyle(fontSize: 20)),
);
}).toList(),
onChanged: (themeName) {
if (themeName != null) {
setState(() { _selectedThemeName = themeName; });
}
},
),
const SizedBox(height: 30),
if (isLoading)
const CircularProgressIndicator()
else
ElevatedButton(
onPressed: _startGame,
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 15)),
child: const Text('게임 시작', style: TextStyle(fontSize: 18)),
),
const SizedBox(height: 10),
// "랭킹 보기"
TextButton(
onPressed: () {
// (String)
final String currentDifficultyName = levelLabels[_difficultyLevel.round() - 1];
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => RankingScreen(
initialDifficultyName: currentDifficultyName,
),
),
);
},
child: const Text('랭킹 보기'),
),
],
), ),
), ],
), ),
), ),
const AdBannerWidget(),
// 2. 🔽 [] (Slider )
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;
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
child: ListTile(
leading: Icon(
isUnlocked ? Icons.lock_open_rounded : Icons.lock_rounded,
color: isUnlocked ? Colors.blue : Colors.grey,
),
title: Text(level.name, style: TextStyle(
fontSize: 18,
fontWeight: isUnlocked ? FontWeight.bold : FontWeight.normal,
color: isUnlocked ? Colors.black : Colors.grey,
)),
trailing: isUnlocked ? const Icon(Icons.play_arrow_rounded) : null,
onTap: isUnlocked && !_isLoading
? () => _startGame(level)
: null, //
),
);
},
),
),
// 3.
TextButton(
onPressed: () {
//
final String currentDifficultyName = AppLevels.getLevel(_maxUnlockedLevel).name;
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => RankingScreen(
initialDifficultyName: currentDifficultyName,
),
),
);
},
child: const Text('전체 랭킹 보기'),
),
], ],
), ),
), ),
); );
}, },
), ),
bottomNavigationBar: const AdBannerWidget(), // 👈 [] body -> bottomNavigationBar
); );
} }
} }

View File

@ -1,14 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:sudoku_app/models/game_level.dart';
import 'package:sudoku_app/models/game_rank_dto.dart'; import 'package:sudoku_app/models/game_rank_dto.dart';
import 'package:sudoku_app/services/puzzle_service.dart'; import 'package:sudoku_app/services/puzzle_service.dart';
class RankingScreen extends StatefulWidget { class RankingScreen extends StatefulWidget {
// 🔽 [] // 🔽 []
final String? initialDifficultyName; final String? initialDifficultyName;
const RankingScreen({ const RankingScreen({
super.key, super.key,
this.initialDifficultyName, // 👈 this.initialDifficultyName, // 👈 []
}); });
@override @override
@ -19,39 +20,28 @@ class _RankingScreenState extends State<RankingScreen> {
final PuzzleService _puzzleService = PuzzleService(); final PuzzleService _puzzleService = PuzzleService();
late Future<List<GameRankDto>> _rankingFuture; late Future<List<GameRankDto>> _rankingFuture;
// 8 Context ID // 9 ( -> ContextId)
final Map<String, String> difficultyContexts = { final Map<String, String> difficultyContexts = {
"입문 (4x4)": "SUDOKU_4x4_L1", for (var level in AppLevels.allLevels) level.name : level.contextId
"초급 (4x4)": "SUDOKU_4x4_L2",
"쉬움 (9x9)": "SUDOKU_9x9_L3",
"중급 (9x9)": "SUDOKU_9x9_L4",
"어려움 (9x9)": "SUDOKU_9x9_L5",
"전문가 (16x16)": "SUDOKU_16x16_L6",
"마스터 (16x16)": "SUDOKU_16x16_L7",
"지옥 (16x16)": "SUDOKU_16x16_L8",
}; };
late String _selectedDifficulty; late String _selectedDifficultyName;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// 🔽 []
// 1. HomeScreen에서
String defaultDifficulty = widget.initialDifficultyName ?? "중급 (9x9)"; String defaultDifficulty = widget.initialDifficultyName ?? "중급 (9x9)";
// 2. () (: )
if (!difficultyContexts.containsKey(defaultDifficulty)) { if (!difficultyContexts.containsKey(defaultDifficulty)) {
defaultDifficulty = "중급 (9x9)"; defaultDifficulty = "중급 (9x9)";
} }
// 3.
_fetchRanksForDifficulty(defaultDifficulty); _fetchRanksForDifficulty(defaultDifficulty);
} }
void _fetchRanksForDifficulty(String difficultyName) { void _fetchRanksForDifficulty(String difficultyName) {
setState(() { setState(() {
_selectedDifficulty = difficultyName; _selectedDifficultyName = difficultyName;
_rankingFuture = _puzzleService.fetchRanks('SUDOKU', difficultyContexts[_selectedDifficulty]); _rankingFuture = _puzzleService.fetchRanks('SUDOKU', difficultyContexts[_selectedDifficultyName]);
}); });
} }
@ -78,12 +68,12 @@ class _RankingScreenState extends State<RankingScreen> {
Padding( Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: DropdownButton<String>( child: DropdownButton<String>(
value: _selectedDifficulty, // 👈 initState에서 value: _selectedDifficultyName,
isExpanded: true, isExpanded: true,
items: difficultyContexts.keys.map((String difficultyName) { items: AppLevels.allLevels.map((level) {
return DropdownMenuItem<String>( return DropdownMenuItem<String>(
value: difficultyName, value: level.name,
child: Text(difficultyName), child: Text(level.name),
); );
}).toList(), }).toList(),
onChanged: (String? newValue) { onChanged: (String? newValue) {

View File

@ -1,12 +1,11 @@
// lib/services/identity_service.dart
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
// - ID와 // - ID와 ,
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'; // 👈 []
// 1. - ID ( ) // 1. - ID ( )
Future<String> getOrCreateUserId() async { Future<String> getOrCreateUserId() async {
@ -32,4 +31,17 @@ class IdentityService {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setString(_userNameKey, name); await prefs.setString(_userNameKey, name);
} }
// 4. 🔽 []
Future<int> getMaxUnlockedLevel() async {
final prefs = await SharedPreferences.getInstance();
// 1 (L1) , 9 99
return prefs.getInt(_maxLevelKey) ?? 1;
}
// 5. 🔽 []
Future<void> saveMaxUnlockedLevel(int level) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_maxLevelKey, level);
}
} }

View File

@ -1,5 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:developer'; // 👈 [] log import 'dart:developer';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:sudoku_app/models/sudoku_game_dto.dart'; import 'package:sudoku_app/models/sudoku_game_dto.dart';
import 'package:sudoku_app/models/unified_rank_dto.dart'; import 'package:sudoku_app/models/unified_rank_dto.dart';
@ -8,11 +8,13 @@ import 'package:sudoku_app/models/game_rank_dto.dart';
class PuzzleService { class PuzzleService {
final String _baseUrl = "https://lunaticbum.kr"; final String _baseUrl = "https://lunaticbum.kr";
// ... (startGame ) ... // 🔽 [] 'difficulty' 1 (1~11)
Future<SudokuGameDto> startGame(String difficulty) async { Future<SudokuGameDto> startGame(String difficulty) async {
final response = await http.get( final response = await http.get(
// 🔽 [] 'difficulty'
Uri.parse('$_baseUrl/puzzle/sudoku/start?difficulty=$difficulty'), Uri.parse('$_baseUrl/puzzle/sudoku/start?difficulty=$difficulty'),
); );
if (response.statusCode == 200) { if (response.statusCode == 200) {
final data = jsonDecode(utf8.decode(response.bodyBytes)); final data = jsonDecode(utf8.decode(response.bodyBytes));
return SudokuGameDto.fromJson(data); return SudokuGameDto.fromJson(data);
@ -21,7 +23,7 @@ class PuzzleService {
} }
} }
// ... (validateSolution ) ... // 'puzzleId' ( DTO와 )
Future<bool> validateSolution(int puzzleId, String answer) async { Future<bool> validateSolution(int puzzleId, String answer) async {
final response = await http.post( final response = await http.post(
Uri.parse('$_baseUrl/puzzle/sudoku/validate'), Uri.parse('$_baseUrl/puzzle/sudoku/validate'),
@ -31,6 +33,7 @@ class PuzzleService {
'answer': answer, 'answer': answer,
}), }),
); );
if (response.statusCode == 200) { if (response.statusCode == 200) {
return jsonDecode(response.body)['correct'] ?? false; return jsonDecode(response.body)['correct'] ?? false;
} else { } else {
@ -40,12 +43,10 @@ class PuzzleService {
} }
} }
// POST /api/ranks/submit //
Future<void> submitRank(UnifiedRankDto rankDto) async { Future<void> submitRank(UnifiedRankDto rankDto) async {
final requestBody = jsonEncode(rankDto.toJson()); final requestBody = jsonEncode(rankDto.toJson());
// 🔽 [ ] 1. JSON
log(">>> 랭킹 등록 요청: $requestBody"); log(">>> 랭킹 등록 요청: $requestBody");
final response = await http.post( final response = await http.post(
@ -55,22 +56,19 @@ class PuzzleService {
); );
if (response.statusCode != 200) { if (response.statusCode != 200) {
// 🔽 [ ] 2. 200(OK)
log("<<< 랭킹 등록 실패: ${response.statusCode}"); log("<<< 랭킹 등록 실패: ${response.statusCode}");
try { try {
final errorBody = utf8.decode(response.bodyBytes); final errorBody = utf8.decode(response.bodyBytes);
log("<<< 서버 에러 메시지: $errorBody"); // 👈 (: "이미 사용 중인 이름입니다.") log("<<< 서버 에러 메시지: $errorBody");
throw Exception(errorBody); throw Exception(errorBody);
} catch (e) { } catch (e) {
throw Exception('랭킹 등록 실패: ${response.reasonPhrase}'); throw Exception('랭킹 등록 실패: ${response.reasonPhrase}');
} }
} }
// 🔽 [ ] 3.
log("<<< 랭킹 등록 성공: 200 OK"); log("<<< 랭킹 등록 성공: 200 OK");
} }
// ... (fetchRanks ) ... //
Future<List<GameRankDto>> fetchRanks(String gameType, String? contextId) async { Future<List<GameRankDto>> fetchRanks(String gameType, String? contextId) async {
final queryParams = { final queryParams = {
'gameType': gameType, 'gameType': gameType,

View File

@ -1,13 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:sudoku_app/models/sudoku_theme.dart'; // 👈 import 'package:sudoku_app/models/sudoku_theme.dart';
class SudokuBoard extends StatelessWidget { class SudokuBoard extends StatelessWidget {
final int blockSize; final int blockSize;
final SudokuTheme theme; // 👈 final SudokuTheme theme;
final List<int> cells; // 👈 List<int> (0, 1, 10...) final List<int> cells;
final List<int> originalCells; // 👈 List<int> (0, 1, 10...) final List<int> originalCells;
final int? selectedIndex; final int? selectedIndex;
final int? selectedNumberPad; // 10 (1, 10...) final int? selectedNumberPad;
final Set<int> incorrectCells; final Set<int> incorrectCells;
final Function(int) onCellTapped; final Function(int) onCellTapped;
@ -26,7 +26,6 @@ class SudokuBoard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final int gridSize = blockSize * blockSize; final int gridSize = blockSize * blockSize;
//
final double fontSize = (gridSize > 9) ? (gridSize > 16 ? 12 : 16) : 24; final double fontSize = (gridSize > 9) ? (gridSize > 16 ? 12 : 16) : 24;
return AspectRatio( return AspectRatio(
@ -41,18 +40,19 @@ class SudokuBoard extends StatelessWidget {
int row = index ~/ gridSize; int row = index ~/ gridSize;
int col = index % gridSize; int col = index % gridSize;
int cellValue = cells[index]; // 0, 1, 10... int cellValue = cells[index];
bool isEditable = (originalCells[index] == 0); bool isEditable = (originalCells[index] == 0);
bool isSelected = (index == selectedIndex); bool isSelected = (index == selectedIndex);
// int == int int selectedNumAsInt = selectedNumberPad ?? -1;
String selectedNumAsSymbol = (selectedNumberPad != null) ? theme.getSymbol(selectedNumberPad!) : "";
bool isHighlighted = (cellValue != 0 && bool isHighlighted = (cellValue != 0 &&
selectedNumberPad != null && selectedNumberPad != null &&
cellValue == selectedNumberPad); cellValue == selectedNumberPad);
bool isIncorrect = incorrectCells.contains(index); bool isIncorrect = incorrectCells.contains(index);
// blockSize에
BorderSide thickBorder = const BorderSide(color: Colors.black, width: 2.0); BorderSide thickBorder = const BorderSide(color: Colors.black, width: 2.0);
BorderSide thinBorder = const BorderSide(color: Colors.grey, width: 0.5); BorderSide thinBorder = const BorderSide(color: Colors.grey, width: 0.5);
@ -61,15 +61,14 @@ class SudokuBoard extends StatelessWidget {
child: Container( child: Container(
alignment: Alignment.center, alignment: Alignment.center,
decoration: BoxDecoration( decoration: BoxDecoration(
// 🔽 [] 'isSelected' ( )
color: isIncorrect color: isIncorrect
? Colors.red.shade100 ? Colors.red.shade100 // 1:
: isSelected : isHighlighted
? Colors.blue.shade100 ? Colors.blue.shade200 // 2:
: isHighlighted : isEditable
? Colors.blue.shade200 ? Colors.white
: isEditable : Colors.grey.shade200, //
? Colors.white
: Colors.grey.shade200,
border: Border( border: Border(
top: (row == 0) ? thickBorder : thinBorder, top: (row == 0) ? thickBorder : thinBorder,
left: (col == 0) ? thickBorder : thinBorder, left: (col == 0) ? thickBorder : thinBorder,
@ -78,16 +77,18 @@ class SudokuBoard extends StatelessWidget {
), ),
), ),
child: Text( child: Text(
// 0 , ("1", "A", "🍎")
cellValue == 0 ? '' : theme.getSymbol(cellValue), cellValue == 0 ? '' : theme.getSymbol(cellValue),
style: TextStyle( style: TextStyle(
fontSize: fontSize, fontSize: fontSize,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: isIncorrect // 🔽 [] 'isSelected' ( )
? Colors.red.shade900 color: isSelected
: isEditable ? Colors.orange.shade700 // 1:
? Colors.blue : isIncorrect
: Colors.black, ? Colors.red.shade900 // 2:
: isEditable
? Colors.blue // 3:
: Colors.black, // ( )
), ),
), ),
), ),