This commit is contained in:
2025-11-11 17:45:02 +09:00
parent 5a0785f262
commit 3b6328cc4c
5 changed files with 373 additions and 124 deletions
+125 -70
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,
],
);
}
+182 -33
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(),
);
}
}