Faster game screen (#3230)

This commit is contained in:
Vincent Velociter
2026-05-25 12:26:07 +02:00
committed by GitHub
parent 7c03908df4
commit 5be90665a7
57 changed files with 2632 additions and 1598 deletions
+36
View File
@@ -89,6 +89,32 @@ overrides: {
Direct provider overrides are acceptable for **non-network providers** (repositories backed by mocks, services with no HTTP, etc.) where the provider has no `keepAlive` dependency and the override doesn't skip meaningful logic.
### Chessboard Testing Patterns
Since chessground v10, pieces and highlights are rendered by `CustomPainter`s, not as individual widgets. **Do not use `find.byKey(Key('e2-whitepawn'))` or `find.byKey(ValueKey('${sq}-highlight'))` — those keys no longer exist.**
Use the helpers in `test/test_helpers.dart` instead:
```dart
// Check pieces on any Chessboard (interactive or Chessboard.fixed)
getBoardPieces(tester) // Map<Square, Piece>
boardHasPiece(tester, Square.f3, Piece.whiteKnight) // bool
// Check square highlights (squareHighlights prop on Chessboard.fixed)
boardHasHighlight(tester, square) // bool
// Check premove highlight
boardHasPremove(tester, move) // bool
// Tap/move at a board square
squareOffset(square, tester.getRect(find.byType(Chessboard)))
```
For `ChessboardEditor` (board editor screen), read pieces directly from the widget:
```dart
tester.widget<ChessboardEditor>(find.byType(ChessboardEditor)).pieces
```
### Analysis Rules (CRITICAL)
**Always run `flutter analyze` on every file you edit, including test files, before finishing.**
@@ -183,6 +209,16 @@ lib/src/
**State Management**: Riverpod providers throughout `lib/src/model/`. Controllers, repositories, and services are implemented as providers. State is immutable and managed with Freezed data classes.
**Riverpod version: 3.x — API differences from 2.x (CRITICAL)**
This project uses `flutter_riverpod 3.x` / `riverpod 3.x`. Several APIs changed from 2.x:
- **`AsyncValue.value`** (not `valueOrNull`): In 3.x, `AsyncValue<T>.value` returns `T?` (null while loading/error). `valueOrNull` no longer exists.
- **`ProviderListenable` is not exported**: Do not use `ProviderListenable<T>` as a type annotation — it is an internal interface. The return type of `.select()` is also internal (`_ProviderSelector`). `ProviderListenableSelect` is exported but is an *extension* on `ProviderListenable`, not a class — it cannot be used as a type.
- When you need to pass a provider or select result to `ref.watch`/`ref.read`/`ref.listenManual`, just pass it directly without naming the type. If a return type annotation is required (e.g. an abstract method), use two concrete methods (`readCurrentState()` + `listenToStateChanges()`) instead of trying to name the provider type.
- **`ref.listenManual` type inference**: When the selected type is nullable (e.g. `provider.select((v) => v.value)``T?`), Dart cannot infer `StateT` because the callback signature is `void Function(StateT?, StateT)`. Always add an explicit type: `ref.listenManual<T?>(provider.select(...), listener)`.
- **`listenManual` in `ConsumerState`**: Subscriptions set up in `initState()` via `ref.listenManual` are automatically cancelled when the widget is disposed. No need to store or cancel manually.
**Binding Layer**: `LichessBinding` (in `binding.dart`) provides a testable abstraction for plugins and external APIs:
- SharedPreferences
- Firebase (messaging, crashlytics)
@@ -473,11 +473,6 @@ class AnalysisController extends AsyncNotifier<AnalysisState>
void onUserMove(Move move, {bool shouldReplace = false}) {
if (!state.requireValue.currentPosition.isLegal(move)) return;
if (move case NormalMove() when isPromotionPawnMove(state.requireValue.currentPosition, move)) {
state = AsyncValue.data(state.requireValue.copyWith(promotionMove: move));
return;
}
final (newPath, isNewNode) = _root.addMoveAt(
state.requireValue.currentPath,
move,
@@ -489,18 +484,6 @@ class AnalysisController extends AsyncNotifier<AnalysisState>
}
}
void onPromotionSelection(Role? role) {
if (role == null) {
state = AsyncData(state.requireValue.copyWith(promotionMove: null));
return;
}
final promotionMove = state.requireValue.promotionMove;
if (promotionMove != null) {
final promotion = promotionMove.withPromotion(role);
onUserMove(promotion);
}
}
void userPrevious() {
_setPath(state.requireValue.currentPath.penultimate, isNavigating: true);
}
@@ -755,7 +738,6 @@ class AnalysisController extends AsyncNotifier<AnalysisState>
currentNode: AnalysisCurrentNode.fromNode(currentNode),
currentBranchOpening: opening,
lastMove: currentNode.sanMove.move,
promotionMove: null,
root: rootView,
),
);
@@ -767,7 +749,6 @@ class AnalysisController extends AsyncNotifier<AnalysisState>
currentNode: AnalysisCurrentNode.fromNode(currentNode),
currentBranchOpening: opening,
lastMove: null,
promotionMove: null,
root: rootView,
),
);
@@ -888,9 +869,6 @@ sealed class AnalysisState
/// The last move played.
Move? lastMove,
/// Possible promotion move to be played.
NormalMove? promotionMove,
/// Opening of the analysis context (from lichess archived games).
Opening? contextOpening,
@@ -30,9 +30,6 @@ abstract class CommonAnalysisState {
/// The side to display the board from.
Side get pov;
/// Possible promotion move to be played.
NormalMove? get promotionMove;
/// Squares that should have an atomic explosion animation after the last move.
///
/// Returns `null` if the variant is not atomic, there is no last move, or the
@@ -258,32 +258,12 @@ class RetroController extends AsyncNotifier<RetroState>
void onUserMove(Move move) {
if (!state.requireValue.currentPosition.isLegal(move)) return;
if (move case NormalMove() when isPromotionPawnMove(state.requireValue.currentPosition, move)) {
state = AsyncValue.data(state.requireValue.copyWith(promotionMove: move));
return;
}
final (newPath, isNewNode) = _root.addMoveAt(state.requireValue.currentPath, move);
if (newPath != null) {
_setPath(newPath);
}
}
void onPromotionSelection(Role? role) {
final state = this.state.value;
if (state == null) return;
if (role == null) {
this.state = AsyncValue.data(state.copyWith(promotionMove: null));
return;
}
final promotionMove = state.promotionMove;
if (promotionMove != null) {
final promotion = promotionMove.withPromotion(role);
onUserMove(promotion);
}
}
void userNext() {
_setPath(
state.requireValue.currentPath +
@@ -375,7 +355,6 @@ class RetroController extends AsyncNotifier<RetroState>
currentPath: path,
currentNode: RetroCurrentNode.fromNode(currentNode),
lastMove: currentNode.sanMove.move,
promotionMove: null,
root: isNavigating ? state.root : _root.view,
),
);
@@ -385,7 +364,6 @@ class RetroController extends AsyncNotifier<RetroState>
currentPath: path,
currentNode: RetroCurrentNode.fromNode(currentNode),
lastMove: null,
promotionMove: null,
root: isNavigating ? state.root : _root.view,
),
);
@@ -523,7 +501,6 @@ sealed class RetroState
required ViewRoot root,
DateTime? evalRequestedAt,
Move? lastMove,
NormalMove? promotionMove,
@Default(false) bool engineInThreatMode,
}) = _RetroState;
+2 -2
View File
@@ -132,7 +132,7 @@ typedef BroadcastTournamentGroup = ({
});
typedef BroadcastCustomPointsPerColor = ({double win, double draw});
typedef BroadcastCustomScoring = BySide<BroadcastCustomPointsPerColor>;
typedef BroadcastCustomScoring = IMap<Side, BroadcastCustomPointsPerColor>;
extension BroadcastCustomScoringExt on BroadcastCustomScoring {
String pointsForResult(Side side, BroadcastResult result) {
@@ -193,7 +193,7 @@ sealed class BroadcastGame with _$BroadcastGame {
const factory BroadcastGame({
required BroadcastGameId id,
required BySide<BroadcastPlayerWithClock> players,
required IMap<Side, BroadcastPlayerWithClock> players,
required String fen,
required Move? lastMove,
required Duration? thinkTime,
@@ -301,31 +301,12 @@ class BroadcastAnalysisController extends AsyncNotifier<BroadcastAnalysisState>
if (!state.requireValue.currentPosition.isLegal(move)) return;
if (move case NormalMove() when isPromotionPawnMove(state.requireValue.currentPosition, move)) {
state = AsyncData(state.requireValue.copyWith(promotionMove: move));
return;
}
final (newPath, isNewNode) = _root.addMoveAt(state.requireValue.currentPath, move);
if (newPath != null) {
_setPath(newPath, shouldRecomputeRootView: isNewNode, shouldForceShowVariation: true);
}
}
void onPromotionSelection(Role? role) {
if (!state.hasValue) return;
if (role == null) {
state = AsyncData(state.requireValue.copyWith(promotionMove: null));
return;
}
final promotionMove = state.requireValue.promotionMove;
if (promotionMove != null) {
final promotion = promotionMove.withPromotion(role);
onUserMove(promotion);
}
}
void userPrevious() {
_setPath(state.requireValue.currentPath.penultimate, isNavigating: true);
}
@@ -478,7 +459,6 @@ class BroadcastAnalysisController extends AsyncNotifier<BroadcastAnalysisState>
isOnMainline: _root.isOnMainline(path),
currentNode: AnalysisCurrentNode.fromNode(currentNode),
lastMove: currentNode.sanMove.move,
promotionMove: null,
root: rootView,
clocks: _getClocks(path),
),
@@ -491,7 +471,6 @@ class BroadcastAnalysisController extends AsyncNotifier<BroadcastAnalysisState>
isOnMainline: _root.isOnMainline(path),
currentNode: AnalysisCurrentNode.fromNode(currentNode),
lastMove: null,
promotionMove: null,
root: rootView,
clocks: _getClocks(path),
),
@@ -600,9 +579,6 @@ sealed class BroadcastAnalysisState
/// The last move played.
Move? lastMove,
/// Possible promotion move to be played.
NormalMove? promotionMove,
/// The PGN headers of the game.
required IMap<String, String> pgnHeaders,
+30 -3
View File
@@ -19,10 +19,8 @@ sealed class GameBoardParams with _$GameBoardParams {
required Variant variant,
required Position position,
required PlayerSide playerSide,
required NormalMove? promotionMove,
required void Function(Move, {bool? viaDragAndDrop}) onMove,
required void Function(Role? role) onPromotionSelection,
required Premovable? premovable,
Move? lastMove,
}) = InteractiveBoardParams;
static const emptyBoard = ReadonlyBoardParams(
@@ -46,3 +44,32 @@ sealed class GameBoardParams with _$GameBoardParams {
InteractiveBoardParams(:final playerSide) => playerSide,
};
}
/// Board parameters for the high-performance path, where the caller owns a
/// [ChessboardController] and drives it directly.
///
/// Mutually exclusive with [GameBoardParams]: the [controller] carries the live
/// position and game state (including `playerSide` and `sideToMove`), so this
/// only needs to provide what isn't part of the controller — the [variant] (for
/// board settings), the [onMove] callback, and the crazyhouse [pockets] (which
/// the owner must refresh on each move).
class ControllerBoardParams {
const ControllerBoardParams({
required this.controller,
required this.variant,
this.onMove,
this.pockets,
});
/// The externally owned controller. [GameLayout] renders the board with it but
/// never creates, disposes, or drives it.
final ChessboardController controller;
final Variant variant;
/// Called when the user completes a move on the board.
final void Function(Move, {bool? viaDragAndDrop})? onMove;
/// Crazyhouse pockets, or null for variants without pockets.
final Pockets? pockets;
}
+5 -69
View File
@@ -179,13 +179,8 @@ class GameController extends AsyncNotifier<GameState> {
void userMove(Move move, {bool? viaDragAndDrop, bool? isPremove}) {
final curState = state.requireValue;
if (move case NormalMove() when isPromotionPawnMove(curState.game.lastPosition, move)) {
state = AsyncValue.data(curState.copyWith(promotionMove: move));
return;
}
if (curState.shouldConfirmMove && isPremove != true) {
state = AsyncValue.data(curState.copyWith(moveToConfirm: move, promotionMove: null));
state = AsyncValue.data(curState.copyWith(moveToConfirm: move));
return;
}
@@ -214,8 +209,6 @@ class GameController extends AsyncNotifier<GameState> {
curState.copyWith(
game: curState.game.copyWith(steps: curState.game.steps.add(newStep)),
stepCursor: curState.stepCursor + 1,
promotionMove: null,
premove: null,
),
);
@@ -230,21 +223,6 @@ class GameController extends AsyncNotifier<GameState> {
);
}
void onPromotionSelection(Role? role) {
final curState = state.requireValue;
if (role == null) {
state = AsyncValue.data(curState.copyWith(promotionMove: null));
return;
}
if (curState.promotionMove == null) {
assert(false, 'promotionMove must not be null on promotion select');
return;
}
final move = curState.promotionMove!.withPromotion(role);
userMove(move, viaDragAndDrop: true);
}
/// Called if the player cancels the move when confirm move preference is enabled
void cancelMove() {
final curState = state.requireValue;
@@ -310,12 +288,6 @@ class GameController extends AsyncNotifier<GameState> {
);
}
/// Set or unset a premove.
void setPremove(Move? move) {
final curState = state.requireValue;
state = AsyncValue.data(curState.copyWith(premove: move));
}
void cursorAt(int cursor) {
if (state.hasValue) {
final currentCursor = state.requireValue.stepCursor;
@@ -323,7 +295,7 @@ class GameController extends AsyncNotifier<GameState> {
return;
}
final (newState, _) = _tryCancelMoveConfirmation(state.requireValue);
state = AsyncValue.data(newState.copyWith(stepCursor: cursor, premove: null));
state = AsyncValue.data(newState.copyWith(stepCursor: cursor));
final san = state.requireValue.game.stepAt(cursor).sanMove?.san;
if (san != null) {
_playReplayMoveSound(san);
@@ -336,13 +308,7 @@ class GameController extends AsyncNotifier<GameState> {
if (state.hasValue) {
final curState = state.requireValue;
if (curState.stepCursor < curState.game.steps.length - 1) {
state = AsyncValue.data(
curState.copyWith(
stepCursor: curState.stepCursor + 1,
premove: null,
promotionMove: null,
),
);
state = AsyncValue.data(curState.copyWith(stepCursor: curState.stepCursor + 1));
final san = curState.game.stepAt(curState.stepCursor + 1).sanMove?.san;
if (san != null) {
_playReplayMoveSound(san);
@@ -357,11 +323,7 @@ class GameController extends AsyncNotifier<GameState> {
if (curState.stepCursor > 0) {
final (newState, didCancel) = _tryCancelMoveConfirmation(curState);
state = AsyncValue.data(
newState.copyWith(
stepCursor: didCancel ? newState.stepCursor : newState.stepCursor - 1,
premove: null,
promotionMove: null,
),
newState.copyWith(stepCursor: didCancel ? newState.stepCursor : newState.stepCursor - 1),
);
final san = state.requireValue.game.stepAt(state.requireValue.stepCursor).sanMove?.san;
if (san != null) {
@@ -474,7 +436,6 @@ class GameController extends AsyncNotifier<GameState> {
void acceptTakeback() {
_socketClient.send('takeback-yes', null);
setPremove(null);
}
void cancelOrDeclineTakeback() {
@@ -617,8 +578,6 @@ class GameController extends AsyncNotifier<GameState> {
state.requireValue.copyWith(
game: newGame,
stepCursor: hasSameNumberOfSteps ? curState.stepCursor : newGame.steps.length - 1,
premove: hasSameNumberOfSteps ? curState.premove : null,
promotionMove: hasSameNumberOfSteps ? curState.promotionMove : null,
moveToConfirm: hasSameNumberOfSteps ? curState.moveToConfirm : null,
opponentLeftCountdown: isOpponentOnGame
? null
@@ -692,10 +651,8 @@ class GameController extends AsyncNotifier<GameState> {
newState = newState.copyWith(
game: newState.game.copyWith(steps: newState.game.steps.add(newStep)),
// Clear any pending move confirmation or promotion since the position
// has changed and these moves are no longer valid.
// Clear any pending move confirmation since the position has changed.
moveToConfirm: null,
promotionMove: null,
);
if (!curState.isReplaying) {
@@ -755,20 +712,6 @@ class GameController extends AsyncNotifier<GameState> {
ref.read(ongoingGamesProvider.notifier).updateGame(gameFullId, newState.game);
}
if (!curState.isReplaying &&
playedSide == curState.game.youAre?.opposite &&
curState.premove != null) {
scheduleMicrotask(() {
final postMovePremove = state.value?.premove;
final postMovePosition = state.value?.game.lastPosition;
if (postMovePremove != null && postMovePosition?.isLegal(postMovePremove) == true) {
userMove(postMovePremove, isPremove: true);
} else if (postMovePremove != null) {
newState = newState.copyWith(premove: null);
}
});
}
state = AsyncValue.data(newState);
// End game event
@@ -783,7 +726,6 @@ class GameController extends AsyncNotifier<GameState> {
white: curState.game.white.copyWith(ratingDiff: endData.ratingDiff?.white),
black: curState.game.black.copyWith(ratingDiff: endData.ratingDiff?.black),
),
premove: null,
);
if (endData.clock != null) {
@@ -1070,12 +1012,6 @@ sealed class GameState with _$GameState {
int? lastDrawOfferAtPly,
(Duration, DateTime)? opponentLeftCountdown,
/// Promotion waiting to be selected (only if auto queen is disabled)
NormalMove? promotionMove,
/// Premove waiting to be played
Move? premove,
/// Game only setting to override the account preference
bool? moveConfirmSettingOverride,
+8 -4
View File
@@ -41,13 +41,17 @@ sealed class MaterialDiff with _$MaterialDiff {
}
int score = 0;
final IMap<Role, int> blackCount = position.board.materialCount(Side.black);
final IMap<Role, int> whiteCount = position.board.materialCount(Side.white);
final IMap<Role, int> blackCount = position.board.materialCount(Side.black).toIMap();
final IMap<Role, int> whiteCount = position.board.materialCount(Side.white).toIMap();
final startingPosition = Position.initialPosition(position.rule);
final IMap<Role, int> blackStartingCount = startingPosition.board.materialCount(Side.black);
final IMap<Role, int> whiteStartingCount = startingPosition.board.materialCount(Side.white);
final IMap<Role, int> blackStartingCount = startingPosition.board
.materialCount(Side.black)
.toIMap();
final IMap<Role, int> whiteStartingCount = startingPosition.board
.materialCount(Side.white)
.toIMap();
IMap<Role, int> subtractPieceCounts(
IMap<Role, int> startingCount,
@@ -152,11 +152,6 @@ class OfflineComputerGameController extends Notifier<OfflineComputerGameState> {
void makeMove(Move move) {
if (state.isEngineThinking || state.isEvaluatingMove || !state.game.playable) return;
if (move case NormalMove() when isPromotionPawnMove(state.currentPosition, move)) {
state = state.copyWith(promotionMove: move);
return;
}
if (state.game.practiceMode) {
_makeMoveWithEvaluation(move);
} else {
@@ -167,27 +162,6 @@ class OfflineComputerGameController extends Notifier<OfflineComputerGameState> {
}
}
void onPromotionSelection(Role? role) {
if (role == null) {
state = state.copyWith(promotionMove: null);
return;
}
final promotionMove = state.promotionMove;
if (promotionMove != null) {
final move = promotionMove.withPromotion(role);
state = state.copyWith(promotionMove: null);
if (state.game.practiceMode) {
_makeMoveWithEvaluation(move);
} else {
_applyMove(move);
if (state.game.playable) {
_playEngineMoveAfterPlayerAnimation();
}
}
}
}
SanMove _applyMove(Move move) {
final (newPos, newSan) = state.currentPosition.makeSan(Move.parse(move.uci)!);
final sanMove = SanMove(newSan, move);
@@ -758,13 +732,13 @@ class OfflineComputerGameController extends Notifier<OfflineComputerGameState> {
void goForward() {
if (state.canGoForward) {
state = state.copyWith(stepCursor: state.stepCursor + 1, promotionMove: null);
state = state.copyWith(stepCursor: state.stepCursor + 1);
}
}
void goBack() {
if (state.canGoBack) {
state = state.copyWith(stepCursor: state.stepCursor - 1, promotionMove: null);
state = state.copyWith(stepCursor: state.stepCursor - 1);
}
}
@@ -892,7 +866,6 @@ sealed class OfflineComputerGameState with _$OfflineComputerGameState {
const factory OfflineComputerGameState({
required OfflineComputerGame game,
@Default(0) int stepCursor,
@Default(null) NormalMove? promotionMove,
@Default(false) bool isEngineThinking,
@Default(false) bool isLoadingHint,
@@ -64,11 +64,6 @@ class OverTheBoardGameController extends Notifier<OverTheBoardGameState> {
}
void makeMove(Move move) {
if (move case NormalMove() when isPromotionPawnMove(state.currentPosition, move)) {
state = state.copyWith(promotionMove: move);
return;
}
final (newPos, newSan) = state.currentPosition.makeSan(Move.parse(move.uci)!);
final sanMove = SanMove(newSan, move);
final newStep = GameStep(
@@ -120,19 +115,6 @@ class OverTheBoardGameController extends Notifier<OverTheBoardGameState> {
_moveFeedback(sanMove);
}
void onPromotionSelection(Role? role) {
if (role == null) {
state = state.copyWith(promotionMove: null);
return;
}
final promotionMove = state.promotionMove;
if (promotionMove != null) {
final move = promotionMove.withPromotion(role);
makeMove(move);
state = state.copyWith(promotionMove: null);
}
}
void onFlag(Side side) {
state = state.copyWith(
game: state.game.copyWith(status: GameStatus.outoftime, winner: side.opposite),
@@ -141,13 +123,13 @@ class OverTheBoardGameController extends Notifier<OverTheBoardGameState> {
void goForward() {
if (state.canGoForward) {
state = state.copyWith(stepCursor: state.stepCursor + 1, promotionMove: null);
state = state.copyWith(stepCursor: state.stepCursor + 1);
}
}
void goBack() {
if (state.canGoBack) {
state = state.copyWith(stepCursor: state.stepCursor - 1, promotionMove: null);
state = state.copyWith(stepCursor: state.stepCursor - 1);
}
}
@@ -170,7 +152,6 @@ sealed class OverTheBoardGameState with _$OverTheBoardGameState {
const factory OverTheBoardGameState({
required OverTheBoardGame game,
@Default(0) int stepCursor,
@Default(null) NormalMove? promotionMove,
}) = _OverTheBoardGameState;
factory OverTheBoardGameState.fromVariant(Variant variant, Speed speed, {String? initialFen}) {
@@ -105,11 +105,6 @@ class PuzzleController extends Notifier<PuzzleState> {
}
Future<void> onUserMove(Move move) async {
if (move case NormalMove() when isPromotionPawnMove(state.currentPosition, move)) {
state = state.copyWith(promotionMove: move);
return;
}
_addMove(move);
if (state.mode == PuzzleMode.play) {
@@ -153,18 +148,6 @@ class PuzzleController extends Notifier<PuzzleState> {
}
}
void onPromotionSelection(Role? role) {
if (role == null) {
state = state.copyWith(promotionMove: null);
return;
}
final promotionMove = state.promotionMove;
if (promotionMove != null) {
final move = promotionMove.withPromotion(role);
onUserMove(move);
}
}
void userNext() {
_viewSolutionTimer?.cancel();
_goToNextNode(isNavigating: true);
@@ -377,7 +360,6 @@ class PuzzleController extends Notifier<PuzzleState> {
root: _gameTree.view,
node: newNode,
lastMove: sanMove.move,
promotionMove: null,
shouldBlinkNextArrow: false,
);
}
@@ -450,7 +432,6 @@ sealed class PuzzleState with _$PuzzleState {
required ViewBranch node,
required ViewNode root,
Move? lastMove,
NormalMove? promotionMove,
PuzzleResult? result,
PuzzleFeedback? feedback,
required bool hintShown,
@@ -90,11 +90,6 @@ class StormController extends Notifier<StormState> {
if (state.clock.endAt != null) return;
state.clock.start();
if (move case NormalMove() when isPromotionPawnMove(state.position, move)) {
state = state.copyWith(promotionMove: move);
return;
}
final expected = state.expectedMove;
_addMove(move, ComboState.noChange, runStarted: true, userMove: true);
state = state.copyWith(moves: state.moves + 1);
@@ -130,18 +125,6 @@ class StormController extends Notifier<StormState> {
}
}
void onPromotionSelection(Role? role) {
if (role == null) {
state = state.copyWith(promotionMove: null);
return;
}
final promotionMove = state.promotionMove;
if (promotionMove != null) {
final move = promotionMove.withPromotion(role);
onUserMove(move);
}
}
Future<void> end() async {
ref.read(soundServiceProvider).play(Sound.puzzleStormEnd);
@@ -230,7 +213,6 @@ class StormController extends Notifier<StormState> {
current: newComboCurrent,
best: math.max(state.combo.best, state.combo.current + 1),
),
promotionMove: null,
);
Future<void>.delayed(userMove ? Duration.zero : const Duration(milliseconds: 250), () {
if (pos.board.pieceAt(move.to) != null) {
@@ -333,9 +315,6 @@ sealed class StormState with _$StormState {
/// bool to indicate that the first move has been played
required bool firstMovePlayed,
/// Promotion move to be selected
NormalMove? promotionMove,
}) = _StormState;
Move? get expectedMove => Move.parse(puzzle.solution[moveIndex + 1]);
+2 -61
View File
@@ -1,6 +1,4 @@
import 'package:chessground/chessground.dart';
import 'package:dartchess/dartchess.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@@ -209,7 +207,7 @@ sealed class BoardPrefs with _$BoardPrefs implements Serializable {
bool get hasColorAdjustments =>
brightness != kBoardDefaultBrightnessFilter || hue != kBoardDefaultHueFilter;
ChessboardSettings toBoardSettings() {
ChessboardSettings toBoardSettings(Variant variant) {
return ChessboardSettings(
pieceAssets: pieceSet.assets,
colorScheme: boardTheme.colors,
@@ -227,30 +225,7 @@ sealed class BoardPrefs with _$BoardPrefs implements Serializable {
dragTargetKind: dragTargetKind,
pieceShiftMethod: pieceShiftMethod,
drawShape: DrawShapeOptions(enable: enableShapeDrawings, newShapeColor: shapeColor.color),
);
}
GameData toGameData({
required Variant variant,
required Position position,
required PlayerSide playerSide,
required NormalMove? promotionMove,
required void Function(Move, {bool? viaDragAndDrop}) onMove,
required void Function(Role? role) onPromotionSelection,
Premovable? premovable,
}) {
return GameData(
playerSide: playerSide,
onMove: onMove,
onPromotionSelection: onPromotionSelection,
premovable: premoves ? premovable : null,
promotionMove: promotionMove,
sideToMove: position.turn,
validMoves: _makeLegalMoves(position, variant: variant, castlingMethod: castlingMethod),
droppable: variant == Variant.crazyhouse
? (validDropSquares: position.legalDrops.squares.toISet())
: null,
isCheck: boardHighlights && position.isCheck,
enableDrops: variant == Variant.crazyhouse,
canPromoteToKing: variant == Variant.antichess,
);
}
@@ -263,40 +238,6 @@ sealed class BoardPrefs with _$BoardPrefs implements Serializable {
pieceAnimation ? const Duration(milliseconds: 150) : Duration.zero;
}
IMap<Square, ISet<Square>> _makeLegalMoves(
Position pos, {
required CastlingMethod castlingMethod,
required Variant variant,
}) {
final Map<Square, ISet<Square>> result = {};
for (final entry in pos.legalMoves.entries) {
final dests = entry.value.squares;
if (dests.isNotEmpty) {
final from = entry.key;
final destSet = dests.toSet();
if (variant != Variant.chess960 &&
from == pos.board.kingOf(pos.turn) &&
entry.key.file == 4) {
if (dests.contains(Square.a1)) {
destSet.add(Square.c1);
} else if (dests.contains(Square.a8)) {
destSet.add(Square.c8);
}
if (dests.contains(Square.h1)) {
destSet.add(Square.g1);
} else if (dests.contains(Square.h8)) {
destSet.add(Square.g8);
}
if (castlingMethod == CastlingMethod.kingTwoSquares) {
destSet.removeAll([Square.a1, Square.h1, Square.a8, Square.h8]);
}
}
result[from] = ISet(destSet);
}
}
return IMap(result);
}
/// Colors taken from lila: https://github.com/lichess-org/chessground/blob/54a7e71bf88701c1109d3b9b8106b464012b94cf/src/state.ts#L178
enum ShapeColor {
green,
-26
View File
@@ -261,12 +261,6 @@ class StudyController extends AsyncNotifier<StudyState>
if (!state.requireValue.currentPosition!.isLegal(move)) return;
if (move case NormalMove()
when isPromotionPawnMove(state.requireValue.currentPosition!, move)) {
state = AsyncValue.data(state.requireValue.copyWith(promotionMove: move));
return;
}
_sendMoveToSocket(move);
final (newPath, isNewNode) = _root.addMoveAt(state.requireValue.currentPath, move);
@@ -289,21 +283,6 @@ class StudyController extends AsyncNotifier<StudyState>
}
}
void onPromotionSelection(Role? role) {
final state = this.state.value;
if (state == null) return;
if (role == null) {
this.state = AsyncValue.data(state.copyWith(promotionMove: null));
return;
}
final promotionMove = state.promotionMove;
if (promotionMove != null) {
final promotion = promotionMove.withPromotion(role);
onUserMove(promotion);
}
}
void showGamebookSolution() {
onUserMove(state.requireValue.currentNode.children.first);
}
@@ -497,7 +476,6 @@ class StudyController extends AsyncNotifier<StudyState>
isOnMainline: _root.isOnMainline(path),
currentNode: StudyCurrentNode.fromNode(currentNode),
lastMove: currentNode.sanMove.move,
promotionMove: null,
root: rootView,
),
);
@@ -508,7 +486,6 @@ class StudyController extends AsyncNotifier<StudyState>
isOnMainline: _root.isOnMainline(path),
currentNode: StudyCurrentNode.fromNode(currentNode),
lastMove: null,
promotionMove: null,
root: rootView,
),
);
@@ -602,9 +579,6 @@ sealed class StudyState
/// The last move played.
Move? lastMove,
/// Possible promotion move to be played.
NormalMove? promotionMove,
/// The PGN root comments of the study
IList<PgnComment>? pgnRootComments,
+2 -3
View File
@@ -1,6 +1,5 @@
import 'package:chessground/chessground.dart';
import 'package:dartchess/dartchess.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/widgets.dart';
/// Computes the set of squares that should have an atomic explosion animation
@@ -8,10 +7,10 @@ import 'package:flutter/widgets.dart';
///
/// Returns `null` if [positionBefore] is not an atomic position, [move] is
/// null, or the move was not a capture (no explosion occurs).
ISet<Square>? atomicExplosionSquares(Position positionBefore, Move? move) {
Set<Square>? atomicExplosionSquares(Position positionBefore, Move? move) {
if (move == null || positionBefore is! Atomic) return null;
final squareSet = positionBefore.explosionSquares(move);
return squareSet.isEmpty ? null : squareSet.squares.toISet();
return squareSet.isEmpty ? null : squareSet.squares.toSet();
}
/// Preload piece images from the specified [PieceSet] into Chessground's image cache.
+155 -68
View File
@@ -18,6 +18,7 @@ import 'package:lichess_mobile/src/view/analysis/game_analysis_board.dart';
import 'package:lichess_mobile/src/view/analysis/retro_screen.dart';
import 'package:lichess_mobile/src/view/broadcast/broadcast_game_screen.dart';
import 'package:lichess_mobile/src/view/study/study_screen.dart';
import 'package:lichess_mobile/src/widgets/board.dart';
import 'package:lichess_mobile/src/widgets/pgn.dart';
/// An abstract widget that provides the common interface for three types of analysis boards:
@@ -51,24 +52,121 @@ abstract class AnalysisBoardState<
void onUserMove(Move move);
void onPromotionSelection(Role? role);
/// Reads the current analysis state without subscribing.
///
/// Called once in [initState] to eagerly create the board controller.
/// Implement as: `ref.read(yourControllerProvider(...)).value`
AnalysisState? readCurrentState();
/// For the study board to set a different fen if the position is `null`.
String get fen => analysisState.currentPosition!.fen;
/// Sets up a subscription to analysis state changes.
///
/// Called in [initState] to drive controller position updates.
/// Implement as: `ref.listenManual(yourControllerProvider(...).select((v) => v.value), listener)`
/// The subscription is managed by Riverpod and cancelled automatically on dispose.
void listenToStateChanges(void Function(AnalysisState? prev, AnalysisState? next) listener);
/// Computes the board FEN string from [state].
///
/// Each board defines its own FEN semantics and is responsible for handling
/// (or asserting the absence of) a null [CommonAnalysisState.currentPosition].
String computeFen(AnalysisState state);
String get fen => computeFen(analysisState);
/// Whether the board should be interactive for [state].
///
/// Override to conditionally disable user interaction.
bool computeInteractive(AnalysisState state) => true;
/// Whether the board should be interactive for the current [analysisState].
bool get interactive => computeInteractive(analysisState);
/// E.g. for the study board to add pgn shapes and variations arrows.
ISet<Shape> get extraShapes => ISet();
/// Can be used to disable interaction with the board in certain states
bool get interactive => true;
/// Filters to identify the correct engine evaluation provider instance.
EngineEvaluationFilters get engineEvaluationFilters;
/// Set of shapes drawn by the user on the board (arrows, circle).
ISet<Shape> userShapes = ISet();
ChessboardController? _controller;
ISet<Shape> _bestMoveShapes(PieceAssets pieceAssets) {
/// Clears all user-drawn shapes from the board.
void clearDrawnShapes() => _controller?.clearDrawnShapes();
@override
void initState() {
super.initState();
final initialState = readCurrentState();
if (initialState != null) {
_controller = _createController(initialState, ref.read(boardPreferencesProvider));
}
listenToStateChanges(_onAnalysisStateChanged);
ref.listenManual<BoardPrefs>(boardPreferencesProvider, _onBoardPrefsChanged);
}
@override
void dispose() {
_controller?.dispose();
super.dispose();
}
GameData? _buildGameData(AnalysisState state, BoardPrefs boardPrefs) {
final position = state.currentPosition;
if (position == null) return null;
final playerSide = !computeInteractive(state) || position.isGameOver
? PlayerSide.none
: position.turn == Side.white
? PlayerSide.white
: PlayerSide.black;
return buildGameData(
fen: computeFen(state),
variant: state.variant,
position: position,
playerSide: playerSide,
lastMove: state.lastMove,
castlingMethod: boardPrefs.castlingMethod,
boardHighlights: boardPrefs.boardHighlights,
);
}
ChessboardController? _createController(AnalysisState state, BoardPrefs boardPrefs) {
final gameData = _buildGameData(state, boardPrefs);
if (gameData == null) return null;
return ChessboardController(game: gameData);
}
void _onAnalysisStateChanged(AnalysisState? prev, AnalysisState? next) {
if (!mounted || next == null) return;
final boardPrefs = ref.read(boardPreferencesProvider);
final controller = _controller;
if (controller == null) {
final ctrl = _createController(next, boardPrefs);
if (ctrl != null) setState(() => _controller = ctrl);
return;
}
final newFen = computeFen(next);
final gameData = _buildGameData(next, boardPrefs);
final prevFen = prev != null ? computeFen(prev) : null;
if (prevFen != newFen) {
if (gameData != null) controller.jumpToPosition(gameData);
final explosionSquares = next.explosionSquares;
if (explosionSquares != null) {
controller.triggerExplosion(explosionSquares.toSet());
}
} else if (gameData != null) {
controller.animatePosition(gameData);
}
}
void _onBoardPrefsChanged(BoardPrefs? prev, BoardPrefs next) {
final controller = _controller;
if (controller == null) return;
final state = readCurrentState();
if (state == null) return;
final gameData = _buildGameData(state, next);
if (gameData != null) controller.animatePosition(gameData);
}
Set<Shape> _bestMoveShapes(PieceAssets pieceAssets) {
final enginePrefs = ref.watch(engineEvaluationPreferencesProvider);
final currentPosition = analysisState.currentPosition;
@@ -78,7 +176,7 @@ abstract class AnalysisBoardState<
analysisPrefs.showBestMoveArrow;
if (!showBestMoveArrow || currentPosition == null) {
return ISet();
return {};
}
final localEval = ref.watch(
@@ -90,13 +188,13 @@ abstract class AnalysisBoardState<
: pickBestClientEval(localEval: localEval, savedEval: analysisState.currentNode.eval);
if (eval == null) {
return ISet();
return {};
}
if (eval.position.fen != currentPosition.fen) {
// Eval is out of sync, this usually happens after making a move on the board.
// While waiting for the updated eval we don't want to show the best moves from the previous position.
return ISet();
return {};
}
final bestMoveShapes = computeBestMoveShapes(
@@ -118,10 +216,10 @@ abstract class AnalysisBoardState<
bestMoveColor: LichessColors.red.withValues(alpha: 0.6),
nextBestMovesColor: LichessColors.red.withValues(alpha: 0.4),
);
return {...threatMoveShapes, if (bestMoveShapes.isNotEmpty) bestMoveShapes.first}.toISet();
return {...threatMoveShapes, if (bestMoveShapes.isNotEmpty) bestMoveShapes.first};
}
return bestMoveShapes;
return bestMoveShapes.toSet();
}
@override
@@ -129,65 +227,54 @@ abstract class AnalysisBoardState<
final boardPrefs = ref.watch(boardPreferencesProvider);
final currentNode = analysisState.currentNode;
final currentPosition = analysisState.currentPosition;
final annotation = showAnnotations ? makeAnnotation(currentNode.nags) : null;
final sanMove = currentNode.sanMove;
return Chessboard(
final externalShapes = {..._bestMoveShapes(boardPrefs.pieceSet.assets), ...extraShapes.unlock};
final boardAnnotations = sanMove != null && annotation != null
? (sanMove.isCastles && altCastles.containsKey(sanMove.move.uci)
? {Move.parse(altCastles[sanMove.move.uci]!)!.to: annotation}
: {sanMove.move.to: annotation})
: const <Square, Annotation>{};
// The controller is normally created in initState. If it is somehow absent,
// fall back to a non-interactive board rather than crashing.
final ctrl = _controller;
if (ctrl == null) {
return StaticChessboard(
size: widget.boardSize,
orientation: analysisState.pov,
fen: fen,
lastMove: analysisState.lastMove,
shapes: externalShapes,
settings: StaticChessboardSettings.fromBoardSettings(
boardPrefs
.toBoardSettings(analysisState.variant)
.copyWith(
borderRadius: widget.boardRadius,
boxShadow: widget.boardRadius != null ? boardShadows : const <BoxShadow>[],
),
),
);
}
return BoardWidget(
size: widget.boardSize,
orientation: analysisState.pov,
fen: fen,
lastMove: analysisState.lastMove,
explosionSquares: analysisState.explosionSquares,
game: (interactive && currentPosition != null)
? boardPrefs.toGameData(
variant: analysisState.variant,
position: currentPosition,
playerSide: analysisState.currentPosition!.isGameOver
? PlayerSide.none
: analysisState.currentPosition!.turn == Side.white
? PlayerSide.white
: PlayerSide.black,
promotionMove: analysisState.promotionMove,
onMove: (move, {viaDragAndDrop}) => onUserMove(move),
onPromotionSelection: onPromotionSelection,
)
: null,
shapes: userShapes.union(_bestMoveShapes(boardPrefs.pieceSet.assets)).union(extraShapes),
annotations: sanMove != null && annotation != null
? sanMove.isCastles && altCastles.containsKey(sanMove.move.uci)
? IMap({Move.parse(altCastles[sanMove.move.uci]!)!.to: annotation})
: IMap({sanMove.move.to: annotation})
: null,
settings: boardPrefs.toBoardSettings().copyWith(
borderRadius: widget.boardRadius,
boxShadow: widget.boardRadius != null ? boardShadows : const <BoxShadow>[],
drawShape: DrawShapeOptions(
enable: boardPrefs.enableShapeDrawings,
onCompleteShape: _onCompleteShape,
onClearShapes: _onClearShapes,
newShapeColor: boardPrefs.shapeColor.color,
),
),
controller: ctrl,
onMove: (move, {viaDragAndDrop}) => onUserMove(move),
shapes: externalShapes,
settings: boardPrefs
.toBoardSettings(analysisState.variant)
.copyWith(
borderRadius: widget.boardRadius,
boxShadow: widget.boardRadius != null ? boardShadows : const <BoxShadow>[],
drawShape: DrawShapeOptions(
enable: boardPrefs.enableShapeDrawings,
newShapeColor: boardPrefs.shapeColor.color,
),
),
annotations: boardAnnotations,
);
}
void _onCompleteShape(Shape shape) {
if (userShapes.any((element) => element == shape)) {
setState(() {
userShapes = userShapes.remove(shape);
});
} else {
setState(() {
userShapes = userShapes.add(shape);
});
}
}
void _onClearShapes() {
setState(() {
userShapes = ISet();
});
}
}
+13 -4
View File
@@ -27,6 +27,16 @@ class GameAnalysisBoard extends AnalysisBoard {
class _GameAnalysisBoardState
extends AnalysisBoardState<GameAnalysisBoard, AnalysisState, AnalysisPrefs> {
@override
AnalysisState? readCurrentState() => ref.read(analysisControllerProvider(widget.options)).value;
@override
void listenToStateChanges(void Function(AnalysisState? prev, AnalysisState? next) listener) =>
ref.listenManual<AnalysisState?>(
analysisControllerProvider(widget.options).select((v) => v.value),
listener,
);
@override
AnalysisState get analysisState =>
ref.watch(analysisControllerProvider(widget.options)).requireValue;
@@ -34,6 +44,9 @@ class _GameAnalysisBoardState
EngineEvaluationFilters get engineEvaluationFilters =>
(id: analysisState.evaluationContext.id, path: analysisState.currentPath);
@override
String computeFen(AnalysisState state) => state.currentPosition.fen;
@override
AnalysisPrefs get analysisPrefs => ref.watch(analysisPreferencesProvider);
@@ -48,10 +61,6 @@ class _GameAnalysisBoardState
.read(analysisControllerProvider(widget.options).notifier)
.onUserMove(move, shouldReplace: widget.shouldReplaceChildOnUserMove);
@override
void onPromotionSelection(Role? role) =>
ref.read(analysisControllerProvider(widget.options).notifier).onPromotionSelection(role);
@override
ISet<Shape> get extraShapes {
final analysisState = ref.watch(analysisControllerProvider(widget.options)).requireValue;
+12 -8
View File
@@ -167,6 +167,16 @@ class RetroAnalysisBoard extends AnalysisBoard {
class _RetroAnalysisBoardState
extends AnalysisBoardState<RetroAnalysisBoard, RetroState, AnalysisPrefs> {
@override
RetroState? readCurrentState() => ref.read(retroControllerProvider(widget.options)).value;
@override
void listenToStateChanges(void Function(RetroState? prev, RetroState? next) listener) =>
ref.listenManual<RetroState?>(
retroControllerProvider(widget.options).select((v) => v.value),
listener,
);
@override
RetroState get analysisState => ref.watch(retroControllerProvider(widget.options)).requireValue;
@override
@@ -178,9 +188,8 @@ class _RetroAnalysisBoardState
@override
bool get hideBestMoveArrow => true;
// Disable interaction while the engine is evaluating the move
@override
bool get interactive => analysisState.feedback != RetroFeedback.evalMove;
bool computeInteractive(RetroState state) => state.feedback != RetroFeedback.evalMove;
@override
void onUserMove(Move move) {
@@ -192,12 +201,7 @@ class _RetroAnalysisBoardState
(id: analysisState.evaluationContext.id, path: analysisState.currentPath);
@override
void onPromotionSelection(Role? role) {
ref.read(retroControllerProvider(widget.options).notifier).onPromotionSelection(role);
}
@override
String get fen => analysisState.currentPosition.board.fen;
String computeFen(RetroState state) => state.currentPosition.board.fen;
@override
ISet<Shape> get extraShapes {
@@ -143,10 +143,12 @@ class _BoardEditor extends ConsumerWidget {
size: boardSize,
pieces: pieces,
orientation: orientation,
settings: boardPrefs.toBoardSettings().copyWith(
borderRadius: isTablet ? Styles.boardBorderRadius : BorderRadius.zero,
boxShadow: isTablet ? boardShadows : const <BoxShadow>[],
),
settings: boardPrefs
.toBoardSettings(editorState.variant)
.copyWith(
borderRadius: isTablet ? Styles.boardBorderRadius : BorderRadius.zero,
boxShadow: isTablet ? boardShadows : const <BoxShadow>[],
),
pointerMode: editorState.editorPointerMode,
onDiscardedPiece: (Square square) =>
ref.read(boardEditorControllerProvider(params).notifier).discardPiece(square),
@@ -504,6 +504,22 @@ class BroadcastAnalysisBoard extends AnalysisBoard {
class _BroadcastAnalysisBoardState
extends AnalysisBoardState<BroadcastAnalysisBoard, BroadcastAnalysisState, BroadcastPrefs> {
@override
BroadcastAnalysisState? readCurrentState() => ref
.read(broadcastAnalysisControllerProvider((roundId: widget.roundId, gameId: widget.gameId)))
.value;
@override
void listenToStateChanges(
void Function(BroadcastAnalysisState? prev, BroadcastAnalysisState? next) listener,
) => ref.listenManual<BroadcastAnalysisState?>(
broadcastAnalysisControllerProvider((
roundId: widget.roundId,
gameId: widget.gameId,
)).select((v) => v.value),
listener,
);
@override
BroadcastAnalysisState get analysisState => ref
.watch(broadcastAnalysisControllerProvider((roundId: widget.roundId, gameId: widget.gameId)))
@@ -531,14 +547,7 @@ class _BroadcastAnalysisBoardState
(id: analysisState.evaluationContext.id, path: analysisState.currentPath);
@override
void onPromotionSelection(Role? role) => ref
.read(
broadcastAnalysisControllerProvider((
roundId: widget.roundId,
gameId: widget.gameId,
)).notifier,
)
.onPromotionSelection(role);
String computeFen(BroadcastAnalysisState state) => state.currentPosition.fen;
}
enum _PlayerWidgetPosition { bottom, top }
@@ -6,6 +6,7 @@ import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lichess_mobile/src/constants.dart';
import 'package:lichess_mobile/src/model/common/chess.dart';
import 'package:lichess_mobile/src/model/common/game.dart';
import 'package:lichess_mobile/src/model/coordinate_training/coordinate_training_controller.dart';
import 'package:lichess_mobile/src/model/coordinate_training/coordinate_training_preferences.dart';
@@ -431,17 +432,21 @@ class _TrainingBoardState extends ConsumerState<_TrainingBoard> {
Stack(
alignment: Alignment.center,
children: [
Chessboard.fixed(
StaticChessboard(
size: widget.boardSize,
fen: trainingPrefs.showPieces ? kInitialFEN : kEmptyFEN,
squareHighlights: widget.squareHighlights,
squareHighlights: widget.squareHighlights.unlock,
orientation: widget.orientation,
settings: boardPrefs.toBoardSettings().copyWith(
enableCoordinates: trainingPrefs.showCoordinates,
borderRadius: widget.isTablet
? const BorderRadius.all(Radius.circular(4.0))
: BorderRadius.zero,
boxShadow: widget.isTablet ? boardShadows : const <BoxShadow>[],
settings: StaticChessboardSettings.fromBoardSettings(
boardPrefs
.toBoardSettings(Variant.standard)
.copyWith(
enableCoordinates: trainingPrefs.showCoordinates,
borderRadius: widget.isTablet
? const BorderRadius.all(Radius.circular(4.0))
: BorderRadius.zero,
boxShadow: widget.isTablet ? boardShadows : const <BoxShadow>[],
),
),
onTouchedSquare: (square) {
if (trainingState.trainingActive && trainingPrefs.mode == TrainingMode.findSquare) {
@@ -101,7 +101,6 @@ class _BodyState extends ConsumerState<_Body> {
int stepCursor = 0;
(String, Move)? moveToConfirm;
bool isBoardTurned = false;
NormalMove? promotionMove;
bool get isReplaying => stepCursor < game.steps.length - 1;
bool get canGoForward => stepCursor < game.steps.length - 1;
@@ -206,12 +205,9 @@ class _BodyState extends ConsumerState<_Body> {
? PlayerSide.white
: PlayerSide.black
: PlayerSide.none,
promotionMove: promotionMove,
onMove: (move, {viaDragAndDrop}) {
onUserMove(move);
},
onPromotionSelection: onPromotionSelection,
premovable: null,
),
topTable: topPlayer,
bottomTable: bottomPlayer,
@@ -313,7 +309,6 @@ class _BodyState extends ConsumerState<_Body> {
if (stepCursor > 0) {
setState(() {
stepCursor = stepCursor - 1;
promotionMove = null;
});
_playReplayMoveSound();
}
@@ -323,20 +318,12 @@ class _BodyState extends ConsumerState<_Body> {
if (stepCursor < game.steps.length - 1) {
setState(() {
stepCursor = stepCursor + 1;
promotionMove = null;
});
_playReplayMoveSound();
}
}
void onUserMove(Move move) {
if (move case NormalMove() when isPromotionPawnMove(game.lastPosition, move)) {
setState(() {
promotionMove = move;
});
return;
}
final (newPos, newSan) = game.lastPosition.makeSan(move);
final sanMove = SanMove(newSan, move);
final newStep = GameStep(
@@ -348,26 +335,12 @@ class _BodyState extends ConsumerState<_Body> {
setState(() {
moveToConfirm = (game.sanMoves, move);
game = game.copyWith(steps: game.steps.add(newStep));
promotionMove = null;
stepCursor = stepCursor + 1;
});
_moveFeedback(sanMove);
}
void onPromotionSelection(Role? role) {
if (role == null) {
setState(() {
promotionMove = null;
});
return;
}
if (promotionMove != null) {
final move = promotionMove!.withPromotion(role);
onUserMove(move);
}
}
Future<void> confirmMove() async {
setState(() {
game = game.copyWith(registeredMoveAtPgn: (moveToConfirm!.$1, moveToConfirm!.$2));
File diff suppressed because it is too large Load Diff
+9 -5
View File
@@ -158,8 +158,9 @@ class _GameScreenState extends ConsumerState<GameScreen> {
),
);
case AsyncData(value: GameCreatedState(:final createdGameId)):
final isRealTimePlayingGame =
ref.watch(_isRealTimePlayableGameProvider(createdGameId)).value ?? false;
final isRealTimePlayingGame = ref.watch(
_isRealTimePlayableGameProvider(createdGameId).select((s) => s.value ?? false),
);
final socketUri = GameController.socketUri(createdGameId);
@@ -529,9 +530,12 @@ class _WatcherButton extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(gameControllerProvider(gameId).select((s) => s.value));
final nb = state?.nbWatchers ?? 0;
final isZenModeActive = state?.isZenModeActive ?? false;
final (nb, isZenModeActive) = ref.watch(
gameControllerProvider(gameId).select((s) {
final state = s.value;
return (state?.nbWatchers ?? 0, state?.isZenModeActive ?? false);
}),
);
if (nb <= 0 || isZenModeActive) return const SizedBox.shrink();
return SemanticIconButton(
semanticsLabel: context.l10n.spectatorRoom,
+12 -10
View File
@@ -224,21 +224,23 @@ class _BoardCarouselItem extends ConsumerWidget {
SizedBox(
height: boardSize,
child: StaticChessboard(
hue: boardPrefs.hue,
brightness: boardPrefs.brightness,
size: boardSize,
fen: fen,
orientation: orientation,
lastMove: lastMove,
enableCoordinates: false,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(10.0),
topRight: Radius.circular(10.0),
settings: StaticChessboardSettings(
hue: boardPrefs.hue,
brightness: boardPrefs.brightness,
enableCoordinates: false,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(10.0),
topRight: Radius.circular(10.0),
),
pieceAssets: boardPrefs.pieceSet.assets,
colorScheme: isRealTimeGame
? realTimeColors(context)
: boardPrefs.boardTheme.colors,
),
pieceAssets: boardPrefs.pieceSet.assets,
colorScheme: isRealTimeGame
? realTimeColors(context)
: boardPrefs.boardTheme.colors,
),
),
Positioned(
@@ -261,6 +261,7 @@ class _BodyState extends ConsumerState<_Body> {
)
: null,
shapes: _buildBoardShapes(gameState, boardColorScheme),
boardSettingsOverrides: const BoardSettingsOverrides(enablePremoves: false),
boardParams: GameBoardParams.interactive(
variant: gameState.game.meta.variant,
position: gameState.currentPosition,
@@ -269,14 +270,9 @@ class _BodyState extends ConsumerState<_Body> {
: isPlayerTurn && !gameState.isEvaluatingMove
? (orientation == Side.white ? PlayerSide.white : PlayerSide.black)
: PlayerSide.none,
onPromotionSelection: ref
.read(offlineComputerGameControllerProvider.notifier)
.onPromotionSelection,
promotionMove: gameState.promotionMove,
onMove: (move, {viaDragAndDrop}) {
ref.read(offlineComputerGameControllerProvider.notifier).makeMove(move);
},
premovable: null,
),
moves: gameState.moves,
currentMoveIndex: gameState.stepCursor,
@@ -782,12 +778,14 @@ class _NewGameSheetState extends ConsumerState<_NewGameSheet> {
size: 150,
fen: widget.initialFen!,
orientation: _selectedSideChoice.toSide(fen: widget.initialFen) ?? Side.white,
pieceAssets: boardPrefs.pieceSet.assets,
colorScheme: boardPrefs.boardTheme.colors,
brightness: boardPrefs.brightness,
hue: boardPrefs.hue,
enableCoordinates: false,
borderRadius: const BorderRadius.all(Radius.circular(4)),
settings: StaticChessboardSettings(
pieceAssets: boardPrefs.pieceSet.assets,
colorScheme: boardPrefs.boardTheme.colors,
brightness: boardPrefs.brightness,
hue: boardPrefs.hue,
enableCoordinates: false,
borderRadius: const BorderRadius.all(Radius.circular(4)),
),
),
),
const SizedBox(height: 8),
@@ -265,27 +265,12 @@ class _BodyState extends ConsumerState<_Body> {
: gameState.turn == Side.white
? PlayerSide.white
: PlayerSide.black,
onPromotionSelection: (role) {
ref
.read(overTheBoardGameControllerProvider.notifier)
.onPromotionSelection(role);
if (role != null) {
ref
.read(overTheBoardClockProvider.notifier)
.onMove(newSideToMove: gameState.turn.opposite);
}
},
promotionMove: gameState.promotionMove,
onMove: (move, {viaDragAndDrop}) {
if (move is! NormalMove ||
!isPromotionPawnMove(gameState.currentPosition, move)) {
ref
.read(overTheBoardClockProvider.notifier)
.onMove(newSideToMove: gameState.turn.opposite);
}
ref.read(overTheBoardGameControllerProvider.notifier).makeMove(move);
ref
.read(overTheBoardClockProvider.notifier)
.onMove(newSideToMove: gameState.turn.opposite);
},
premovable: null,
),
moves: gameState.moves,
currentMoveIndex: gameState.stepCursor,
@@ -297,6 +282,7 @@ class _BodyState extends ConsumerState<_Body> {
pieceAssets: overTheBoardPrefs.symmetricPieces
? PieceSet.symmetric.assets
: null,
enablePremoves: false,
),
userActionsBar: _BottomBar(
onFlipBoard: () {
@@ -1,11 +1,12 @@
import 'package:chessground/chessground.dart';
import 'package:dartchess/dartchess.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lichess_mobile/src/constants.dart';
import 'package:lichess_mobile/src/model/common/chess.dart';
import 'package:lichess_mobile/src/model/settings/board_preferences.dart';
import 'package:lichess_mobile/src/styles/styles.dart';
import 'package:lichess_mobile/src/utils/screen.dart';
import 'package:lichess_mobile/src/widgets/board.dart';
class PuzzleErrorBoardWidget extends ConsumerWidget {
const PuzzleErrorBoardWidget({this.errorMessage});
@@ -28,10 +29,47 @@ class PuzzleErrorBoardWidget extends ConsumerWidget {
: Orientation.portrait;
final isTablet = isTabletOrLarger(context);
final defaultSettings = boardPreferences.toBoardSettings().copyWith(
borderRadius: isTablet ? Styles.boardBorderRadius : BorderRadius.zero,
boxShadow: isTablet ? boardShadows : const <BoxShadow>[],
);
final defaultSettings = boardPreferences
.toBoardSettings(Variant.standard)
.copyWith(
borderRadius: isTablet ? Styles.boardBorderRadius : BorderRadius.zero,
boxShadow: isTablet ? boardShadows : const <BoxShadow>[],
);
Widget board(double size) {
final chessboard = StaticChessboard(
size: size,
fen: kEmptyBoardFEN,
orientation: Side.white,
settings: StaticChessboardSettings.fromBoardSettings(defaultSettings),
);
if (errorMessage == null) return chessboard;
return Stack(
clipBehavior: Clip.none,
children: [
chessboard,
Positioned(
left: 16.0,
right: 16.0,
top: 0,
bottom: 0,
child: Center(
child: OverflowBox(
maxHeight: double.infinity,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: const BorderRadius.all(Radius.circular(10.0)),
),
padding: const EdgeInsets.all(10.0),
child: Text(errorMessage!),
),
),
),
),
],
);
}
if (orientation == Orientation.landscape) {
final defaultBoardSize =
@@ -43,19 +81,7 @@ class PuzzleErrorBoardWidget extends ConsumerWidget {
(kTabletBoardTableSidePadding * 2);
return Padding(
padding: const EdgeInsets.all(kTabletBoardTableSidePadding),
child: Row(
mainAxisSize: MainAxisSize.max,
children: [
BoardWidget(
size: boardSize,
fen: kEmptyBoardFEN,
orientation: Side.white,
gameData: null,
settings: defaultSettings,
error: errorMessage,
),
],
),
child: Row(mainAxisSize: MainAxisSize.max, children: [board(boardSize)]),
);
} else {
final defaultBoardSize = constraints.biggest.shortestSide;
@@ -71,14 +97,7 @@ class PuzzleErrorBoardWidget extends ConsumerWidget {
padding: isTablet
? const EdgeInsets.symmetric(horizontal: kTabletBoardTableSidePadding)
: EdgeInsets.zero,
child: BoardWidget(
size: boardSize,
fen: kEmptyBoardFEN,
orientation: Side.white,
gameData: null,
settings: defaultSettings,
error: errorMessage,
),
child: board(boardSize),
),
],
);
+92 -77
View File
@@ -2,7 +2,6 @@ import 'dart:async';
import 'package:chessground/chessground.dart';
import 'package:dartchess/dartchess.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -380,7 +379,60 @@ class _Body extends ConsumerStatefulWidget {
}
class _BodyState extends ConsumerState<_Body> {
ISet<Shape> userShapes = ISet();
late final ChessboardController _controller;
@override
void initState() {
super.initState();
_controller = ChessboardController(game: _buildGameData());
}
@override
void didUpdateWidget(_Body oldWidget) {
super.didUpdateWidget(oldWidget);
// The streak feeds new puzzles by swapping the puzzle context (and thus the
// controller provider), so push the new puzzle onto the board.
if (oldWidget.initialPuzzleContext != widget.initialPuzzleContext) {
_applyBoardUpdate();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
PlayerSide _playerSide(PuzzleState state) {
return state.mode == PuzzleMode.load ||
state.currentPosition.isGameOver ||
(state.mode == PuzzleMode.play && state.canGoNext)
? PlayerSide.none
: state.mode == PuzzleMode.view
? PlayerSide.both
: state.pov == Side.white
? PlayerSide.white
: PlayerSide.black;
}
GameData _buildGameData() {
final state = ref.read(puzzleControllerProvider(widget.initialPuzzleContext));
final boardPreferences = ref.read(boardPreferencesProvider);
return buildGameData(
fen: state.currentPosition.fen,
variant: Variant.standard,
position: state.currentPosition,
playerSide: _playerSide(state),
lastMove: state.lastMove,
castlingMethod: boardPreferences.castlingMethod,
boardHighlights: boardPreferences.boardHighlights,
);
}
/// Pushes the latest puzzle position to the board controller without rebuilding it.
void _applyBoardUpdate() {
_controller.animatePosition(_buildGameData());
}
@override
Widget build(BuildContext context) {
@@ -388,49 +440,33 @@ class _BodyState extends ConsumerState<_Body> {
final ctrlProvider = puzzleControllerProvider(widget.initialPuzzleContext);
final puzzleState = ref.watch(ctrlProvider);
// Clear user shapes when puzzle or position changes.
// Drive the board on position/interactivity changes without rebuilding it.
ref.listen(
ctrlProvider.select(
(s) => (fen: s.currentPosition.fen, lastMoveUci: s.lastMove?.uci, side: _playerSide(s)),
),
(_, _) => _applyBoardUpdate(),
);
ref.listen(
boardPreferencesProvider.select((p) => (p.castlingMethod, p.boardHighlights)),
(_, _) => _applyBoardUpdate(),
);
// Clear drawn shapes when puzzle or position changes.
ref.listen(ctrlProvider.select((state) => state.puzzle.puzzle.id), (previous, next) {
if (previous != null && previous != next && mounted) {
setState(() {
userShapes = ISet();
});
if (previous != null && previous != next) {
_controller.clearDrawnShapes();
}
});
ref.listen(ctrlProvider.select((state) => state.currentPath), (previous, next) {
if (previous != null && previous != next && mounted) {
setState(() {
userShapes = ISet();
});
if (previous != null && previous != next) {
_controller.clearDrawnShapes();
}
});
final generatedShapes = puzzleState.hintSquare != null
? ISet([Circle(color: ShapeColor.green.color, orig: puzzleState.hintSquare!)])
: null;
final shapes = userShapes.union(generatedShapes ?? ISet());
final gameData = boardPreferences.toGameData(
variant: Variant.standard,
position: puzzleState.currentPosition,
playerSide:
puzzleState.mode == PuzzleMode.load ||
puzzleState.currentPosition.isGameOver ||
(puzzleState.mode == PuzzleMode.play && puzzleState.canGoNext)
? PlayerSide.none
: puzzleState.mode == PuzzleMode.view
? PlayerSide.both
: puzzleState.pov == Side.white
? PlayerSide.white
: PlayerSide.black,
promotionMove: puzzleState.promotionMove,
onMove: (move, {viaDragAndDrop}) {
ref.read(ctrlProvider.notifier).onUserMove(move);
},
onPromotionSelection: (role) {
ref.read(ctrlProvider.notifier).onPromotionSelection(role);
},
premovable: null,
);
final shapes = puzzleState.hintSquare != null
? <Shape>{Circle(color: ShapeColor.green.color, orig: puzzleState.hintSquare!)}
: const <Shape>{};
final content = PopScope(
canPop:
@@ -446,16 +482,16 @@ class _BodyState extends ConsumerState<_Body> {
: Orientation.portrait;
final isTablet = isTabletOrLarger(context);
final defaultSettings = boardPreferences.toBoardSettings().copyWith(
borderRadius: isTablet ? Styles.boardBorderRadius : BorderRadius.zero,
boxShadow: isTablet ? boardShadows : const <BoxShadow>[],
drawShape: DrawShapeOptions(
enable: boardPreferences.enableShapeDrawings,
onCompleteShape: _onCompleteShape,
onClearShapes: _onClearShapes,
newShapeColor: boardPreferences.shapeColor.color,
),
);
final defaultSettings = boardPreferences
.toBoardSettings(Variant.standard)
.copyWith(
borderRadius: isTablet ? Styles.boardBorderRadius : BorderRadius.zero,
boxShadow: isTablet ? boardShadows : const <BoxShadow>[],
drawShape: DrawShapeOptions(
enable: boardPreferences.enableShapeDrawings,
newShapeColor: boardPreferences.shapeColor.color,
),
);
if (orientation == Orientation.landscape) {
final defaultBoardSize =
@@ -473,10 +509,11 @@ class _BodyState extends ConsumerState<_Body> {
BoardWidget(
boardKey: widget.boardKey,
size: boardSize,
fen: puzzleState.currentPosition.fen,
controller: _controller,
onMove: (move, {viaDragAndDrop}) {
ref.read(ctrlProvider.notifier).onUserMove(move);
},
orientation: puzzleState.pov,
gameData: gameData,
lastMove: puzzleState.lastMove,
shapes: shapes,
settings: defaultSettings,
),
@@ -564,10 +601,11 @@ class _BodyState extends ConsumerState<_Body> {
child: BoardWidget(
boardKey: widget.boardKey,
size: boardSize,
fen: puzzleState.currentPosition.fen,
controller: _controller,
onMove: (move, {viaDragAndDrop}) {
ref.read(ctrlProvider.notifier).onUserMove(move);
},
orientation: puzzleState.pov,
gameData: gameData,
lastMove: puzzleState.lastMove,
shapes: shapes,
settings: defaultSettings,
),
@@ -601,29 +639,6 @@ class _BodyState extends ConsumerState<_Body> {
)
: content;
}
void _onCompleteShape(Shape shape) {
if (!mounted) return;
if (userShapes.any((element) => element == shape)) {
setState(() {
userShapes = userShapes.remove(shape);
});
return;
} else {
setState(() {
userShapes = userShapes.add(shape);
});
}
}
void _onClearShapes() {
if (!mounted) return;
setState(() {
userShapes = ISet();
});
}
}
class _PuzzleStatus extends ConsumerWidget {
+76 -58
View File
@@ -1,7 +1,6 @@
import 'package:chessground/chessground.dart';
import 'package:collection/collection.dart';
import 'package:dartchess/dartchess.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -89,8 +88,57 @@ class _Body extends ConsumerStatefulWidget {
}
class _BodyState extends ConsumerState<_Body> {
ISet<Shape> userShapes = ISet();
final _boardKey = GlobalKey(debugLabel: 'boardOnStormScreen');
late final ChessboardController _controller;
@override
void initState() {
super.initState();
_controller = ChessboardController(game: _buildGameData());
}
@override
void didUpdateWidget(_Body oldWidget) {
super.didUpdateWidget(oldWidget);
// A new run swaps the storm data (and thus the controller provider), so push
// the new position onto the board.
if (oldWidget.data != widget.data) {
_applyBoardUpdate();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
PlayerSide _playerSide(StormState state) {
return !state.firstMovePlayed || state.mode == StormMode.ended || state.position.isGameOver
? PlayerSide.none
: state.pov == Side.white
? PlayerSide.white
: PlayerSide.black;
}
GameData _buildGameData() {
final state = ref.read(stormControllerProvider((widget.data.puzzles, widget.data.timestamp)));
final boardPreferences = ref.read(boardPreferencesProvider);
return buildGameData(
fen: state.position.fen,
variant: Variant.standard,
position: state.position,
playerSide: _playerSide(state),
lastMove: state.lastMove,
castlingMethod: boardPreferences.castlingMethod,
boardHighlights: boardPreferences.boardHighlights,
);
}
/// Pushes the latest storm position to the board controller without rebuilding it.
void _applyBoardUpdate() {
_controller.animatePosition(_buildGameData());
}
@override
Widget build(BuildContext context) {
@@ -112,21 +160,16 @@ class _BodyState extends ConsumerState<_Body> {
}
});
final gameData = boardPreferences.toGameData(
variant: Variant.standard,
position: stormState.position,
playerSide:
!stormState.firstMovePlayed ||
stormState.mode == StormMode.ended ||
stormState.position.isGameOver
? PlayerSide.none
: stormState.pov == Side.white
? PlayerSide.white
: PlayerSide.black,
promotionMove: stormState.promotionMove,
onMove: (move, {viaDragAndDrop}) => ref.read(ctrlProvider.notifier).onUserMove(move),
onPromotionSelection: (role) => ref.read(ctrlProvider.notifier).onPromotionSelection(role),
premovable: null,
// Drive the board on position/interactivity changes without rebuilding it.
ref.listen(
ctrlProvider.select(
(s) => (fen: s.position.fen, lastMoveUci: s.lastMove?.uci, side: _playerSide(s)),
),
(_, _) => _applyBoardUpdate(),
);
ref.listen(
boardPreferencesProvider.select((p) => (p.castlingMethod, p.boardHighlights)),
(_, _) => _applyBoardUpdate(),
);
final content = PopScope(
@@ -167,16 +210,16 @@ class _BodyState extends ConsumerState<_Body> {
: Orientation.portrait;
final isTablet = isTabletOrLarger(context);
final defaultSettings = boardPreferences.toBoardSettings().copyWith(
borderRadius: isTablet ? Styles.boardBorderRadius : BorderRadius.zero,
boxShadow: isTablet ? boardShadows : const <BoxShadow>[],
drawShape: DrawShapeOptions(
enable: boardPreferences.enableShapeDrawings,
onCompleteShape: _onCompleteShape,
onClearShapes: _onClearShapes,
newShapeColor: boardPreferences.shapeColor.color,
),
);
final defaultSettings = boardPreferences
.toBoardSettings(Variant.standard)
.copyWith(
borderRadius: isTablet ? Styles.boardBorderRadius : BorderRadius.zero,
boxShadow: isTablet ? boardShadows : const <BoxShadow>[],
drawShape: DrawShapeOptions(
enable: boardPreferences.enableShapeDrawings,
newShapeColor: boardPreferences.shapeColor.color,
),
);
if (orientation == Orientation.landscape) {
final defaultBoardSize =
@@ -194,11 +237,10 @@ class _BodyState extends ConsumerState<_Body> {
BoardWidget(
boardKey: _boardKey,
size: boardSize,
fen: stormState.position.fen,
controller: _controller,
onMove: (move, {viaDragAndDrop}) =>
ref.read(ctrlProvider.notifier).onUserMove(move),
orientation: stormState.pov,
gameData: gameData,
lastMove: stormState.lastMove,
shapes: userShapes,
settings: defaultSettings,
),
const SizedBox(width: 16.0),
@@ -313,11 +355,10 @@ class _BodyState extends ConsumerState<_Body> {
child: BoardWidget(
boardKey: _boardKey,
size: boardSize,
fen: stormState.position.fen,
controller: _controller,
onMove: (move, {viaDragAndDrop}) =>
ref.read(ctrlProvider.notifier).onUserMove(move),
orientation: stormState.pov,
gameData: gameData,
lastMove: stormState.lastMove,
shapes: userShapes,
settings: defaultSettings,
),
),
@@ -353,29 +394,6 @@ class _BodyState extends ConsumerState<_Body> {
)
: content;
}
void _onCompleteShape(Shape shape) {
if (!mounted) return;
if (userShapes.any((element) => element == shape)) {
setState(() {
userShapes = userShapes.remove(shape);
});
return;
} else {
setState(() {
userShapes = userShapes.add(shape);
});
}
}
void _onClearShapes() {
if (!mounted) return;
setState(() {
userShapes = ISet();
});
}
}
Future<void> _stormInfoDialogBuilder(BuildContext context) {
+81 -62
View File
@@ -1,6 +1,5 @@
import 'package:chessground/chessground.dart';
import 'package:dartchess/dartchess.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -91,8 +90,59 @@ class _Body extends ConsumerStatefulWidget {
}
class _BodyState extends ConsumerState<_Body> {
ISet<Shape> userShapes = ISet();
final _boardKey = GlobalKey(debugLabel: 'boardOnPuzzleStreakScreen');
late final ChessboardController _controller;
@override
void initState() {
super.initState();
_controller = ChessboardController(game: _buildGameData());
}
@override
void didUpdateWidget(_Body oldWidget) {
super.didUpdateWidget(oldWidget);
// The streak feeds new puzzles by swapping the puzzle context (and thus the
// controller provider), so push the new puzzle onto the board.
if (oldWidget.initialPuzzleContext != widget.initialPuzzleContext) {
_applyBoardUpdate();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
PlayerSide _playerSide(PuzzleState state) {
return state.mode == PuzzleMode.load || state.currentPosition.isGameOver
? PlayerSide.none
: state.mode == PuzzleMode.view
? PlayerSide.both
: state.pov == Side.white
? PlayerSide.white
: PlayerSide.black;
}
GameData _buildGameData() {
final state = ref.read(puzzleControllerProvider(widget.initialPuzzleContext));
final boardPreferences = ref.read(boardPreferencesProvider);
return buildGameData(
fen: state.currentPosition.fen,
variant: Variant.standard,
position: state.currentPosition,
playerSide: _playerSide(state),
lastMove: state.lastMove,
castlingMethod: boardPreferences.castlingMethod,
boardHighlights: boardPreferences.boardHighlights,
);
}
/// Pushes the latest puzzle position to the board controller without rebuilding it.
void _applyBoardUpdate() {
_controller.animatePosition(_buildGameData());
}
@override
Widget build(BuildContext context) {
@@ -106,7 +156,7 @@ class _BodyState extends ConsumerState<_Body> {
if (previous?.hasValue == true && next.hasValue) {
if (next.requireValue.streak.finished == false &&
previous!.requireValue.streak.finished == true) {
_onClearShapes();
_controller.clearDrawnShapes();
final authUser = ref.read(authControllerProvider);
ref
.read(ctrlProvider.notifier)
@@ -129,24 +179,16 @@ class _BodyState extends ConsumerState<_Body> {
}
});
final gameData = boardPreferences.toGameData(
variant: Variant.standard,
position: puzzleState.currentPosition,
playerSide: puzzleState.mode == PuzzleMode.load || puzzleState.currentPosition.isGameOver
? PlayerSide.none
: puzzleState.mode == PuzzleMode.view
? PlayerSide.both
: puzzleState.pov == Side.white
? PlayerSide.white
: PlayerSide.black,
promotionMove: puzzleState.promotionMove,
onMove: (move, {viaDragAndDrop}) {
ref.read(ctrlProvider.notifier).onUserMove(move);
},
onPromotionSelection: (role) {
ref.read(ctrlProvider.notifier).onPromotionSelection(role);
},
premovable: null,
// Drive the board on position/interactivity changes without rebuilding it.
ref.listen(
ctrlProvider.select(
(s) => (fen: s.currentPosition.fen, lastMoveUci: s.lastMove?.uci, side: _playerSide(s)),
),
(_, _) => _applyBoardUpdate(),
);
ref.listen(
boardPreferencesProvider.select((p) => (p.castlingMethod, p.boardHighlights)),
(_, _) => _applyBoardUpdate(),
);
final content = PopScope(
@@ -185,16 +227,16 @@ class _BodyState extends ConsumerState<_Body> {
: Orientation.portrait;
final isTablet = isTabletOrLarger(context);
final defaultSettings = boardPreferences.toBoardSettings().copyWith(
borderRadius: isTablet ? Styles.boardBorderRadius : BorderRadius.zero,
boxShadow: isTablet ? boardShadows : const <BoxShadow>[],
drawShape: DrawShapeOptions(
enable: boardPreferences.enableShapeDrawings,
onCompleteShape: _onCompleteShape,
onClearShapes: _onClearShapes,
newShapeColor: boardPreferences.shapeColor.color,
),
);
final defaultSettings = boardPreferences
.toBoardSettings(Variant.standard)
.copyWith(
borderRadius: isTablet ? Styles.boardBorderRadius : BorderRadius.zero,
boxShadow: isTablet ? boardShadows : const <BoxShadow>[],
drawShape: DrawShapeOptions(
enable: boardPreferences.enableShapeDrawings,
newShapeColor: boardPreferences.shapeColor.color,
),
);
if (orientation == Orientation.landscape) {
final defaultBoardSize =
@@ -212,11 +254,11 @@ class _BodyState extends ConsumerState<_Body> {
BoardWidget(
boardKey: _boardKey,
size: boardSize,
fen: puzzleState.currentPosition.fen,
controller: _controller,
onMove: (move, {viaDragAndDrop}) {
ref.read(ctrlProvider.notifier).onUserMove(move);
},
orientation: puzzleState.pov,
gameData: gameData,
lastMove: puzzleState.lastMove,
shapes: userShapes,
settings: defaultSettings,
),
const SizedBox(width: 16.0),
@@ -345,11 +387,11 @@ class _BodyState extends ConsumerState<_Body> {
child: BoardWidget(
boardKey: _boardKey,
size: boardSize,
fen: puzzleState.currentPosition.fen,
controller: _controller,
onMove: (move, {viaDragAndDrop}) {
ref.read(ctrlProvider.notifier).onUserMove(move);
},
orientation: puzzleState.pov,
gameData: gameData,
lastMove: puzzleState.lastMove,
shapes: userShapes,
settings: defaultSettings,
),
),
@@ -426,29 +468,6 @@ class _BodyState extends ConsumerState<_Body> {
)
: content;
}
void _onCompleteShape(Shape shape) {
if (!mounted) return;
if (userShapes.any((element) => element == shape)) {
setState(() {
userShapes = userShapes.remove(shape);
});
return;
} else {
setState(() {
userShapes = userShapes.add(shape);
});
}
}
void _onClearShapes() {
if (!mounted) return;
setState(() {
userShapes = ISet();
});
}
}
class _BottomBar extends ConsumerWidget {
@@ -8,6 +8,7 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:lichess_mobile/src/model/common/chess.dart';
import 'package:lichess_mobile/src/model/common/preloaded_data.dart';
import 'package:lichess_mobile/src/model/settings/board_preferences.dart';
import 'package:lichess_mobile/src/model/settings/general_preferences.dart';
@@ -256,13 +257,15 @@ class _ConfirmColorBackgroundScreenState extends State<ConfirmColorBackgroundScr
padding: EdgeInsets.only(
left: orientation == Orientation.portrait ? 0 : 16.0,
),
child: Chessboard.fixed(
child: StaticChessboard(
size: orientation == Orientation.portrait
? constraints.maxWidth
: constraints.maxHeight - landscapeBoardPadding * 2,
fen: kInitialFEN,
orientation: Side.white,
settings: widget.boardPrefs.toBoardSettings(),
settings: StaticChessboardSettings.fromBoardSettings(
widget.boardPrefs.toBoardSettings(Variant.standard),
),
),
),
),
@@ -431,13 +434,15 @@ class _ConfirmImageBackgroundScreenState extends State<ConfirmImageBackgroundScr
padding: EdgeInsets.only(
left: widget.viewportOrientation == Orientation.portrait ? 0 : 16.0,
),
child: Chessboard.fixed(
child: StaticChessboard(
size: widget.viewportOrientation == Orientation.portrait
? widget.viewport.width
: widget.viewport.height - landscapeBoardPadding * 2,
fen: kInitialFEN,
orientation: Side.white,
settings: widget.boardPrefs.toBoardSettings(),
settings: StaticChessboardSettings.fromBoardSettings(
widget.boardPrefs.toBoardSettings(Variant.standard),
),
),
),
),
@@ -1,9 +1,9 @@
import 'package:chessground/chessground.dart';
import 'package:dartchess/dartchess.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lichess_mobile/src/constants.dart';
import 'package:lichess_mobile/src/model/common/chess.dart';
import 'package:lichess_mobile/src/model/settings/board_preferences.dart';
import 'package:lichess_mobile/src/model/settings/general_preferences.dart';
import 'package:lichess_mobile/src/styles/lichess_icons.dart';
@@ -300,24 +300,28 @@ class _BoardPreview extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: Chessboard.fixed(
child: StaticChessboard(
size: size,
orientation: Side.white,
lastMove: const NormalMove(from: Square.e2, to: Square.e4),
fen: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1',
shapes: <Shape>{
shapes: {
Circle(color: boardPrefs.shapeColor.color, orig: Square.fromName('b8')),
Arrow(
color: boardPrefs.shapeColor.color,
orig: Square.fromName('b8'),
dest: Square.fromName('c6'),
),
}.lock,
settings: boardPrefs.toBoardSettings().copyWith(
brightness: brightness,
hue: hue,
borderRadius: Styles.boardBorderRadius,
boxShadow: boardShadows,
},
settings: StaticChessboardSettings.fromBoardSettings(
boardPrefs
.toBoardSettings(Variant.standard)
.copyWith(
brightness: brightness,
hue: hue,
borderRadius: Styles.boardBorderRadius,
boxShadow: boardShadows,
),
),
),
);
+29 -20
View File
@@ -82,11 +82,15 @@ class _StudyScreenLoader extends ConsumerWidget {
child: AnalysisLayout(
pov: Side.white,
sideToMove: null,
boardBuilder: (context, boardSize, borderRadius) => Chessboard.fixed(
boardBuilder: (context, boardSize, borderRadius) => StaticChessboard(
size: boardSize,
settings: boardPrefs.toBoardSettings().copyWith(
borderRadius: borderRadius,
boxShadow: borderRadius != null ? boardShadows : const <BoxShadow>[],
settings: StaticChessboardSettings.fromBoardSettings(
boardPrefs
.toBoardSettings(Variant.standard)
.copyWith(
borderRadius: borderRadius,
boxShadow: borderRadius != null ? boardShadows : const <BoxShadow>[],
),
),
orientation: Side.white,
fen: kEmptyFEN,
@@ -120,11 +124,15 @@ class _StudyScreenLoader extends ConsumerWidget {
child: AnalysisLayout(
pov: Side.white,
sideToMove: null,
boardBuilder: (context, boardSize, borderRadius) => Chessboard.fixed(
boardBuilder: (context, boardSize, borderRadius) => StaticChessboard(
size: boardSize,
settings: boardPrefs.toBoardSettings().copyWith(
borderRadius: borderRadius,
boxShadow: borderRadius != null ? boardShadows : const <BoxShadow>[],
settings: StaticChessboardSettings.fromBoardSettings(
boardPrefs
.toBoardSettings(Variant.standard)
.copyWith(
borderRadius: borderRadius,
boxShadow: borderRadius != null ? boardShadows : const <BoxShadow>[],
),
),
orientation: Side.white,
fen: kEmptyFEN,
@@ -569,6 +577,16 @@ class StudyAnalysisBoard extends AnalysisBoard {
class _StudyAnalysisBoardState
extends AnalysisBoardState<StudyAnalysisBoard, StudyState, StudyPrefs> {
@override
StudyState? readCurrentState() => ref.read(studyControllerProvider(widget.options)).value;
@override
void listenToStateChanges(void Function(StudyState? prev, StudyState? next) listener) =>
ref.listenManual<StudyState?>(
studyControllerProvider(widget.options).select((v) => v.value),
listener,
);
@override
StudyState get analysisState => ref.watch(studyControllerProvider(widget.options)).requireValue;
@override
@@ -587,15 +605,8 @@ class _StudyAnalysisBoardState
(id: analysisState.evaluationContext.id, path: analysisState.currentPath);
@override
void onPromotionSelection(Role? role) {
ref.read(studyControllerProvider(widget.options).notifier).onPromotionSelection(role);
}
@override
String get fen =>
analysisState.currentPosition?.board.fen ??
analysisState.study.currentChapterMeta.fen ??
kInitialFEN;
String computeFen(StudyState state) =>
state.currentPosition?.board.fen ?? state.study.currentChapterMeta.fen ?? kInitialFEN;
@override
ISet<Shape> get extraShapes {
@@ -635,9 +646,7 @@ class _StudyAnalysisBoardState
next,
) {
if (prev != next) {
setState(() {
userShapes = ISet();
});
clearDrawnShapes();
}
});
+106 -15
View File
@@ -1,47 +1,61 @@
import 'dart:async';
import 'package:chessground/chessground.dart';
import 'package:dartchess/dartchess.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/material.dart';
import 'package:lichess_mobile/src/model/common/chess.dart';
import 'package:lichess_mobile/src/model/settings/board_preferences.dart';
/// A widget that displays an interactive chessboard driven by a [ChessboardController].
///
/// For a non-interactive board, use [StaticChessboard] instead. To disable user
/// interaction on this board (e.g. at the end of a game), drive the [controller]
/// with game data whose `playerSide` is [PlayerSide.none].
class BoardWidget extends StatelessWidget {
const BoardWidget({
required this.size,
required this.fen,
required this.orientation,
required this.gameData,
this.lastMove,
this.shapes,
required this.settings,
required this.controller,
this.onMove,
this.shapes = const {},
this.annotations = const {},
this.boardOverlay,
this.error,
this.boardKey,
this.explosionSquares,
});
final double size;
final String fen;
final Side orientation;
final GameData? gameData;
final Move? lastMove;
final ISet<Shape>? shapes;
final ChessboardSettings settings;
/// Controller that drives the board position and game state.
final ChessboardController controller;
/// Called when the user completes a move on the board.
final void Function(Move, {bool? viaDragAndDrop})? onMove;
/// External shapes to draw on the board (engine arrows, analysis annotations, etc.).
final Set<Shape> shapes;
/// Move annotations to display on the board.
final Map<Square, Annotation> annotations;
final String? error;
final Widget? boardOverlay;
final GlobalKey? boardKey;
final ISet<Square>? explosionSquares;
@override
Widget build(BuildContext context) {
final board = Chessboard(
key: boardKey,
controller: controller,
size: size,
fen: fen,
orientation: orientation,
game: gameData,
lastMove: lastMove,
onMove: onMove,
shapes: shapes,
annotations: annotations,
settings: settings,
explosionSquares: explosionSquares,
);
final overlay = boardOverlay ?? (error != null ? _ErrorWidget(errorMessage: error!) : null);
@@ -84,3 +98,80 @@ class _ErrorWidget extends StatelessWidget {
);
}
}
/// Executes a pending premove on [ctrl] if it is legal in [position], calling [onMove] via
/// [scheduleMicrotask] to avoid modifying Riverpod providers inside widget lifecycle callbacks.
/// Clears the premove if it is illegal.
void tryExecutePremove(ChessboardController ctrl, Position position, void Function(Move) onMove) {
final premove = ctrl.premove;
if (premove == null) return;
if (position.isLegal(premove)) {
if (premove is NormalMove && isPromotionPawnMove(position, premove)) {
ctrl.premove = null;
ctrl.pendingPromotion = premove;
} else {
ctrl.premove = null;
scheduleMicrotask(() => onMove(premove));
}
} else {
// Premove became illegal (e.g. after a takeback) clear it.
ctrl.premove = null;
}
}
/// Builds a [GameData] object for the given position and variant, including legal moves, check status, and crazyhouse drops.
GameData buildGameData({
required String fen,
required Variant variant,
required Position position,
required PlayerSide playerSide,
required CastlingMethod castlingMethod,
required bool boardHighlights,
Move? lastMove,
}) {
return GameData(
fen: fen,
playerSide: playerSide,
sideToMove: position.turn,
validMoves: _makeLegalMoves(position, variant: variant, castlingMethod: castlingMethod),
lastMove: lastMove,
kingSquareInCheck: boardHighlights && position.isCheck
? position.board.kingOf(position.turn)
: null,
validDropSquares: variant == Variant.crazyhouse ? position.legalDrops.squares.toSet() : null,
);
}
Map<Square, Set<Square>> _makeLegalMoves(
Position pos, {
required CastlingMethod castlingMethod,
required Variant variant,
}) {
final result = <Square, Set<Square>>{};
for (final entry in pos.legalMoves.entries) {
final dests = entry.value.squares;
if (dests.isNotEmpty) {
final from = entry.key;
final destSet = dests.toSet();
if (variant != Variant.chess960 &&
from == pos.board.kingOf(pos.turn) &&
entry.key.file == 4) {
if (dests.contains(Square.a1)) {
destSet.add(Square.c1);
} else if (dests.contains(Square.a8)) {
destSet.add(Square.c8);
}
if (dests.contains(Square.h1)) {
destSet.add(Square.g1);
} else if (dests.contains(Square.h8)) {
destSet.add(Square.g8);
}
if (castlingMethod == CastlingMethod.kingTwoSquares) {
destSet.removeAll([Square.a1, Square.h1, Square.a8, Square.h8]);
}
}
result[from] = destSet;
}
}
return result;
}
+10 -8
View File
@@ -76,14 +76,16 @@ class SmallBoardPreview extends ConsumerWidget {
fen: fen,
orientation: orientation,
lastMove: lastMove,
pieceAssets: boardPrefs.pieceSet.assets,
colorScheme: boardPrefs.boardTheme.colors,
brightness: boardPrefs.brightness,
hue: boardPrefs.hue,
enableCoordinates: false,
borderRadius: Styles.boardBorderRadius,
boxShadow: boardShadows,
animationDuration: const Duration(milliseconds: 150),
settings: StaticChessboardSettings(
pieceAssets: boardPrefs.pieceSet.assets,
colorScheme: boardPrefs.boardTheme.colors,
brightness: boardPrefs.brightness,
hue: boardPrefs.hue,
enableCoordinates: false,
borderRadius: Styles.boardBorderRadius,
boxShadow: boardShadows,
animationDuration: const Duration(milliseconds: 150),
),
),
const SizedBox(width: 10.0),
if (_showLoadingPlaceholder)
+12 -10
View File
@@ -89,16 +89,18 @@ class _BoardThumbnailState extends ConsumerState<BoardThumbnail> {
fen: widget.fen,
orientation: widget.orientation,
lastMove: widget.lastMove,
enableCoordinates: false,
borderRadius: (widget.showEvaluationGauge)
? Styles.boardBorderRadius.copyWith(topRight: Radius.zero, bottomRight: Radius.zero)
: Styles.boardBorderRadius,
boxShadow: (widget.showEvaluationGauge) ? [] : boardShadows,
pieceAssets: boardPrefs.pieceSet.assets,
colorScheme: boardPrefs.boardTheme.colors,
animationDuration: widget.animationDuration,
hue: boardPrefs.hue,
brightness: boardPrefs.brightness,
settings: StaticChessboardSettings(
enableCoordinates: false,
borderRadius: (widget.showEvaluationGauge)
? Styles.boardBorderRadius.copyWith(topRight: Radius.zero, bottomRight: Radius.zero)
: Styles.boardBorderRadius,
boxShadow: (widget.showEvaluationGauge) ? [] : boardShadows,
pieceAssets: boardPrefs.pieceSet.assets,
colorScheme: boardPrefs.boardTheme.colors,
animationDuration: widget.animationDuration,
hue: boardPrefs.hue,
brightness: boardPrefs.brightness,
),
);
final boardWithMaybeEvalBar = widget.showEvaluationGauge
+221 -88
View File
@@ -43,9 +43,14 @@ Side variantBoardOrientation({
/// An optional overlay or error message can be displayed on top of the board.
class GameLayout extends ConsumerStatefulWidget {
/// Creates a game layout with the given values.
///
/// Exactly one of [boardParams] (the screen lets [GameLayout] own the board
/// controller) or [controllerParams] (the screen owns the controller, for the
/// high-performance path) must be provided.
const GameLayout({
required this.orientation,
required this.boardParams,
this.boardParams,
this.controllerParams,
this.lastMove,
this.boardSettingsOverrides,
this.topTable = const SizedBox.shrink(),
@@ -58,19 +63,26 @@ class GameLayout extends ConsumerStatefulWidget {
this.moves,
this.currentMoveIndex = 0,
this.onSelectMove,
this.moveListBuilder,
this.boardOverlay,
this.errorMessage,
this.boardKey,
this.zenMode = false,
this.userActionsBar,
this.explosionSquares,
this.isReplaying = false,
this.onPremove,
super.key,
});
}) : assert(
(boardParams == null) != (controllerParams == null),
'Provide exactly one of boardParams or controllerParams',
);
/// Creates an empty game layout (useful for loading).
const GameLayout.empty({this.moves, this.errorMessage})
: orientation = Side.white,
boardParams = GameBoardParams.emptyBoard,
controllerParams = null,
lastMove = null,
boardSettingsOverrides = null,
topTable = const SizedBox.shrink(),
@@ -82,16 +94,32 @@ class GameLayout extends ConsumerStatefulWidget {
shapes = null,
currentMoveIndex = 0,
onSelectMove = null,
moveListBuilder = null,
boardOverlay = null,
boardKey = null,
zenMode = false,
userActionsBar = null,
explosionSquares = null;
explosionSquares = null,
isReplaying = false,
onPremove = null;
final GameBoardParams boardParams;
/// Board parameters for the owned-controller path: [GameLayout] creates and
/// drives the controller from these, updating it in [didUpdateWidget].
///
/// Null in the [controllerParams] (high-performance) path.
final GameBoardParams? boardParams;
/// Board parameters for the high-performance path: the caller owns the
/// [ChessboardController] and drives it directly. [GameLayout] renders the
/// board with it but never creates, disposes, or drives it, and does no
/// position work in [didUpdateWidget].
///
/// Null in the [boardParams] (owned-controller) path.
final ControllerBoardParams? controllerParams;
final Side orientation;
/// Last move highlight for readonly boards. For interactive boards, use [InteractiveBoardParams.lastMove].
final Move? lastMove;
final BoardSettingsOverrides? boardSettingsOverrides;
@@ -130,6 +158,14 @@ class GameLayout extends ConsumerStatefulWidget {
/// Callback that will be called when a move is selected from the [moves] list.
final void Function(int moveIndex)? onSelectMove;
/// Optional builder for a self-watching move list (high-performance path).
///
/// When provided, it takes precedence over [moves]/[currentMoveIndex]/[onSelectMove]
/// and is responsible for rendering a [MoveList] of the requested [MoveListType].
/// This lets the caller drive the move list from its own provider so that move
/// updates do not force [GameLayout] to rebuild.
final Widget Function(MoveListType type)? moveListBuilder;
/// Optional error message that will be displayed on top of the board.
final String? errorMessage;
@@ -144,21 +180,178 @@ class GameLayout extends ConsumerStatefulWidget {
final Widget? userActionsBar;
/// Squares on which an atomic chess explosion should be shown.
final Set<Square>? explosionSquares;
/// Whether the board is currently replaying move history (e.g. analysis navigation).
///
/// See [Chessboard.explosionSquares] for details.
final ISet<Square>? explosionSquares;
/// When true, position changes use [ChessboardController.jumpToPosition] instead of
/// [ChessboardController.animatePosition], skipping animation and clearing the premove.
final bool isReplaying;
/// Called when a premove is ready to be executed after the opponent moves.
final void Function(Move)? onPremove;
@override
ConsumerState<GameLayout> createState() => _GameLayoutState();
}
class _GameLayoutState extends ConsumerState<GameLayout> {
ISet<Shape> userShapes = ISet();
/// The controller created and owned by this state, used only in the
/// [GameBoardParams] path. Null in the [ControllerBoardParams] path.
ChessboardController? _ownController;
/// The controller actually rendered by the board: the externally owned one if
/// provided, otherwise the one we created.
ChessboardController? get _controller => widget.controllerParams?.controller ?? _ownController;
@override
void initState() {
super.initState();
if (widget.controllerParams == null) {
_initController();
}
_controller?.premoveNotifier.addListener(_onPremoveChanged);
}
@override
void dispose() {
_controller?.premoveNotifier.removeListener(_onPremoveChanged);
_ownController?.dispose();
super.dispose();
}
@override
void didUpdateWidget(GameLayout old) {
super.didUpdateWidget(old);
// If the externally owned controller instance changed, move the premove
// listener from the old controller to the new one.
final oldController = old.controllerParams?.controller ?? _ownController;
if (oldController != _controller) {
oldController?.premoveNotifier.removeListener(_onPremoveChanged);
_controller?.premoveNotifier.addListener(_onPremoveChanged);
}
// External-controller path: the owner drives position updates.
if (widget.controllerParams != null) return;
final ctrl = _ownController;
if (ctrl == null) return;
final newParams = widget.boardParams!;
final newFen = newParams.fen;
final fenChanged = old.boardParams?.fen != newFen;
final newGameData = _gameDataFor(newParams);
if (!fenChanged) {
// Only game metadata changed (e.g. playerSide, validMoves) update without animation.
ctrl.animatePosition(newGameData);
return;
}
if (widget.isReplaying) {
ctrl.jumpToPosition(newGameData);
return;
}
ctrl.animatePosition(newGameData);
if (widget.explosionSquares != null) {
ctrl.triggerExplosion(widget.explosionSquares!);
}
if (newParams is InteractiveBoardParams) {
final onPremove = widget.onPremove;
if (onPremove != null) {
tryExecutePremove(ctrl, newParams.position, onPremove);
}
}
}
void _initController() {
final params = widget.boardParams;
if (params == null) return;
_ownController = ChessboardController(game: _gameDataFor(params));
}
void _onPremoveChanged() {
if (mounted) setState(() {});
}
/// Whether a move list can be rendered (either inline moves or a builder).
bool get _hasMoveList => widget.moves != null || widget.moveListBuilder != null;
/// Builds the move list content for [type], using the [GameLayout.moveListBuilder]
/// when provided, otherwise the inline [GameLayout.moves]. Must only be called
/// when [_hasMoveList] is true. Does not apply zen-mode handling callers do.
Widget _moveListContent(MoveListType type) {
final builder = widget.moveListBuilder;
if (builder != null) return builder(type);
return MoveList(
type: type,
slicedMoves: widget.moves!.asMap().entries.slices(2),
currentMoveIndex: widget.currentMoveIndex,
onSelectMove: widget.onSelectMove,
);
}
/// Builds the [GameData] driving the owned controller.
///
/// Readonly boards are still controller-backed; they are made non-interactive
/// by using [PlayerSide.none] (so the user cannot move) with no valid moves.
GameData _gameDataFor(GameBoardParams params) {
final boardPrefs = ref.read(boardPreferencesProvider);
return switch (params) {
InteractiveBoardParams(:final variant, :final position, :final playerSide, :final lastMove) =>
buildGameData(
fen: position.fen,
variant: variant,
position: position,
playerSide: playerSide,
lastMove: lastMove,
castlingMethod: boardPrefs.castlingMethod,
boardHighlights: boardPrefs.boardHighlights,
),
ReadonlyBoardParams(:final fen) => GameData(
fen: fen,
playerSide: PlayerSide.none,
sideToMove: _sideToMoveFromFen(fen),
validMoves: const <Square, Set<Square>>{},
lastMove: widget.lastMove,
),
};
}
Side _sideToMoveFromFen(String fen) {
final parts = fen.split(' ');
return parts.length > 1 && parts[1] == 'b' ? Side.black : Side.white;
}
@override
Widget build(BuildContext context) {
final boardPrefs = ref.watch(boardPreferencesProvider);
// Board info needed for the layout, derived from whichever path is active.
// In the controller path, playerSide/sideToMove come from the controller's
// game data; in the owned path they come from the board params.
final cp = widget.controllerParams;
final variant = cp?.variant ?? widget.boardParams!.variant;
final pockets = cp != null ? cp.pockets : widget.boardParams!.pockets;
final playerSide = cp != null
? (_controller?.game.playerSide ?? PlayerSide.none)
: widget.boardParams!.playerSide;
final sideToMove = cp != null
? (playerSide == PlayerSide.none ? null : _controller?.game.sideToMove)
: switch (widget.boardParams!) {
ReadonlyBoardParams() => null,
InteractiveBoardParams(:final position, :final playerSide) =>
playerSide == PlayerSide.none ? null : position.turn,
};
final void Function(Move, {bool? viaDragAndDrop})? rawOnMove = cp != null
? cp.onMove
: switch (widget.boardParams!) {
InteractiveBoardParams(:final onMove) => onMove,
_ => null,
};
return LayoutBuilder(
builder: (context, constraints) {
final orientation = constraints.maxWidth > constraints.maxHeight
@@ -166,46 +359,20 @@ class _GameLayoutState extends ConsumerState<GameLayout> {
: Orientation.portrait;
final isTablet = isTabletOrLarger(context);
final defaultSettings = boardPrefs.toBoardSettings().copyWith(
borderRadius: isTablet ? Styles.boardBorderRadius : BorderRadius.zero,
boxShadow: isTablet ? boardShadows : const <BoxShadow>[],
drawShape: DrawShapeOptions(
enable: boardPrefs.enableShapeDrawings,
onCompleteShape: _onCompleteShape,
onClearShapes: _onClearShapes,
newShapeColor: boardPrefs.shapeColor.color,
),
);
final defaultSettings = boardPrefs
.toBoardSettings(variant)
.copyWith(
borderRadius: isTablet ? Styles.boardBorderRadius : BorderRadius.zero,
boxShadow: isTablet ? boardShadows : const <BoxShadow>[],
);
final settings = widget.boardSettingsOverrides != null
? widget.boardSettingsOverrides!.merge(defaultSettings)
: defaultSettings;
final shapes = userShapes.union(widget.shapes ?? ISet());
final slicedMoves = widget.moves?.asMap().entries.slices(2);
final shapes = widget.shapes?.unlock ?? const <Shape>{};
final fen = widget.boardParams.fen;
final gameData = switch (widget.boardParams) {
ReadonlyBoardParams() => null,
final InteractiveBoardParams board => boardPrefs.toGameData(
variant: board.variant,
position: board.position,
playerSide: board.playerSide,
promotionMove: board.promotionMove,
onMove: board.onMove,
onPromotionSelection: board.onPromotionSelection,
premovable: board.premovable,
),
};
final sideToMove = switch (widget.boardParams) {
ReadonlyBoardParams() => null,
InteractiveBoardParams(:final position, :final playerSide) =>
playerSide == PlayerSide.none ? null : position.turn,
};
final pockets = widget.boardParams.pockets;
final premoveDropRole = switch (gameData?.premovable?.premove) {
final premoveDropRole = switch (_controller?.premove) {
DropMove(:final role) => role,
_ => null,
};
@@ -222,7 +389,7 @@ class _GameLayoutState extends ConsumerState<GameLayout> {
PocketsMenu(
side: widget.orientation.opposite,
sideToMove: sideToMove,
playerSide: widget.boardParams.playerSide,
playerSide: playerSide,
pockets: pockets,
squareSize: pocketSquareSize(boardSize: boardSize, isTablet: isTablet),
isUpsideDown: widget.topTableUpsideDown,
@@ -246,7 +413,7 @@ class _GameLayoutState extends ConsumerState<GameLayout> {
PocketsMenu(
side: widget.orientation,
sideToMove: sideToMove,
playerSide: widget.boardParams.playerSide,
playerSide: playerSide,
pockets: pockets,
squareSize: pocketSquareSize(boardSize: boardSize, isTablet: isTablet),
isUpsideDown: widget.bottomTableUpsideDown,
@@ -275,16 +442,14 @@ class _GameLayoutState extends ConsumerState<GameLayout> {
children: [
BoardWidget(
size: boardSize,
fen: fen,
orientation: widget.orientation,
gameData: gameData,
lastMove: widget.lastMove,
controller: _controller!,
onMove: rawOnMove,
shapes: shapes,
settings: settings,
boardKey: widget.boardKey,
boardOverlay: widget.boardOverlay,
error: widget.errorMessage,
explosionSquares: widget.explosionSquares,
),
const SizedBox(width: 16.0),
Expanded(
@@ -293,16 +458,11 @@ class _GameLayoutState extends ConsumerState<GameLayout> {
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
topTable(boardSize: boardSize),
if (boardPrefs.moveListDisplay && !widget.zenMode && slicedMoves != null)
if (boardPrefs.moveListDisplay && !widget.zenMode && _hasMoveList)
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 16.0),
child: MoveList(
type: MoveListType.stacked,
slicedMoves: slicedMoves,
currentMoveIndex: widget.currentMoveIndex,
onSelectMove: widget.onSelectMove,
),
child: _moveListContent(MoveListType.stacked),
),
)
else
@@ -343,19 +503,14 @@ class _GameLayoutState extends ConsumerState<GameLayout> {
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (boardPrefs.moveListDisplay &&
slicedMoves != null &&
_hasMoveList &&
!isShortScreen &&
!(isTablet && pockets != null))
if (widget.zenMode)
// display empty move list to keep the layout consistent in zen mode
const MoveList(type: MoveListType.inline, slicedMoves: [], currentMoveIndex: 0)
else
MoveList(
type: MoveListType.inline,
slicedMoves: slicedMoves,
currentMoveIndex: widget.currentMoveIndex,
onSelectMove: widget.onSelectMove,
),
_moveListContent(MoveListType.inline),
Expanded(
flex: widget.topTableFlex,
child: Padding(
@@ -371,16 +526,14 @@ class _GameLayoutState extends ConsumerState<GameLayout> {
: EdgeInsets.zero,
child: BoardWidget(
size: effectiveBoardSize,
fen: fen,
orientation: widget.orientation,
gameData: gameData,
lastMove: widget.lastMove,
controller: _controller!,
onMove: rawOnMove,
shapes: shapes,
settings: settings,
boardKey: widget.boardKey,
boardOverlay: widget.boardOverlay,
error: widget.errorMessage,
explosionSquares: widget.explosionSquares,
),
),
Expanded(
@@ -399,29 +552,6 @@ class _GameLayoutState extends ConsumerState<GameLayout> {
},
);
}
void _onCompleteShape(Shape shape) {
if (!mounted) return;
if (userShapes.any((element) => element == shape)) {
setState(() {
userShapes = userShapes.remove(shape);
});
return;
} else {
setState(() {
userShapes = userShapes.add(shape);
});
}
}
void _onClearShapes() {
if (!mounted) return;
setState(() {
userShapes = ISet();
});
}
}
class BoardSettingsOverrides {
@@ -433,6 +563,7 @@ class BoardSettingsOverrides {
this.drawShape,
this.pieceOrientationBehavior,
this.pieceAssets,
this.enablePremoves,
});
final Duration? animationDuration;
@@ -442,6 +573,7 @@ class BoardSettingsOverrides {
final DrawShapeOptions? drawShape;
final PieceOrientationBehavior? pieceOrientationBehavior;
final PieceAssets? pieceAssets;
final bool? enablePremoves;
ChessboardSettings merge(ChessboardSettings settings) {
return settings.copyWith(
@@ -452,6 +584,7 @@ class BoardSettingsOverrides {
drawShape: drawShape,
pieceOrientationBehavior: pieceOrientationBehavior,
pieceAssets: pieceAssets,
enablePremoves: enablePremoves,
);
}
}
+1
View File
@@ -125,6 +125,7 @@ class _Pocket extends StatelessWidget {
ignoring: !interactive || count == 0,
child: Draggable(
key: ValueKey('pocket-${side.name}${role.name}'),
dragAnchorStrategy: pointerDragAnchorStrategy,
data: Piece(role: role, color: side),
feedback: RotatedBox(
quarterTurns: isUpsideDown ? 2 : 0,
+8 -7
View File
@@ -180,11 +180,12 @@ packages:
chessground:
dependency: "direct main"
description:
name: chessground
sha256: e2545b2475259c5665e7c6162affa6dbcb7dd3eb3050f603162833fbb7a8a260
url: "https://pub.dev"
source: hosted
version: "9.0.0"
path: "."
ref: f00bb0921db2dd3140a040d73d9f2df2d6c37a93
resolved-ref: f00bb0921db2dd3140a040d73d9f2df2d6c37a93
url: "https://github.com/lichess-org/flutter-chessground.git"
source: git
version: "10.0.0"
cli_config:
dependency: transitive
description:
@@ -309,10 +310,10 @@ packages:
dependency: "direct main"
description:
name: dartchess
sha256: "489095ecd3d03466ffd9e3d9680ff115919432d7a069a93932222edc8756d6e8"
sha256: "0f2413624840484c1c316dbbbea7d777c83f7dccb8b8b00e5ca55c2058d2fa23"
url: "https://pub.dev"
source: hosted
version: "0.12.3"
version: "0.13.0"
dbus:
dependency: transitive
description:
+6 -3
View File
@@ -2,7 +2,7 @@ name: lichess_mobile
description: Lichess mobile app V2
publish_to: "none"
version: 0.23.14+002314 # See README.md for details about versioning
version: 0.24.0+002400 # See README.md for details about versioning
environment:
sdk: ^3.11.5
@@ -14,7 +14,10 @@ dependencies:
app_settings: ^7.0.0
async: ^2.13.1
auto_size_text: ^3.0.0
chessground: ^9.0.0
chessground:
git:
url: https://github.com/lichess-org/flutter-chessground.git
ref: f00bb0921db2dd3140a040d73d9f2df2d6c37a93
clock: ^1.1.2
collection: ^1.19.1
connectivity_plus: ^7.1.1
@@ -22,7 +25,7 @@ dependencies:
crypto: ^3.0.7
cupertino_http: ^2.4.0
cupertino_icons: ^1.0.9
dartchess: ^0.12.3
dartchess: ^0.13.0
deep_pick: ^1.1.0
device_info_plus: ^13.1.0
dynamic_system_colors:
+60 -4
View File
@@ -86,6 +86,64 @@ Future<void> meetsTapTargetGuideline(WidgetTester tester) async {
}
}
/// Finds either an interactive [Chessboard] or a [StaticChessboard].
Finder _anyBoard() => find.byWidgetPredicate((w) => w is Chessboard || w is StaticChessboard);
/// Returns the pieces on the chessboard.
Map<Square, Piece> getBoardPieces(WidgetTester tester) {
for (final element
in find.descendant(of: _anyBoard(), matching: find.byType(CustomPaint)).evaluate()) {
final widget = element.widget as CustomPaint;
if (widget.painter is PiecesPainter) {
return (widget.painter! as PiecesPainter).pieces;
}
}
throw StateError('PiecesPainter not found');
}
HighlightsPainter _highlightsPainter(WidgetTester tester) {
for (final element
in find.descendant(of: _anyBoard(), matching: find.byType(CustomPaint)).evaluate()) {
final widget = element.widget as CustomPaint;
if (widget.painter is HighlightsPainter) {
return widget.painter! as HighlightsPainter;
}
}
throw StateError('HighlightsPainter not found');
}
/// Returns true if the board has [piece] at [square].
bool boardHasPiece(WidgetTester tester, Square square, Piece piece) {
return getBoardPieces(tester)[square] == piece;
}
/// Returns the valid moves set currently highlighted on the interactive chessboard.
Set<Square> getBoardValidMoves(WidgetTester tester) {
return _highlightsPainter(tester).interactionNotifier.moveDests;
}
/// Returns the last move currently highlighted on the chessboard, or null if no last move is highlighted.
Move? getBoardLastMove(WidgetTester tester) {
return _highlightsPainter(tester).interactionNotifier.lastMove;
}
/// Returns true if the board has a highlight on [square].
bool boardHasHighlight(WidgetTester tester, Square square) {
return _highlightsPainter(tester).squareHighlights.containsKey(square);
}
/// Returns true if the board has a premove highlight set for [move].
bool boardHasPremove(WidgetTester tester, Move move) {
final p = _highlightsPainter(tester);
return p.interactionNotifier.premove != null &&
switch (move) {
NormalMove(:final from, :final to) =>
p.interactionNotifier.premove!.hasSquare(from) &&
p.interactionNotifier.premove!.hasSquare(to),
DropMove(:final to) => p.interactionNotifier.premove!.hasSquare(to),
};
}
/// Returns the offset of a square on a board defined by [Rect].
Offset squareOffset(Square square, Rect boardRect, {Side orientation = Side.white}) {
final squareSize = boardRect.width / 8;
@@ -121,11 +179,9 @@ Future<void> playDropMove(
Side orientation = Side.white,
}) async {
final rect = boardRect ?? tester.getRect(find.byType(Chessboard));
final targetOffset = squareOffset(Square.fromName(to), rect, orientation: orientation);
final fromOffset = tester.getCenter(find.byKey(ValueKey('pocket-${side.name}${role.name}')));
await tester.dragFrom(
fromOffset,
squareOffset(Square.fromName(to), rect, orientation: orientation) - fromOffset,
);
await tester.dragFrom(fromOffset, targetOffset - fromOffset);
await tester.pumpAndSettle();
}
+6 -6
View File
@@ -28,7 +28,7 @@ void main() {
pov: Side.white,
sideToMove: Side.white,
boardBuilder: (context, boardSize, boardRadius) {
return Chessboard.fixed(
return StaticChessboard(
size: boardSize,
fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR',
orientation: Side.white,
@@ -51,7 +51,7 @@ void main() {
reason: 'Board background size is square on $surface',
);
final boardSize = tester.getSize(find.byType(Chessboard));
final boardSize = tester.getSize(find.byType(StaticChessboard));
expect(boardSize.width, boardSize.height, reason: 'Board size is square on $surface');
@@ -77,7 +77,7 @@ void main() {
pov: Side.white,
sideToMove: Side.white,
boardBuilder: (context, boardSize, boardRadius) {
return Chessboard.fixed(
return StaticChessboard(
size: boardSize,
fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR',
orientation: Side.white,
@@ -94,7 +94,7 @@ void main() {
final isPortrait = surface.aspectRatio < 1.0;
final isTablet = surface.shortestSide > 600;
final boardSize = tester.getSize(find.byType(Chessboard));
final boardSize = tester.getSize(find.byType(StaticChessboard));
if (isPortrait) {
final expectedBoardSize = isTablet ? surface.width - 32.0 : surface.width;
@@ -147,7 +147,7 @@ void main() {
pov: Side.white,
sideToMove: Side.white,
boardBuilder: (context, boardSize, boardRadius) {
return Chessboard.fixed(
return StaticChessboard(
size: boardSize,
fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR',
orientation: Side.white,
@@ -167,7 +167,7 @@ void main() {
);
await tester.pumpWidget(app);
final boardTopLeft = tester.getTopLeft(find.byType(Chessboard));
final boardTopLeft = tester.getTopLeft(find.byType(StaticChessboard));
final tabBarTopLeft = tester.getTopLeft(find.byType(TabBarView));
expect(
+57 -53
View File
@@ -62,7 +62,7 @@ void main() {
await tester.pumpWidget(app);
expect(find.byType(Chessboard), findsOneWidget);
expect(find.byType(PieceWidget), findsNWidgets(25));
expect(getBoardPieces(tester).length, 25);
final currentMove = find.textContaining('Qe1#');
expect(currentMove, findsOneWidget);
expect(
@@ -149,7 +149,7 @@ void main() {
expect(find.byType(PocketsMenu), findsNothing);
// Horde starting position should be loaded:
expect(find.byKey(const ValueKey('b5-whitepawn')), findsOneWidget);
expect(boardHasPiece(tester, Square.b5, Piece.whitePawn), isTrue);
// Change to crazhouse, pockets should be displayed:
await tester.tap(find.bySemanticsLabel('Menu'));
@@ -212,14 +212,14 @@ void main() {
expect(find.byType(PocketsMenu), findsNWidgets(2));
await playDropMove(tester, Side.white, Role.pawn, 'a4');
expect(find.byKey(const ValueKey('a4-whitepawn')), findsOneWidget);
expect(boardHasPiece(tester, Square.a4, Piece.whitePawn), isTrue);
// Illegal drop move for black, should not be played
await playDropMove(tester, Side.black, Role.queen, 'h5');
expect(find.byKey(const ValueKey('h5-blackqueen')), findsNothing);
expect(boardHasPiece(tester, Square.h5, Piece.blackQueen), isFalse);
await playDropMove(tester, Side.black, Role.pawn, 'h5');
expect(find.byKey(const ValueKey('h5-blackpawn')), findsOneWidget);
expect(boardHasPiece(tester, Square.h5, Piece.blackPawn), isTrue);
});
testWidgets('Continue against computer', (tester) async {
@@ -994,7 +994,7 @@ void main() {
await tester.pumpAndSettle();
// White pawn should appear on c4 after the drop move
expect(find.byKey(const ValueKey('c4-whitepawn')), findsOneWidget);
expect(boardHasPiece(tester, Square.c4, Piece.whitePawn), isTrue);
});
});
@@ -1195,27 +1195,29 @@ void main() {
await tester.pumpWidget(app);
expect(find.byKey(const Key('e1-whiteking')), findsOneWidget);
expect(boardHasPiece(tester, Square.e1, Piece.whiteKing), isTrue);
await tester.tap(find.byKey(const Key('e1-whiteking')));
final boardRect = tester.getRect(find.byType(Chessboard));
await tester.tapAt(squareOffset(Square.e1, boardRect));
await tester.pump();
final validMoves = getBoardValidMoves(tester);
switch (castlingMethod) {
case CastlingMethod.kingOverRook:
// kingOverRook acts as either kingTwoSquares or kingOverRook
expect(find.byKey(const Key('f1-dest')), findsOneWidget);
expect(find.byKey(const Key('g1-dest')), findsOneWidget);
expect(find.byKey(const Key('h1-dest')), findsOneWidget);
expect(find.byKey(const Key('c1-dest')), findsOneWidget);
expect(find.byKey(const Key('d1-dest')), findsOneWidget);
expect(find.byKey(const Key('a1-dest')), findsOneWidget);
expect(validMoves.contains(Square.f1), isTrue);
expect(validMoves.contains(Square.g1), isTrue);
expect(validMoves.contains(Square.h1), isTrue);
expect(validMoves.contains(Square.c1), isTrue);
expect(validMoves.contains(Square.d1), isTrue);
expect(validMoves.contains(Square.a1), isTrue);
case CastlingMethod.kingTwoSquares:
expect(find.byKey(const Key('f1-dest')), findsOneWidget);
expect(find.byKey(const Key('g1-dest')), findsOneWidget);
expect(find.byKey(const Key('h1-dest')), findsNothing);
expect(find.byKey(const Key('c1-dest')), findsOneWidget);
expect(find.byKey(const Key('d1-dest')), findsOneWidget);
expect(find.byKey(const Key('a1-dest')), findsNothing);
expect(validMoves.contains(Square.f1), isTrue);
expect(validMoves.contains(Square.g1), isTrue);
expect(validMoves.contains(Square.h1), isFalse);
expect(validMoves.contains(Square.c1), isTrue);
expect(validMoves.contains(Square.d1), isTrue);
expect(validMoves.contains(Square.a1), isFalse);
}
});
}
@@ -1244,17 +1246,19 @@ void main() {
await tester.pumpWidget(app);
await tester.tap(find.byKey(const Key('e1-whiteking')));
final boardRect = tester.getRect(find.byType(Chessboard));
await tester.tapAt(squareOffset(Square.e1, boardRect));
await tester.pump();
// in chess960, castling is only king over rook, no matter the preference
expect(find.byKey(const Key('f1-dest')), findsOneWidget);
expect(find.byKey(const Key('g1-dest')), findsNothing);
expect(find.byKey(const Key('h1-dest')), findsOneWidget);
expect(find.byKey(const Key('c1-dest')), findsNothing);
expect(find.byKey(const Key('d1-dest')), findsOneWidget);
expect(find.byKey(const Key('a1-dest')), findsOneWidget);
final validMoves = getBoardValidMoves(tester);
expect(validMoves.contains(Square.f1), isTrue);
expect(validMoves.contains(Square.g1), isFalse);
expect(validMoves.contains(Square.h1), isTrue);
expect(validMoves.contains(Square.c1), isFalse);
expect(validMoves.contains(Square.d1), isTrue);
expect(validMoves.contains(Square.a1), isTrue);
});
}
});
@@ -1300,9 +1304,9 @@ void main() {
await tester.tap(find.byKey(const Key('goto-previous')));
await tester.pump();
expect(find.byKey(const ValueKey('d4-whiteknight')), findsOneWidget);
expect(find.byKey(const ValueKey('a3-blackking')), findsOneWidget);
expect(find.byKey(const ValueKey('g3-whiteking')), findsNothing);
expect(boardHasPiece(tester, Square.d4, Piece.whiteKnight), isTrue);
expect(boardHasPiece(tester, Square.a3, Piece.blackKing), isTrue);
expect(boardHasPiece(tester, Square.g3, Piece.whiteKing), isFalse);
// Navigate back to More tab
await tester.pageBack();
@@ -1321,9 +1325,9 @@ void main() {
expect(find.textContaining('Kg3'), findsOneWidget);
// Verify board state is correct
expect(find.byKey(const ValueKey('d4-whiteknight')), findsOneWidget);
expect(find.byKey(const ValueKey('a3-blackking')), findsOneWidget);
expect(find.byKey(const ValueKey('g3-whiteking')), findsNothing);
expect(boardHasPiece(tester, Square.d4, Piece.whiteKnight), isTrue);
expect(boardHasPiece(tester, Square.a3, Piece.blackKing), isTrue);
expect(boardHasPiece(tester, Square.g3, Piece.whiteKing), isFalse);
});
testWidgets('Clear moves clears standalone analysis', (tester) async {
// Open from More tab and navigate to board analysis
@@ -1350,7 +1354,7 @@ void main() {
// Verify we made the moves
expect(find.textContaining('f4'), findsOneWidget);
expect(find.byKey(const ValueKey('f4-whitepawn')), findsOneWidget);
expect(boardHasPiece(tester, Square.f4, Piece.whitePawn), isTrue);
//open menu
await tester.tap(find.byIcon(Icons.menu));
@@ -1363,7 +1367,7 @@ void main() {
//verify moves are cleared
expect(find.textContaining('e4'), findsNothing);
expect(find.byKey(const ValueKey('e4-whitepawn')), findsNothing);
expect(boardHasPiece(tester, Square.e4, Piece.whitePawn), isFalse);
// Navigate back to More tab
await tester.pageBack();
@@ -1378,7 +1382,7 @@ void main() {
// Verify moves are no longer present and analysis was cleared
expect(find.textContaining('e4'), findsNothing);
expect(find.byKey(const ValueKey('e4-whitepawn')), findsNothing);
expect(boardHasPiece(tester, Square.e4, Piece.whitePawn), isFalse);
});
testWidgets('Opening a position from board editor overwrites saved standalone analysis', (
@@ -1408,7 +1412,7 @@ void main() {
// Verify we made the moves
expect(find.textContaining('f4'), findsOneWidget);
expect(find.byKey(const ValueKey('f4-whitepawn')), findsOneWidget);
expect(boardHasPiece(tester, Square.f4, Piece.whitePawn), isTrue);
// Navigate back to More tab
await tester.pageBack();
@@ -1431,9 +1435,9 @@ void main() {
await tester.pumpAndSettle();
// Verify board state is correct and previous analysis was overwritten
expect(find.byKey(const ValueKey('d4-whitepawn')), findsOneWidget);
expect(find.byKey(const ValueKey('d5-blackpawn')), findsOneWidget);
expect(find.byKey(const ValueKey('f4-whitepawn')), findsNothing);
expect(boardHasPiece(tester, Square.d4, Piece.whitePawn), isTrue);
expect(boardHasPiece(tester, Square.d5, Piece.blackPawn), isTrue);
expect(boardHasPiece(tester, Square.f4, Piece.whitePawn), isFalse);
});
group('conditional premoves', () {
@@ -1794,8 +1798,8 @@ void main() {
await switchToPremoveTab(tester);
// Should be in starting position
expect(find.byKey(const ValueKey('e2-whitepawn')), findsOneWidget);
expect(find.byKey(const ValueKey('e7-blackpawn')), findsOneWidget);
expect(boardHasPiece(tester, Square.e2, Piece.whitePawn), isTrue);
expect(boardHasPiece(tester, Square.e7, Piece.blackPawn), isTrue);
await tester.tap(find.text('1. e4 e5'));
@@ -1803,10 +1807,10 @@ void main() {
await tester.pumpAndSettle();
// Should switch to the final position of the premove line (1. e4 e5)
expect(find.byKey(const ValueKey('e2-whitepawn')), findsNothing);
expect(find.byKey(const ValueKey('e4-whitepawn')), findsOneWidget);
expect(find.byKey(const ValueKey('e7-blackpawn')), findsNothing);
expect(find.byKey(const ValueKey('e5-blackpawn')), findsOneWidget);
expect(boardHasPiece(tester, Square.e2, Piece.whitePawn), isFalse);
expect(boardHasPiece(tester, Square.e4, Piece.whitePawn), isTrue);
expect(boardHasPiece(tester, Square.e7, Piece.blackPawn), isFalse);
expect(boardHasPiece(tester, Square.e5, Piece.blackPawn), isTrue);
});
testWidgets('New move by opponent updates premove lines', (tester) async {
@@ -1872,8 +1876,8 @@ void main() {
// Wait for socket message to arrive and board to update
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('e2-whitepawn')), findsNothing);
expect(find.byKey(const ValueKey('e4-whitepawn')), findsOneWidget);
expect(boardHasPiece(tester, Square.e2, Piece.whitePawn), isFalse);
expect(boardHasPiece(tester, Square.e4, Piece.whitePawn), isTrue);
// We've premoved e5, so the server will play this move for us
sendServerSocketMessages(AnalysisController.socketUri, [
@@ -1882,8 +1886,8 @@ void main() {
// Wait for socket message to arrive and board to update
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('e7-blackpawn')), findsNothing);
expect(find.byKey(const ValueKey('e5-blackpawn')), findsOneWidget);
expect(boardHasPiece(tester, Square.e7, Piece.blackPawn), isFalse);
expect(boardHasPiece(tester, Square.e5, Piece.blackPawn), isTrue);
// Only lines that started with 1. e4 e5 should be kept
expect(find.text('2. Nf3 Nc6'), findsOneWidget);
@@ -1978,12 +1982,12 @@ String makeCorrespondenceGameJsonWithForecast({
}
Future<void> dragFromTo(WidgetTester tester, String from, String to) async {
final fromOffset = squareOffset(tester, Square.fromName(from));
await tester.dragFrom(fromOffset, squareOffset(tester, Square.fromName(to)) - fromOffset);
final fromOffset = editorSquareOffset(tester, Square.fromName(from));
await tester.dragFrom(fromOffset, editorSquareOffset(tester, Square.fromName(to)) - fromOffset);
await tester.pumpAndSettle();
}
Offset squareOffset(WidgetTester tester, Square square) {
Offset editorSquareOffset(WidgetTester tester, Square square) {
final editor = find.byType(ChessboardEditor);
final squareSize = tester.getSize(editor).width / 8;
+5 -5
View File
@@ -197,7 +197,7 @@ void main() {
await playMove(tester, 'g1', 'f3');
// Wait for failure message to appear and move to be taken back
await tester.pump(const Duration(milliseconds: 500));
expect(find.byKey(const ValueKey('g1-whiteknight')), findsOneWidget);
expect(boardHasPiece(tester, Square.g1, Piece.whiteKnight), isTrue);
expect(find.text('You can do better'), findsOneWidget);
expect(find.text('Try another move for white'), findsOneWidget);
@@ -209,7 +209,7 @@ void main() {
// Should not be able to interact with the board while waiting for eval
await playMove(tester, 'a7', 'a6');
await tester.pump();
expect(find.byKey(const ValueKey('a7-blackpawn')), findsOneWidget);
expect(boardHasPiece(tester, Square.a7, Piece.blackPawn), isTrue);
// Pretend d4 isn't a good move either
sendServerSocketMessages(AnalysisController.socketUri, [
@@ -219,7 +219,7 @@ void main() {
await tester.pump(); // Wait for eval to be processed
// Move should be taken back
expect(find.byKey(const ValueKey('d2-whitepawn')), findsOneWidget);
expect(boardHasPiece(tester, Square.d2, Piece.whitePawn), isTrue);
expect(find.text('You can do better'), findsOneWidget);
expect(find.text('Try another move for white'), findsOneWidget);
@@ -248,7 +248,7 @@ void main() {
expect(find.text('Best was 1... c5'), findsOneWidget);
// Correct move should be on the board
expect(find.byKey(const ValueKey('c5-blackpawn')), findsOneWidget);
expect(boardHasPiece(tester, Square.c5, Piece.blackPawn), isTrue);
expect(find.text('Next mistake'), findsOneWidget);
await tester.tap(find.text('Next mistake'));
@@ -301,7 +301,7 @@ void main() {
// Wait for failure message to appear and move to be taken back
await tester.pumpAndSettle(const Duration(milliseconds: 500));
expect(find.byKey(const ValueKey('a2-whitepawn')), findsOneWidget);
expect(boardHasPiece(tester, Square.a2, Piece.whitePawn), isTrue);
// This one has been played multiple times, so it should be accepted as a solution (without consulting the engine)
await playMove(tester, 'd2', 'd4');
@@ -1,6 +1,5 @@
import 'package:chessground/chessground.dart';
import 'package:dartchess/dartchess.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:lichess_mobile/src/model/common/game.dart';
@@ -20,7 +19,7 @@ void main() {
await tester.tap(find.text('Start training'));
await tester.pumpAndSettle();
final container = ProviderScope.containerOf(tester.element(find.byType(Chessboard)));
final container = ProviderScope.containerOf(tester.element(find.byType(StaticChessboard)));
final controllerProvider = coordinateTrainingControllerProvider;
final trainingPrefsNotifier = container.read(coordinateTrainingPreferencesProvider.notifier);
@@ -41,7 +40,7 @@ void main() {
final app = await makeTestProviderScopeApp(tester, home: const CoordinateTrainingScreen());
await tester.pumpWidget(app);
final container = ProviderScope.containerOf(tester.element(find.byType(Chessboard)));
final container = ProviderScope.containerOf(tester.element(find.byType(StaticChessboard)));
final trainingPrefsNotifier = container.read(coordinateTrainingPreferencesProvider.notifier);
trainingPrefsNotifier.setMode(TrainingMode.findSquare);
trainingPrefsNotifier.setSideChoice(SideChoice.white);
@@ -56,7 +55,7 @@ void main() {
final wrongCoord = Square.values[(currentCoord! + 1) % Square.values.length];
await tester.tapAt(squareOffset(wrongCoord, tester.getRect(find.byType(Chessboard))));
await tester.tapAt(squareOffset(wrongCoord, tester.getRect(find.byType(StaticChessboard))));
await tester.pump();
expect(container.read(controllerProvider).score, 0);
@@ -64,17 +63,17 @@ void main() {
expect(container.read(controllerProvider).nextCoord, nextCoord);
expect(container.read(controllerProvider).trainingActive, true);
expect(find.byKey(ValueKey('${wrongCoord.name}-highlight')), findsOneWidget);
expect(boardHasHighlight(tester, wrongCoord), isTrue);
await tester.pump(const Duration(milliseconds: 300));
expect(find.byKey(ValueKey('${wrongCoord.name}-highlight')), findsNothing);
expect(boardHasHighlight(tester, wrongCoord), isFalse);
});
testWidgets('Tap correct square', (tester) async {
final app = await makeTestProviderScopeApp(tester, home: const CoordinateTrainingScreen());
await tester.pumpWidget(app);
final container = ProviderScope.containerOf(tester.element(find.byType(Chessboard)));
final container = ProviderScope.containerOf(tester.element(find.byType(StaticChessboard)));
final trainingPrefsNotifier = container.read(coordinateTrainingPreferencesProvider.notifier);
trainingPrefsNotifier.setMode(TrainingMode.findSquare);
trainingPrefsNotifier.setSideChoice(SideChoice.white);
@@ -87,18 +86,20 @@ void main() {
final currentCoord = container.read(controllerProvider).currentCoord;
final nextCoord = container.read(controllerProvider).nextCoord;
await tester.tapAt(squareOffset(currentCoord!, tester.getRect(find.byType(Chessboard))));
await tester.tapAt(
squareOffset(currentCoord!, tester.getRect(find.byType(StaticChessboard))),
);
await tester.pump();
expect(find.byKey(ValueKey('${currentCoord.name}-highlight')), findsOneWidget);
expect(boardHasHighlight(tester, currentCoord), isTrue);
expect(container.read(controllerProvider).score, 1);
expect(container.read(controllerProvider).currentCoord, nextCoord);
expect(container.read(controllerProvider).trainingActive, true);
await tester.pumpAndSettle(const Duration(milliseconds: 300));
expect(find.byKey(ValueKey('${currentCoord.name}-highlight')), findsNothing);
expect(boardHasHighlight(tester, currentCoord), isFalse);
expect(find.text(container.read(controllerProvider).currentCoord!.name), findsOneWidget);
expect(find.text(container.read(controllerProvider).nextCoord!.name), findsOneWidget);
@@ -205,7 +205,7 @@ void main() {
await tester.pumpAndSettle(); // wait for analysis screen to open
await playMove(tester, 'e2', 'e4');
expect(find.byKey(const ValueKey('e4-whitepawn')), findsOneWidget);
expect(boardHasPiece(tester, Square.e4, Piece.whitePawn), isTrue);
// Go back to "more" screen and open opening explorer
await tester.pageBack();
@@ -215,12 +215,12 @@ void main() {
await tester.pumpAndSettle(); // wait for opening explorer screen to open
// Should not use saved standalone analysis here
expect(find.byKey(const ValueKey('e2-whitepawn')), findsOneWidget);
expect(boardHasPiece(tester, Square.e2, Piece.whitePawn), isTrue);
// There was a bug where the opening explorer would partially load saved analysis,
// leading to not being to move any pieces.
await playMove(tester, 'd2', 'd4');
expect(find.byKey(const ValueKey('d4-whitepawn')), findsOneWidget);
expect(boardHasPiece(tester, Square.d4, Piece.whitePawn), isTrue);
});
});
}
+402 -153
View File
@@ -34,10 +34,13 @@ import 'package:lichess_mobile/src/network/socket.dart';
import 'package:lichess_mobile/src/styles/lichess_icons.dart';
import 'package:lichess_mobile/src/view/chat/chat_screen.dart';
import 'package:lichess_mobile/src/view/game/correspondence_clock_widget.dart';
import 'package:lichess_mobile/src/view/game/game_body.dart';
import 'package:lichess_mobile/src/view/game/game_player.dart';
import 'package:lichess_mobile/src/view/game/game_screen.dart';
import 'package:lichess_mobile/src/view/game/game_screen_providers.dart';
import 'package:lichess_mobile/src/widgets/bottom_bar.dart';
import 'package:lichess_mobile/src/widgets/clock.dart';
import 'package:lichess_mobile/src/widgets/game_layout.dart';
import 'package:lichess_mobile/src/widgets/pockets.dart';
import 'package:mocktail/mocktail.dart';
import 'package:qr_flutter/qr_flutter.dart';
@@ -109,14 +112,14 @@ void main() {
// while loading, displays an empty board
expect(find.byType(Chessboard), findsOneWidget);
expect(find.byType(PieceWidget), findsNothing);
expect(getBoardPieces(tester), isEmpty);
final initialBoardPosition = tester.getTopLeft(find.byType(Chessboard));
// now the game controller is loading and screen doesn't have changed yet
await tester.pump(const Duration(milliseconds: 10));
expect(find.byType(Chessboard), findsOneWidget);
expect(find.byType(PieceWidget), findsNothing);
expect(getBoardPieces(tester), isEmpty);
expect(
tester.getTopLeft(find.byType(Chessboard)),
initialBoardPosition,
@@ -136,7 +139,7 @@ void main() {
// wait for socket message handling
await tester.pump();
expect(find.byType(PieceWidget), findsNWidgets(32));
expect(getBoardPieces(tester).length, 32);
expect(find.text('Peter'), findsOneWidget);
expect(find.text('Steven'), findsOneWidget);
expect(
@@ -163,7 +166,7 @@ void main() {
await tester.pumpWidget(app);
expect(find.byType(Chessboard), findsOneWidget);
expect(find.byType(PieceWidget), findsNothing);
expect(getBoardPieces(tester), isEmpty);
expect(find.text('Waiting for opponent to join...'), findsOneWidget);
expect(find.text('3+2'), findsOneWidget);
expect(find.widgetWithText(BottomBarButton, 'Cancel'), findsOneWidget);
@@ -182,7 +185,7 @@ void main() {
// now the game controller is loading
expect(find.byType(Chessboard), findsOneWidget);
expect(find.byType(PieceWidget), findsNothing);
expect(getBoardPieces(tester), isEmpty);
expect(find.text('Waiting for opponent to join...'), findsNothing);
expect(find.text('3+2'), findsNothing);
expect(find.widgetWithText(BottomBarButton, 'Cancel'), findsNothing);
@@ -206,7 +209,7 @@ void main() {
// wait for socket message handling
await tester.pump();
expect(find.byType(PieceWidget), findsNWidgets(32));
expect(getBoardPieces(tester).length, 32);
expect(find.text('Peter'), findsOneWidget);
expect(find.text('Steven'), findsOneWidget);
expect(find.text('Waiting for opponent to join...'), findsNothing);
@@ -268,7 +271,7 @@ void main() {
await tester.pumpAndSettle();
expect(find.byType(Chessboard), findsOneWidget);
expect(find.byType(PieceWidget), findsNothing);
expect(getBoardPieces(tester), isEmpty);
expect(find.text('To invite someone to play, give this URL'), findsOneWidget);
expect(find.text('Or let your opponent scan this QR code'), findsOneWidget);
expect(find.byType(QrImageView), findsOneWidget);
@@ -615,14 +618,12 @@ void main() {
final container = ProviderScope.containerOf(tester.element(find.byType(GameScreen)));
final ctrlProvider = gameControllerProvider(const GameFullId('qVChCOTcHSeW'));
expect(container.read(ctrlProvider).requireValue.promotionMove, isNotNull);
expect(container.read(ctrlProvider).requireValue.moveToConfirm, isNull);
final boardRect = tester.getRect(find.byType(Chessboard));
await tester.tapAt(squareOffset(Square.fromName('e8'), boardRect));
await tester.pump();
expect(container.read(ctrlProvider).requireValue.promotionMove, isNull);
expect(container.read(ctrlProvider).requireValue.moveToConfirm, isNotNull);
});
@@ -648,14 +649,14 @@ void main() {
),
);
expect(find.byType(Chessboard), findsOneWidget);
expect(find.byType(PieceWidget), findsNWidgets(32));
expect(getBoardPieces(tester).length, 32);
await playMove(tester, 'g1', 'f3');
// see confirmation dialog
expect(find.text('Confirm move'), findsOneWidget);
// move is shown on board
expect(find.byKey(const Key('f3-whiteknight')), findsOneWidget);
expect(boardHasPiece(tester, Square.f3, Piece.whiteKnight), isTrue);
// move is not yet played so it doesn't appear in the move list
expect(find.text('Nf3'), findsNothing);
@@ -664,129 +665,180 @@ void main() {
await tester.pump();
// move still shown on board
expect(find.byKey(const Key('f3-whiteknight')), findsOneWidget);
expect(boardHasPiece(tester, Square.f3, Piece.whiteKnight), isTrue);
// move appears in move list
expect(find.text('Nf3'), findsOneWidget);
});
testWidgets('illegal premove is cancelled after opponent move with move confirmation', (
WidgetTester tester,
) async {
const gameFullId = GameFullId('qVChCOTcHSeW');
final gameSocketUri = GameController.socketUri(gameFullId);
group('Premoves', () {
testWidgets('premove is applied after opponent move', (WidgetTester tester) async {
const gameFullId = GameFullId('qVChCOTcHSeW');
final gameSocketUri = GameController.socketUri(gameFullId);
await createTestGame(
tester,
pgn: 'e4 e5',
clock: const (
running: true,
initial: Duration(minutes: 1),
increment: Duration.zero,
white: Duration(seconds: 58),
black: Duration(seconds: 54),
emerg: Duration(seconds: 10),
),
serverPrefs: const ServerGamePrefs(
showRatings: true,
enablePremove: true,
autoQueen: .always,
confirmResign: true,
submitMove: true,
zenMode: .no,
),
);
expect(find.byType(Chessboard), findsOneWidget);
expect(find.byType(PieceWidget), findsNWidgets(32));
// After e4 it's black's turn, white can premove
await createTestGame(
tester,
pgn: 'e4',
clock: const (
running: true,
initial: Duration(minutes: 1),
increment: Duration.zero,
white: Duration(seconds: 58),
black: Duration(seconds: 58),
emerg: Duration(seconds: 10),
),
serverPrefs: const ServerGamePrefs(
showRatings: true,
enablePremove: true,
autoQueen: .always,
confirmResign: true,
submitMove: false,
zenMode: .no,
),
);
// white plays d4 with confirmation
await playMove(tester, 'd2', 'd4');
expect(find.text('Confirm move'), findsOneWidget);
expect(find.byKey(const Key('d4-whitepawn')), findsOneWidget);
expect(find.byType(Chessboard), findsOneWidget);
// white premoves d4-d5 (push the d-pawn, anticipating d5 stays free)
await playMove(tester, 'd4', 'd5');
await tester.pump();
// white premoves d2-d4
await playMove(tester, 'd2', 'd4');
// premove indicators should be visible
expect(find.byKey(const ValueKey('d4-premove')), findsOneWidget);
expect(find.byKey(const ValueKey('d5-premove')), findsOneWidget);
// premove indicator should be visible
expect(boardHasPremove(tester, const NormalMove(from: Square.d2, to: Square.d4)), isTrue);
// confirm the move
await tester.tap(find.byIcon(CupertinoIcons.checkmark_rectangle_fill));
await tester.pump();
// opponent plays e7-e5 (ply 2)
sendServerSocketMessages(gameSocketUri, [
'{"t": "move", "v": 1, "d": {"ply": 2, "uci": "e7e5", "san": "e5", "clock": {"white": 58, "black": 56}}}',
]);
await tester.pump();
// premove indicators should still be visible after confirmation
expect(find.byKey(const ValueKey('d4-premove')), findsOneWidget);
expect(find.byKey(const ValueKey('d5-premove')), findsOneWidget);
// let the premove microtask run
await tester.pump(const Duration(milliseconds: 1));
// let the board rebuild from userMove
await tester.pump();
// server acknowledges white's d4 move (ply 3)
sendServerSocketMessages(gameSocketUri, [
'{"t": "move", "v": 1, "d": {"ply": 3, "uci": "d2d4", "san": "d4", "clock": {"white": 57, "black": 54}}}',
]);
await tester.pump();
// premove should have been played
expect(boardHasPremove(tester, const NormalMove(from: Square.d2, to: Square.d4)), isFalse);
expect(boardHasPiece(tester, Square.d4, Piece.whitePawn), isTrue);
});
// opponent plays d7-d5 (ply 4), blocking the premove
sendServerSocketMessages(gameSocketUri, [
'{"t": "move", "v": 2, "d": {"ply": 4, "uci": "d7d5", "san": "d5", "clock": {"white": 57, "black": 52}}}',
]);
await tester.pump();
testWidgets('illegal premove is cancelled after opponent move with move confirmation', (
WidgetTester tester,
) async {
const gameFullId = GameFullId('qVChCOTcHSeW');
final gameSocketUri = GameController.socketUri(gameFullId);
// let the premove microtask run
await tester.pump(const Duration(milliseconds: 1));
await createTestGame(
tester,
pgn: 'e4 e5',
clock: const (
running: true,
initial: Duration(minutes: 1),
increment: Duration.zero,
white: Duration(seconds: 58),
black: Duration(seconds: 54),
emerg: Duration(seconds: 10),
),
serverPrefs: const ServerGamePrefs(
showRatings: true,
enablePremove: true,
autoQueen: .always,
confirmResign: true,
submitMove: true,
zenMode: .no,
),
);
expect(find.byType(Chessboard), findsOneWidget);
expect(getBoardPieces(tester).length, 32);
// premove should be cancelled since d4-d5 is now illegal (d5 is occupied)
expect(find.byKey(const ValueKey('d4-premove')), findsNothing);
expect(find.byKey(const ValueKey('d5-premove')), findsNothing);
// white plays d4 with confirmation
await playMove(tester, 'd2', 'd4');
expect(find.text('Confirm move'), findsOneWidget);
expect(boardHasPiece(tester, Square.d4, Piece.whitePawn), isTrue);
// d5 should have black's pawn (opponent's move was applied)
expect(find.byKey(const Key('d5-blackpawn')), findsOneWidget);
// d4 should still have white's pawn
expect(find.byKey(const Key('d4-whitepawn')), findsOneWidget);
});
// white premoves d4-d5 (push the d-pawn, anticipating d5 stays free)
await playMove(tester, 'd4', 'd5');
await tester.pump();
testWidgets('can premove drop moves in Crazyhouse', (WidgetTester tester) async {
const gameFullId = GameFullId('qVChCOTcHSeW');
final gameSocketUri = GameController.socketUri(gameFullId);
// premove indicators should be visible
expect(boardHasPremove(tester, const NormalMove(from: Square.d4, to: Square.d5)), isTrue);
await createTestGame(
tester,
pgn: 'e4 d5 exd5',
variant: Variant.crazyhouse,
clock: const (
running: true,
initial: Duration(minutes: 1),
increment: Duration.zero,
white: Duration(seconds: 58),
black: Duration(seconds: 54),
emerg: Duration(seconds: 10),
),
serverPrefs: const ServerGamePrefs(
showRatings: true,
enablePremove: true,
autoQueen: .always,
confirmResign: true,
submitMove: false,
zenMode: .no,
),
);
// confirm the move
await tester.tap(find.byIcon(CupertinoIcons.checkmark_rectangle_fill));
await tester.pump();
await playDropMove(tester, Side.white, Role.pawn, 'a4');
// premove indicators should still be visible after confirmation
expect(boardHasPremove(tester, const NormalMove(from: Square.d4, to: Square.d5)), isTrue);
// premove indicator should be visible
expect(find.byKey(const ValueKey('a4-premove')), findsOneWidget);
// server acknowledges white's d4 move (ply 3)
sendServerSocketMessages(gameSocketUri, [
'{"t": "move", "v": 1, "d": {"ply": 3, "uci": "d2d4", "san": "d4", "clock": {"white": 57, "black": 54}}}',
]);
await tester.pump();
// opponent plays Qxd5
sendServerSocketMessages(gameSocketUri, [
'{"t": "move", "v": 1, "d": {"ply": 4, "uci": "d8d5", "san": "Qxd5", "clock": {"white": 57, "black": 52}}}',
]);
await tester.pump();
// opponent plays d7-d5 (ply 4), blocking the premove
sendServerSocketMessages(gameSocketUri, [
'{"t": "move", "v": 2, "d": {"ply": 4, "uci": "d7d5", "san": "d5", "clock": {"white": 57, "black": 52}}}',
]);
await tester.pump();
// let the premove microtask run
await tester.pump(const Duration(milliseconds: 1));
// let the premove microtask run
await tester.pump(const Duration(milliseconds: 1));
// premove should have been played
expect(find.byKey(const ValueKey('a4-premove')), findsNothing);
expect(find.byKey(const Key('a4-whitepawn')), findsOneWidget);
// premove should be cancelled since d4-d5 is now illegal (d5 is occupied)
expect(boardHasPremove(tester, const NormalMove(from: Square.d4, to: Square.d5)), isFalse);
// d5 should have black's pawn (opponent's move was applied)
expect(boardHasPiece(tester, Square.d5, Piece.blackPawn), isTrue);
// d4 should still have white's pawn
expect(boardHasPiece(tester, Square.d4, Piece.whitePawn), isTrue);
});
testWidgets('can premove drop moves in Crazyhouse', (WidgetTester tester) async {
const gameFullId = GameFullId('qVChCOTcHSeW');
final gameSocketUri = GameController.socketUri(gameFullId);
await createTestGame(
tester,
pgn: 'e4 d5 exd5',
variant: Variant.crazyhouse,
clock: const (
running: true,
initial: Duration(minutes: 1),
increment: Duration.zero,
white: Duration(seconds: 58),
black: Duration(seconds: 54),
emerg: Duration(seconds: 10),
),
serverPrefs: const ServerGamePrefs(
showRatings: true,
enablePremove: true,
autoQueen: .always,
confirmResign: true,
submitMove: false,
zenMode: .no,
),
);
await playDropMove(tester, Side.white, Role.pawn, 'a4');
// premove indicator should be visible
expect(boardHasPremove(tester, const DropMove(to: Square.a4, role: Role.pawn)), isTrue);
// opponent plays Qxd5
sendServerSocketMessages(gameSocketUri, [
'{"t": "move", "v": 1, "d": {"ply": 4, "uci": "d8d5", "san": "Qxd5", "clock": {"white": 57, "black": 52}}}',
]);
await tester.pump();
// let the premove microtask run
await tester.pump(const Duration(milliseconds: 1));
// let the board rebuild from userMove
await tester.pump();
// premove should have been played
expect(boardHasPremove(tester, const DropMove(to: Square.a4, role: Role.pawn)), isFalse);
expect(boardHasPiece(tester, Square.a4, Piece.whitePawn), isTrue);
});
});
testWidgets('takeback', (WidgetTester tester) async {
@@ -803,14 +855,14 @@ void main() {
),
);
expect(find.byType(Chessboard), findsOneWidget);
expect(find.byType(PieceWidget), findsNWidgets(32));
expect(getBoardPieces(tester).length, 32);
// black plays
sendServerSocketMessages(testGameSocketUri, [
'{"t": "move", "v": 1, "d": {"ply": 4, "uci": "b8c6", "san": "Nc6", "clock": {"white": 58, "black": 52}}}',
]);
await tester.pump(const Duration(milliseconds: 500));
expect(find.byKey(const Key('c6-blackknight')), findsOneWidget);
expect(boardHasPiece(tester, Square.c6, Piece.blackKnight), isTrue);
expect(
tester.widgetList<Clock>(find.byType(Clock)).last.active,
true,
@@ -863,8 +915,8 @@ void main() {
await tester.pump(const Duration(milliseconds: 1));
// black move is cancelled
expect(find.byKey(const Key('c6-blackknight')), findsNothing);
expect(find.byKey(const Key('b8-blackknight')), findsOneWidget);
expect(boardHasPiece(tester, Square.c6, Piece.blackKnight), isFalse);
expect(boardHasPiece(tester, Square.b8, Piece.blackKnight), isTrue);
expect(tester.widget<Clock>(findClock(Side.black)).active, true);
expect(tester.widget<Clock>(findClock(Side.white)).active, false);
// black clock is ticking
@@ -892,27 +944,29 @@ void main() {
tester,
);
expect(find.byKey(const Key('e1-whiteking')), findsOneWidget);
expect(boardHasPiece(tester, Square.e1, Piece.whiteKing), isTrue);
await tester.tap(find.byKey(const Key('e1-whiteking')));
final boardRect = tester.getRect(find.byType(Chessboard));
await tester.tapAt(squareOffset(Square.e1, boardRect));
await tester.pump();
final validMoves = getBoardValidMoves(tester);
switch (castlingMethod) {
case CastlingMethod.kingOverRook:
// kingOverRook acts as either kingTwoSquares or kingOverRook
expect(find.byKey(const Key('f1-dest')), findsOneWidget);
expect(find.byKey(const Key('g1-dest')), findsOneWidget);
expect(find.byKey(const Key('h1-dest')), findsOneWidget);
expect(find.byKey(const Key('c1-dest')), findsOneWidget);
expect(find.byKey(const Key('d1-dest')), findsOneWidget);
expect(find.byKey(const Key('a1-dest')), findsOneWidget);
expect(validMoves.contains(Square.f1), isTrue);
expect(validMoves.contains(Square.g1), isTrue);
expect(validMoves.contains(Square.h1), isTrue);
expect(validMoves.contains(Square.c1), isTrue);
expect(validMoves.contains(Square.d1), isTrue);
expect(validMoves.contains(Square.a1), isTrue);
case CastlingMethod.kingTwoSquares:
expect(find.byKey(const Key('f1-dest')), findsOneWidget);
expect(find.byKey(const Key('g1-dest')), findsOneWidget);
expect(find.byKey(const Key('h1-dest')), findsNothing);
expect(find.byKey(const Key('c1-dest')), findsOneWidget);
expect(find.byKey(const Key('d1-dest')), findsOneWidget);
expect(find.byKey(const Key('a1-dest')), findsNothing);
expect(validMoves.contains(Square.f1), isTrue);
expect(validMoves.contains(Square.g1), isTrue);
expect(validMoves.contains(Square.h1), isFalse);
expect(validMoves.contains(Square.c1), isTrue);
expect(validMoves.contains(Square.d1), isTrue);
expect(validMoves.contains(Square.a1), isFalse);
}
});
}
@@ -931,17 +985,19 @@ void main() {
tester,
);
await tester.tap(find.byKey(const Key('e1-whiteking')));
final boardRect = tester.getRect(find.byType(Chessboard));
await tester.tapAt(squareOffset(Square.e1, boardRect));
await tester.pump();
// in chess960, castling is only king over rook, no matter the preference
expect(find.byKey(const Key('f1-dest')), findsOneWidget);
expect(find.byKey(const Key('g1-dest')), findsNothing);
expect(find.byKey(const Key('h1-dest')), findsOneWidget);
expect(find.byKey(const Key('c1-dest')), findsNothing);
expect(find.byKey(const Key('d1-dest')), findsOneWidget);
expect(find.byKey(const Key('a1-dest')), findsOneWidget);
final validMoves = getBoardValidMoves(tester);
expect(validMoves.contains(Square.f1), isTrue);
expect(validMoves.contains(Square.g1), isFalse);
expect(validMoves.contains(Square.h1), isTrue);
expect(validMoves.contains(Square.c1), isFalse);
expect(validMoves.contains(Square.d1), isTrue);
expect(validMoves.contains(Square.a1), isTrue);
});
}
});
@@ -1453,9 +1509,9 @@ void main() {
await tester.pump();
expect(find.byType(Chessboard), findsOneWidget);
expect(find.byKey(const Key('d3-whitebishop')), findsOneWidget);
expect(find.byKey(const Key('b5-lastMove')), findsOneWidget);
expect(find.byKey(const Key('d3-lastMove')), findsOneWidget);
expect(boardHasPiece(tester, Square.d3, Piece.whiteBishop), isTrue);
expect(getBoardLastMove(tester)?.hasSquare(Square.b5), isTrue);
expect(getBoardLastMove(tester)?.hasSquare(Square.d3), isTrue);
await tester.tap(find.byIcon(Icons.menu));
await tester.pump(const Duration(milliseconds: 500));
expect(find.byType(Dialog), findsOneWidget);
@@ -1465,10 +1521,10 @@ void main() {
find.widgetWithText(AppBar, 'Analysis board'),
findsOneWidget,
); // analysis screen is now open
expect(find.byKey(const Key('f3-whitequeen')), findsOneWidget);
expect(find.byKey(const Key('d3-whitebishop')), findsOneWidget);
expect(find.byKey(const Key('b5-lastMove')), findsOneWidget);
expect(find.byKey(const Key('d3-lastMove')), findsOneWidget);
expect(boardHasPiece(tester, Square.f3, Piece.whiteQueen), isTrue);
expect(boardHasPiece(tester, Square.d3, Piece.whiteBishop), isTrue);
expect(getBoardLastMove(tester)?.hasSquare(Square.b5), isTrue);
expect(getBoardLastMove(tester)?.hasSquare(Square.d3), isTrue);
expect(find.bySemanticsLabel(RegExp('Moves played')), findsOneWidget);
// computer analysis is not available when game is not finished
expect(find.bySemanticsLabel(RegExp('Computer analysis')), findsNothing);
@@ -1477,9 +1533,9 @@ void main() {
testWidgets('for a finished game', (WidgetTester tester) async {
await loadFinishedTestGame(tester);
expect(find.byType(Chessboard), findsOneWidget);
expect(find.byKey(const Key('e6-whitequeen')), findsOneWidget);
expect(find.byKey(const Key('d5-lastMove')), findsOneWidget);
expect(find.byKey(const Key('e6-lastMove')), findsOneWidget);
expect(boardHasPiece(tester, Square.e6, Piece.whiteQueen), isTrue);
expect(getBoardLastMove(tester)?.hasSquare(Square.d5), isTrue);
expect(getBoardLastMove(tester)?.hasSquare(Square.e6), isTrue);
await tester.pump(const Duration(milliseconds: 500)); // wait for popup
await tester.tap(find.text('Analysis board'));
await tester.pumpAndSettle(); // wait for analysis screen to open
@@ -1488,9 +1544,9 @@ void main() {
findsOneWidget,
); // analysis screen is now open
expect(find.byType(Chessboard), findsOneWidget);
expect(find.byKey(const Key('e6-whitequeen')), findsOneWidget);
expect(find.byKey(const Key('d5-lastMove')), findsOneWidget);
expect(find.byKey(const Key('e6-lastMove')), findsOneWidget);
expect(boardHasPiece(tester, Square.e6, Piece.whiteQueen), isTrue);
expect(getBoardLastMove(tester)?.hasSquare(Square.d5), isTrue);
expect(getBoardLastMove(tester)?.hasSquare(Square.e6), isTrue);
expect(find.bySemanticsLabel(RegExp('Moves played')), findsOneWidget);
expect(
find.bySemanticsLabel(RegExp('Computer analysis')),
@@ -1590,11 +1646,40 @@ void main() {
await tester.pumpAndSettle();
// Pawn should appear on c4 (transient move before server ack)
expect(find.byKey(const ValueKey('c4-whitepawn')), findsOneWidget);
expect(boardHasPiece(tester, Square.c4, Piece.whitePawn), isTrue);
await dropExpectation;
});
testWidgets('pocket count display updates after a player drop move', (tester) async {
// Regression test: the pocket counts are rendered by the GameLayout from
// the board params. With the high-performance board, a move no longer
// rebuilds the layout shell, so the displayed pocket count could go stale
// (the dropped pawn would still show in the pocket after being played).
// After 1.e4 d5 2.exd5 Qxd5, white has a pawn in pocket and it's white's turn.
await createTestGame(
tester,
variant: Variant.crazyhouse,
pgn: 'e4 d5 exd5 Qxd5',
youAre: Side.white,
);
final whitePawnPocket = find.byKey(const ValueKey('pocket-whitepawn'));
expect(whitePawnPocket, findsOneWidget);
// The white pawn pocket initially shows a count badge of 1.
expect(find.descendant(of: whitePawnPocket, matching: find.text('1')), findsOneWidget);
// White drops the pawn to c4.
await playDropMove(tester, Side.white, Role.pawn, 'c4');
await tester.pumpAndSettle();
// The pawn is on the board and the pocket count badge is gone (count 0).
expect(boardHasPiece(tester, Square.c4, Piece.whitePawn), isTrue);
expect(find.descendant(of: whitePawnPocket, matching: find.text('1')), findsNothing);
});
testWidgets("Cannot interact with the opponent's pockets", (tester) async {
// After 1.e4 d5 2.exd5 Qxd5 Nf3, white and black both have a pawn in pocket and it's black's turn
await createTestGame(
@@ -1612,7 +1697,7 @@ void main() {
await tester.pumpAndSettle();
// Move should not be played since it's not our turn and the opponent's pockets should not be interactable
expect(find.byKey(const ValueKey('d6-blackpawn')), findsNothing);
expect(boardHasPiece(tester, Square.d6, Piece.blackPawn), isFalse);
});
testWidgets('correctly handles opponent drop move received from server', (tester) async {
@@ -1637,7 +1722,107 @@ void main() {
await tester.pump();
// White pawn should appear on c4
expect(find.byKey(const ValueKey('c4-whitepawn')), findsOneWidget);
expect(boardHasPiece(tester, Square.c4, Piece.whitePawn), isTrue);
});
});
group('Widget rebuilds', () {
// These tests guard the GameScreen performance optimization: a move must
// update the board through the ChessboardController WITHOUT rebuilding the
// expensive ancestors (GameScreen/GameBody, the GameLayout shell, the board).
// Only the leaf widgets that depend on the move (e.g. the player tables) may
// rebuild. The rebuild probe is widget-instance identity: if an ancestor does
// not rebuild, the child widget instance found in the tree is unchanged.
testWidgets('a local move does not rebuild GameBody, GameLayout or the board', (tester) async {
await createTestGame(tester, pgn: 'e4 e5'); // white (us) to move
// Flush the one-time load rebuilds (e.g. the real-time-playable future resolving).
await tester.pump(const Duration(milliseconds: 50));
final gameBodyBefore = tester.widget<GameBody>(find.byType(GameBody));
final gameLayoutBefore = tester.widget<GameLayout>(find.byType(GameLayout));
final boardBefore = tester.widget<Chessboard>(find.byType(Chessboard));
final playerBefore = tester.widgetList<GamePlayer>(find.byType(GamePlayer)).first;
final bottomBarBefore = tester.widget<BottomBar>(find.byType(BottomBar));
await playMove(tester, 'g1', 'f3');
await tester.pump();
// The move reached the board (controller-driven repaint)
expect(boardHasPiece(tester, Square.f3, Piece.whiteKnight), isTrue);
// but the expensive ancestors were not rebuilt.
expect(
identical(tester.widget<GameBody>(find.byType(GameBody)), gameBodyBefore),
isTrue,
reason: 'GameScreen must not rebuild GameBody on a move',
);
expect(
identical(tester.widget<GameLayout>(find.byType(GameLayout)), gameLayoutBefore),
isTrue,
reason: 'the GameLayout shell must not rebuild on a move',
);
expect(
identical(tester.widget<Chessboard>(find.byType(Chessboard)), boardBefore),
isTrue,
reason: 'the board must not rebuild on a move (the controller drives the repaint)',
);
// The player table (material diff) is a contained, necessary rebuild.
expect(
identical(tester.widgetList<GamePlayer>(find.byType(GamePlayer)).first, playerBefore),
isFalse,
reason: 'the player table should rebuild on a move',
);
// The bottom bar (minus the isolated prev/next nav buttons) watches only
// discrete flags that don't change on a plain move, so it must not rebuild.
expect(
identical(tester.widget<BottomBar>(find.byType(BottomBar)), bottomBarBefore),
isTrue,
reason: 'the bottom bar must not rebuild on a move',
);
});
testWidgets('an opponent move does not rebuild GameBody, GameLayout or the board', (
tester,
) async {
await createTestGame(tester, pgn: 'e4 e5 Nf3'); // black (opponent) to move
await tester.pump(const Duration(milliseconds: 50));
final gameBodyBefore = tester.widget<GameBody>(find.byType(GameBody));
final gameLayoutBefore = tester.widget<GameLayout>(find.byType(GameLayout));
final boardBefore = tester.widget<Chessboard>(find.byType(Chessboard));
final bottomBarBefore = tester.widget<BottomBar>(find.byType(BottomBar));
// Opponent (black) plays Nf6, received from the server.
sendServerSocketMessages(testGameSocketUri, [
'{"t": "move", "v": 1, "d": {"ply": 4, "uci": "g8f6", "san": "Nf6", "clock": {"white": 180, "black": 180}}}',
]);
await tester.pump(const Duration(milliseconds: 10));
expect(boardHasPiece(tester, Square.f6, Piece.blackKnight), isTrue);
expect(
identical(tester.widget<GameBody>(find.byType(GameBody)), gameBodyBefore),
isTrue,
reason: 'GameScreen must not rebuild GameBody on an opponent move',
);
expect(
identical(tester.widget<GameLayout>(find.byType(GameLayout)), gameLayoutBefore),
isTrue,
reason: 'the GameLayout shell must not rebuild on an opponent move',
);
expect(
identical(tester.widget<Chessboard>(find.byType(Chessboard)), boardBefore),
isTrue,
reason: 'the board must not rebuild on an opponent move',
);
expect(
identical(tester.widget<BottomBar>(find.byType(BottomBar)), bottomBarBefore),
isTrue,
reason: 'the bottom bar must not rebuild on an opponent move',
);
});
});
@@ -1685,6 +1870,70 @@ void main() {
);
}
});
group('Claim win', () {
testWidgets('shows the countdown when the opponent leaves and claims victory', (
WidgetTester tester,
) async {
final socketFactory = ListenableFakeWebSocketChannelFactory(
createDefaultFakeWebSocketChannel,
);
await createTestGame(
tester,
socketFactory: socketFactory,
// fullmoves >= 2 so the game is resignable (not abortable), and it is the
// opponent's turn so the claim-win countdown is allowed to show.
pgn: 'e4 e5 Nf3',
clock: const (
running: true,
initial: Duration(minutes: 3),
increment: Duration(seconds: 2),
white: Duration(minutes: 2, seconds: 58),
black: Duration(minutes: 2, seconds: 54),
emerg: Duration(seconds: 30),
),
);
// No countdown until the opponent leaves.
expect(find.byType(CountdownClockBuilder), findsNothing);
// 'goneIn' announces the opponent left and starts the claim-win countdown.
sendServerSocketMessages(testGameSocketUri, ['{"t": "goneIn", "v": 1, "d": 30}']);
await tester.pump();
expect(find.textContaining('claim victory in 30 seconds'), findsOneWidget);
final countdownButton = find.ancestor(
of: find.textContaining('claim victory in 30 seconds'),
matching: find.byType(InkWell),
);
// Victory is not claimable until the 'gone' threshold is reached, so the
// countdown is not tappable yet (InkWell.onTap is null).
await tester.tap(countdownButton, warnIfMissed: false);
await tester.pump();
expect(find.text('Claim victory'), findsNothing);
// 'gone' confirms the opponent has been gone long enough to claim.
sendServerSocketMessages(testGameSocketUri, ['{"t": "gone", "v": 2, "d": true}']);
await tester.pump();
// Tapping the countdown now opens the claim-win dialog. (warnIfMissed: the
// tap reaches the InkWell's gesture listener, but layered widgets mean the
// InkWell RenderBox isn't the topmost hit-test object at its center.)
await tester.tap(countdownButton, warnIfMissed: false);
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(find.text('Claim victory'), findsOneWidget);
expect(find.text('Call draw'), findsOneWidget);
// Claiming victory sends the force-resign message and closes the dialog.
expectLater(socketFactory.outgoingMessages(testGameSocketUri), emits('{"t":"resign-force"}'));
await tester.tap(find.text('Claim victory'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 500));
expect(find.text('Claim victory'), findsNothing);
});
});
}
/// Finds the clock on the specified [side].
@@ -148,7 +148,7 @@ void main() {
await tester.pump(const Duration(milliseconds: 200));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('c4-whitepawn')), findsOneWidget);
expect(boardHasPiece(tester, Square.c4, Piece.whitePawn), isTrue);
});
testWidgets('Engine responds after player move', (tester) async {
@@ -537,11 +537,11 @@ void main() {
expect(find.text('Game setup'), findsNothing);
// Should load the game's current position, i.e. e4 and e5 were played
expect(find.byKey(const ValueKey('e2-whitepawn')), findsNothing);
expect(find.byKey(const ValueKey('e4-whitepawn')), findsOneWidget);
expect(boardHasPiece(tester, Square.e2, Piece.whitePawn), isFalse);
expect(boardHasPiece(tester, Square.e4, Piece.whitePawn), isTrue);
expect(find.byKey(const ValueKey('e7-blackpawn')), findsNothing);
expect(find.byKey(const ValueKey('e5-blackpawn')), findsOneWidget);
expect(boardHasPiece(tester, Square.e7, Piece.blackPawn), isFalse);
expect(boardHasPiece(tester, Square.e5, Piece.blackPawn), isTrue);
// Move list should show the played moves
expect(find.text('e4'), findsOneWidget);
@@ -992,12 +992,12 @@ void main() {
expect(find.byType(Chessboard), findsOneWidget);
// The position should show e4 and e5 pawns
expect(find.byKey(const ValueKey('e4-whitepawn')), findsOneWidget);
expect(find.byKey(const ValueKey('e5-blackpawn')), findsOneWidget);
expect(boardHasPiece(tester, Square.e4, Piece.whitePawn), isTrue);
expect(boardHasPiece(tester, Square.e5, Piece.blackPawn), isTrue);
// e2 and e7 should be empty
expect(find.byKey(const ValueKey('e2-whitepawn')), findsNothing);
expect(find.byKey(const ValueKey('e7-blackpawn')), findsNothing);
expect(boardHasPiece(tester, Square.e2, Piece.whitePawn), isFalse);
expect(boardHasPiece(tester, Square.e7, Piece.blackPawn), isFalse);
});
testWidgets('Engine plays first when custom position turn differs from player side', (
@@ -56,13 +56,10 @@ void main() {
group('Playing over the board (offline)', () {
testWidgets('Checkmate and Rematch', (tester) async {
final boardRect = await initOverTheBoardGame(tester, const TimeIncrement(60, 5));
await initOverTheBoardGame(tester, const TimeIncrement(60, 5));
// Default orientation is white at the bottom
expect(
tester.getBottomLeft(find.byKey(const ValueKey('a1-whiterook'))),
boardRect.bottomLeft,
);
expect(tester.widget<Chessboard>(find.byType(Chessboard)).orientation, Side.white);
await playMove(tester, 'e2', 'e4');
await playMove(tester, 'f7', 'f6');
@@ -82,7 +79,7 @@ void main() {
expect(gameState.game.steps.first.position, Chess.initial);
// Rematch should flip orientation
expect(tester.getTopRight(find.byKey(const ValueKey('a1-whiterook'))), boardRect.topRight);
expect(tester.widget<Chessboard>(find.byType(Chessboard)).orientation, Side.black);
expect(activeClock(tester), null);
});
@@ -148,13 +145,13 @@ void main() {
await tester.tap(find.byTooltip('Previous'));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('e7-blackpawn')), findsOneWidget);
expect(boardHasPiece(tester, Square.e7, Piece.blackPawn), isTrue);
expect(activeClock(tester), Side.black);
await tester.tap(find.byTooltip('Next'));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('e5-blackpawn')), findsOneWidget);
expect(boardHasPiece(tester, Square.e5, Piece.blackPawn), isTrue);
expect(activeClock(tester), Side.white);
@@ -165,13 +162,13 @@ void main() {
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Previous'));
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('e2-whitepawn')), findsOneWidget);
expect(find.byKey(const ValueKey('e7-blackpawn')), findsOneWidget);
expect(boardHasPiece(tester, Square.e2, Piece.whitePawn), isTrue);
expect(boardHasPiece(tester, Square.e7, Piece.blackPawn), isTrue);
expect(activeClock(tester), Side.white);
await playMove(tester, 'e2', 'e4');
expect(find.byKey(const ValueKey('e4-whitepawn')), findsOneWidget);
expect(boardHasPiece(tester, Square.e4, Piece.whitePawn), isTrue);
expect(activeClock(tester), Side.black);
});
@@ -293,11 +290,11 @@ void main() {
expect(find.text('Play'), findsNothing);
// Should load the game's current position, i.e. e4 and e5 were played
expect(find.byKey(const ValueKey('e2-whitepawn')), findsNothing);
expect(find.byKey(const ValueKey('e4-whitepawn')), findsOneWidget);
expect(boardHasPiece(tester, Square.e2, Piece.whitePawn), isFalse);
expect(boardHasPiece(tester, Square.e4, Piece.whitePawn), isTrue);
expect(find.byKey(const ValueKey('e7-blackpawn')), findsNothing);
expect(find.byKey(const ValueKey('e5-blackpawn')), findsOneWidget);
expect(boardHasPiece(tester, Square.e7, Piece.blackPawn), isFalse);
expect(boardHasPiece(tester, Square.e5, Piece.blackPawn), isTrue);
expect(activeClock(tester), null);
expect(findWhiteClock(tester).timeLeft, const Duration(minutes: 2));
@@ -452,10 +449,10 @@ void main() {
expect(gameState.game.meta.variant, Variant.fromPosition);
expect(gameState.game.initialFen, _customFen);
// Board should show the custom position (e4 pawn on e4, black pawn on e5)
expect(find.byKey(const ValueKey('e2-whitepawn')), findsNothing);
expect(find.byKey(const ValueKey('e4-whitepawn')), findsOneWidget);
expect(find.byKey(const ValueKey('e7-blackpawn')), findsNothing);
expect(find.byKey(const ValueKey('e5-blackpawn')), findsOneWidget);
expect(boardHasPiece(tester, Square.e2, Piece.whitePawn), isFalse);
expect(boardHasPiece(tester, Square.e4, Piece.whitePawn), isTrue);
expect(boardHasPiece(tester, Square.e7, Piece.blackPawn), isFalse);
expect(boardHasPiece(tester, Square.e5, Piece.blackPawn), isTrue);
},
);
@@ -643,8 +640,8 @@ void main() {
expect(gameState.game.initialFen, _customFen);
expect(gameState.game.meta.variant, Variant.atomic);
expect(gameState.game.steps.length, 1);
expect(find.byKey(const ValueKey('e4-whitepawn')), findsOneWidget);
expect(find.byKey(const ValueKey('e5-blackpawn')), findsOneWidget);
expect(boardHasPiece(tester, Square.e4, Piece.whitePawn), isTrue);
expect(boardHasPiece(tester, Square.e5, Piece.blackPawn), isTrue);
});
});
}
+28 -27
View File
@@ -205,8 +205,9 @@ void main() {
expect(find.text('Your turn'), findsOneWidget);
// before the first move is played, puzzle is not interactable
expect(find.byKey(const Key('g4-blackrook')), findsOneWidget);
await tester.tap(find.byKey(const Key('g4-blackrook')));
expect(boardHasPiece(tester, Square.g4, Piece.blackRook), isTrue);
final boardRect = tester.getRect(find.byType(Chessboard));
await tester.tapAt(squareOffset(Square.g4, boardRect, orientation: Side.black));
await tester.pump();
expect(find.byKey(const Key('g4-selected')), findsNothing);
@@ -220,23 +221,23 @@ void main() {
// in play mode we see the solution button
expect(find.byIcon(Icons.flag_outlined), findsOneWidget);
expect(find.byKey(const Key('g4-blackrook')), findsOneWidget);
expect(find.byKey(const Key('h8-whitequeen')), findsOneWidget);
expect(boardHasPiece(tester, Square.g4, Piece.blackRook), isTrue);
expect(boardHasPiece(tester, Square.h8, Piece.whiteQueen), isTrue);
await playMove(tester, 'g4', 'h4', orientation: orientation);
expect(find.byKey(const Key('h4-blackrook')), findsOneWidget);
expect(boardHasPiece(tester, Square.h4, Piece.blackRook), isTrue);
expect(find.text('Best move!'), findsOneWidget);
// wait for line reply and move animation
await tester.pump(const Duration(milliseconds: 500));
await tester.pumpAndSettle();
expect(find.byKey(const Key('h4-whitequeen')), findsOneWidget);
expect(boardHasPiece(tester, Square.h4, Piece.whiteQueen), isTrue);
await playMove(tester, 'b4', 'h4', orientation: orientation);
expect(find.byKey(const Key('h4-blackrook')), findsOneWidget);
expect(boardHasPiece(tester, Square.h4, Piece.blackRook), isTrue);
expect(find.text('Success!'), findsOneWidget);
// wait for move animation
@@ -317,7 +318,7 @@ void main() {
// await for first move to be played
await tester.pump(const Duration(milliseconds: 1500));
expect(find.byKey(const Key('g4-blackrook')), findsOneWidget);
expect(boardHasPiece(tester, Square.g4, Piece.blackRook), isTrue);
await playMove(tester, 'g4', 'f4', orientation: orientation);
@@ -328,11 +329,11 @@ void main() {
await tester.pumpAndSettle();
// can still play the puzzle
expect(find.byKey(const Key('g4-blackrook')), findsOneWidget);
expect(boardHasPiece(tester, Square.g4, Piece.blackRook), isTrue);
await playMove(tester, 'g4', 'h4', orientation: orientation);
expect(find.byKey(const Key('h4-blackrook')), findsOneWidget);
expect(boardHasPiece(tester, Square.h4, Piece.blackRook), isTrue);
expect(find.text('Best move!'), findsOneWidget);
// wait for line reply and move animation
@@ -341,7 +342,7 @@ void main() {
await playMove(tester, 'b4', 'h4', orientation: orientation);
expect(find.byKey(const Key('h4-blackrook')), findsOneWidget);
expect(boardHasPiece(tester, Square.h4, Piece.blackRook), isTrue);
expect(find.text('Puzzle complete!'), findsOneWidget);
final expectedPlayedXTimes =
'Played ${puzzle2.puzzle.plays.toString().localizeNumbers()} times.';
@@ -414,7 +415,7 @@ void main() {
// await for first move to be played
await tester.pump(const Duration(milliseconds: 1500));
expect(find.byKey(const Key('g4-blackrook')), findsOneWidget);
expect(boardHasPiece(tester, Square.g4, Piece.blackRook), isTrue);
// Help button should still be disabled
expect(find.byIcon(Icons.flag_outlined), findsOneWidget);
@@ -438,8 +439,8 @@ void main() {
// wait for solution replay animation to finish
await tester.pump(const Duration(seconds: 1));
expect(find.byKey(const Key('h4-blackrook')), findsOneWidget);
expect(find.byKey(const Key('h8-whitequeen')), findsOneWidget);
expect(boardHasPiece(tester, Square.h4, Piece.blackRook), isTrue);
expect(boardHasPiece(tester, Square.h8, Piece.whiteQueen), isTrue);
expect(find.text('Puzzle complete!'), findsOneWidget);
final nextMoveBtnEnabled = find.byWidgetPredicate(
@@ -778,7 +779,7 @@ void main() {
// wait for previous opponent's move to be played
await tester.pump(const Duration(milliseconds: 1500));
expect(find.byKey(const Key('g4-blackrook')), findsOneWidget);
expect(boardHasPiece(tester, Square.g4, Piece.blackRook), isTrue);
bool isPrevEnabled() {
return tester
@@ -816,7 +817,7 @@ void main() {
await tester.pumpAndSettle();
// computer replied by capturing our rook with its queen
expect(find.byKey(const Key('h4-whitequeen')), findsOneWidget);
expect(boardHasPiece(tester, Square.h4, Piece.whiteQueen), isTrue);
expect(isPrevEnabled(), isTrue);
@@ -825,19 +826,19 @@ void main() {
await tester.pump();
// verify we see our original bold move (black rook on h4)
expect(find.byKey(const Key('h4-blackrook')), findsOneWidget);
expect(boardHasPiece(tester, Square.h4, Piece.blackRook), isTrue);
// tap "Previous" again to undo our first move
await tester.tap(find.byIcon(CupertinoIcons.chevron_back));
await tester.pump();
// verify we are back at the start (black rook on g4)
expect(find.byKey(const Key('g4-blackrook')), findsOneWidget);
expect(boardHasPiece(tester, Square.g4, Piece.blackRook), isTrue);
// check that the user can not play a different move in the starting position
await playMove(tester, 'g4', 'g5', orientation: orientation);
// check that the rook stayed on g4
expect(find.byKey(const Key('g4-blackrook')), findsOneWidget);
expect(boardHasPiece(tester, Square.g4, Piece.blackRook), isTrue);
// check that the "Next" button is now enabled
expect(isNextEnabled(), isTrue);
@@ -849,7 +850,7 @@ void main() {
await tester.pump();
// verify we are back to the current state (computer's white queen on h4)
expect(find.byKey(const Key('h4-whitequeen')), findsOneWidget);
expect(boardHasPiece(tester, Square.h4, Piece.whiteQueen), isTrue);
},
);
@@ -922,7 +923,7 @@ void main() {
// await for first move to be played
await tester.pump(const Duration(milliseconds: 1500));
expect(find.byKey(const Key('b2-blackrook')), findsOneWidget);
expect(boardHasPiece(tester, Square.b2, Piece.blackRook), isTrue);
await playMove(tester, 'e8', 'a8', orientation: Side.white);
@@ -1005,7 +1006,7 @@ void main() {
// await for first move to be played (Nxc3)
await tester.pump(const Duration(milliseconds: 1500));
expect(find.byKey(const Key('e1-whiteking')), findsOneWidget);
expect(boardHasPiece(tester, Square.e1, Piece.whiteKing), isTrue);
// Play castling move (O-O) by moving king to g1
await playMove(tester, 'e1', 'g1', orientation: Side.white);
@@ -1023,7 +1024,7 @@ void main() {
// Wait for the move animation to complete
await tester.pumpAndSettle();
expect(find.byKey(const Key('h6-whitebishop')), findsOneWidget);
expect(boardHasPiece(tester, Square.h6, Piece.whiteBishop), isTrue);
},
);
@@ -1149,22 +1150,22 @@ void main() {
// wait for first move to be played
await tester.pump(const Duration(milliseconds: 1500));
expect(find.byKey(const Key('g4-blackrook')), findsOneWidget);
expect(boardHasPiece(tester, Square.g4, Piece.blackRook), isTrue);
await playMove(tester, 'g4', 'h4', orientation: orientation);
expect(find.byKey(const Key('h4-blackrook')), findsOneWidget);
expect(boardHasPiece(tester, Square.h4, Piece.blackRook), isTrue);
expect(find.text('Best move!'), findsOneWidget);
// wait for line reply and move animation
await tester.pump(const Duration(milliseconds: 500));
await tester.pumpAndSettle();
expect(find.byKey(const Key('h4-whitequeen')), findsOneWidget);
expect(boardHasPiece(tester, Square.h4, Piece.whiteQueen), isTrue);
await playMove(tester, 'b4', 'h4', orientation: orientation);
expect(find.byKey(const Key('h4-blackrook')), findsOneWidget);
expect(boardHasPiece(tester, Square.h4, Piece.blackRook), isTrue);
expect(find.text('Success!'), findsOneWidget);
// wait for move animation
+8 -8
View File
@@ -76,29 +76,29 @@ void main() {
await tester.pumpWidget(app);
// before the first move is played, puzzle is not interactable
expect(find.byKey(const Key('h5-whiterook')), findsOneWidget);
await tester.tap(find.byKey(const Key('h5-whiterook')));
expect(boardHasPiece(tester, Square.h5, Piece.whiteRook), isTrue);
await tester.tapAt(squareOffset(Square.h5, tester.getRect(find.byType(Chessboard))));
await tester.pump();
expect(find.byKey(const Key('h5-selected')), findsNothing);
// wait for first move to be played
await tester.pump(const Duration(seconds: 1));
expect(find.byKey(const Key('g8-blackking')), findsOneWidget);
expect(boardHasPiece(tester, Square.g8, Piece.blackKing), isTrue);
await playMove(tester, 'h5', 'h7', orientation: Side.white);
await tester.pump(const Duration(milliseconds: 500));
await tester.pumpAndSettle();
expect(find.byKey(const Key('h7-whiterook')), findsOneWidget);
expect(find.byKey(const Key('d1-blackqueen')), findsOneWidget);
expect(boardHasPiece(tester, Square.h7, Piece.whiteRook), isTrue);
expect(boardHasPiece(tester, Square.d1, Piece.blackQueen), isTrue);
await playMove(tester, 'e3', 'g1', orientation: Side.white);
await tester.pump(const Duration(milliseconds: 500));
// should have loaded next puzzle
expect(find.byKey(const Key('h6-blackking')), findsOneWidget);
expect(boardHasPiece(tester, Square.h6, Piece.blackKing), isTrue);
}, variant: kPlatformVariant);
testWidgets('shows end run result', (tester) async {
@@ -125,7 +125,7 @@ void main() {
await tester.pump(const Duration(milliseconds: 500));
// should have loaded next puzzle
expect(find.byKey(const Key('h6-blackking')), findsOneWidget);
expect(boardHasPiece(tester, Square.h6, Piece.blackKing), isTrue);
await tester.tap(find.text('End run'));
await tester.pumpAndSettle();
@@ -152,7 +152,7 @@ void main() {
await playMove(tester, 'h5', 'h6');
await tester.pump(const Duration(milliseconds: 500));
expect(find.byKey(const Key('h6-blackking')), findsOneWidget);
expect(boardHasPiece(tester, Square.h6, Piece.blackKing), isTrue);
});
testWidgets('play again starts new run', (tester) async {
+7 -7
View File
@@ -282,13 +282,13 @@ void main() {
await playMove(tester, 'e2', 'e4', orientation: Side.black);
expect(find.byKey(const Key('e2-whitepawn')), findsNothing);
expect(find.byKey(const Key('e4-whitepawn')), findsOneWidget);
expect(boardHasPiece(tester, Square.e2, Piece.whitePawn), isFalse);
expect(boardHasPiece(tester, Square.e4, Piece.whitePawn), isTrue);
await playMove(tester, 'e7', 'e5', orientation: Side.black);
expect(find.byKey(const Key('e5-blackpawn')), findsOneWidget);
expect(find.byKey(const Key('e7-blackpawn')), findsNothing);
expect(boardHasPiece(tester, Square.e5, Piece.blackPawn), isTrue);
expect(boardHasPiece(tester, Square.e7, Piece.blackPawn), isFalse);
expect(find.text('1. e4'), findsOneWidget);
expect(find.text('e5'), findsOneWidget);
@@ -695,9 +695,9 @@ void main() {
final board = tester.widget<Chessboard>(find.byType(Chessboard));
if (annotations.isEmpty) {
expect(board.annotations, isNull);
expect(board.annotations, isEmpty);
} else {
expect(board.annotations!.length, annotations.length);
expect(board.annotations.length, annotations.length);
expect(board.annotations, allOf(annotations));
}
}
@@ -784,7 +784,7 @@ void main() {
await tester.pumpAndSettle(); // Wait for O-O-O move to be played
final board = tester.widget<Chessboard>(find.byType(Chessboard));
expect(board.annotations!.length, 1);
expect(board.annotations.length, 1);
expect(
board.annotations,
containsPair(Square.c1, predicate<Annotation>((annotation) => annotation.symbol == '!!')),
@@ -451,7 +451,10 @@ void main() {
expect(find.text('BlackFeatured'), findsOneWidget);
expect(find.text('WhiteFeatured'), findsOneWidget);
expect(find.byType(PieceWidget), findsAny);
expect(
tester.widget<StaticChessboard>(find.byType(StaticChessboard)).fen,
isNot(kEmptyBoardFEN),
);
expect(find.byType(BoardThumbnail), findsOneWidget);
// Pretend all the pieces are gone to check that the board is updated
@@ -463,7 +466,7 @@ void main() {
await tester.pumpAndSettle();
expect(find.byType(BoardThumbnail), findsOneWidget);
expect(find.byType(PieceWidget), findsNothing);
expect(tester.widget<StaticChessboard>(find.byType(StaticChessboard)).fen, kEmptyBoardFEN);
});
testWidgets('Can join tournament', (WidgetTester tester) async {
+98
View File
@@ -0,0 +1,98 @@
import 'package:chessground/chessground.dart';
import 'package:dartchess/dartchess.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:lichess_mobile/src/model/common/id.dart';
import 'package:lichess_mobile/src/model/tv/tv_channel.dart';
import 'package:lichess_mobile/src/view/watch/tv_screen.dart';
import '../../model/game/game_socket_example_data.dart';
import '../../network/fake_websocket_channel.dart';
import '../../test_helpers.dart';
import '../../test_provider_scope.dart';
void main() {
const gameId = GameId('qVChCOTc');
// The TV controller opens this socket route for the initial game.
final tvSocketUri = Uri(path: '/watch/$gameId/white/v6');
Future<void> loadGame(WidgetTester tester, {String pgn = ''}) async {
await tester.pump(kFakeWebSocketConnectionLag);
sendServerSocketMessages(tvSocketUri, [
makeFullEvent(gameId, pgn, whiteUserName: 'Peter', blackUserName: 'Steven'),
]);
// wait for socket message handling
await tester.pump();
}
group('TvScreen (readonly GameLayout board)', () {
testWidgets('loads the game and displays a non-interactive board', (tester) async {
final app = await makeTestProviderScopeApp(
tester,
home: const TvScreen(channel: TvChannel.best, initialGame: (gameId, Side.white)),
);
await tester.pumpWidget(app);
// While loading, an empty board is shown.
expect(find.byType(Chessboard), findsOneWidget);
expect(getBoardPieces(tester), isEmpty);
await loadGame(tester);
// The full position is displayed once the game loads.
expect(getBoardPieces(tester).length, 32);
expect(find.text('Peter'), findsOneWidget);
expect(find.text('Steven'), findsOneWidget);
// The TV board is a spectator board: it must not be interactive.
expect(tester.widget<Chessboard>(find.byType(Chessboard)).interactive, isFalse);
});
testWidgets('updates the board when a move event is received', (tester) async {
final app = await makeTestProviderScopeApp(
tester,
home: const TvScreen(channel: TvChannel.best, initialGame: (gameId, Side.white)),
);
await tester.pumpWidget(app);
await loadGame(tester);
expect(boardHasPiece(tester, Square.e2, Piece.whitePawn), isTrue);
// Server broadcasts the first move (e2-e4).
sendServerSocketMessages(tvSocketUri, [
'{"t": "move", "v": 1, "d": {"ply": 1, "uci": "e2e4", "san": "e4", "clock": {"white": 180, "black": 180}}}',
]);
await tester.pump();
// The board advances to the new position and stays non-interactive.
expect(boardHasPiece(tester, Square.e4, Piece.whitePawn), isTrue);
expect(getBoardPieces(tester).containsKey(Square.e2), isFalse);
expect(getBoardLastMove(tester), const NormalMove(from: Square.e2, to: Square.e4));
expect(tester.widget<Chessboard>(find.byType(Chessboard)).interactive, isFalse);
});
testWidgets('navigates to the previous move with the bottom bar', (tester) async {
final app = await makeTestProviderScopeApp(
tester,
home: const TvScreen(channel: TvChannel.best, initialGame: (gameId, Side.white)),
);
await tester.pumpWidget(app);
await loadGame(tester);
sendServerSocketMessages(tvSocketUri, [
'{"t": "move", "v": 1, "d": {"ply": 1, "uci": "e2e4", "san": "e4", "clock": {"white": 180, "black": 180}}}',
]);
await tester.pump();
expect(boardHasPiece(tester, Square.e4, Piece.whitePawn), isTrue);
// Step back one move via the bottom bar.
await tester.tap(find.byKey(const ValueKey('goto-previous')));
await tester.pump();
// The board shows the position before the move again.
expect(boardHasPiece(tester, Square.e2, Piece.whitePawn), isTrue);
expect(getBoardPieces(tester).containsKey(Square.e4), isFalse);
expect(tester.widget<Chessboard>(find.byType(Chessboard)).interactive, isFalse);
});
});
}
+190 -3
View File
@@ -10,6 +10,7 @@ import 'package:lichess_mobile/src/model/common/chess.dart';
import 'package:lichess_mobile/src/model/game/game_board_params.dart';
import 'package:lichess_mobile/src/model/settings/board_preferences.dart';
import 'package:lichess_mobile/src/model/settings/preferences_storage.dart';
import 'package:lichess_mobile/src/widgets/board.dart';
import 'package:lichess_mobile/src/widgets/game_layout.dart';
import 'package:lichess_mobile/src/widgets/move_list.dart';
import 'package:lichess_mobile/src/widgets/pockets.dart';
@@ -250,6 +251,49 @@ void main() {
);
});
testWidgets(
'owned board becomes interactive when boardParams transitions from readonly',
(WidgetTester tester) async {
final paramsNotifier = ValueNotifier<GameBoardParams>(GameBoardParams.emptyBoard);
addTearDown(paramsNotifier.dispose);
final playedMoves = <Move>[];
final app = await makeTestProviderScope(
tester,
child: MaterialApp(
home: ValueListenableBuilder<GameBoardParams>(
valueListenable: paramsNotifier,
builder: (context, params, _) =>
GameLayout(orientation: Side.white, boardParams: params),
),
),
);
await tester.pumpWidget(app);
// Readonly boards are controller-backed but non-interactive (PlayerSide.none).
expect(tester.widget<Chessboard>(find.byType(Chessboard)).interactive, isFalse);
// Transition the same GameLayout to interactive params (triggers didUpdateWidget).
paramsNotifier.value = GameBoardParams.interactive(
variant: Variant.standard,
position: Chess.initial,
playerSide: PlayerSide.white,
onMove: (move, {viaDragAndDrop}) {
playedMoves.add(move);
},
);
await tester.pump();
// The same board is now interactive.
expect(tester.widget<Chessboard>(find.byType(Chessboard)).interactive, isTrue);
// And user interaction reaches the onMove callback.
await playMove(tester, 'e2', 'e4');
expect(playedMoves, [const NormalMove(from: Square.e2, to: Square.e4)]);
},
variant: kPlatformVariant,
);
testWidgets('Crazyhouse displays pockets and supports drop moves', (WidgetTester tester) async {
final playedMoves = <Move>[];
final app = await makeTestProviderScope(
@@ -266,9 +310,6 @@ void main() {
onMove: (move, {viaDragAndDrop}) {
playedMoves.add(move);
},
onPromotionSelection: (_) {},
premovable: null,
promotionMove: null,
),
),
),
@@ -283,4 +324,150 @@ void main() {
expect(playedMoves, [const DropMove(to: Square.a4, role: Role.pawn)]);
});
testWidgets('readonly board animates to a new position and highlights the last move', (
tester,
) async {
final after1e4 = Chess.initial.play(const NormalMove(from: Square.e2, to: Square.e4));
final boardNotifier = ValueNotifier<({String fen, Move? lastMove})>((
fen: kInitialFEN,
lastMove: null,
));
addTearDown(boardNotifier.dispose);
final app = await makeTestProviderScope(
tester,
child: MaterialApp(
home: ValueListenableBuilder<({String fen, Move? lastMove})>(
valueListenable: boardNotifier,
builder: (context, value, _) => GameLayout(
orientation: Side.white,
lastMove: value.lastMove,
boardParams: GameBoardParams.readonly(
fen: value.fen,
variant: Variant.standard,
pockets: null,
),
),
),
),
);
await tester.pumpWidget(app);
expect(boardHasPiece(tester, Square.e2, Piece.whitePawn), isTrue);
expect(tester.widget<Chessboard>(find.byType(Chessboard)).interactive, isFalse);
// Advance the readonly board to the position after 1.e4.
boardNotifier.value = (
fen: after1e4.fen,
lastMove: const NormalMove(from: Square.e2, to: Square.e4),
);
await tester.pumpAndSettle();
expect(boardHasPiece(tester, Square.e4, Piece.whitePawn), isTrue);
expect(getBoardPieces(tester).containsKey(Square.e2), isFalse);
expect(getBoardLastMove(tester), const NormalMove(from: Square.e2, to: Square.e4));
// It must remain non-interactive throughout.
expect(tester.widget<Chessboard>(find.byType(Chessboard)).interactive, isFalse);
});
testWidgets('interactive board can be disabled via a metadata-only update (PlayerSide.none)', (
tester,
) async {
void noopOnMove(Move move, {bool? viaDragAndDrop}) {}
final sideNotifier = ValueNotifier<PlayerSide>(PlayerSide.white);
addTearDown(sideNotifier.dispose);
final app = await makeTestProviderScope(
tester,
child: MaterialApp(
home: ValueListenableBuilder<PlayerSide>(
valueListenable: sideNotifier,
builder: (context, playerSide, _) => GameLayout(
orientation: Side.white,
boardParams: GameBoardParams.interactive(
variant: Variant.standard,
position: Chess.initial,
playerSide: playerSide,
onMove: noopOnMove,
),
),
),
),
);
await tester.pumpWidget(app);
expect(tester.widget<Chessboard>(find.byType(Chessboard)).interactive, isTrue);
// Same position, only the playerSide changes (e.g. game over) the board
// should become non-interactive without a position change.
sideNotifier.value = PlayerSide.none;
await tester.pump();
expect(tester.widget<Chessboard>(find.byType(Chessboard)).interactive, isFalse);
expect(getBoardPieces(tester).length, 32);
});
testWidgets('controllerParams path renders the external controller and does not dispose it', (
tester,
) async {
final playedMoves = <Move>[];
final controller = ChessboardController(
game: buildGameData(
fen: kInitialFEN,
variant: Variant.standard,
position: Chess.initial,
playerSide: PlayerSide.white,
castlingMethod: CastlingMethod.kingTwoSquares,
boardHighlights: true,
),
);
addTearDown(controller.dispose);
final showBoard = ValueNotifier<bool>(true);
addTearDown(showBoard.dispose);
final app = await makeTestProviderScope(
tester,
child: MaterialApp(
home: ValueListenableBuilder<bool>(
valueListenable: showBoard,
builder: (context, show, _) => show
? GameLayout(
orientation: Side.white,
controllerParams: ControllerBoardParams(
controller: controller,
variant: Variant.standard,
onMove: (move, {viaDragAndDrop}) => playedMoves.add(move),
),
)
: const SizedBox.shrink(),
),
),
);
await tester.pumpWidget(app);
expect(tester.widget<Chessboard>(find.byType(Chessboard)).interactive, isTrue);
await playMove(tester, 'e2', 'e4');
expect(playedMoves, [const NormalMove(from: Square.e2, to: Square.e4)]);
// Removing the GameLayout must not dispose the externally-owned controller.
showBoard.value = false;
await tester.pumpAndSettle();
expect(
() => controller.animatePosition(
buildGameData(
fen: kInitialFEN,
variant: Variant.standard,
position: Chess.initial,
playerSide: PlayerSide.white,
castlingMethod: CastlingMethod.kingTwoSquares,
boardHighlights: true,
),
),
returnsNormally,
);
});
}