This commit is contained in:
2025-11-14 18:03:50 +09:00
parent 1f5cea9a96
commit 13ed537b23
342 changed files with 18293 additions and 0 deletions
@@ -0,0 +1,525 @@
// packages/feature_game_spider/lib/screens/spider_game_screen.dart
import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:service_api/service_api.dart';
import 'package:feature_common/feature_common.dart';
import '../controllers/spider_game_controller.dart';
import '../models/spider_difficulty.dart';
import '../models/spider_card.dart';
import '../widgets/tableau_pile_widget.dart';
import '../widgets/bottom_bar_widget.dart';
import '../widgets/card_widget.dart';
// ❌ [삭제] enum _RankSubmissionStep
class SpiderGameScreen extends StatefulWidget {
const SpiderGameScreen({super.key});
@override
State<SpiderGameScreen> createState() => _SpiderGameScreenState();
}
class _SpiderGameScreenState extends State<SpiderGameScreen> {
bool _isDialogShowing = false;
final List<GlobalKey> _tableauKeys = List.generate(10, (_) => GlobalKey());
final GlobalKey _stockKey = GlobalKey();
final GlobalKey _bodyStackKey = GlobalKey();
final List<Widget> _animationOverlays = [];
bool _showDimOverlay = false;
VoidCallback? _controllerListener;
bool _isDealAnimationRunning = false;
bool _isStackAnimationRunning = false;
// ( _buildGameAppBar, _showSurrenderDialog 는 동일 )
AppBar _buildGameAppBar(BuildContext context, SpiderGameController controller) {
return AppBar(
leading: IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop()),
title: Consumer<SpiderGameController>(
builder: (context, controller, child) {
final seconds = controller.secondsElapsed;
final timeStr = "${(seconds ~/ 60).toString().padLeft(2, '0')}:${(seconds % 60).toString().padLeft(2, '0')}";
return Text(timeStr, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 22));
},
),
centerTitle: true,
actions: [
Consumer<SpiderGameController>(
builder: (context, controller, child) {
final bool canUndo = controller.canUndo;
return IconButton(
icon: Icon(Icons.undo, color: canUndo ? null : Colors.grey),
onPressed: canUndo ? () {
final int currentCount = controller.undo();
if (currentCount >= SpiderGameController.maxUndoCount) {
_showSurrenderDialog(context);
}
} : null,
);
},
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () {
Provider.of<SpiderGameController>(context, listen: false).restartGame();
},
),
],
);
}
void _showSurrenderDialog(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('게임 포기'),
content: const Text('되돌리기 횟수를 모두 사용했습니다. 게임을 포기하고 로비로 돌아가시겠습니까?'),
actions: [
TextButton(child: const Text('취소'), onPressed: () => Navigator.of(ctx).pop()),
TextButton(
child: const Text('포기하기'),
onPressed: () {
Navigator.of(ctx).pop();
Navigator.of(context).pop();
},
),
],
),
);
}
@override
void initState() {
super.initState();
final controller = Provider.of<SpiderGameController>(context, listen: false);
_controllerListener = () {
final screenSize = MediaQuery.of(context).size;
const double horizontalPadding = 10;
const double cardGap = 5;
final double cardWidth = (screenSize.width - (horizontalPadding * 2) - (cardGap * 9)) / 10;
final double cardHeight = cardWidth * 1.45;
// 🔽 [수정] 덱 분배 애니메이션 (플래그 가드 추가)
if (controller.cardsToDealAnimate.isNotEmpty && !_isDealAnimationRunning) {
_isDealAnimationRunning = true; // 👈 [잠금]
debugPrint("[LOG] initState Listener: Detected cardsToDealAnimate. Running animation...");
_runDealAnimation(controller, cardWidth, cardHeight);
}
// 🔽 [수정] 스택 완성 애니메이션 (경주 조건 해결 로직)
if (controller.cardsToAnimateStack.isNotEmpty && !_isStackAnimationRunning) {
_isStackAnimationRunning = true; // 👈 [잠금]
// [핵심] 큐를 복사하고, 인덱스도 *지금* 읽어서 복사합니다.
final List<SpiderCard> cardsToAnimate = List.of(controller.cardsToAnimateStack);
final int sourceIndex = controller.animationSourcePileIndex;
final int targetIndex = controller.animationTargetFoundationIndex;
// 큐를 즉시 비웁니다.
controller.clearStackAnimationTrigger();
debugPrint("[LOG] initState Listener: Detected cardsToAnimateStack (Source: $sourceIndex). Running animation...");
// 복사한 데이터를 인자로 전달합니다.
_runStackCompletionAnimation(controller, cardWidth, cardHeight, cardsToAnimate, sourceIndex, targetIndex);
}
};
controller.addListener(_controllerListener!);
}
@override
void dispose() {
if (_controllerListener != null) {
final controller = Provider.of<SpiderGameController>(context, listen: false);
controller.removeListener(_controllerListener!);
}
super.dispose();
}
@override
Widget build(BuildContext context) {
debugPrint("[LOG] SpiderGameScreen: --- Main Build Method CALLED ---");
final controller = context.read<SpiderGameController>();
final screenSize = MediaQuery.of(context).size;
const double horizontalPadding = 10;
const double cardGap = 5;
final double cardWidth = (screenSize.width - (horizontalPadding * 2) - (cardGap * 9)) / 10;
final double cardHeight = cardWidth * 1.45;
final double cardOverlap = cardHeight * 0.4;
final bool isGameCompleted = context.select((SpiderGameController c) => c.isGameCompleted);
if (isGameCompleted && !_isDialogShowing) {
_isDialogShowing = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
_runGameWinAnimation(context, controller, cardWidth, cardHeight);
}
});
}
return Scaffold(
appBar: _buildGameAppBar(context, controller),
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: Stack(
key: _bodyStackKey,
children: [
Column(
children: [
Expanded(
child: Container(
color: const Color(0xFF008000),
padding: EdgeInsets.symmetric(horizontal: horizontalPadding, vertical: 10),
child: Stack(
children: [
Consumer<SpiderGameController>(
builder: (context, controller, child) {
debugPrint("[LOG] Tableau Consumer: Rebuilding");
return Stack(
children: List.generate(10, (index) {
return Positioned(
left: index * (cardWidth + cardGap),
top: 0,
child: TableauPileWidget(
key: _tableauKeys[index],
pileIndex: index,
cards: controller.currentState.tableau[index],
cardWidth: cardWidth,
cardHeight: cardHeight,
cardOverlap: cardOverlap,
),
);
}),
);
}
),
],
),
),
),
const AdBannerWidget(),
BottomBarWidget(
key: _stockKey,
cardWidth: cardWidth,
cardHeight: cardHeight,
),
],
),
if (_showDimOverlay)
Container(
color: Colors.black.withOpacity(0.5),
),
..._animationOverlays,
],
),
bottomNavigationBar: null,
);
}
/// 🔽 덱 분배 애니메이션 (오버레이)
void _runDealAnimation(
SpiderGameController controller,
double cardWidth,
double cardHeight,
) {
// 🔽 [수정] 덱 분배 애니메이션도 경주 조건을 피하기 위해 인자로 받도록 수정
final List<SpiderCard> cardsToDeal = List.of(controller.cardsToDealAnimate);
controller.clearDealAnimationTrigger();
debugPrint("[LOG] _runDealAnimation: Starting. ${cardsToDeal.length} cards.");
final RenderBox? bodyStackBox = _bodyStackKey.currentContext?.findRenderObject() as RenderBox?;
final RenderBox? stockBox = _stockKey.currentContext?.findRenderObject() as RenderBox?;
if (bodyStackBox == null || stockBox == null) {
debugPrint("[LOG] _runDealAnimation: FAILED (Keys not ready)");
_isDealAnimationRunning = false; // 👈 [잠금 해제]
return;
}
final Offset globalStartPos = stockBox.localToGlobal(Offset.zero);
final Offset localStartPos = bodyStackBox.globalToLocal(globalStartPos);
for (int i = 0; i < cardsToDeal.length; i++) {
final card = cardsToDeal[i];
final RenderBox? targetBox = _tableauKeys[i].currentContext?.findRenderObject() as RenderBox?;
if (targetBox == null) continue;
final Offset globalEndPos = targetBox.localToGlobal(Offset.zero);
final double targetY = globalEndPos.dy + controller.currentState.tableau[i].length * (cardHeight * 0.4);
final Offset localEndPos = bodyStackBox.globalToLocal(Offset(globalEndPos.dx, targetY));
final animationDelayMs = i * 100;
final animationDurationMs = 600;
final overlayEntry = TweenAnimationBuilder<double>(
key: ValueKey('deal_${card.id}'),
tween: Tween(begin: 0.0, end: 1.0),
duration: Duration(milliseconds: animationDurationMs),
builder: (context, value, child) {
final currentPos = Offset.lerp(localStartPos, localEndPos, value)!;
final bool isFlipping = value > 0.5;
final double rotationY = isFlipping ? (value - 0.5) * 2 * pi : 0;
return Positioned(
left: currentPos.dx,
top: currentPos.dy,
child: Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
..rotateY(rotationY),
child: CardWidget(
card: card..isFaceUp = (value > 0.5),
width: cardWidth,
height: cardHeight,
isDraggable: false,
),
),
);
},
);
Future.delayed(Duration(milliseconds: animationDelayMs), () {
if (mounted) {
setState(() {
_animationOverlays.add(overlayEntry);
});
Future.delayed(Duration(milliseconds: animationDurationMs), () {
if (mounted) {
setState(() {
_animationOverlays.remove(overlayEntry);
});
if (i == cardsToDeal.length - 1) {
debugPrint("[LOG] _runDealAnimation: Animation FINISHED. Calling finalizeDealFromStock.");
controller.finalizeDealFromStock(cardsToDeal);
_isDealAnimationRunning = false; // 👈 [잠금 해제]
}
}
});
}
});
}
}
/// 🔽 스택 완성 애니메이션 (오버레이)
void _runStackCompletionAnimation(
SpiderGameController controller,
double cardWidth,
double cardHeight,
// 🔽 [수정] 인자를 받습니다.
List<SpiderCard> cardsToAnimate,
int sourceIndex,
int targetIndex,
) {
debugPrint("[LOG] _runStackCompletionAnimation: Starting. ${cardsToAnimate.length} cards from index $sourceIndex.");
final RenderBox? bodyStackBox = _bodyStackKey.currentContext?.findRenderObject() as RenderBox?;
final RenderBox? stockBox = _stockKey.currentContext?.findRenderObject() as RenderBox?;
// 🔽 [수정] 인자로 받은 sourceIndex 사용
final RenderBox? startBox = _tableauKeys[sourceIndex].currentContext?.findRenderObject() as RenderBox?;
if (bodyStackBox == null || stockBox == null || startBox == null) {
debugPrint("[LOG] _runStackCompletionAnimation: FAILED (Keys not ready for index $sourceIndex)");
_isStackAnimationRunning = false; // 👈 [잠금 해제]
return;
}
final Offset globalStartPos = startBox.localToGlobal(Offset.zero);
final Offset localStartPos = bodyStackBox.globalToLocal(globalStartPos);
// 🔽 [수정] 스택이 제거되기 '전'의 길이를 기준으로 계산 (정확)
final double startY = localStartPos.dy + (controller.currentState.tableau[sourceIndex].length - cardsToAnimate.length) * (cardHeight * 0.4);
// 🔽 [수정] 인자로 받은 targetIndex 사용
final Offset globalEndPos = stockBox.localToGlobal(Offset( (targetIndex * (cardWidth * 0.15)) - cardWidth*3, 10));
final Offset localEndPos = bodyStackBox.globalToLocal(globalEndPos);
for (int i = 0; i < cardsToAnimate.length; i++) {
final card = cardsToAnimate[i];
final animationDelayMs = i * 80;
final animationDurationMs = 400;
final overlayEntry = TweenAnimationBuilder<double>(
key: ValueKey('stack_${card.id}'),
tween: Tween(begin: 0.0, end: 1.0),
duration: Duration(milliseconds: animationDurationMs),
builder: (context, value, child) {
final currentPos = Offset.lerp(Offset(localStartPos.dx, startY + (i * cardHeight * 0.4)), localEndPos, value)!;
return Positioned(
left: currentPos.dx,
top: currentPos.dy,
child: CardWidget(
card: card..isFaceUp = true,
width: cardWidth,
height: cardHeight,
isDraggable: false,
),
);
},
);
Future.delayed(Duration(milliseconds: animationDelayMs), () {
if (mounted) {
setState(() {
_animationOverlays.add(overlayEntry);
});
Future.delayed(Duration(milliseconds: animationDurationMs), () {
if (mounted) {
setState(() {
_animationOverlays.remove(overlayEntry);
});
if (i == cardsToAnimate.length - 1) {
debugPrint("[LOG] _runStackCompletionAnimation: Animation FINISHED. Calling finalizeStackCompletion for index $sourceIndex.");
// 🔽 [수정] finalize가 어떤 스택을 처리할지 인덱스를 전달
controller.finalizeStackCompletion(cardsToAnimate, sourceIndex);
_isStackAnimationRunning = false; // 👈 [잠금 해제]
}
}
});
}
});
}
}
/// 🔽 [수정] _runGameWinAnimation (팝업 호출 로직 변경)
void _runGameWinAnimation(
BuildContext context,
SpiderGameController controller,
double cardWidth,
double cardHeight,
) {
final RenderBox? bodyStackBox = _bodyStackKey.currentContext?.findRenderObject() as RenderBox?;
if (bodyStackBox == null) return;
final screenSize = MediaQuery.of(context).size;
final random = Random();
final List<SpiderCard> allCards = controller.currentState.foundation.expand((pile) => pile).toList();
final Offset globalStartPos = Offset(screenSize.width / 2, screenSize.height * 0.8);
final Offset localStartPos = bodyStackBox.globalToLocal(globalStartPos);
for (int i = 0; i < allCards.length; i++) {
final card = allCards[i];
final animationDelay = Duration(milliseconds: i * 30);
final animationDuration = const Duration(milliseconds: 1500);
final Offset globalEndPos = Offset(random.nextDouble() * screenSize.width, -cardHeight - (AppBar().preferredSize.height));
final Offset localEndPos = bodyStackBox.globalToLocal(globalEndPos);
final overlayEntry = TweenAnimationBuilder<double>(
key: ValueKey('win_${card.id}'),
tween: Tween(begin: 0.0, end: 1.0),
duration: animationDuration,
builder: (context, value, child) {
final currentPos = Offset.lerp(localStartPos, localEndPos, value)!;
return Positioned(
left: currentPos.dx,
top: currentPos.dy,
child: Transform.rotate(
angle: value * pi * 2,
child: CardWidget(
card: card..isFaceUp=true,
width: cardWidth,
height: cardHeight,
isDraggable: false,
),
),
);
},
);
Future.delayed(animationDelay, () {
if (mounted) {
setState(() {
_animationOverlays.add(overlayEntry);
});
// ❌ [삭제] 500ms 후에 팝업을 띄우는 로직
// if (i == 0) { ... }
Future.delayed(animationDuration, () {
if (mounted) {
setState(() {
_animationOverlays.remove(overlayEntry);
});
}
});
}
});
}
// 🔽 [추가] 딤 오버레이(배경 어두워짐)는 500ms 뒤에 바로 표시
Future.delayed(const Duration(milliseconds: 500), () {
if (mounted && controller.isGameCompleted) {
setState(() { _showDimOverlay = true; });
}
});
// 🔽 [추가] 랭킹 팝업은 약 2초 뒤 표시
final popupDelay = (allCards.length > 70) ? const Duration(seconds: 2) : const Duration(milliseconds: 500);
Future.delayed(popupDelay, () {
if (mounted && controller.isGameCompleted) {
// [수정] _showGameCompletedDialog() 호출 대신 공통 화면으로 이동
// 1. 점수 포맷터 정의
String formatSpiderScore(int primary, int? secondary) {
final moves = primary.toString();
final time = (secondary ?? 0).toString();
return '${moves}회 (${time}초)';
}
// 2. 레벨 저장 콜백 정의
Future<void> saveSpiderProgress(String playerName) async {
// (playerName은 공통 화면이 IdentityService로 저장)
final identityService = IdentityService();
final int currentMaxLevel = await identityService.getMaxUnlockedLevel(gameType: 'SPIDER');
if (currentMaxLevel < 99) {
if (controller.difficulty.levelIndex >= currentMaxLevel) {
int nextLevel = controller.difficulty.levelIndex + 1;
if (nextLevel > SpiderDifficulties.allDifficulties.length) {
await identityService.saveMaxUnlockedLevel(99, gameType: 'SPIDER');
} else {
await identityService.saveMaxUnlockedLevel(nextLevel, gameType: 'SPIDER');
}
}
}
}
// 3. 화면 이동
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => GameCompletionScreen(
args: GameResultArgs(
gameType: 'SPIDER',
contextId: controller.difficulty.contextId,
primaryScore: controller.currentState.moves,
secondaryScore: controller.secondsElapsed,
userId: controller.userId,
userName: controller.userName,
scoreFormatter: formatSpiderScore,
onProgressSave: saveSpiderProgress,
onScreenClose: () {
// 공통 화면에서 '닫기'를 누르면 게임 화면도 닫힘
if (Navigator.canPop(context)) {
Navigator.pop(context);
}
},
),
),
),
);
}
});
}
// ❌ [삭제] _showGameCompletedDialog() 메서드 전체 (약 200줄) 삭제
}
@@ -0,0 +1,205 @@
// packages/feature_game_spider/lib/screens/spider_lobby_screen.dart
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:service_api/service_api.dart';
import 'package:feature_common/feature_common.dart';
import 'spider_game_screen.dart';
import '../models/spider_difficulty.dart';
import '../controllers/spider_game_controller.dart';
class SpiderLobbyScreen extends StatefulWidget {
const SpiderLobbyScreen({ super.key });
@override
State<SpiderLobbyScreen> createState() => _SpiderLobbyScreenState();
}
class _SpiderLobbyScreenState extends State<SpiderLobbyScreen> {
int _maxUnlockedLevel = 1;
Map<int, (int, int)> _rankHistory = {};
String? _userName;
bool _isLoading = false;
final PuzzleService _puzzleService = PuzzleService();
final IdentityService _identityService = IdentityService();
@override
void initState() {
super.initState();
_loadProgress();
}
// ( _loadProgress 메서드는 이전과 동일 )
Future<void> _loadProgress() async {
final maxLevel = await _identityService.getMaxUnlockedLevel(gameType: 'SPIDER');
final String? myName = await _identityService.getSavedUserName();
if (mounted) { setState(() { _maxUnlockedLevel = maxLevel; _userName = myName; }); }
if (myName == null) return;
try {
final Map<int, int> oldRankMap = await _identityService.getLastSavedRankMap(gameType: 'SPIDER');
List<Future<List<GameRankDto>>> rankFutures = [];
for (final level in SpiderDifficulties.allDifficulties) {
rankFutures.add(_puzzleService.fetchRanks('SPIDER', level.contextId));
}
final List<List<GameRankDto>> allRankResults = await Future.wait(rankFutures);
Map<int, int> newRankMapForStorage = {};
Map<int, (int, int)> newRankHistoryForState = {};
for (int i = 0; i < SpiderDifficulties.allDifficulties.length; i++) {
final level = SpiderDifficulties.allDifficulties[i];
final currentRanks = allRankResults[i];
final int levelIndex = level.levelIndex;
final int oldRank = oldRankMap[levelIndex] ?? 0;
int currentRank = 0;
int myRankIndex = currentRanks.indexWhere((r) => r.playerName == myName);
if (myRankIndex != -1) { currentRank = myRankIndex + 1; }
newRankMapForStorage[levelIndex] = currentRank;
newRankHistoryForState[levelIndex] = (oldRank, currentRank);
}
await _identityService.saveLastRankMap(newRankMapForStorage, gameType: 'SPIDER');
if (mounted) { setState(() { _rankHistory = newRankHistoryForState; }); }
log("스파이더 랭킹 변동 확인 완료. (유저: $myName)");
} catch (e) {
log("SpiderLobbyScreen: 랭킹 확인 실패: $e");
}
}
/// 🔽 [수정] _startGame 메서드 (UserInfo 주입)
Future<void> _startGame(SpiderDifficulty level) async {
setState(() { _isLoading = true; });
// 1. [수정] 랭킹 등록에 필요한 정보 미리 로드
final String userId = await _identityService.getOrCreateUserId();
final String? userName = _userName; // (이미 _loadProgress에서 로드됨)
// 2. 컨트롤러 생성 및 새 게임 시작
final gameController = SpiderGameController();
gameController.setUserInfo(userId, userName); // 👈 유저 정보 주입
gameController.startNewGame(level);
setState(() { _isLoading = false; });
if (!mounted) return;
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) =>
ChangeNotifierProvider.value(
value: gameController,
child: const SpiderGameScreen(),
),
),
);
_loadProgress();
}
// ( build 메서드는 이전과 동일 )
@override
Widget build(BuildContext context) {
context.watch<ThemeNotifier>();
final bool allLevelsUnlocked = _maxUnlockedLevel >= 9;
final theme = Theme.of(context);
return CommonGameShell(
title: '스파이더 솔리테어',
onRankingPressed: () {
final List<GameDifficulty> spiderDifficulties = SpiderDifficulties.allDifficulties
.map((level) => GameDifficulty(
name: level.name,
contextId: level.contextId,
))
.toList();
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => RankingScreen(
gameType: 'SPIDER',
difficulties: spiderDifficulties,
initialDifficultyName: SpiderDifficulties.getLevel(_maxUnlockedLevel).name,
),
),
);
},
body: LayoutBuilder(
builder: (context, constraints) {
const double maxContentRatio = 0.6;
final double constrainedWidth = (constraints.maxHeight * maxContentRatio) > 500
? 500 : (constraints.maxHeight * maxContentRatio);
return Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: constrainedWidth),
child: Column(
children: [
Expanded(
child: ListView.builder(
itemCount: SpiderDifficulties.allDifficulties.length,
itemBuilder: (context, index) {
final SpiderDifficulty level = SpiderDifficulties.allDifficulties[index];
final bool isUnlocked = allLevelsUnlocked || level.levelIndex <= _maxUnlockedLevel;
final (int oldRank, int currentRank) = _rankHistory[level.levelIndex] ?? (0, 0);
Widget? trailingWidget = isUnlocked ? const Icon(Icons.play_arrow_rounded) : null;
String? subtitleText;
Color? subtitleColor;
if (currentRank > 0) {
String rankStr = "${currentRank}";
if (oldRank > 0) {
int change = oldRank - currentRank;
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 {
subtitleText = "$rankStr (신규 진입)";
subtitleColor = Colors.blue;
trailingWidget = const Icon(Icons.new_releases_rounded, color: Colors.blue, size: 28);
}
} else {
if (oldRank > 0) {
subtitleText = "랭킹 이탈 (이전 ${oldRank}위)";
subtitleColor = Colors.orange;
trailingWidget = const Icon(Icons.warning_amber_rounded, color: Colors.orange, size: 28);
}
}
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 ? theme.primaryColor : Colors.grey,
),
title: Text(level.name, style: TextStyle(
fontSize: 18,
fontWeight: isUnlocked ? FontWeight.bold : FontWeight.normal,
color: isUnlocked ? theme.textTheme.bodyLarge?.color : Colors.grey,
)),
subtitle: subtitleText != null
? Text(subtitleText, style: TextStyle(color: subtitleColor, fontWeight: FontWeight.bold))
: null,
trailing: trailingWidget,
onTap: isUnlocked && !_isLoading
? () => _startGame(level)
: null,
),
);
},
),
),
],
),
),
);
},
),
);
}
}