This commit is contained in:
2025-11-17 18:21:49 +09:00
parent 13ed537b23
commit 86611ce092
160 changed files with 7829 additions and 452 deletions
@@ -1,4 +1,3 @@
// packages/feature_game_spider/lib/screens/spider_game_screen.dart
import 'dart:convert';
import 'dart:math';
import 'package:flutter/material.dart';
@@ -12,8 +11,6 @@ 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});
@@ -34,7 +31,9 @@ class _SpiderGameScreenState extends State<SpiderGameScreen> {
bool _isDealAnimationRunning = false;
bool _isStackAnimationRunning = false;
// ( _buildGameAppBar, _showSurrenderDialog 는 동일 )
// ( ... _buildGameAppBar, _showSurrenderDialog, initState, dispose ... )
// ( ... build, _runDealAnimation, _runStackCompletionAnimation ... )
// ( ... 이 메서드들은 모두 동일합니다 ... )
AppBar _buildGameAppBar(BuildContext context, SpiderGameController controller) {
return AppBar(
leading: IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.of(context).pop()),
@@ -89,47 +88,33 @@ class _SpiderGameScreenState extends State<SpiderGameScreen> {
),
);
}
@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; // 👈 [잠금]
_isDealAnimationRunning = true;
debugPrint("[LOG] initState Listener: Detected cardsToDealAnimate. Running animation...");
_runDealAnimation(controller, cardWidth, cardHeight);
}
// 🔽 [수정] 스택 완성 애니메이션 (경주 조건 해결 로직)
if (controller.cardsToAnimateStack.isNotEmpty && !_isStackAnimationRunning) {
_isStackAnimationRunning = true; // 👈 [잠금]
// [핵심] 큐를 복사하고, 인덱스도 *지금* 읽어서 복사합니다.
_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) {
@@ -138,20 +123,16 @@ class _SpiderGameScreenState extends State<SpiderGameScreen> {
}
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;
@@ -161,7 +142,6 @@ class _SpiderGameScreenState extends State<SpiderGameScreen> {
}
});
}
return Scaffold(
appBar: _buildGameAppBar(context, controller),
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
@@ -209,54 +189,42 @@ class _SpiderGameScreenState extends State<SpiderGameScreen> {
),
],
),
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; // 👈 [잠금 해제]
_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),
@@ -265,7 +233,6 @@ class _SpiderGameScreenState extends State<SpiderGameScreen> {
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,
@@ -284,23 +251,16 @@ class _SpiderGameScreenState extends State<SpiderGameScreen> {
);
},
);
Future.delayed(Duration(milliseconds: animationDelayMs), () {
if (mounted) {
setState(() {
_animationOverlays.add(overlayEntry);
});
setState(() { _animationOverlays.add(overlayEntry); });
Future.delayed(Duration(milliseconds: animationDurationMs), () {
if (mounted) {
setState(() {
_animationOverlays.remove(overlayEntry);
});
setState(() { _animationOverlays.remove(overlayEntry); });
if (i == cardsToDeal.length - 1) {
debugPrint("[LOG] _runDealAnimation: Animation FINISHED. Calling finalizeDealFromStock.");
controller.finalizeDealFromStock(cardsToDeal);
_isDealAnimationRunning = false; // 👈 [잠금 해제]
_isDealAnimationRunning = false;
}
}
});
@@ -308,44 +268,32 @@ class _SpiderGameScreenState extends State<SpiderGameScreen> {
});
}
}
/// 🔽 스택 완성 애니메이션 (오버레이)
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; // 👈 [잠금 해제]
_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),
@@ -364,23 +312,16 @@ class _SpiderGameScreenState extends State<SpiderGameScreen> {
);
},
);
Future.delayed(Duration(milliseconds: animationDelayMs), () {
if (mounted) {
setState(() {
_animationOverlays.add(overlayEntry);
});
setState(() { _animationOverlays.add(overlayEntry); });
Future.delayed(Duration(milliseconds: animationDurationMs), () {
if (mounted) {
setState(() {
_animationOverlays.remove(overlayEntry);
});
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; // 👈 [잠금 해제]
_isStackAnimationRunning = false;
}
}
});
@@ -388,8 +329,8 @@ class _SpiderGameScreenState extends State<SpiderGameScreen> {
});
}
}
/// 🔽 [수정] _runGameWinAnimation (팝업 호출 로직 변경)
/// 🔽 [수정] _runGameWinAnimation (Navigation 로직 변경)
void _runGameWinAnimation(
BuildContext context,
SpiderGameController controller,
@@ -405,12 +346,12 @@ class _SpiderGameScreenState extends State<SpiderGameScreen> {
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),
@@ -432,41 +373,28 @@ class _SpiderGameScreenState extends State<SpiderGameScreen> {
);
},
);
Future.delayed(animationDelay, () {
if (mounted) {
setState(() {
_animationOverlays.add(overlayEntry);
});
// ❌ [삭제] 500ms 후에 팝업을 띄우는 로직
// if (i == 0) { ... }
setState(() { _animationOverlays.add(overlayEntry); });
Future.delayed(animationDuration, () {
if (mounted) {
setState(() {
_animationOverlays.remove(overlayEntry);
});
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, () {
Future.delayed(popupDelay, () async { // 👈 [수정] async 추가
if (mounted && controller.isGameCompleted) {
// [수정] _showGameCompletedDialog() 호출 대신 공통 화면으로 이동
// 1. 점수 포맷터 정의
String formatSpiderScore(int primary, int? secondary) {
final moves = primary.toString();
@@ -476,10 +404,7 @@ class _SpiderGameScreenState extends State<SpiderGameScreen> {
// 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) {
@@ -493,8 +418,8 @@ class _SpiderGameScreenState extends State<SpiderGameScreen> {
}
}
// 3. 화면 이동
Navigator.pushReplacement(
// 3. [수정] 'pushReplacement' 대신 'await push' 사용
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => GameCompletionScreen(
@@ -507,16 +432,16 @@ class _SpiderGameScreenState extends State<SpiderGameScreen> {
userName: controller.userName,
scoreFormatter: formatSpiderScore,
onProgressSave: saveSpiderProgress,
onScreenClose: () {
// 공통 화면에서 '닫기'를 누르면 게임 화면도 닫힘
if (Navigator.canPop(context)) {
Navigator.pop(context);
}
},
// ❌ onScreenClose 제거
),
),
),
);
// 4. [추가] 랭킹 화면에서 돌아오면, 게임 화면(self)을 닫고 로비로 돌아감
if (mounted && Navigator.canPop(context)) {
Navigator.pop(context);
}
}
});
}
@@ -2,7 +2,7 @@
import 'dart:developer';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:service_api/service_api.dart';
import 'package:service_api/service_api.dart'; // 👈 SessionNotifier 포함
import 'package:feature_common/feature_common.dart';
import 'spider_game_screen.dart';
import '../models/spider_difficulty.dart';
@@ -17,24 +17,42 @@ class SpiderLobbyScreen extends StatefulWidget {
class _SpiderLobbyScreenState extends State<SpiderLobbyScreen> {
int _maxUnlockedLevel = 1;
Map<int, (int, int)> _rankHistory = {};
String? _userName;
// ❌ String? _userName; (SessionNotifier가 관리)
bool _isLoading = false;
// 🔽 [수정] SessionNotifier를 사용하기 위해 IdentityService 대신 추가
late final SessionNotifier _sessionNotifier;
final PuzzleService _puzzleService = PuzzleService();
final IdentityService _identityService = IdentityService();
final IdentityService _identityService = IdentityService(); // 👈 (save/load progress를 위해 유지)
@override
void initState() {
super.initState();
_loadProgress();
// 🔽 [수정] initState에서 SessionNotifier를 read
// SessionNotifier의 loadSession()이 먼저 완료되었다고 가정
_sessionNotifier = context.read<SessionNotifier>();
// 🔽 [수정] _loadProgress가 랭킹까지 모두 새로고침 (최초 1회)
_loadProgress(forceRefreshRanks: true);
}
// ( _loadProgress 메서드는 이전과 동일 )
Future<void> _loadProgress() async {
/// 🔽 [수정] _loadProgress 메서드 (SessionNotifier 사용 및 로직 분리)
Future<void> _loadProgress({bool forceRefreshRanks = false}) async {
// 1. (가벼움) 레벨 정보 새로고침
final maxLevel = await _identityService.getMaxUnlockedLevel(gameType: 'SPIDER');
final String? myName = await _identityService.getSavedUserName();
if (mounted) { setState(() { _maxUnlockedLevel = maxLevel; _userName = myName; }); }
if (myName == null) return;
if (mounted) {
setState(() {
_maxUnlockedLevel = maxLevel;
});
}
// 2. (무거움) 랭킹 정보 새로고침 (필요할 때만)
if (!forceRefreshRanks) return;
// 🔽 [수정] _sessionNotifier에서 유저 이름을 가져옴
final String? myName = _sessionNotifier.session?.userName;
if (myName == null) return; // 게스트이거나 아직 이름 저장을 안 함
try {
final Map<int, int> oldRankMap = await _identityService.getLastSavedRankMap(gameType: 'SPIDER');
List<Future<List<GameRankDto>>> rankFutures = [];
@@ -64,13 +82,20 @@ class _SpiderLobbyScreenState extends State<SpiderLobbyScreen> {
}
/// 🔽 [수정] _startGame 메서드 (UserInfo 주입)
/// 🔽 [수정] _startGame 메서드 (SessionNotifier 사용)
Future<void> _startGame(SpiderDifficulty level) async {
setState(() { _isLoading = true; });
// 1. [수정] 랭킹 등록에 필요한 정보 미리 로드
final String userId = await _identityService.getOrCreateUserId();
final String? userName = _userName; // (이미 _loadProgress에서 로드됨)
// 1. [수정] SessionNotifier에서 유저 정보 가져오기
final session = _sessionNotifier.session;
if (session == null) {
log("세션이 로드되지 않아 게임을 시작할 수 없습니다.");
setState(() { _isLoading = false; });
return;
}
final String userId = session.userId;
final String? userName = session.userName;
// 2. 컨트롤러 생성 및 새 게임 시작
final gameController = SpiderGameController();
@@ -91,36 +116,27 @@ class _SpiderLobbyScreenState extends State<SpiderLobbyScreen> {
),
);
_loadProgress();
// 🔽 [핵심 수정]
// 랭킹(forceRefreshRanks: true)은 새로고침하지 않고,
// 레벨 잠금 상태(forceRefreshRanks: false)만 새로고침합니다.
_loadProgress(forceRefreshRanks: false);
}
// ( build 메서드는 이전과 동일 )
@override
Widget build(BuildContext context) {
// 🔽 [수정] ThemeNotifier와 SessionNotifier를 모두 watch
context.watch<ThemeNotifier>();
context.watch<SessionNotifier>(); // 👈 세션 변경(로그인/로그아웃) 감지
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,
),
),
);
// ... (랭킹 버튼 로직 동일)
},
// 🔽 [수정] RefreshIndicator 추가 (당겨서 랭킹 새로고침)
body: LayoutBuilder(
builder: (context, constraints) {
const double maxContentRatio = 0.6;
@@ -132,66 +148,70 @@ class _SpiderLobbyScreenState extends State<SpiderLobbyScreen> {
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);
child: RefreshIndicator(
onRefresh: () => _loadProgress(forceRefreshRanks: true),
child: ListView.builder(
itemCount: SpiderDifficulties.allDifficulties.length,
itemBuilder: (context, index) {
// ... (이하 ListTile 로직은 모두 동일)
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.grey;
trailingWidget = const Icon(Icons.check_circle_outline_rounded, color: Colors.grey, size: 28);
subtitleText = "$rankStr (신규 진입)";
subtitleColor = Colors.blue;
trailingWidget = const Icon(Icons.new_releases_rounded, color: Colors.blue, size: 28);
}
} else {
subtitleText = "$rankStr (신규 진입)";
subtitleColor = Colors.blue;
trailingWidget = const Icon(Icons.new_releases_rounded, color: Colors.blue, size: 28);
if (oldRank > 0) {
subtitleText = "랭킹 이탈 (이전 ${oldRank}위)";
subtitleColor = Colors.orange;
trailingWidget = const Icon(Icons.warning_amber_rounded, color: Colors.orange, 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)
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,
),
);
},
),
),
),
],