fa2784519b
- daily claim: record the claim before granting boosters, so a crash mid-claim forfeits at most one reward instead of allowing a re-claim (booster farming) on next launch. - game screen: disarm the booster target synchronously before awaiting, so a rapid second board tap can't double-fire a use or stack a dialog. - new players: seed one of each booster once (idempotent persisted flag), fulfilling the spec's starting inventory. Wired in main(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
727 lines
26 KiB
Dart
727 lines
26 KiB
Dart
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../game/engine/game_engine.dart';
|
|
import '../../game/models/booster.dart';
|
|
import '../../game/models/grid.dart';
|
|
import '../../l10n/gen/app_localizations.dart';
|
|
import '../../services/audio_service.dart';
|
|
import '../../state/game_session_notifier.dart';
|
|
import '../../state/providers.dart';
|
|
import '../theme/palette.dart';
|
|
import '../widgets/board_geometry.dart';
|
|
import '../widgets/board_painter.dart';
|
|
import '../widgets/board_widget.dart';
|
|
import '../widgets/booster_bar.dart';
|
|
import '../widgets/effects_overlay.dart';
|
|
import '../widgets/hud_widget.dart';
|
|
import '../widgets/piece_painter.dart';
|
|
import '../widgets/season_background.dart';
|
|
import '../widgets/tray_widget.dart';
|
|
import '../widgets/tutorial_overlay.dart';
|
|
|
|
/// Which line a line-bomb clears, chosen from the tapped cell's row or column.
|
|
enum _LineAxis { row, col }
|
|
|
|
/// Renders whatever session [gameSessionProvider] holds; callers start the
|
|
/// stage (via SeasonFlowNotifier) before navigating here.
|
|
class GameScreen extends ConsumerStatefulWidget {
|
|
const GameScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<GameScreen> createState() => _GameScreenState();
|
|
}
|
|
|
|
class _GameScreenState extends ConsumerState<GameScreen>
|
|
with TickerProviderStateMixin {
|
|
final _boardKey = GlobalKey();
|
|
final _stackKey = GlobalKey();
|
|
final _effectsKey = GlobalKey<EffectsOverlayState>();
|
|
late final AnimationController _shake = AnimationController(
|
|
vsync: this,
|
|
duration: const Duration(milliseconds: 350),
|
|
);
|
|
|
|
bool _tutorialStartChecked = false;
|
|
bool _endlessNewBest = false;
|
|
|
|
int? _dragIndex;
|
|
Offset? _dragGlobal;
|
|
|
|
/// Non-null while a targeted booster is armed and waiting for a board tap.
|
|
/// Shuffle never sets this (it applies immediately).
|
|
BoosterType? _arming;
|
|
|
|
/// How far the dragged piece floats above the finger so it stays visible.
|
|
static const double _lift = 70;
|
|
|
|
RenderBox? get _boardBox =>
|
|
_boardKey.currentContext?.findRenderObject() as RenderBox?;
|
|
|
|
/// Global top-left of the dragged piece, rendered at board cell size.
|
|
Offset? _draggedPieceTopLeft(GameViewState view) {
|
|
final i = _dragIndex;
|
|
final g = _dragGlobal;
|
|
final box = _boardBox;
|
|
if (i == null || g == null || box == null || i >= view.tray.length) {
|
|
return null;
|
|
}
|
|
final geo = BoardGeometry(boardSize: box.size.width);
|
|
final piece = view.tray[i];
|
|
final (w, h) = pieceCellBounds(piece);
|
|
final pw = w * geo.cellSize;
|
|
final ph = h * geo.cellSize;
|
|
return g + Offset(-pw / 2, -_lift - ph);
|
|
}
|
|
|
|
GhostSpec? _ghost(GameViewState view) {
|
|
final i = _dragIndex;
|
|
final box = _boardBox;
|
|
final topLeftGlobal = _draggedPieceTopLeft(view);
|
|
if (i == null || box == null || topLeftGlobal == null) return null;
|
|
|
|
final geo = BoardGeometry(boardSize: box.size.width);
|
|
final piece = view.tray[i];
|
|
final (w, h) = pieceCellBounds(piece);
|
|
final centerLocal = box.globalToLocal(
|
|
topLeftGlobal + Offset(w * geo.cellSize / 2, h * geo.cellSize / 2),
|
|
);
|
|
// Only preview while the piece is actually over the board.
|
|
if (centerLocal.dx < -geo.cellSize ||
|
|
centerLocal.dy < -geo.cellSize ||
|
|
centerLocal.dx > box.size.width + geo.cellSize ||
|
|
centerLocal.dy > box.size.height + geo.cellSize) {
|
|
return null;
|
|
}
|
|
|
|
final topLeftLocal = box.globalToLocal(topLeftGlobal);
|
|
final (ax, ay) = geo.snapAnchor(piece, topLeftLocal);
|
|
final legal =
|
|
ref.read(gameSessionProvider.notifier).canPlaceAt(i, ax, ay);
|
|
return GhostSpec(piece: piece, anchorX: ax, anchorY: ay, legal: legal);
|
|
}
|
|
|
|
void _onDragEnd(GameViewState view) {
|
|
final i = _dragIndex;
|
|
final ghost = _ghost(view);
|
|
setState(() {
|
|
_dragIndex = null;
|
|
_dragGlobal = null;
|
|
});
|
|
if (i != null && ghost != null && ghost.legal) {
|
|
ref
|
|
.read(gameSessionProvider.notifier)
|
|
.tryPlace(i, ghost.anchorX, ghost.anchorY);
|
|
}
|
|
}
|
|
|
|
/// True when a [SaveRepository] is wired up. The default provider throws
|
|
/// until overridden (in app start and most tests); a couple of legacy widget
|
|
/// tests mount GameScreen without it, and the booster bar tolerates that.
|
|
bool _hasSaveRepository() {
|
|
try {
|
|
ref.read(saveRepositoryProvider);
|
|
return true;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ---- Booster targeting ----
|
|
|
|
/// Tapping a booster button. Empty → offer a rewarded ad; shuffle applies
|
|
/// immediately; hammer/line-bomb arm targeting and show a hint.
|
|
Future<void> _onBoosterTap(BoosterType type) async {
|
|
if ((ref.read(boosterInventoryProvider)[type] ?? 0) <= 0) {
|
|
await _offerBoosterAd(type);
|
|
return;
|
|
}
|
|
if (type == BoosterType.shuffle) {
|
|
await ref.read(gameSessionProvider.notifier).useShuffle();
|
|
return;
|
|
}
|
|
// hammer / lineBomb need a board target.
|
|
setState(() => _arming = type);
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final hint =
|
|
type == BoosterType.hammer ? l10n.boosterTapTarget : l10n.boosterTapLine;
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context)
|
|
..hideCurrentSnackBar()
|
|
..showSnackBar(SnackBar(
|
|
content: Text(hint),
|
|
duration: const Duration(seconds: 3),
|
|
));
|
|
}
|
|
|
|
/// Converts a board tap into a cell, then applies the armed booster.
|
|
Future<void> _onBoardTapUp(TapUpDetails details) async {
|
|
final armed = _arming;
|
|
if (armed == null) return;
|
|
final box = _boardBox;
|
|
if (box == null) return;
|
|
final local = box.globalToLocal(details.globalPosition);
|
|
final cell = BoardGeometry(boardSize: box.size.width).cellSize;
|
|
final x = (local.dx / cell).floor();
|
|
final y = (local.dy / cell).floor();
|
|
if (x < 0 || x >= GridState.size || y < 0 || y >= GridState.size) return;
|
|
|
|
// Disarm synchronously, before any await, so a rapid second tap on the
|
|
// board is a no-op rather than a redundant booster use / stacked dialog.
|
|
setState(() => _arming = null);
|
|
|
|
final session = ref.read(gameSessionProvider.notifier);
|
|
if (armed == BoosterType.hammer) {
|
|
await session.useHammer(x, y);
|
|
} else if (armed == BoosterType.lineBomb) {
|
|
final axis = await _chooseLineAxis();
|
|
if (axis == _LineAxis.row) {
|
|
await session.useLineBomb(row: y);
|
|
} else if (axis == _LineAxis.col) {
|
|
await session.useLineBomb(col: x);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Small chooser for the line-bomb: clear the tapped row or column.
|
|
Future<_LineAxis?> _chooseLineAxis() {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
return showDialog<_LineAxis>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
content: Text(l10n.boosterTapLine),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(_LineAxis.row),
|
|
child: Text('↔ ${l10n.boosterLineRow}'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(_LineAxis.col),
|
|
child: Text('↕ ${l10n.boosterLineCol}'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Task 15: an empty booster offers a rewarded ad; on reward, grant +1.
|
|
Future<void> _offerBoosterAd(BoosterType type) async {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
content: Text(l10n.boosterGetWithAd),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
child: Text(l10n.giveUp),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.of(context).pop(true),
|
|
child: Text(l10n.boosterGetWithAd),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (confirmed != true) return;
|
|
final earned = await ref.read(adServiceProvider).showRewarded();
|
|
if (earned) {
|
|
await ref.read(boosterInventoryProvider.notifier).grant(type, 1);
|
|
ref
|
|
.read(analyticsProvider)
|
|
.boosterGranted(type: type.name, count: 1, source: 'ad');
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_shake.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onSessionChange(GameViewState? prev, GameViewState? next) {
|
|
if (next == null) return;
|
|
final audio = ref.read(audioServiceProvider);
|
|
if (prev?.fxTick != next.fxTick && next.lastPlacement != null) {
|
|
final placement = next.lastPlacement!;
|
|
final hapticsOn = ref.read(soundEnabledProvider);
|
|
if (placement.linesCleared > 0) {
|
|
audio.play(placement.comboStreak >= 2 ? Sfx.combo : Sfx.clear);
|
|
if (hapticsOn) HapticFeedback.mediumImpact();
|
|
if (placement.comboStreak >= 4) {
|
|
if (hapticsOn) HapticFeedback.heavyImpact();
|
|
_shake.forward(from: 0);
|
|
}
|
|
} else {
|
|
audio.play(Sfx.place);
|
|
if (hapticsOn) HapticFeedback.lightImpact();
|
|
}
|
|
ref.read(tutorialProvider.notifier).onPlaced();
|
|
if (placement.linesCleared > 0) {
|
|
ref.read(tutorialProvider.notifier).onLineCleared();
|
|
}
|
|
final boardBox = _boardBox;
|
|
final stackBox =
|
|
_stackKey.currentContext?.findRenderObject() as RenderBox?;
|
|
if (boardBox != null && stackBox != null) {
|
|
final topLeft =
|
|
stackBox.globalToLocal(boardBox.localToGlobal(Offset.zero));
|
|
_effectsKey.currentState?.onPlacement(
|
|
placement,
|
|
boardRect: topLeft & boardBox.size,
|
|
);
|
|
}
|
|
}
|
|
if (prev?.phase != next.phase) {
|
|
// A finished stage ends the tutorial; otherwise the overlay would sit
|
|
// on top of the result card and leak into the next stage.
|
|
if (next.phase != GamePhase.playing) {
|
|
ref.read(tutorialProvider.notifier).skip();
|
|
}
|
|
if (next.phase == GamePhase.won || next.phase == GamePhase.lost) {
|
|
ref.read(adServiceProvider).onStageCompleted();
|
|
}
|
|
if (next.phase == GamePhase.won) {
|
|
audio.play(Sfx.win);
|
|
// recordResult keeps the best run, so re-entry is harmless.
|
|
if (!next.endless) {
|
|
ref
|
|
.read(seasonFlowProvider.notifier)
|
|
.recordWin(stars: next.starsEarned, score: next.score);
|
|
final flow = ref.read(seasonFlowProvider);
|
|
if (flow != null) {
|
|
ref.read(analyticsProvider).stageEnd(
|
|
seasonId: flow.pack.seasonId,
|
|
stageId: flow.stage.id,
|
|
won: true,
|
|
stars: next.starsEarned,
|
|
score: next.score,
|
|
movesUsed: next.moveLimit - next.movesLeft,
|
|
);
|
|
}
|
|
// recordWin (above) has already updated progress synchronously, so
|
|
// the policy's cleared-stage count includes this win. Fire-and-forget
|
|
// — a review prompt must never block the result card.
|
|
ref
|
|
.read(reviewServiceProvider)
|
|
.maybeRequestAfterWin(stars: next.starsEarned);
|
|
}
|
|
final stackBox =
|
|
_stackKey.currentContext?.findRenderObject() as RenderBox?;
|
|
if (stackBox != null) {
|
|
_effectsKey.currentState?.onWin(stackBox.size);
|
|
}
|
|
}
|
|
if (next.phase == GamePhase.lost) audio.play(Sfx.lose);
|
|
if (next.phase == GamePhase.lost && next.endless) {
|
|
ref.read(endlessBestProvider.notifier).record(next.score).then((isNew) {
|
|
if (mounted) setState(() => _endlessNewBest = isNew);
|
|
ref.read(analyticsProvider).endlessEnd(score: next.score, isNewBest: isNew);
|
|
});
|
|
}
|
|
if (next.phase == GamePhase.lost && !next.endless) {
|
|
final flow = ref.read(seasonFlowProvider);
|
|
if (flow != null) {
|
|
ref.read(analyticsProvider).stageEnd(
|
|
seasonId: flow.pack.seasonId,
|
|
stageId: flow.stage.id,
|
|
won: false,
|
|
stars: 0,
|
|
score: next.score,
|
|
movesUsed: next.moveLimit - next.movesLeft,
|
|
);
|
|
}
|
|
}
|
|
if (next.phase == GamePhase.won || next.phase == GamePhase.lost) {
|
|
ref.read(streakProvider.notifier).onStagePlayed(DateTime.now());
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
ref.listen<GameViewState?>(gameSessionProvider, _onSessionChange);
|
|
ref.listen(streakProvider, (prev, next) {
|
|
final milestone = next.hitMilestone;
|
|
if (milestone != null && prev?.hitMilestone != milestone) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l10n.streakMilestone(milestone))),
|
|
);
|
|
}
|
|
});
|
|
final view = ref.watch(gameSessionProvider);
|
|
if (view == null) {
|
|
return const Scaffold(body: Center(child: CircularProgressIndicator()));
|
|
}
|
|
|
|
final tutorialStep = ref.watch(tutorialProvider);
|
|
if (!_tutorialStartChecked) {
|
|
_tutorialStartChecked = true;
|
|
final flow = ref.read(seasonFlowProvider);
|
|
if (flow != null &&
|
|
flow.index == 0 &&
|
|
!ref.read(saveRepositoryProvider).tutorialDone) {
|
|
WidgetsBinding.instance.addPostFrameCallback(
|
|
(_) => ref.read(tutorialProvider.notifier).start());
|
|
}
|
|
}
|
|
|
|
final ghost = _ghost(view);
|
|
final draggedTopLeft = _draggedPieceTopLeft(view);
|
|
final boardBox = _boardBox;
|
|
|
|
// The booster bar needs the save-backed inventory. A few legacy widget
|
|
// tests mount GameScreen without a SaveRepository override; in that case
|
|
// the inventory provider throws, so only watch it (and mount the bar) when
|
|
// the repository is actually wired up.
|
|
final hasSave = _hasSaveRepository();
|
|
final boosterCounts = hasSave ? ref.watch(boosterInventoryProvider) : null;
|
|
|
|
final theme = ref.watch(activeThemeProvider);
|
|
return Scaffold(
|
|
backgroundColor: Colors.transparent,
|
|
body: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
SeasonBackground(theme: theme),
|
|
SafeArea(
|
|
child: Stack(
|
|
key: _stackKey,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
children: [
|
|
// Close/back sits on its own top row so it never overlaps
|
|
// the HUD's moves chip (which lives at the top-left).
|
|
if (Navigator.of(context).canPop())
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: IconButton(
|
|
padding: EdgeInsets.zero,
|
|
visualDensity: VisualDensity.compact,
|
|
icon: const Icon(Icons.close, color: Colors.white54),
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
),
|
|
),
|
|
HudWidget(view: view),
|
|
Expanded(
|
|
child: Center(
|
|
child: AnimatedBuilder(
|
|
animation: _shake,
|
|
builder: (context, child) {
|
|
final t = _shake.value;
|
|
final dx =
|
|
math.sin(t * math.pi * 10) * 6 * (1 - t);
|
|
return Transform.translate(
|
|
offset: Offset(dx, 0), child: child);
|
|
},
|
|
// While a targeted booster is armed, taps on the
|
|
// board pick a cell. When not arming, onTapUp
|
|
// returns immediately so it never steals the
|
|
// tray-drag placement gestures.
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.deferToChild,
|
|
onTapUp: _arming == null
|
|
? null
|
|
: (details) => _onBoardTapUp(details),
|
|
child: BoardWidget(
|
|
key: _boardKey,
|
|
view: view,
|
|
ghost: ghost,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
TrayWidget(
|
|
tray: view.tray,
|
|
draggingIndex: _dragIndex,
|
|
onDragStart: (index, global) => setState(() {
|
|
_dragIndex = index;
|
|
_dragGlobal = global;
|
|
}),
|
|
onDragUpdate: (global) =>
|
|
setState(() => _dragGlobal = global),
|
|
onDragEnd: () => _onDragEnd(view),
|
|
),
|
|
if (view.phase == GamePhase.playing &&
|
|
boosterCounts != null) ...[
|
|
const SizedBox(height: 8),
|
|
BoosterBar(
|
|
counts: boosterCounts,
|
|
onTap: _onBoosterTap,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
if (_dragIndex != null &&
|
|
draggedTopLeft != null &&
|
|
boardBox != null &&
|
|
_dragIndex! < view.tray.length)
|
|
_draggedPieceOverlay(view, draggedTopLeft, boardBox),
|
|
Positioned.fill(child: EffectsOverlay(key: _effectsKey)),
|
|
if (view.phase != GamePhase.playing) _resultOverlay(view),
|
|
if (tutorialStep != null)
|
|
Positioned.fill(
|
|
child: TutorialOverlay(
|
|
step: tutorialStep,
|
|
handFrom: _tutorialHandFrom(),
|
|
handTo: _tutorialHandTo(view),
|
|
onSkip: () => ref.read(tutorialProvider.notifier).skip(),
|
|
onDismissHud: () =>
|
|
ref.read(tutorialProvider.notifier).dismissHud(),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Offset _tutorialHandFrom() {
|
|
final stackBox =
|
|
_stackKey.currentContext?.findRenderObject() as RenderBox?;
|
|
if (stackBox == null) return Offset.zero;
|
|
// Tray sits at the bottom; aim at the left slot.
|
|
final size = stackBox.size;
|
|
return Offset(size.width * 0.18, size.height - 80);
|
|
}
|
|
|
|
Offset _tutorialHandTo(GameViewState view) {
|
|
final boardBox = _boardBox;
|
|
final stackBox =
|
|
_stackKey.currentContext?.findRenderObject() as RenderBox?;
|
|
if (boardBox == null || stackBox == null || view.tray.isEmpty) {
|
|
return Offset.zero;
|
|
}
|
|
final geo = BoardGeometry(boardSize: boardBox.size.width);
|
|
final notifier = ref.read(gameSessionProvider.notifier);
|
|
for (var y = 0; y < GridState.size; y++) {
|
|
for (var x = 0; x < GridState.size; x++) {
|
|
if (notifier.canPlaceAt(0, x, y)) {
|
|
final local =
|
|
Offset((x + 0.5) * geo.cellSize, (y + 0.5) * geo.cellSize);
|
|
return stackBox.globalToLocal(boardBox.localToGlobal(local));
|
|
}
|
|
}
|
|
}
|
|
return Offset.zero;
|
|
}
|
|
|
|
Widget _draggedPieceOverlay(
|
|
GameViewState view, Offset topLeftGlobal, RenderBox boardBox) {
|
|
final stackBox =
|
|
_stackKey.currentContext!.findRenderObject()! as RenderBox;
|
|
final local = stackBox.globalToLocal(topLeftGlobal);
|
|
final cellSize = boardBox.size.width / 8;
|
|
return Positioned(
|
|
left: local.dx,
|
|
top: local.dy,
|
|
child: IgnorePointer(
|
|
child: PieceWidget(piece: view.tray[_dragIndex!], cellSize: cellSize),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _resultOverlay(GameViewState view) {
|
|
final l10n = AppLocalizations.of(context)!;
|
|
final notifier = ref.read(gameSessionProvider.notifier);
|
|
final theme = Theme.of(context);
|
|
|
|
final flow = ref.read(seasonFlowProvider);
|
|
final (title, actions) = switch ((view.phase, view.stuckReason)) {
|
|
(GamePhase.won, _) => (
|
|
l10n.stageClear,
|
|
[
|
|
if (flow != null && flow.hasNext)
|
|
FilledButton(
|
|
onPressed: () {
|
|
ref.read(seasonFlowProvider.notifier).nextStage();
|
|
if (!view.endless) {
|
|
ref.read(adServiceProvider).maybeShowInterstitial();
|
|
}
|
|
},
|
|
child: Text(l10n.nextStage),
|
|
),
|
|
TextButton(
|
|
onPressed: notifier.restart,
|
|
child: Text(l10n.playAgain),
|
|
),
|
|
],
|
|
),
|
|
(GamePhase.stuck, StuckReason.outOfMoves) => (
|
|
l10n.outOfMoves,
|
|
[
|
|
if (!view.rescueUsed)
|
|
FilledButton(
|
|
onPressed: () async {
|
|
final earned =
|
|
await ref.read(adServiceProvider).showRewarded();
|
|
if (!earned) return;
|
|
ref.read(analyticsProvider).rescueUsed(type: 'extra_moves');
|
|
notifier.addExtraMoves();
|
|
},
|
|
child: Text(l10n.plusFiveMoves),
|
|
),
|
|
if (view.rescueUsed)
|
|
FilledButton(
|
|
onPressed: notifier.declineAndLose,
|
|
child: Text(l10n.giveUp),
|
|
)
|
|
else
|
|
TextButton(
|
|
onPressed: notifier.declineAndLose,
|
|
child: Text(l10n.giveUp),
|
|
),
|
|
],
|
|
),
|
|
(GamePhase.stuck, _) => (
|
|
l10n.boardFull,
|
|
[
|
|
if (!view.rescueUsed)
|
|
FilledButton(
|
|
onPressed: () async {
|
|
final earned =
|
|
await ref.read(adServiceProvider).showRewarded();
|
|
if (!earned) return;
|
|
ref.read(analyticsProvider).rescueUsed(type: 'continue');
|
|
notifier.useContinue();
|
|
},
|
|
child: Text(l10n.watchAdContinue),
|
|
),
|
|
if (view.rescueUsed)
|
|
FilledButton(
|
|
onPressed: notifier.declineAndLose,
|
|
child: Text(l10n.giveUp),
|
|
)
|
|
else
|
|
TextButton(
|
|
onPressed: notifier.declineAndLose,
|
|
child: Text(l10n.giveUp),
|
|
),
|
|
],
|
|
),
|
|
(GamePhase.lost, _) when view.endless => (
|
|
l10n.gameOver,
|
|
[
|
|
FilledButton(
|
|
onPressed: () {
|
|
setState(() => _endlessNewBest = false);
|
|
notifier.restart();
|
|
},
|
|
child: Text(l10n.playAgain),
|
|
),
|
|
],
|
|
),
|
|
(_, _) => (
|
|
l10n.stageFailed,
|
|
[
|
|
FilledButton(
|
|
onPressed: () {
|
|
ref.read(adServiceProvider).maybeShowInterstitial();
|
|
notifier.restart();
|
|
},
|
|
child: Text(l10n.playAgain),
|
|
),
|
|
],
|
|
),
|
|
};
|
|
|
|
return Positioned.fill(
|
|
child: ColoredBox(
|
|
color: Colors.black54,
|
|
child: Center(
|
|
child: Card(
|
|
color: GamePalette.boardBackground,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(title, style: theme.textTheme.headlineSmall),
|
|
if (view.phase == GamePhase.won) ...[
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
for (var i = 0; i < 3; i++)
|
|
TweenAnimationBuilder<double>(
|
|
key: ValueKey(i),
|
|
tween: Tween(begin: 0, end: 1),
|
|
duration: Duration(milliseconds: 400 + i * 250),
|
|
curve: Interval(i * 0.22, 1, curve: Curves.elasticOut),
|
|
builder: (context, v, child) =>
|
|
Transform.scale(scale: v, child: child),
|
|
child: Icon(
|
|
Icons.star,
|
|
size: 44,
|
|
color: i < view.starsEarned ? Colors.amber : Colors.white24,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
if (view.phase == GamePhase.lost && view.endless) ...[
|
|
const SizedBox(height: 10),
|
|
Text('${view.score}',
|
|
style: theme.textTheme.displaySmall
|
|
?.copyWith(fontWeight: FontWeight.w900)),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
_endlessNewBest
|
|
? l10n.newBest
|
|
: l10n.bestScore(ref.read(endlessBestProvider)),
|
|
style: TextStyle(
|
|
color:
|
|
_endlessNewBest ? Colors.amber : Colors.white60,
|
|
fontWeight: FontWeight.w800,
|
|
),
|
|
),
|
|
],
|
|
if (view.phase == GamePhase.lost && !view.endless && view.objectiveProgress > 0) ...[
|
|
const SizedBox(height: 16),
|
|
SizedBox(
|
|
width: 88,
|
|
height: 88,
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
CircularProgressIndicator(
|
|
value: view.objectiveProgress,
|
|
strokeWidth: 7,
|
|
backgroundColor: Colors.white12,
|
|
color: Colors.amber,
|
|
),
|
|
Center(
|
|
child: Text(
|
|
l10n.almostThere((view.objectiveProgress * 100).round()),
|
|
textAlign: TextAlign.center,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: theme.textTheme.labelSmall,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 20),
|
|
...actions,
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|