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. 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) ### Analysis Rules (CRITICAL)
**Always run `flutter analyze` on every file you edit, including test files, before finishing.** **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. **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: **Binding Layer**: `LichessBinding` (in `binding.dart`) provides a testable abstraction for plugins and external APIs:
- SharedPreferences - SharedPreferences
- Firebase (messaging, crashlytics) - Firebase (messaging, crashlytics)
@@ -473,11 +473,6 @@ class AnalysisController extends AsyncNotifier<AnalysisState>
void onUserMove(Move move, {bool shouldReplace = false}) { void onUserMove(Move move, {bool shouldReplace = false}) {
if (!state.requireValue.currentPosition.isLegal(move)) return; 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( final (newPath, isNewNode) = _root.addMoveAt(
state.requireValue.currentPath, state.requireValue.currentPath,
move, 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() { void userPrevious() {
_setPath(state.requireValue.currentPath.penultimate, isNavigating: true); _setPath(state.requireValue.currentPath.penultimate, isNavigating: true);
} }
@@ -755,7 +738,6 @@ class AnalysisController extends AsyncNotifier<AnalysisState>
currentNode: AnalysisCurrentNode.fromNode(currentNode), currentNode: AnalysisCurrentNode.fromNode(currentNode),
currentBranchOpening: opening, currentBranchOpening: opening,
lastMove: currentNode.sanMove.move, lastMove: currentNode.sanMove.move,
promotionMove: null,
root: rootView, root: rootView,
), ),
); );
@@ -767,7 +749,6 @@ class AnalysisController extends AsyncNotifier<AnalysisState>
currentNode: AnalysisCurrentNode.fromNode(currentNode), currentNode: AnalysisCurrentNode.fromNode(currentNode),
currentBranchOpening: opening, currentBranchOpening: opening,
lastMove: null, lastMove: null,
promotionMove: null,
root: rootView, root: rootView,
), ),
); );
@@ -888,9 +869,6 @@ sealed class AnalysisState
/// The last move played. /// The last move played.
Move? lastMove, Move? lastMove,
/// Possible promotion move to be played.
NormalMove? promotionMove,
/// Opening of the analysis context (from lichess archived games). /// Opening of the analysis context (from lichess archived games).
Opening? contextOpening, Opening? contextOpening,
@@ -30,9 +30,6 @@ abstract class CommonAnalysisState {
/// The side to display the board from. /// The side to display the board from.
Side get pov; Side get pov;
/// Possible promotion move to be played.
NormalMove? get promotionMove;
/// Squares that should have an atomic explosion animation after the last move. /// 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 /// 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) { void onUserMove(Move move) {
if (!state.requireValue.currentPosition.isLegal(move)) return; 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); final (newPath, isNewNode) = _root.addMoveAt(state.requireValue.currentPath, move);
if (newPath != null) { if (newPath != null) {
_setPath(newPath); _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() { void userNext() {
_setPath( _setPath(
state.requireValue.currentPath + state.requireValue.currentPath +
@@ -375,7 +355,6 @@ class RetroController extends AsyncNotifier<RetroState>
currentPath: path, currentPath: path,
currentNode: RetroCurrentNode.fromNode(currentNode), currentNode: RetroCurrentNode.fromNode(currentNode),
lastMove: currentNode.sanMove.move, lastMove: currentNode.sanMove.move,
promotionMove: null,
root: isNavigating ? state.root : _root.view, root: isNavigating ? state.root : _root.view,
), ),
); );
@@ -385,7 +364,6 @@ class RetroController extends AsyncNotifier<RetroState>
currentPath: path, currentPath: path,
currentNode: RetroCurrentNode.fromNode(currentNode), currentNode: RetroCurrentNode.fromNode(currentNode),
lastMove: null, lastMove: null,
promotionMove: null,
root: isNavigating ? state.root : _root.view, root: isNavigating ? state.root : _root.view,
), ),
); );
@@ -523,7 +501,6 @@ sealed class RetroState
required ViewRoot root, required ViewRoot root,
DateTime? evalRequestedAt, DateTime? evalRequestedAt,
Move? lastMove, Move? lastMove,
NormalMove? promotionMove,
@Default(false) bool engineInThreatMode, @Default(false) bool engineInThreatMode,
}) = _RetroState; }) = _RetroState;
+2 -2
View File
@@ -132,7 +132,7 @@ typedef BroadcastTournamentGroup = ({
}); });
typedef BroadcastCustomPointsPerColor = ({double win, double draw}); typedef BroadcastCustomPointsPerColor = ({double win, double draw});
typedef BroadcastCustomScoring = BySide<BroadcastCustomPointsPerColor>; typedef BroadcastCustomScoring = IMap<Side, BroadcastCustomPointsPerColor>;
extension BroadcastCustomScoringExt on BroadcastCustomScoring { extension BroadcastCustomScoringExt on BroadcastCustomScoring {
String pointsForResult(Side side, BroadcastResult result) { String pointsForResult(Side side, BroadcastResult result) {
@@ -193,7 +193,7 @@ sealed class BroadcastGame with _$BroadcastGame {
const factory BroadcastGame({ const factory BroadcastGame({
required BroadcastGameId id, required BroadcastGameId id,
required BySide<BroadcastPlayerWithClock> players, required IMap<Side, BroadcastPlayerWithClock> players,
required String fen, required String fen,
required Move? lastMove, required Move? lastMove,
required Duration? thinkTime, required Duration? thinkTime,
@@ -301,31 +301,12 @@ class BroadcastAnalysisController extends AsyncNotifier<BroadcastAnalysisState>
if (!state.requireValue.currentPosition.isLegal(move)) return; 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); final (newPath, isNewNode) = _root.addMoveAt(state.requireValue.currentPath, move);
if (newPath != null) { if (newPath != null) {
_setPath(newPath, shouldRecomputeRootView: isNewNode, shouldForceShowVariation: true); _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() { void userPrevious() {
_setPath(state.requireValue.currentPath.penultimate, isNavigating: true); _setPath(state.requireValue.currentPath.penultimate, isNavigating: true);
} }
@@ -478,7 +459,6 @@ class BroadcastAnalysisController extends AsyncNotifier<BroadcastAnalysisState>
isOnMainline: _root.isOnMainline(path), isOnMainline: _root.isOnMainline(path),
currentNode: AnalysisCurrentNode.fromNode(currentNode), currentNode: AnalysisCurrentNode.fromNode(currentNode),
lastMove: currentNode.sanMove.move, lastMove: currentNode.sanMove.move,
promotionMove: null,
root: rootView, root: rootView,
clocks: _getClocks(path), clocks: _getClocks(path),
), ),
@@ -491,7 +471,6 @@ class BroadcastAnalysisController extends AsyncNotifier<BroadcastAnalysisState>
isOnMainline: _root.isOnMainline(path), isOnMainline: _root.isOnMainline(path),
currentNode: AnalysisCurrentNode.fromNode(currentNode), currentNode: AnalysisCurrentNode.fromNode(currentNode),
lastMove: null, lastMove: null,
promotionMove: null,
root: rootView, root: rootView,
clocks: _getClocks(path), clocks: _getClocks(path),
), ),
@@ -600,9 +579,6 @@ sealed class BroadcastAnalysisState
/// The last move played. /// The last move played.
Move? lastMove, Move? lastMove,
/// Possible promotion move to be played.
NormalMove? promotionMove,
/// The PGN headers of the game. /// The PGN headers of the game.
required IMap<String, String> pgnHeaders, required IMap<String, String> pgnHeaders,
+30 -3
View File
@@ -19,10 +19,8 @@ sealed class GameBoardParams with _$GameBoardParams {
required Variant variant, required Variant variant,
required Position position, required Position position,
required PlayerSide playerSide, required PlayerSide playerSide,
required NormalMove? promotionMove,
required void Function(Move, {bool? viaDragAndDrop}) onMove, required void Function(Move, {bool? viaDragAndDrop}) onMove,
required void Function(Role? role) onPromotionSelection, Move? lastMove,
required Premovable? premovable,
}) = InteractiveBoardParams; }) = InteractiveBoardParams;
static const emptyBoard = ReadonlyBoardParams( static const emptyBoard = ReadonlyBoardParams(
@@ -46,3 +44,32 @@ sealed class GameBoardParams with _$GameBoardParams {
InteractiveBoardParams(:final playerSide) => playerSide, 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}) { void userMove(Move move, {bool? viaDragAndDrop, bool? isPremove}) {
final curState = state.requireValue; 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) { if (curState.shouldConfirmMove && isPremove != true) {
state = AsyncValue.data(curState.copyWith(moveToConfirm: move, promotionMove: null)); state = AsyncValue.data(curState.copyWith(moveToConfirm: move));
return; return;
} }
@@ -214,8 +209,6 @@ class GameController extends AsyncNotifier<GameState> {
curState.copyWith( curState.copyWith(
game: curState.game.copyWith(steps: curState.game.steps.add(newStep)), game: curState.game.copyWith(steps: curState.game.steps.add(newStep)),
stepCursor: curState.stepCursor + 1, 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 /// Called if the player cancels the move when confirm move preference is enabled
void cancelMove() { void cancelMove() {
final curState = state.requireValue; 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) { void cursorAt(int cursor) {
if (state.hasValue) { if (state.hasValue) {
final currentCursor = state.requireValue.stepCursor; final currentCursor = state.requireValue.stepCursor;
@@ -323,7 +295,7 @@ class GameController extends AsyncNotifier<GameState> {
return; return;
} }
final (newState, _) = _tryCancelMoveConfirmation(state.requireValue); 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; final san = state.requireValue.game.stepAt(cursor).sanMove?.san;
if (san != null) { if (san != null) {
_playReplayMoveSound(san); _playReplayMoveSound(san);
@@ -336,13 +308,7 @@ class GameController extends AsyncNotifier<GameState> {
if (state.hasValue) { if (state.hasValue) {
final curState = state.requireValue; final curState = state.requireValue;
if (curState.stepCursor < curState.game.steps.length - 1) { if (curState.stepCursor < curState.game.steps.length - 1) {
state = AsyncValue.data( state = AsyncValue.data(curState.copyWith(stepCursor: curState.stepCursor + 1));
curState.copyWith(
stepCursor: curState.stepCursor + 1,
premove: null,
promotionMove: null,
),
);
final san = curState.game.stepAt(curState.stepCursor + 1).sanMove?.san; final san = curState.game.stepAt(curState.stepCursor + 1).sanMove?.san;
if (san != null) { if (san != null) {
_playReplayMoveSound(san); _playReplayMoveSound(san);
@@ -357,11 +323,7 @@ class GameController extends AsyncNotifier<GameState> {
if (curState.stepCursor > 0) { if (curState.stepCursor > 0) {
final (newState, didCancel) = _tryCancelMoveConfirmation(curState); final (newState, didCancel) = _tryCancelMoveConfirmation(curState);
state = AsyncValue.data( state = AsyncValue.data(
newState.copyWith( newState.copyWith(stepCursor: didCancel ? newState.stepCursor : newState.stepCursor - 1),
stepCursor: didCancel ? newState.stepCursor : newState.stepCursor - 1,
premove: null,
promotionMove: null,
),
); );
final san = state.requireValue.game.stepAt(state.requireValue.stepCursor).sanMove?.san; final san = state.requireValue.game.stepAt(state.requireValue.stepCursor).sanMove?.san;
if (san != null) { if (san != null) {
@@ -474,7 +436,6 @@ class GameController extends AsyncNotifier<GameState> {
void acceptTakeback() { void acceptTakeback() {
_socketClient.send('takeback-yes', null); _socketClient.send('takeback-yes', null);
setPremove(null);
} }
void cancelOrDeclineTakeback() { void cancelOrDeclineTakeback() {
@@ -617,8 +578,6 @@ class GameController extends AsyncNotifier<GameState> {
state.requireValue.copyWith( state.requireValue.copyWith(
game: newGame, game: newGame,
stepCursor: hasSameNumberOfSteps ? curState.stepCursor : newGame.steps.length - 1, stepCursor: hasSameNumberOfSteps ? curState.stepCursor : newGame.steps.length - 1,
premove: hasSameNumberOfSteps ? curState.premove : null,
promotionMove: hasSameNumberOfSteps ? curState.promotionMove : null,
moveToConfirm: hasSameNumberOfSteps ? curState.moveToConfirm : null, moveToConfirm: hasSameNumberOfSteps ? curState.moveToConfirm : null,
opponentLeftCountdown: isOpponentOnGame opponentLeftCountdown: isOpponentOnGame
? null ? null
@@ -692,10 +651,8 @@ class GameController extends AsyncNotifier<GameState> {
newState = newState.copyWith( newState = newState.copyWith(
game: newState.game.copyWith(steps: newState.game.steps.add(newStep)), game: newState.game.copyWith(steps: newState.game.steps.add(newStep)),
// Clear any pending move confirmation or promotion since the position // Clear any pending move confirmation since the position has changed.
// has changed and these moves are no longer valid.
moveToConfirm: null, moveToConfirm: null,
promotionMove: null,
); );
if (!curState.isReplaying) { if (!curState.isReplaying) {
@@ -755,20 +712,6 @@ class GameController extends AsyncNotifier<GameState> {
ref.read(ongoingGamesProvider.notifier).updateGame(gameFullId, newState.game); 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); state = AsyncValue.data(newState);
// End game event // End game event
@@ -783,7 +726,6 @@ class GameController extends AsyncNotifier<GameState> {
white: curState.game.white.copyWith(ratingDiff: endData.ratingDiff?.white), white: curState.game.white.copyWith(ratingDiff: endData.ratingDiff?.white),
black: curState.game.black.copyWith(ratingDiff: endData.ratingDiff?.black), black: curState.game.black.copyWith(ratingDiff: endData.ratingDiff?.black),
), ),
premove: null,
); );
if (endData.clock != null) { if (endData.clock != null) {
@@ -1070,12 +1012,6 @@ sealed class GameState with _$GameState {
int? lastDrawOfferAtPly, int? lastDrawOfferAtPly,
(Duration, DateTime)? opponentLeftCountdown, (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 /// Game only setting to override the account preference
bool? moveConfirmSettingOverride, bool? moveConfirmSettingOverride,
+8 -4
View File
@@ -41,13 +41,17 @@ sealed class MaterialDiff with _$MaterialDiff {
} }
int score = 0; int score = 0;
final IMap<Role, int> blackCount = position.board.materialCount(Side.black); final IMap<Role, int> blackCount = position.board.materialCount(Side.black).toIMap();
final IMap<Role, int> whiteCount = position.board.materialCount(Side.white); final IMap<Role, int> whiteCount = position.board.materialCount(Side.white).toIMap();
final startingPosition = Position.initialPosition(position.rule); final startingPosition = Position.initialPosition(position.rule);
final IMap<Role, int> blackStartingCount = startingPosition.board.materialCount(Side.black); final IMap<Role, int> blackStartingCount = startingPosition.board
final IMap<Role, int> whiteStartingCount = startingPosition.board.materialCount(Side.white); .materialCount(Side.black)
.toIMap();
final IMap<Role, int> whiteStartingCount = startingPosition.board
.materialCount(Side.white)
.toIMap();
IMap<Role, int> subtractPieceCounts( IMap<Role, int> subtractPieceCounts(
IMap<Role, int> startingCount, IMap<Role, int> startingCount,
@@ -152,11 +152,6 @@ class OfflineComputerGameController extends Notifier<OfflineComputerGameState> {
void makeMove(Move move) { void makeMove(Move move) {
if (state.isEngineThinking || state.isEvaluatingMove || !state.game.playable) return; 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) { if (state.game.practiceMode) {
_makeMoveWithEvaluation(move); _makeMoveWithEvaluation(move);
} else { } 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) { SanMove _applyMove(Move move) {
final (newPos, newSan) = state.currentPosition.makeSan(Move.parse(move.uci)!); final (newPos, newSan) = state.currentPosition.makeSan(Move.parse(move.uci)!);
final sanMove = SanMove(newSan, move); final sanMove = SanMove(newSan, move);
@@ -758,13 +732,13 @@ class OfflineComputerGameController extends Notifier<OfflineComputerGameState> {
void goForward() { void goForward() {
if (state.canGoForward) { if (state.canGoForward) {
state = state.copyWith(stepCursor: state.stepCursor + 1, promotionMove: null); state = state.copyWith(stepCursor: state.stepCursor + 1);
} }
} }
void goBack() { void goBack() {
if (state.canGoBack) { 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({ const factory OfflineComputerGameState({
required OfflineComputerGame game, required OfflineComputerGame game,
@Default(0) int stepCursor, @Default(0) int stepCursor,
@Default(null) NormalMove? promotionMove,
@Default(false) bool isEngineThinking, @Default(false) bool isEngineThinking,
@Default(false) bool isLoadingHint, @Default(false) bool isLoadingHint,
@@ -64,11 +64,6 @@ class OverTheBoardGameController extends Notifier<OverTheBoardGameState> {
} }
void makeMove(Move move) { 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 (newPos, newSan) = state.currentPosition.makeSan(Move.parse(move.uci)!);
final sanMove = SanMove(newSan, move); final sanMove = SanMove(newSan, move);
final newStep = GameStep( final newStep = GameStep(
@@ -120,19 +115,6 @@ class OverTheBoardGameController extends Notifier<OverTheBoardGameState> {
_moveFeedback(sanMove); _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) { void onFlag(Side side) {
state = state.copyWith( state = state.copyWith(
game: state.game.copyWith(status: GameStatus.outoftime, winner: side.opposite), game: state.game.copyWith(status: GameStatus.outoftime, winner: side.opposite),
@@ -141,13 +123,13 @@ class OverTheBoardGameController extends Notifier<OverTheBoardGameState> {
void goForward() { void goForward() {
if (state.canGoForward) { if (state.canGoForward) {
state = state.copyWith(stepCursor: state.stepCursor + 1, promotionMove: null); state = state.copyWith(stepCursor: state.stepCursor + 1);
} }
} }
void goBack() { void goBack() {
if (state.canGoBack) { 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({ const factory OverTheBoardGameState({
required OverTheBoardGame game, required OverTheBoardGame game,
@Default(0) int stepCursor, @Default(0) int stepCursor,
@Default(null) NormalMove? promotionMove,
}) = _OverTheBoardGameState; }) = _OverTheBoardGameState;
factory OverTheBoardGameState.fromVariant(Variant variant, Speed speed, {String? initialFen}) { factory OverTheBoardGameState.fromVariant(Variant variant, Speed speed, {String? initialFen}) {
@@ -105,11 +105,6 @@ class PuzzleController extends Notifier<PuzzleState> {
} }
Future<void> onUserMove(Move move) async { Future<void> onUserMove(Move move) async {
if (move case NormalMove() when isPromotionPawnMove(state.currentPosition, move)) {
state = state.copyWith(promotionMove: move);
return;
}
_addMove(move); _addMove(move);
if (state.mode == PuzzleMode.play) { 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() { void userNext() {
_viewSolutionTimer?.cancel(); _viewSolutionTimer?.cancel();
_goToNextNode(isNavigating: true); _goToNextNode(isNavigating: true);
@@ -377,7 +360,6 @@ class PuzzleController extends Notifier<PuzzleState> {
root: _gameTree.view, root: _gameTree.view,
node: newNode, node: newNode,
lastMove: sanMove.move, lastMove: sanMove.move,
promotionMove: null,
shouldBlinkNextArrow: false, shouldBlinkNextArrow: false,
); );
} }
@@ -450,7 +432,6 @@ sealed class PuzzleState with _$PuzzleState {
required ViewBranch node, required ViewBranch node,
required ViewNode root, required ViewNode root,
Move? lastMove, Move? lastMove,
NormalMove? promotionMove,
PuzzleResult? result, PuzzleResult? result,
PuzzleFeedback? feedback, PuzzleFeedback? feedback,
required bool hintShown, required bool hintShown,
@@ -90,11 +90,6 @@ class StormController extends Notifier<StormState> {
if (state.clock.endAt != null) return; if (state.clock.endAt != null) return;
state.clock.start(); state.clock.start();
if (move case NormalMove() when isPromotionPawnMove(state.position, move)) {
state = state.copyWith(promotionMove: move);
return;
}
final expected = state.expectedMove; final expected = state.expectedMove;
_addMove(move, ComboState.noChange, runStarted: true, userMove: true); _addMove(move, ComboState.noChange, runStarted: true, userMove: true);
state = state.copyWith(moves: state.moves + 1); 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 { Future<void> end() async {
ref.read(soundServiceProvider).play(Sound.puzzleStormEnd); ref.read(soundServiceProvider).play(Sound.puzzleStormEnd);
@@ -230,7 +213,6 @@ class StormController extends Notifier<StormState> {
current: newComboCurrent, current: newComboCurrent,
best: math.max(state.combo.best, state.combo.current + 1), best: math.max(state.combo.best, state.combo.current + 1),
), ),
promotionMove: null,
); );
Future<void>.delayed(userMove ? Duration.zero : const Duration(milliseconds: 250), () { Future<void>.delayed(userMove ? Duration.zero : const Duration(milliseconds: 250), () {
if (pos.board.pieceAt(move.to) != null) { 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 /// bool to indicate that the first move has been played
required bool firstMovePlayed, required bool firstMovePlayed,
/// Promotion move to be selected
NormalMove? promotionMove,
}) = _StormState; }) = _StormState;
Move? get expectedMove => Move.parse(puzzle.solution[moveIndex + 1]); Move? get expectedMove => Move.parse(puzzle.solution[moveIndex + 1]);
+2 -61
View File
@@ -1,6 +1,4 @@
import 'package:chessground/chessground.dart'; 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/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
@@ -209,7 +207,7 @@ sealed class BoardPrefs with _$BoardPrefs implements Serializable {
bool get hasColorAdjustments => bool get hasColorAdjustments =>
brightness != kBoardDefaultBrightnessFilter || hue != kBoardDefaultHueFilter; brightness != kBoardDefaultBrightnessFilter || hue != kBoardDefaultHueFilter;
ChessboardSettings toBoardSettings() { ChessboardSettings toBoardSettings(Variant variant) {
return ChessboardSettings( return ChessboardSettings(
pieceAssets: pieceSet.assets, pieceAssets: pieceSet.assets,
colorScheme: boardTheme.colors, colorScheme: boardTheme.colors,
@@ -227,30 +225,7 @@ sealed class BoardPrefs with _$BoardPrefs implements Serializable {
dragTargetKind: dragTargetKind, dragTargetKind: dragTargetKind,
pieceShiftMethod: pieceShiftMethod, pieceShiftMethod: pieceShiftMethod,
drawShape: DrawShapeOptions(enable: enableShapeDrawings, newShapeColor: shapeColor.color), drawShape: DrawShapeOptions(enable: enableShapeDrawings, newShapeColor: shapeColor.color),
); enableDrops: variant == Variant.crazyhouse,
}
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,
canPromoteToKing: variant == Variant.antichess, canPromoteToKing: variant == Variant.antichess,
); );
} }
@@ -263,40 +238,6 @@ sealed class BoardPrefs with _$BoardPrefs implements Serializable {
pieceAnimation ? const Duration(milliseconds: 150) : Duration.zero; 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 /// Colors taken from lila: https://github.com/lichess-org/chessground/blob/54a7e71bf88701c1109d3b9b8106b464012b94cf/src/state.ts#L178
enum ShapeColor { enum ShapeColor {
green, green,
-26
View File
@@ -261,12 +261,6 @@ class StudyController extends AsyncNotifier<StudyState>
if (!state.requireValue.currentPosition!.isLegal(move)) return; 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); _sendMoveToSocket(move);
final (newPath, isNewNode) = _root.addMoveAt(state.requireValue.currentPath, 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() { void showGamebookSolution() {
onUserMove(state.requireValue.currentNode.children.first); onUserMove(state.requireValue.currentNode.children.first);
} }
@@ -497,7 +476,6 @@ class StudyController extends AsyncNotifier<StudyState>
isOnMainline: _root.isOnMainline(path), isOnMainline: _root.isOnMainline(path),
currentNode: StudyCurrentNode.fromNode(currentNode), currentNode: StudyCurrentNode.fromNode(currentNode),
lastMove: currentNode.sanMove.move, lastMove: currentNode.sanMove.move,
promotionMove: null,
root: rootView, root: rootView,
), ),
); );
@@ -508,7 +486,6 @@ class StudyController extends AsyncNotifier<StudyState>
isOnMainline: _root.isOnMainline(path), isOnMainline: _root.isOnMainline(path),
currentNode: StudyCurrentNode.fromNode(currentNode), currentNode: StudyCurrentNode.fromNode(currentNode),
lastMove: null, lastMove: null,
promotionMove: null,
root: rootView, root: rootView,
), ),
); );
@@ -602,9 +579,6 @@ sealed class StudyState
/// The last move played. /// The last move played.
Move? lastMove, Move? lastMove,
/// Possible promotion move to be played.
NormalMove? promotionMove,
/// The PGN root comments of the study /// The PGN root comments of the study
IList<PgnComment>? pgnRootComments, IList<PgnComment>? pgnRootComments,
+2 -3
View File
@@ -1,6 +1,5 @@
import 'package:chessground/chessground.dart'; import 'package:chessground/chessground.dart';
import 'package:dartchess/dartchess.dart'; import 'package:dartchess/dartchess.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
/// Computes the set of squares that should have an atomic explosion animation /// 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 /// Returns `null` if [positionBefore] is not an atomic position, [move] is
/// null, or the move was not a capture (no explosion occurs). /// 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; if (move == null || positionBefore is! Atomic) return null;
final squareSet = positionBefore.explosionSquares(move); 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. /// 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/analysis/retro_screen.dart';
import 'package:lichess_mobile/src/view/broadcast/broadcast_game_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/view/study/study_screen.dart';
import 'package:lichess_mobile/src/widgets/board.dart';
import 'package:lichess_mobile/src/widgets/pgn.dart'; import 'package:lichess_mobile/src/widgets/pgn.dart';
/// An abstract widget that provides the common interface for three types of analysis boards: /// 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 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`. /// Sets up a subscription to analysis state changes.
String get fen => analysisState.currentPosition!.fen; ///
/// 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. /// E.g. for the study board to add pgn shapes and variations arrows.
ISet<Shape> get extraShapes => ISet(); 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. /// Filters to identify the correct engine evaluation provider instance.
EngineEvaluationFilters get engineEvaluationFilters; EngineEvaluationFilters get engineEvaluationFilters;
/// Set of shapes drawn by the user on the board (arrows, circle). ChessboardController? _controller;
ISet<Shape> userShapes = ISet();
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 enginePrefs = ref.watch(engineEvaluationPreferencesProvider);
final currentPosition = analysisState.currentPosition; final currentPosition = analysisState.currentPosition;
@@ -78,7 +176,7 @@ abstract class AnalysisBoardState<
analysisPrefs.showBestMoveArrow; analysisPrefs.showBestMoveArrow;
if (!showBestMoveArrow || currentPosition == null) { if (!showBestMoveArrow || currentPosition == null) {
return ISet(); return {};
} }
final localEval = ref.watch( final localEval = ref.watch(
@@ -90,13 +188,13 @@ abstract class AnalysisBoardState<
: pickBestClientEval(localEval: localEval, savedEval: analysisState.currentNode.eval); : pickBestClientEval(localEval: localEval, savedEval: analysisState.currentNode.eval);
if (eval == null) { if (eval == null) {
return ISet(); return {};
} }
if (eval.position.fen != currentPosition.fen) { if (eval.position.fen != currentPosition.fen) {
// Eval is out of sync, this usually happens after making a move on the board. // 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. // 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( final bestMoveShapes = computeBestMoveShapes(
@@ -118,10 +216,10 @@ abstract class AnalysisBoardState<
bestMoveColor: LichessColors.red.withValues(alpha: 0.6), bestMoveColor: LichessColors.red.withValues(alpha: 0.6),
nextBestMovesColor: LichessColors.red.withValues(alpha: 0.4), 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 @override
@@ -129,65 +227,54 @@ abstract class AnalysisBoardState<
final boardPrefs = ref.watch(boardPreferencesProvider); final boardPrefs = ref.watch(boardPreferencesProvider);
final currentNode = analysisState.currentNode; final currentNode = analysisState.currentNode;
final currentPosition = analysisState.currentPosition;
final annotation = showAnnotations ? makeAnnotation(currentNode.nags) : null; final annotation = showAnnotations ? makeAnnotation(currentNode.nags) : null;
final sanMove = currentNode.sanMove; 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, size: widget.boardSize,
orientation: analysisState.pov, orientation: analysisState.pov,
fen: fen, controller: ctrl,
lastMove: analysisState.lastMove, onMove: (move, {viaDragAndDrop}) => onUserMove(move),
explosionSquares: analysisState.explosionSquares, shapes: externalShapes,
game: (interactive && currentPosition != null) settings: boardPrefs
? boardPrefs.toGameData( .toBoardSettings(analysisState.variant)
variant: analysisState.variant, .copyWith(
position: currentPosition, borderRadius: widget.boardRadius,
playerSide: analysisState.currentPosition!.isGameOver boxShadow: widget.boardRadius != null ? boardShadows : const <BoxShadow>[],
? PlayerSide.none drawShape: DrawShapeOptions(
: analysisState.currentPosition!.turn == Side.white enable: boardPrefs.enableShapeDrawings,
? PlayerSide.white newShapeColor: boardPrefs.shapeColor.color,
: PlayerSide.black, ),
promotionMove: analysisState.promotionMove, ),
onMove: (move, {viaDragAndDrop}) => onUserMove(move), annotations: boardAnnotations,
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,
),
),
); );
} }
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 class _GameAnalysisBoardState
extends AnalysisBoardState<GameAnalysisBoard, AnalysisState, AnalysisPrefs> { extends AnalysisBoardState<GameAnalysisBoard, AnalysisState, AnalysisPrefs> {
@override @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 => AnalysisState get analysisState =>
ref.watch(analysisControllerProvider(widget.options)).requireValue; ref.watch(analysisControllerProvider(widget.options)).requireValue;
@@ -34,6 +44,9 @@ class _GameAnalysisBoardState
EngineEvaluationFilters get engineEvaluationFilters => EngineEvaluationFilters get engineEvaluationFilters =>
(id: analysisState.evaluationContext.id, path: analysisState.currentPath); (id: analysisState.evaluationContext.id, path: analysisState.currentPath);
@override
String computeFen(AnalysisState state) => state.currentPosition.fen;
@override @override
AnalysisPrefs get analysisPrefs => ref.watch(analysisPreferencesProvider); AnalysisPrefs get analysisPrefs => ref.watch(analysisPreferencesProvider);
@@ -48,10 +61,6 @@ class _GameAnalysisBoardState
.read(analysisControllerProvider(widget.options).notifier) .read(analysisControllerProvider(widget.options).notifier)
.onUserMove(move, shouldReplace: widget.shouldReplaceChildOnUserMove); .onUserMove(move, shouldReplace: widget.shouldReplaceChildOnUserMove);
@override
void onPromotionSelection(Role? role) =>
ref.read(analysisControllerProvider(widget.options).notifier).onPromotionSelection(role);
@override @override
ISet<Shape> get extraShapes { ISet<Shape> get extraShapes {
final analysisState = ref.watch(analysisControllerProvider(widget.options)).requireValue; final analysisState = ref.watch(analysisControllerProvider(widget.options)).requireValue;
+12 -8
View File
@@ -167,6 +167,16 @@ class RetroAnalysisBoard extends AnalysisBoard {
class _RetroAnalysisBoardState class _RetroAnalysisBoardState
extends AnalysisBoardState<RetroAnalysisBoard, RetroState, AnalysisPrefs> { extends AnalysisBoardState<RetroAnalysisBoard, RetroState, AnalysisPrefs> {
@override @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; RetroState get analysisState => ref.watch(retroControllerProvider(widget.options)).requireValue;
@override @override
@@ -178,9 +188,8 @@ class _RetroAnalysisBoardState
@override @override
bool get hideBestMoveArrow => true; bool get hideBestMoveArrow => true;
// Disable interaction while the engine is evaluating the move
@override @override
bool get interactive => analysisState.feedback != RetroFeedback.evalMove; bool computeInteractive(RetroState state) => state.feedback != RetroFeedback.evalMove;
@override @override
void onUserMove(Move move) { void onUserMove(Move move) {
@@ -192,12 +201,7 @@ class _RetroAnalysisBoardState
(id: analysisState.evaluationContext.id, path: analysisState.currentPath); (id: analysisState.evaluationContext.id, path: analysisState.currentPath);
@override @override
void onPromotionSelection(Role? role) { String computeFen(RetroState state) => state.currentPosition.board.fen;
ref.read(retroControllerProvider(widget.options).notifier).onPromotionSelection(role);
}
@override
String get fen => analysisState.currentPosition.board.fen;
@override @override
ISet<Shape> get extraShapes { ISet<Shape> get extraShapes {
@@ -143,10 +143,12 @@ class _BoardEditor extends ConsumerWidget {
size: boardSize, size: boardSize,
pieces: pieces, pieces: pieces,
orientation: orientation, orientation: orientation,
settings: boardPrefs.toBoardSettings().copyWith( settings: boardPrefs
borderRadius: isTablet ? Styles.boardBorderRadius : BorderRadius.zero, .toBoardSettings(editorState.variant)
boxShadow: isTablet ? boardShadows : const <BoxShadow>[], .copyWith(
), borderRadius: isTablet ? Styles.boardBorderRadius : BorderRadius.zero,
boxShadow: isTablet ? boardShadows : const <BoxShadow>[],
),
pointerMode: editorState.editorPointerMode, pointerMode: editorState.editorPointerMode,
onDiscardedPiece: (Square square) => onDiscardedPiece: (Square square) =>
ref.read(boardEditorControllerProvider(params).notifier).discardPiece(square), ref.read(boardEditorControllerProvider(params).notifier).discardPiece(square),
@@ -504,6 +504,22 @@ class BroadcastAnalysisBoard extends AnalysisBoard {
class _BroadcastAnalysisBoardState class _BroadcastAnalysisBoardState
extends AnalysisBoardState<BroadcastAnalysisBoard, BroadcastAnalysisState, BroadcastPrefs> { 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 @override
BroadcastAnalysisState get analysisState => ref BroadcastAnalysisState get analysisState => ref
.watch(broadcastAnalysisControllerProvider((roundId: widget.roundId, gameId: widget.gameId))) .watch(broadcastAnalysisControllerProvider((roundId: widget.roundId, gameId: widget.gameId)))
@@ -531,14 +547,7 @@ class _BroadcastAnalysisBoardState
(id: analysisState.evaluationContext.id, path: analysisState.currentPath); (id: analysisState.evaluationContext.id, path: analysisState.currentPath);
@override @override
void onPromotionSelection(Role? role) => ref String computeFen(BroadcastAnalysisState state) => state.currentPosition.fen;
.read(
broadcastAnalysisControllerProvider((
roundId: widget.roundId,
gameId: widget.gameId,
)).notifier,
)
.onPromotionSelection(role);
} }
enum _PlayerWidgetPosition { bottom, top } 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/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lichess_mobile/src/constants.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/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_controller.dart';
import 'package:lichess_mobile/src/model/coordinate_training/coordinate_training_preferences.dart'; import 'package:lichess_mobile/src/model/coordinate_training/coordinate_training_preferences.dart';
@@ -431,17 +432,21 @@ class _TrainingBoardState extends ConsumerState<_TrainingBoard> {
Stack( Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
Chessboard.fixed( StaticChessboard(
size: widget.boardSize, size: widget.boardSize,
fen: trainingPrefs.showPieces ? kInitialFEN : kEmptyFEN, fen: trainingPrefs.showPieces ? kInitialFEN : kEmptyFEN,
squareHighlights: widget.squareHighlights, squareHighlights: widget.squareHighlights.unlock,
orientation: widget.orientation, orientation: widget.orientation,
settings: boardPrefs.toBoardSettings().copyWith( settings: StaticChessboardSettings.fromBoardSettings(
enableCoordinates: trainingPrefs.showCoordinates, boardPrefs
borderRadius: widget.isTablet .toBoardSettings(Variant.standard)
? const BorderRadius.all(Radius.circular(4.0)) .copyWith(
: BorderRadius.zero, enableCoordinates: trainingPrefs.showCoordinates,
boxShadow: widget.isTablet ? boardShadows : const <BoxShadow>[], borderRadius: widget.isTablet
? const BorderRadius.all(Radius.circular(4.0))
: BorderRadius.zero,
boxShadow: widget.isTablet ? boardShadows : const <BoxShadow>[],
),
), ),
onTouchedSquare: (square) { onTouchedSquare: (square) {
if (trainingState.trainingActive && trainingPrefs.mode == TrainingMode.findSquare) { if (trainingState.trainingActive && trainingPrefs.mode == TrainingMode.findSquare) {
@@ -101,7 +101,6 @@ class _BodyState extends ConsumerState<_Body> {
int stepCursor = 0; int stepCursor = 0;
(String, Move)? moveToConfirm; (String, Move)? moveToConfirm;
bool isBoardTurned = false; bool isBoardTurned = false;
NormalMove? promotionMove;
bool get isReplaying => stepCursor < game.steps.length - 1; bool get isReplaying => stepCursor < game.steps.length - 1;
bool get canGoForward => 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.white
: PlayerSide.black : PlayerSide.black
: PlayerSide.none, : PlayerSide.none,
promotionMove: promotionMove,
onMove: (move, {viaDragAndDrop}) { onMove: (move, {viaDragAndDrop}) {
onUserMove(move); onUserMove(move);
}, },
onPromotionSelection: onPromotionSelection,
premovable: null,
), ),
topTable: topPlayer, topTable: topPlayer,
bottomTable: bottomPlayer, bottomTable: bottomPlayer,
@@ -313,7 +309,6 @@ class _BodyState extends ConsumerState<_Body> {
if (stepCursor > 0) { if (stepCursor > 0) {
setState(() { setState(() {
stepCursor = stepCursor - 1; stepCursor = stepCursor - 1;
promotionMove = null;
}); });
_playReplayMoveSound(); _playReplayMoveSound();
} }
@@ -323,20 +318,12 @@ class _BodyState extends ConsumerState<_Body> {
if (stepCursor < game.steps.length - 1) { if (stepCursor < game.steps.length - 1) {
setState(() { setState(() {
stepCursor = stepCursor + 1; stepCursor = stepCursor + 1;
promotionMove = null;
}); });
_playReplayMoveSound(); _playReplayMoveSound();
} }
} }
void onUserMove(Move move) { 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 (newPos, newSan) = game.lastPosition.makeSan(move);
final sanMove = SanMove(newSan, move); final sanMove = SanMove(newSan, move);
final newStep = GameStep( final newStep = GameStep(
@@ -348,26 +335,12 @@ class _BodyState extends ConsumerState<_Body> {
setState(() { setState(() {
moveToConfirm = (game.sanMoves, move); moveToConfirm = (game.sanMoves, move);
game = game.copyWith(steps: game.steps.add(newStep)); game = game.copyWith(steps: game.steps.add(newStep));
promotionMove = null;
stepCursor = stepCursor + 1; stepCursor = stepCursor + 1;
}); });
_moveFeedback(sanMove); _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 { Future<void> confirmMove() async {
setState(() { setState(() {
game = game.copyWith(registeredMoveAtPgn: (moveToConfirm!.$1, moveToConfirm!.$2)); 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)): case AsyncData(value: GameCreatedState(:final createdGameId)):
final isRealTimePlayingGame = final isRealTimePlayingGame = ref.watch(
ref.watch(_isRealTimePlayableGameProvider(createdGameId)).value ?? false; _isRealTimePlayableGameProvider(createdGameId).select((s) => s.value ?? false),
);
final socketUri = GameController.socketUri(createdGameId); final socketUri = GameController.socketUri(createdGameId);
@@ -529,9 +530,12 @@ class _WatcherButton extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(gameControllerProvider(gameId).select((s) => s.value)); final (nb, isZenModeActive) = ref.watch(
final nb = state?.nbWatchers ?? 0; gameControllerProvider(gameId).select((s) {
final isZenModeActive = state?.isZenModeActive ?? false; final state = s.value;
return (state?.nbWatchers ?? 0, state?.isZenModeActive ?? false);
}),
);
if (nb <= 0 || isZenModeActive) return const SizedBox.shrink(); if (nb <= 0 || isZenModeActive) return const SizedBox.shrink();
return SemanticIconButton( return SemanticIconButton(
semanticsLabel: context.l10n.spectatorRoom, semanticsLabel: context.l10n.spectatorRoom,
+12 -10
View File
@@ -224,21 +224,23 @@ class _BoardCarouselItem extends ConsumerWidget {
SizedBox( SizedBox(
height: boardSize, height: boardSize,
child: StaticChessboard( child: StaticChessboard(
hue: boardPrefs.hue,
brightness: boardPrefs.brightness,
size: boardSize, size: boardSize,
fen: fen, fen: fen,
orientation: orientation, orientation: orientation,
lastMove: lastMove, lastMove: lastMove,
enableCoordinates: false, settings: StaticChessboardSettings(
borderRadius: const BorderRadius.only( hue: boardPrefs.hue,
topLeft: Radius.circular(10.0), brightness: boardPrefs.brightness,
topRight: Radius.circular(10.0), 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( Positioned(
@@ -261,6 +261,7 @@ class _BodyState extends ConsumerState<_Body> {
) )
: null, : null,
shapes: _buildBoardShapes(gameState, boardColorScheme), shapes: _buildBoardShapes(gameState, boardColorScheme),
boardSettingsOverrides: const BoardSettingsOverrides(enablePremoves: false),
boardParams: GameBoardParams.interactive( boardParams: GameBoardParams.interactive(
variant: gameState.game.meta.variant, variant: gameState.game.meta.variant,
position: gameState.currentPosition, position: gameState.currentPosition,
@@ -269,14 +270,9 @@ class _BodyState extends ConsumerState<_Body> {
: isPlayerTurn && !gameState.isEvaluatingMove : isPlayerTurn && !gameState.isEvaluatingMove
? (orientation == Side.white ? PlayerSide.white : PlayerSide.black) ? (orientation == Side.white ? PlayerSide.white : PlayerSide.black)
: PlayerSide.none, : PlayerSide.none,
onPromotionSelection: ref
.read(offlineComputerGameControllerProvider.notifier)
.onPromotionSelection,
promotionMove: gameState.promotionMove,
onMove: (move, {viaDragAndDrop}) { onMove: (move, {viaDragAndDrop}) {
ref.read(offlineComputerGameControllerProvider.notifier).makeMove(move); ref.read(offlineComputerGameControllerProvider.notifier).makeMove(move);
}, },
premovable: null,
), ),
moves: gameState.moves, moves: gameState.moves,
currentMoveIndex: gameState.stepCursor, currentMoveIndex: gameState.stepCursor,
@@ -782,12 +778,14 @@ class _NewGameSheetState extends ConsumerState<_NewGameSheet> {
size: 150, size: 150,
fen: widget.initialFen!, fen: widget.initialFen!,
orientation: _selectedSideChoice.toSide(fen: widget.initialFen) ?? Side.white, orientation: _selectedSideChoice.toSide(fen: widget.initialFen) ?? Side.white,
pieceAssets: boardPrefs.pieceSet.assets, settings: StaticChessboardSettings(
colorScheme: boardPrefs.boardTheme.colors, pieceAssets: boardPrefs.pieceSet.assets,
brightness: boardPrefs.brightness, colorScheme: boardPrefs.boardTheme.colors,
hue: boardPrefs.hue, brightness: boardPrefs.brightness,
enableCoordinates: false, hue: boardPrefs.hue,
borderRadius: const BorderRadius.all(Radius.circular(4)), enableCoordinates: false,
borderRadius: const BorderRadius.all(Radius.circular(4)),
),
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -265,27 +265,12 @@ class _BodyState extends ConsumerState<_Body> {
: gameState.turn == Side.white : gameState.turn == Side.white
? PlayerSide.white ? PlayerSide.white
: PlayerSide.black, : 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}) { 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(overTheBoardGameControllerProvider.notifier).makeMove(move);
ref
.read(overTheBoardClockProvider.notifier)
.onMove(newSideToMove: gameState.turn.opposite);
}, },
premovable: null,
), ),
moves: gameState.moves, moves: gameState.moves,
currentMoveIndex: gameState.stepCursor, currentMoveIndex: gameState.stepCursor,
@@ -297,6 +282,7 @@ class _BodyState extends ConsumerState<_Body> {
pieceAssets: overTheBoardPrefs.symmetricPieces pieceAssets: overTheBoardPrefs.symmetricPieces
? PieceSet.symmetric.assets ? PieceSet.symmetric.assets
: null, : null,
enablePremoves: false,
), ),
userActionsBar: _BottomBar( userActionsBar: _BottomBar(
onFlipBoard: () { onFlipBoard: () {
@@ -1,11 +1,12 @@
import 'package:chessground/chessground.dart';
import 'package:dartchess/dartchess.dart'; import 'package:dartchess/dartchess.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lichess_mobile/src/constants.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/board_preferences.dart';
import 'package:lichess_mobile/src/styles/styles.dart'; import 'package:lichess_mobile/src/styles/styles.dart';
import 'package:lichess_mobile/src/utils/screen.dart'; import 'package:lichess_mobile/src/utils/screen.dart';
import 'package:lichess_mobile/src/widgets/board.dart';
class PuzzleErrorBoardWidget extends ConsumerWidget { class PuzzleErrorBoardWidget extends ConsumerWidget {
const PuzzleErrorBoardWidget({this.errorMessage}); const PuzzleErrorBoardWidget({this.errorMessage});
@@ -28,10 +29,47 @@ class PuzzleErrorBoardWidget extends ConsumerWidget {
: Orientation.portrait; : Orientation.portrait;
final isTablet = isTabletOrLarger(context); final isTablet = isTabletOrLarger(context);
final defaultSettings = boardPreferences.toBoardSettings().copyWith( final defaultSettings = boardPreferences
borderRadius: isTablet ? Styles.boardBorderRadius : BorderRadius.zero, .toBoardSettings(Variant.standard)
boxShadow: isTablet ? boardShadows : const <BoxShadow>[], .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) { if (orientation == Orientation.landscape) {
final defaultBoardSize = final defaultBoardSize =
@@ -43,19 +81,7 @@ class PuzzleErrorBoardWidget extends ConsumerWidget {
(kTabletBoardTableSidePadding * 2); (kTabletBoardTableSidePadding * 2);
return Padding( return Padding(
padding: const EdgeInsets.all(kTabletBoardTableSidePadding), padding: const EdgeInsets.all(kTabletBoardTableSidePadding),
child: Row( child: Row(mainAxisSize: MainAxisSize.max, children: [board(boardSize)]),
mainAxisSize: MainAxisSize.max,
children: [
BoardWidget(
size: boardSize,
fen: kEmptyBoardFEN,
orientation: Side.white,
gameData: null,
settings: defaultSettings,
error: errorMessage,
),
],
),
); );
} else { } else {
final defaultBoardSize = constraints.biggest.shortestSide; final defaultBoardSize = constraints.biggest.shortestSide;
@@ -71,14 +97,7 @@ class PuzzleErrorBoardWidget extends ConsumerWidget {
padding: isTablet padding: isTablet
? const EdgeInsets.symmetric(horizontal: kTabletBoardTableSidePadding) ? const EdgeInsets.symmetric(horizontal: kTabletBoardTableSidePadding)
: EdgeInsets.zero, : EdgeInsets.zero,
child: BoardWidget( child: board(boardSize),
size: boardSize,
fen: kEmptyBoardFEN,
orientation: Side.white,
gameData: null,
settings: defaultSettings,
error: errorMessage,
),
), ),
], ],
); );
+92 -77
View File
@@ -2,7 +2,6 @@ import 'dart:async';
import 'package:chessground/chessground.dart'; import 'package:chessground/chessground.dart';
import 'package:dartchess/dartchess.dart'; import 'package:dartchess/dartchess.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -380,7 +379,60 @@ class _Body extends ConsumerStatefulWidget {
} }
class _BodyState extends ConsumerState<_Body> { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -388,49 +440,33 @@ class _BodyState extends ConsumerState<_Body> {
final ctrlProvider = puzzleControllerProvider(widget.initialPuzzleContext); final ctrlProvider = puzzleControllerProvider(widget.initialPuzzleContext);
final puzzleState = ref.watch(ctrlProvider); 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) { ref.listen(ctrlProvider.select((state) => state.puzzle.puzzle.id), (previous, next) {
if (previous != null && previous != next && mounted) { if (previous != null && previous != next) {
setState(() { _controller.clearDrawnShapes();
userShapes = ISet();
});
} }
}); });
ref.listen(ctrlProvider.select((state) => state.currentPath), (previous, next) { ref.listen(ctrlProvider.select((state) => state.currentPath), (previous, next) {
if (previous != null && previous != next && mounted) { if (previous != null && previous != next) {
setState(() { _controller.clearDrawnShapes();
userShapes = ISet();
});
} }
}); });
final generatedShapes = puzzleState.hintSquare != null final shapes = puzzleState.hintSquare != null
? ISet([Circle(color: ShapeColor.green.color, orig: puzzleState.hintSquare!)]) ? <Shape>{Circle(color: ShapeColor.green.color, orig: puzzleState.hintSquare!)}
: null; : const <Shape>{};
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 content = PopScope( final content = PopScope(
canPop: canPop:
@@ -446,16 +482,16 @@ class _BodyState extends ConsumerState<_Body> {
: Orientation.portrait; : Orientation.portrait;
final isTablet = isTabletOrLarger(context); final isTablet = isTabletOrLarger(context);
final defaultSettings = boardPreferences.toBoardSettings().copyWith( final defaultSettings = boardPreferences
borderRadius: isTablet ? Styles.boardBorderRadius : BorderRadius.zero, .toBoardSettings(Variant.standard)
boxShadow: isTablet ? boardShadows : const <BoxShadow>[], .copyWith(
drawShape: DrawShapeOptions( borderRadius: isTablet ? Styles.boardBorderRadius : BorderRadius.zero,
enable: boardPreferences.enableShapeDrawings, boxShadow: isTablet ? boardShadows : const <BoxShadow>[],
onCompleteShape: _onCompleteShape, drawShape: DrawShapeOptions(
onClearShapes: _onClearShapes, enable: boardPreferences.enableShapeDrawings,
newShapeColor: boardPreferences.shapeColor.color, newShapeColor: boardPreferences.shapeColor.color,
), ),
); );
if (orientation == Orientation.landscape) { if (orientation == Orientation.landscape) {
final defaultBoardSize = final defaultBoardSize =
@@ -473,10 +509,11 @@ class _BodyState extends ConsumerState<_Body> {
BoardWidget( BoardWidget(
boardKey: widget.boardKey, boardKey: widget.boardKey,
size: boardSize, size: boardSize,
fen: puzzleState.currentPosition.fen, controller: _controller,
onMove: (move, {viaDragAndDrop}) {
ref.read(ctrlProvider.notifier).onUserMove(move);
},
orientation: puzzleState.pov, orientation: puzzleState.pov,
gameData: gameData,
lastMove: puzzleState.lastMove,
shapes: shapes, shapes: shapes,
settings: defaultSettings, settings: defaultSettings,
), ),
@@ -564,10 +601,11 @@ class _BodyState extends ConsumerState<_Body> {
child: BoardWidget( child: BoardWidget(
boardKey: widget.boardKey, boardKey: widget.boardKey,
size: boardSize, size: boardSize,
fen: puzzleState.currentPosition.fen, controller: _controller,
onMove: (move, {viaDragAndDrop}) {
ref.read(ctrlProvider.notifier).onUserMove(move);
},
orientation: puzzleState.pov, orientation: puzzleState.pov,
gameData: gameData,
lastMove: puzzleState.lastMove,
shapes: shapes, shapes: shapes,
settings: defaultSettings, settings: defaultSettings,
), ),
@@ -601,29 +639,6 @@ class _BodyState extends ConsumerState<_Body> {
) )
: content; : 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 { class _PuzzleStatus extends ConsumerWidget {
+76 -58
View File
@@ -1,7 +1,6 @@
import 'package:chessground/chessground.dart'; import 'package:chessground/chessground.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:dartchess/dartchess.dart'; import 'package:dartchess/dartchess.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -89,8 +88,57 @@ class _Body extends ConsumerStatefulWidget {
} }
class _BodyState extends ConsumerState<_Body> { class _BodyState extends ConsumerState<_Body> {
ISet<Shape> userShapes = ISet();
final _boardKey = GlobalKey(debugLabel: 'boardOnStormScreen'); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -112,21 +160,16 @@ class _BodyState extends ConsumerState<_Body> {
} }
}); });
final gameData = boardPreferences.toGameData( // Drive the board on position/interactivity changes without rebuilding it.
variant: Variant.standard, ref.listen(
position: stormState.position, ctrlProvider.select(
playerSide: (s) => (fen: s.position.fen, lastMoveUci: s.lastMove?.uci, side: _playerSide(s)),
!stormState.firstMovePlayed || ),
stormState.mode == StormMode.ended || (_, _) => _applyBoardUpdate(),
stormState.position.isGameOver );
? PlayerSide.none ref.listen(
: stormState.pov == Side.white boardPreferencesProvider.select((p) => (p.castlingMethod, p.boardHighlights)),
? PlayerSide.white (_, _) => _applyBoardUpdate(),
: PlayerSide.black,
promotionMove: stormState.promotionMove,
onMove: (move, {viaDragAndDrop}) => ref.read(ctrlProvider.notifier).onUserMove(move),
onPromotionSelection: (role) => ref.read(ctrlProvider.notifier).onPromotionSelection(role),
premovable: null,
); );
final content = PopScope( final content = PopScope(
@@ -167,16 +210,16 @@ class _BodyState extends ConsumerState<_Body> {
: Orientation.portrait; : Orientation.portrait;
final isTablet = isTabletOrLarger(context); final isTablet = isTabletOrLarger(context);
final defaultSettings = boardPreferences.toBoardSettings().copyWith( final defaultSettings = boardPreferences
borderRadius: isTablet ? Styles.boardBorderRadius : BorderRadius.zero, .toBoardSettings(Variant.standard)
boxShadow: isTablet ? boardShadows : const <BoxShadow>[], .copyWith(
drawShape: DrawShapeOptions( borderRadius: isTablet ? Styles.boardBorderRadius : BorderRadius.zero,
enable: boardPreferences.enableShapeDrawings, boxShadow: isTablet ? boardShadows : const <BoxShadow>[],
onCompleteShape: _onCompleteShape, drawShape: DrawShapeOptions(
onClearShapes: _onClearShapes, enable: boardPreferences.enableShapeDrawings,
newShapeColor: boardPreferences.shapeColor.color, newShapeColor: boardPreferences.shapeColor.color,
), ),
); );
if (orientation == Orientation.landscape) { if (orientation == Orientation.landscape) {
final defaultBoardSize = final defaultBoardSize =
@@ -194,11 +237,10 @@ class _BodyState extends ConsumerState<_Body> {
BoardWidget( BoardWidget(
boardKey: _boardKey, boardKey: _boardKey,
size: boardSize, size: boardSize,
fen: stormState.position.fen, controller: _controller,
onMove: (move, {viaDragAndDrop}) =>
ref.read(ctrlProvider.notifier).onUserMove(move),
orientation: stormState.pov, orientation: stormState.pov,
gameData: gameData,
lastMove: stormState.lastMove,
shapes: userShapes,
settings: defaultSettings, settings: defaultSettings,
), ),
const SizedBox(width: 16.0), const SizedBox(width: 16.0),
@@ -313,11 +355,10 @@ class _BodyState extends ConsumerState<_Body> {
child: BoardWidget( child: BoardWidget(
boardKey: _boardKey, boardKey: _boardKey,
size: boardSize, size: boardSize,
fen: stormState.position.fen, controller: _controller,
onMove: (move, {viaDragAndDrop}) =>
ref.read(ctrlProvider.notifier).onUserMove(move),
orientation: stormState.pov, orientation: stormState.pov,
gameData: gameData,
lastMove: stormState.lastMove,
shapes: userShapes,
settings: defaultSettings, settings: defaultSettings,
), ),
), ),
@@ -353,29 +394,6 @@ class _BodyState extends ConsumerState<_Body> {
) )
: content; : 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) { Future<void> _stormInfoDialogBuilder(BuildContext context) {
+81 -62
View File
@@ -1,6 +1,5 @@
import 'package:chessground/chessground.dart'; import 'package:chessground/chessground.dart';
import 'package:dartchess/dartchess.dart'; import 'package:dartchess/dartchess.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -91,8 +90,59 @@ class _Body extends ConsumerStatefulWidget {
} }
class _BodyState extends ConsumerState<_Body> { class _BodyState extends ConsumerState<_Body> {
ISet<Shape> userShapes = ISet();
final _boardKey = GlobalKey(debugLabel: 'boardOnPuzzleStreakScreen'); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -106,7 +156,7 @@ class _BodyState extends ConsumerState<_Body> {
if (previous?.hasValue == true && next.hasValue) { if (previous?.hasValue == true && next.hasValue) {
if (next.requireValue.streak.finished == false && if (next.requireValue.streak.finished == false &&
previous!.requireValue.streak.finished == true) { previous!.requireValue.streak.finished == true) {
_onClearShapes(); _controller.clearDrawnShapes();
final authUser = ref.read(authControllerProvider); final authUser = ref.read(authControllerProvider);
ref ref
.read(ctrlProvider.notifier) .read(ctrlProvider.notifier)
@@ -129,24 +179,16 @@ class _BodyState extends ConsumerState<_Body> {
} }
}); });
final gameData = boardPreferences.toGameData( // Drive the board on position/interactivity changes without rebuilding it.
variant: Variant.standard, ref.listen(
position: puzzleState.currentPosition, ctrlProvider.select(
playerSide: puzzleState.mode == PuzzleMode.load || puzzleState.currentPosition.isGameOver (s) => (fen: s.currentPosition.fen, lastMoveUci: s.lastMove?.uci, side: _playerSide(s)),
? PlayerSide.none ),
: puzzleState.mode == PuzzleMode.view (_, _) => _applyBoardUpdate(),
? PlayerSide.both );
: puzzleState.pov == Side.white ref.listen(
? PlayerSide.white boardPreferencesProvider.select((p) => (p.castlingMethod, p.boardHighlights)),
: PlayerSide.black, (_, _) => _applyBoardUpdate(),
promotionMove: puzzleState.promotionMove,
onMove: (move, {viaDragAndDrop}) {
ref.read(ctrlProvider.notifier).onUserMove(move);
},
onPromotionSelection: (role) {
ref.read(ctrlProvider.notifier).onPromotionSelection(role);
},
premovable: null,
); );
final content = PopScope( final content = PopScope(
@@ -185,16 +227,16 @@ class _BodyState extends ConsumerState<_Body> {
: Orientation.portrait; : Orientation.portrait;
final isTablet = isTabletOrLarger(context); final isTablet = isTabletOrLarger(context);
final defaultSettings = boardPreferences.toBoardSettings().copyWith( final defaultSettings = boardPreferences
borderRadius: isTablet ? Styles.boardBorderRadius : BorderRadius.zero, .toBoardSettings(Variant.standard)
boxShadow: isTablet ? boardShadows : const <BoxShadow>[], .copyWith(
drawShape: DrawShapeOptions( borderRadius: isTablet ? Styles.boardBorderRadius : BorderRadius.zero,
enable: boardPreferences.enableShapeDrawings, boxShadow: isTablet ? boardShadows : const <BoxShadow>[],
onCompleteShape: _onCompleteShape, drawShape: DrawShapeOptions(
onClearShapes: _onClearShapes, enable: boardPreferences.enableShapeDrawings,
newShapeColor: boardPreferences.shapeColor.color, newShapeColor: boardPreferences.shapeColor.color,
), ),
); );
if (orientation == Orientation.landscape) { if (orientation == Orientation.landscape) {
final defaultBoardSize = final defaultBoardSize =
@@ -212,11 +254,11 @@ class _BodyState extends ConsumerState<_Body> {
BoardWidget( BoardWidget(
boardKey: _boardKey, boardKey: _boardKey,
size: boardSize, size: boardSize,
fen: puzzleState.currentPosition.fen, controller: _controller,
onMove: (move, {viaDragAndDrop}) {
ref.read(ctrlProvider.notifier).onUserMove(move);
},
orientation: puzzleState.pov, orientation: puzzleState.pov,
gameData: gameData,
lastMove: puzzleState.lastMove,
shapes: userShapes,
settings: defaultSettings, settings: defaultSettings,
), ),
const SizedBox(width: 16.0), const SizedBox(width: 16.0),
@@ -345,11 +387,11 @@ class _BodyState extends ConsumerState<_Body> {
child: BoardWidget( child: BoardWidget(
boardKey: _boardKey, boardKey: _boardKey,
size: boardSize, size: boardSize,
fen: puzzleState.currentPosition.fen, controller: _controller,
onMove: (move, {viaDragAndDrop}) {
ref.read(ctrlProvider.notifier).onUserMove(move);
},
orientation: puzzleState.pov, orientation: puzzleState.pov,
gameData: gameData,
lastMove: puzzleState.lastMove,
shapes: userShapes,
settings: defaultSettings, settings: defaultSettings,
), ),
), ),
@@ -426,29 +468,6 @@ class _BodyState extends ConsumerState<_Body> {
) )
: content; : 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 { class _BottomBar extends ConsumerWidget {
@@ -8,6 +8,7 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.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/common/preloaded_data.dart';
import 'package:lichess_mobile/src/model/settings/board_preferences.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/model/settings/general_preferences.dart';
@@ -256,13 +257,15 @@ class _ConfirmColorBackgroundScreenState extends State<ConfirmColorBackgroundScr
padding: EdgeInsets.only( padding: EdgeInsets.only(
left: orientation == Orientation.portrait ? 0 : 16.0, left: orientation == Orientation.portrait ? 0 : 16.0,
), ),
child: Chessboard.fixed( child: StaticChessboard(
size: orientation == Orientation.portrait size: orientation == Orientation.portrait
? constraints.maxWidth ? constraints.maxWidth
: constraints.maxHeight - landscapeBoardPadding * 2, : constraints.maxHeight - landscapeBoardPadding * 2,
fen: kInitialFEN, fen: kInitialFEN,
orientation: Side.white, 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( padding: EdgeInsets.only(
left: widget.viewportOrientation == Orientation.portrait ? 0 : 16.0, left: widget.viewportOrientation == Orientation.portrait ? 0 : 16.0,
), ),
child: Chessboard.fixed( child: StaticChessboard(
size: widget.viewportOrientation == Orientation.portrait size: widget.viewportOrientation == Orientation.portrait
? widget.viewport.width ? widget.viewport.width
: widget.viewport.height - landscapeBoardPadding * 2, : widget.viewport.height - landscapeBoardPadding * 2,
fen: kInitialFEN, fen: kInitialFEN,
orientation: Side.white, 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:chessground/chessground.dart';
import 'package:dartchess/dartchess.dart'; import 'package:dartchess/dartchess.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:lichess_mobile/src/constants.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/board_preferences.dart';
import 'package:lichess_mobile/src/model/settings/general_preferences.dart'; import 'package:lichess_mobile/src/model/settings/general_preferences.dart';
import 'package:lichess_mobile/src/styles/lichess_icons.dart'; import 'package:lichess_mobile/src/styles/lichess_icons.dart';
@@ -300,24 +300,28 @@ class _BoardPreview extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Center( return Center(
child: Chessboard.fixed( child: StaticChessboard(
size: size, size: size,
orientation: Side.white, orientation: Side.white,
lastMove: const NormalMove(from: Square.e2, to: Square.e4), lastMove: const NormalMove(from: Square.e2, to: Square.e4),
fen: 'rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq - 0 1', 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')), Circle(color: boardPrefs.shapeColor.color, orig: Square.fromName('b8')),
Arrow( Arrow(
color: boardPrefs.shapeColor.color, color: boardPrefs.shapeColor.color,
orig: Square.fromName('b8'), orig: Square.fromName('b8'),
dest: Square.fromName('c6'), dest: Square.fromName('c6'),
), ),
}.lock, },
settings: boardPrefs.toBoardSettings().copyWith( settings: StaticChessboardSettings.fromBoardSettings(
brightness: brightness, boardPrefs
hue: hue, .toBoardSettings(Variant.standard)
borderRadius: Styles.boardBorderRadius, .copyWith(
boxShadow: boardShadows, brightness: brightness,
hue: hue,
borderRadius: Styles.boardBorderRadius,
boxShadow: boardShadows,
),
), ),
), ),
); );
+29 -20
View File
@@ -82,11 +82,15 @@ class _StudyScreenLoader extends ConsumerWidget {
child: AnalysisLayout( child: AnalysisLayout(
pov: Side.white, pov: Side.white,
sideToMove: null, sideToMove: null,
boardBuilder: (context, boardSize, borderRadius) => Chessboard.fixed( boardBuilder: (context, boardSize, borderRadius) => StaticChessboard(
size: boardSize, size: boardSize,
settings: boardPrefs.toBoardSettings().copyWith( settings: StaticChessboardSettings.fromBoardSettings(
borderRadius: borderRadius, boardPrefs
boxShadow: borderRadius != null ? boardShadows : const <BoxShadow>[], .toBoardSettings(Variant.standard)
.copyWith(
borderRadius: borderRadius,
boxShadow: borderRadius != null ? boardShadows : const <BoxShadow>[],
),
), ),
orientation: Side.white, orientation: Side.white,
fen: kEmptyFEN, fen: kEmptyFEN,
@@ -120,11 +124,15 @@ class _StudyScreenLoader extends ConsumerWidget {
child: AnalysisLayout( child: AnalysisLayout(
pov: Side.white, pov: Side.white,
sideToMove: null, sideToMove: null,
boardBuilder: (context, boardSize, borderRadius) => Chessboard.fixed( boardBuilder: (context, boardSize, borderRadius) => StaticChessboard(
size: boardSize, size: boardSize,
settings: boardPrefs.toBoardSettings().copyWith( settings: StaticChessboardSettings.fromBoardSettings(
borderRadius: borderRadius, boardPrefs
boxShadow: borderRadius != null ? boardShadows : const <BoxShadow>[], .toBoardSettings(Variant.standard)
.copyWith(
borderRadius: borderRadius,
boxShadow: borderRadius != null ? boardShadows : const <BoxShadow>[],
),
), ),
orientation: Side.white, orientation: Side.white,
fen: kEmptyFEN, fen: kEmptyFEN,
@@ -569,6 +577,16 @@ class StudyAnalysisBoard extends AnalysisBoard {
class _StudyAnalysisBoardState class _StudyAnalysisBoardState
extends AnalysisBoardState<StudyAnalysisBoard, StudyState, StudyPrefs> { extends AnalysisBoardState<StudyAnalysisBoard, StudyState, StudyPrefs> {
@override @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; StudyState get analysisState => ref.watch(studyControllerProvider(widget.options)).requireValue;
@override @override
@@ -587,15 +605,8 @@ class _StudyAnalysisBoardState
(id: analysisState.evaluationContext.id, path: analysisState.currentPath); (id: analysisState.evaluationContext.id, path: analysisState.currentPath);
@override @override
void onPromotionSelection(Role? role) { String computeFen(StudyState state) =>
ref.read(studyControllerProvider(widget.options).notifier).onPromotionSelection(role); state.currentPosition?.board.fen ?? state.study.currentChapterMeta.fen ?? kInitialFEN;
}
@override
String get fen =>
analysisState.currentPosition?.board.fen ??
analysisState.study.currentChapterMeta.fen ??
kInitialFEN;
@override @override
ISet<Shape> get extraShapes { ISet<Shape> get extraShapes {
@@ -635,9 +646,7 @@ class _StudyAnalysisBoardState
next, next,
) { ) {
if (prev != next) { if (prev != next) {
setState(() { clearDrawnShapes();
userShapes = ISet();
});
} }
}); });
+106 -15
View File
@@ -1,47 +1,61 @@
import 'dart:async';
import 'package:chessground/chessground.dart'; import 'package:chessground/chessground.dart';
import 'package:dartchess/dartchess.dart'; import 'package:dartchess/dartchess.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/material.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 { class BoardWidget extends StatelessWidget {
const BoardWidget({ const BoardWidget({
required this.size, required this.size,
required this.fen,
required this.orientation, required this.orientation,
required this.gameData,
this.lastMove,
this.shapes,
required this.settings, required this.settings,
required this.controller,
this.onMove,
this.shapes = const {},
this.annotations = const {},
this.boardOverlay, this.boardOverlay,
this.error, this.error,
this.boardKey, this.boardKey,
this.explosionSquares,
}); });
final double size; final double size;
final String fen;
final Side orientation; final Side orientation;
final GameData? gameData;
final Move? lastMove;
final ISet<Shape>? shapes;
final ChessboardSettings settings; 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 String? error;
final Widget? boardOverlay; final Widget? boardOverlay;
final GlobalKey? boardKey; final GlobalKey? boardKey;
final ISet<Square>? explosionSquares;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final board = Chessboard( final board = Chessboard(
key: boardKey, key: boardKey,
controller: controller,
size: size, size: size,
fen: fen,
orientation: orientation, orientation: orientation,
game: gameData, onMove: onMove,
lastMove: lastMove,
shapes: shapes, shapes: shapes,
annotations: annotations,
settings: settings, settings: settings,
explosionSquares: explosionSquares,
); );
final overlay = boardOverlay ?? (error != null ? _ErrorWidget(errorMessage: error!) : null); 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, fen: fen,
orientation: orientation, orientation: orientation,
lastMove: lastMove, lastMove: lastMove,
pieceAssets: boardPrefs.pieceSet.assets, settings: StaticChessboardSettings(
colorScheme: boardPrefs.boardTheme.colors, pieceAssets: boardPrefs.pieceSet.assets,
brightness: boardPrefs.brightness, colorScheme: boardPrefs.boardTheme.colors,
hue: boardPrefs.hue, brightness: boardPrefs.brightness,
enableCoordinates: false, hue: boardPrefs.hue,
borderRadius: Styles.boardBorderRadius, enableCoordinates: false,
boxShadow: boardShadows, borderRadius: Styles.boardBorderRadius,
animationDuration: const Duration(milliseconds: 150), boxShadow: boardShadows,
animationDuration: const Duration(milliseconds: 150),
),
), ),
const SizedBox(width: 10.0), const SizedBox(width: 10.0),
if (_showLoadingPlaceholder) if (_showLoadingPlaceholder)
+12 -10
View File
@@ -89,16 +89,18 @@ class _BoardThumbnailState extends ConsumerState<BoardThumbnail> {
fen: widget.fen, fen: widget.fen,
orientation: widget.orientation, orientation: widget.orientation,
lastMove: widget.lastMove, lastMove: widget.lastMove,
enableCoordinates: false, settings: StaticChessboardSettings(
borderRadius: (widget.showEvaluationGauge) enableCoordinates: false,
? Styles.boardBorderRadius.copyWith(topRight: Radius.zero, bottomRight: Radius.zero) borderRadius: (widget.showEvaluationGauge)
: Styles.boardBorderRadius, ? Styles.boardBorderRadius.copyWith(topRight: Radius.zero, bottomRight: Radius.zero)
boxShadow: (widget.showEvaluationGauge) ? [] : boardShadows, : Styles.boardBorderRadius,
pieceAssets: boardPrefs.pieceSet.assets, boxShadow: (widget.showEvaluationGauge) ? [] : boardShadows,
colorScheme: boardPrefs.boardTheme.colors, pieceAssets: boardPrefs.pieceSet.assets,
animationDuration: widget.animationDuration, colorScheme: boardPrefs.boardTheme.colors,
hue: boardPrefs.hue, animationDuration: widget.animationDuration,
brightness: boardPrefs.brightness, hue: boardPrefs.hue,
brightness: boardPrefs.brightness,
),
); );
final boardWithMaybeEvalBar = widget.showEvaluationGauge 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. /// An optional overlay or error message can be displayed on top of the board.
class GameLayout extends ConsumerStatefulWidget { class GameLayout extends ConsumerStatefulWidget {
/// Creates a game layout with the given values. /// 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({ const GameLayout({
required this.orientation, required this.orientation,
required this.boardParams, this.boardParams,
this.controllerParams,
this.lastMove, this.lastMove,
this.boardSettingsOverrides, this.boardSettingsOverrides,
this.topTable = const SizedBox.shrink(), this.topTable = const SizedBox.shrink(),
@@ -58,19 +63,26 @@ class GameLayout extends ConsumerStatefulWidget {
this.moves, this.moves,
this.currentMoveIndex = 0, this.currentMoveIndex = 0,
this.onSelectMove, this.onSelectMove,
this.moveListBuilder,
this.boardOverlay, this.boardOverlay,
this.errorMessage, this.errorMessage,
this.boardKey, this.boardKey,
this.zenMode = false, this.zenMode = false,
this.userActionsBar, this.userActionsBar,
this.explosionSquares, this.explosionSquares,
this.isReplaying = false,
this.onPremove,
super.key, super.key,
}); }) : assert(
(boardParams == null) != (controllerParams == null),
'Provide exactly one of boardParams or controllerParams',
);
/// Creates an empty game layout (useful for loading). /// Creates an empty game layout (useful for loading).
const GameLayout.empty({this.moves, this.errorMessage}) const GameLayout.empty({this.moves, this.errorMessage})
: orientation = Side.white, : orientation = Side.white,
boardParams = GameBoardParams.emptyBoard, boardParams = GameBoardParams.emptyBoard,
controllerParams = null,
lastMove = null, lastMove = null,
boardSettingsOverrides = null, boardSettingsOverrides = null,
topTable = const SizedBox.shrink(), topTable = const SizedBox.shrink(),
@@ -82,16 +94,32 @@ class GameLayout extends ConsumerStatefulWidget {
shapes = null, shapes = null,
currentMoveIndex = 0, currentMoveIndex = 0,
onSelectMove = null, onSelectMove = null,
moveListBuilder = null,
boardOverlay = null, boardOverlay = null,
boardKey = null, boardKey = null,
zenMode = false, zenMode = false,
userActionsBar = null, 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; final Side orientation;
/// Last move highlight for readonly boards. For interactive boards, use [InteractiveBoardParams.lastMove].
final Move? lastMove; final Move? lastMove;
final BoardSettingsOverrides? boardSettingsOverrides; 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. /// Callback that will be called when a move is selected from the [moves] list.
final void Function(int moveIndex)? onSelectMove; 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. /// Optional error message that will be displayed on top of the board.
final String? errorMessage; final String? errorMessage;
@@ -144,21 +180,178 @@ class GameLayout extends ConsumerStatefulWidget {
final Widget? userActionsBar; final Widget? userActionsBar;
/// Squares on which an atomic chess explosion should be shown. /// 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. /// When true, position changes use [ChessboardController.jumpToPosition] instead of
final ISet<Square>? explosionSquares; /// [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 @override
ConsumerState<GameLayout> createState() => _GameLayoutState(); ConsumerState<GameLayout> createState() => _GameLayoutState();
} }
class _GameLayoutState extends ConsumerState<GameLayout> { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final boardPrefs = ref.watch(boardPreferencesProvider); 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( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final orientation = constraints.maxWidth > constraints.maxHeight final orientation = constraints.maxWidth > constraints.maxHeight
@@ -166,46 +359,20 @@ class _GameLayoutState extends ConsumerState<GameLayout> {
: Orientation.portrait; : Orientation.portrait;
final isTablet = isTabletOrLarger(context); final isTablet = isTabletOrLarger(context);
final defaultSettings = boardPrefs.toBoardSettings().copyWith( final defaultSettings = boardPrefs
borderRadius: isTablet ? Styles.boardBorderRadius : BorderRadius.zero, .toBoardSettings(variant)
boxShadow: isTablet ? boardShadows : const <BoxShadow>[], .copyWith(
drawShape: DrawShapeOptions( borderRadius: isTablet ? Styles.boardBorderRadius : BorderRadius.zero,
enable: boardPrefs.enableShapeDrawings, boxShadow: isTablet ? boardShadows : const <BoxShadow>[],
onCompleteShape: _onCompleteShape, );
onClearShapes: _onClearShapes,
newShapeColor: boardPrefs.shapeColor.color,
),
);
final settings = widget.boardSettingsOverrides != null final settings = widget.boardSettingsOverrides != null
? widget.boardSettingsOverrides!.merge(defaultSettings) ? widget.boardSettingsOverrides!.merge(defaultSettings)
: defaultSettings; : defaultSettings;
final shapes = userShapes.union(widget.shapes ?? ISet()); final shapes = widget.shapes?.unlock ?? const <Shape>{};
final slicedMoves = widget.moves?.asMap().entries.slices(2);
final fen = widget.boardParams.fen; final premoveDropRole = switch (_controller?.premove) {
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) {
DropMove(:final role) => role, DropMove(:final role) => role,
_ => null, _ => null,
}; };
@@ -222,7 +389,7 @@ class _GameLayoutState extends ConsumerState<GameLayout> {
PocketsMenu( PocketsMenu(
side: widget.orientation.opposite, side: widget.orientation.opposite,
sideToMove: sideToMove, sideToMove: sideToMove,
playerSide: widget.boardParams.playerSide, playerSide: playerSide,
pockets: pockets, pockets: pockets,
squareSize: pocketSquareSize(boardSize: boardSize, isTablet: isTablet), squareSize: pocketSquareSize(boardSize: boardSize, isTablet: isTablet),
isUpsideDown: widget.topTableUpsideDown, isUpsideDown: widget.topTableUpsideDown,
@@ -246,7 +413,7 @@ class _GameLayoutState extends ConsumerState<GameLayout> {
PocketsMenu( PocketsMenu(
side: widget.orientation, side: widget.orientation,
sideToMove: sideToMove, sideToMove: sideToMove,
playerSide: widget.boardParams.playerSide, playerSide: playerSide,
pockets: pockets, pockets: pockets,
squareSize: pocketSquareSize(boardSize: boardSize, isTablet: isTablet), squareSize: pocketSquareSize(boardSize: boardSize, isTablet: isTablet),
isUpsideDown: widget.bottomTableUpsideDown, isUpsideDown: widget.bottomTableUpsideDown,
@@ -275,16 +442,14 @@ class _GameLayoutState extends ConsumerState<GameLayout> {
children: [ children: [
BoardWidget( BoardWidget(
size: boardSize, size: boardSize,
fen: fen,
orientation: widget.orientation, orientation: widget.orientation,
gameData: gameData, controller: _controller!,
lastMove: widget.lastMove, onMove: rawOnMove,
shapes: shapes, shapes: shapes,
settings: settings, settings: settings,
boardKey: widget.boardKey, boardKey: widget.boardKey,
boardOverlay: widget.boardOverlay, boardOverlay: widget.boardOverlay,
error: widget.errorMessage, error: widget.errorMessage,
explosionSquares: widget.explosionSquares,
), ),
const SizedBox(width: 16.0), const SizedBox(width: 16.0),
Expanded( Expanded(
@@ -293,16 +458,11 @@ class _GameLayoutState extends ConsumerState<GameLayout> {
mainAxisAlignment: MainAxisAlignment.spaceAround, mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [ children: [
topTable(boardSize: boardSize), topTable(boardSize: boardSize),
if (boardPrefs.moveListDisplay && !widget.zenMode && slicedMoves != null) if (boardPrefs.moveListDisplay && !widget.zenMode && _hasMoveList)
Expanded( Expanded(
child: Padding( child: Padding(
padding: const EdgeInsets.only(top: 16.0), padding: const EdgeInsets.only(top: 16.0),
child: MoveList( child: _moveListContent(MoveListType.stacked),
type: MoveListType.stacked,
slicedMoves: slicedMoves,
currentMoveIndex: widget.currentMoveIndex,
onSelectMove: widget.onSelectMove,
),
), ),
) )
else else
@@ -343,19 +503,14 @@ class _GameLayoutState extends ConsumerState<GameLayout> {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
if (boardPrefs.moveListDisplay && if (boardPrefs.moveListDisplay &&
slicedMoves != null && _hasMoveList &&
!isShortScreen && !isShortScreen &&
!(isTablet && pockets != null)) !(isTablet && pockets != null))
if (widget.zenMode) if (widget.zenMode)
// display empty move list to keep the layout consistent in zen mode // display empty move list to keep the layout consistent in zen mode
const MoveList(type: MoveListType.inline, slicedMoves: [], currentMoveIndex: 0) const MoveList(type: MoveListType.inline, slicedMoves: [], currentMoveIndex: 0)
else else
MoveList( _moveListContent(MoveListType.inline),
type: MoveListType.inline,
slicedMoves: slicedMoves,
currentMoveIndex: widget.currentMoveIndex,
onSelectMove: widget.onSelectMove,
),
Expanded( Expanded(
flex: widget.topTableFlex, flex: widget.topTableFlex,
child: Padding( child: Padding(
@@ -371,16 +526,14 @@ class _GameLayoutState extends ConsumerState<GameLayout> {
: EdgeInsets.zero, : EdgeInsets.zero,
child: BoardWidget( child: BoardWidget(
size: effectiveBoardSize, size: effectiveBoardSize,
fen: fen,
orientation: widget.orientation, orientation: widget.orientation,
gameData: gameData, controller: _controller!,
lastMove: widget.lastMove, onMove: rawOnMove,
shapes: shapes, shapes: shapes,
settings: settings, settings: settings,
boardKey: widget.boardKey, boardKey: widget.boardKey,
boardOverlay: widget.boardOverlay, boardOverlay: widget.boardOverlay,
error: widget.errorMessage, error: widget.errorMessage,
explosionSquares: widget.explosionSquares,
), ),
), ),
Expanded( 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 { class BoardSettingsOverrides {
@@ -433,6 +563,7 @@ class BoardSettingsOverrides {
this.drawShape, this.drawShape,
this.pieceOrientationBehavior, this.pieceOrientationBehavior,
this.pieceAssets, this.pieceAssets,
this.enablePremoves,
}); });
final Duration? animationDuration; final Duration? animationDuration;
@@ -442,6 +573,7 @@ class BoardSettingsOverrides {
final DrawShapeOptions? drawShape; final DrawShapeOptions? drawShape;
final PieceOrientationBehavior? pieceOrientationBehavior; final PieceOrientationBehavior? pieceOrientationBehavior;
final PieceAssets? pieceAssets; final PieceAssets? pieceAssets;
final bool? enablePremoves;
ChessboardSettings merge(ChessboardSettings settings) { ChessboardSettings merge(ChessboardSettings settings) {
return settings.copyWith( return settings.copyWith(
@@ -452,6 +584,7 @@ class BoardSettingsOverrides {
drawShape: drawShape, drawShape: drawShape,
pieceOrientationBehavior: pieceOrientationBehavior, pieceOrientationBehavior: pieceOrientationBehavior,
pieceAssets: pieceAssets, pieceAssets: pieceAssets,
enablePremoves: enablePremoves,
); );
} }
} }
+1
View File
@@ -125,6 +125,7 @@ class _Pocket extends StatelessWidget {
ignoring: !interactive || count == 0, ignoring: !interactive || count == 0,
child: Draggable( child: Draggable(
key: ValueKey('pocket-${side.name}${role.name}'), key: ValueKey('pocket-${side.name}${role.name}'),
dragAnchorStrategy: pointerDragAnchorStrategy,
data: Piece(role: role, color: side), data: Piece(role: role, color: side),
feedback: RotatedBox( feedback: RotatedBox(
quarterTurns: isUpsideDown ? 2 : 0, quarterTurns: isUpsideDown ? 2 : 0,
+8 -7
View File
@@ -180,11 +180,12 @@ packages:
chessground: chessground:
dependency: "direct main" dependency: "direct main"
description: description:
name: chessground path: "."
sha256: e2545b2475259c5665e7c6162affa6dbcb7dd3eb3050f603162833fbb7a8a260 ref: f00bb0921db2dd3140a040d73d9f2df2d6c37a93
url: "https://pub.dev" resolved-ref: f00bb0921db2dd3140a040d73d9f2df2d6c37a93
source: hosted url: "https://github.com/lichess-org/flutter-chessground.git"
version: "9.0.0" source: git
version: "10.0.0"
cli_config: cli_config:
dependency: transitive dependency: transitive
description: description:
@@ -309,10 +310,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: dartchess name: dartchess
sha256: "489095ecd3d03466ffd9e3d9680ff115919432d7a069a93932222edc8756d6e8" sha256: "0f2413624840484c1c316dbbbea7d777c83f7dccb8b8b00e5ca55c2058d2fa23"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.3" version: "0.13.0"
dbus: dbus:
dependency: transitive dependency: transitive
description: description:
+6 -3
View File
@@ -2,7 +2,7 @@ name: lichess_mobile
description: Lichess mobile app V2 description: Lichess mobile app V2
publish_to: "none" 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: environment:
sdk: ^3.11.5 sdk: ^3.11.5
@@ -14,7 +14,10 @@ dependencies:
app_settings: ^7.0.0 app_settings: ^7.0.0
async: ^2.13.1 async: ^2.13.1
auto_size_text: ^3.0.0 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 clock: ^1.1.2
collection: ^1.19.1 collection: ^1.19.1
connectivity_plus: ^7.1.1 connectivity_plus: ^7.1.1
@@ -22,7 +25,7 @@ dependencies:
crypto: ^3.0.7 crypto: ^3.0.7
cupertino_http: ^2.4.0 cupertino_http: ^2.4.0
cupertino_icons: ^1.0.9 cupertino_icons: ^1.0.9
dartchess: ^0.12.3 dartchess: ^0.13.0
deep_pick: ^1.1.0 deep_pick: ^1.1.0
device_info_plus: ^13.1.0 device_info_plus: ^13.1.0
dynamic_system_colors: 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]. /// Returns the offset of a square on a board defined by [Rect].
Offset squareOffset(Square square, Rect boardRect, {Side orientation = Side.white}) { Offset squareOffset(Square square, Rect boardRect, {Side orientation = Side.white}) {
final squareSize = boardRect.width / 8; final squareSize = boardRect.width / 8;
@@ -121,11 +179,9 @@ Future<void> playDropMove(
Side orientation = Side.white, Side orientation = Side.white,
}) async { }) async {
final rect = boardRect ?? tester.getRect(find.byType(Chessboard)); 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}'))); final fromOffset = tester.getCenter(find.byKey(ValueKey('pocket-${side.name}${role.name}')));
await tester.dragFrom( await tester.dragFrom(fromOffset, targetOffset - fromOffset);
fromOffset,
squareOffset(Square.fromName(to), rect, orientation: orientation) - fromOffset,
);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
} }
+6 -6
View File
@@ -28,7 +28,7 @@ void main() {
pov: Side.white, pov: Side.white,
sideToMove: Side.white, sideToMove: Side.white,
boardBuilder: (context, boardSize, boardRadius) { boardBuilder: (context, boardSize, boardRadius) {
return Chessboard.fixed( return StaticChessboard(
size: boardSize, size: boardSize,
fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR', fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR',
orientation: Side.white, orientation: Side.white,
@@ -51,7 +51,7 @@ void main() {
reason: 'Board background size is square on $surface', 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'); expect(boardSize.width, boardSize.height, reason: 'Board size is square on $surface');
@@ -77,7 +77,7 @@ void main() {
pov: Side.white, pov: Side.white,
sideToMove: Side.white, sideToMove: Side.white,
boardBuilder: (context, boardSize, boardRadius) { boardBuilder: (context, boardSize, boardRadius) {
return Chessboard.fixed( return StaticChessboard(
size: boardSize, size: boardSize,
fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR', fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR',
orientation: Side.white, orientation: Side.white,
@@ -94,7 +94,7 @@ void main() {
final isPortrait = surface.aspectRatio < 1.0; final isPortrait = surface.aspectRatio < 1.0;
final isTablet = surface.shortestSide > 600; final isTablet = surface.shortestSide > 600;
final boardSize = tester.getSize(find.byType(Chessboard)); final boardSize = tester.getSize(find.byType(StaticChessboard));
if (isPortrait) { if (isPortrait) {
final expectedBoardSize = isTablet ? surface.width - 32.0 : surface.width; final expectedBoardSize = isTablet ? surface.width - 32.0 : surface.width;
@@ -147,7 +147,7 @@ void main() {
pov: Side.white, pov: Side.white,
sideToMove: Side.white, sideToMove: Side.white,
boardBuilder: (context, boardSize, boardRadius) { boardBuilder: (context, boardSize, boardRadius) {
return Chessboard.fixed( return StaticChessboard(
size: boardSize, size: boardSize,
fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR', fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR',
orientation: Side.white, orientation: Side.white,
@@ -167,7 +167,7 @@ void main() {
); );
await tester.pumpWidget(app); 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)); final tabBarTopLeft = tester.getTopLeft(find.byType(TabBarView));
expect( expect(
+57 -53
View File
@@ -62,7 +62,7 @@ void main() {
await tester.pumpWidget(app); await tester.pumpWidget(app);
expect(find.byType(Chessboard), findsOneWidget); expect(find.byType(Chessboard), findsOneWidget);
expect(find.byType(PieceWidget), findsNWidgets(25)); expect(getBoardPieces(tester).length, 25);
final currentMove = find.textContaining('Qe1#'); final currentMove = find.textContaining('Qe1#');
expect(currentMove, findsOneWidget); expect(currentMove, findsOneWidget);
expect( expect(
@@ -149,7 +149,7 @@ void main() {
expect(find.byType(PocketsMenu), findsNothing); expect(find.byType(PocketsMenu), findsNothing);
// Horde starting position should be loaded: // 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: // Change to crazhouse, pockets should be displayed:
await tester.tap(find.bySemanticsLabel('Menu')); await tester.tap(find.bySemanticsLabel('Menu'));
@@ -212,14 +212,14 @@ void main() {
expect(find.byType(PocketsMenu), findsNWidgets(2)); expect(find.byType(PocketsMenu), findsNWidgets(2));
await playDropMove(tester, Side.white, Role.pawn, 'a4'); 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 // Illegal drop move for black, should not be played
await playDropMove(tester, Side.black, Role.queen, 'h5'); 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'); 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 { testWidgets('Continue against computer', (tester) async {
@@ -994,7 +994,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// White pawn should appear on c4 after the drop move // 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); 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(); await tester.pump();
final validMoves = getBoardValidMoves(tester);
switch (castlingMethod) { switch (castlingMethod) {
case CastlingMethod.kingOverRook: case CastlingMethod.kingOverRook:
// kingOverRook acts as either kingTwoSquares or kingOverRook // kingOverRook acts as either kingTwoSquares or kingOverRook
expect(find.byKey(const Key('f1-dest')), findsOneWidget); expect(validMoves.contains(Square.f1), isTrue);
expect(find.byKey(const Key('g1-dest')), findsOneWidget); expect(validMoves.contains(Square.g1), isTrue);
expect(find.byKey(const Key('h1-dest')), findsOneWidget); expect(validMoves.contains(Square.h1), isTrue);
expect(find.byKey(const Key('c1-dest')), findsOneWidget); expect(validMoves.contains(Square.c1), isTrue);
expect(find.byKey(const Key('d1-dest')), findsOneWidget); expect(validMoves.contains(Square.d1), isTrue);
expect(find.byKey(const Key('a1-dest')), findsOneWidget); expect(validMoves.contains(Square.a1), isTrue);
case CastlingMethod.kingTwoSquares: case CastlingMethod.kingTwoSquares:
expect(find.byKey(const Key('f1-dest')), findsOneWidget); expect(validMoves.contains(Square.f1), isTrue);
expect(find.byKey(const Key('g1-dest')), findsOneWidget); expect(validMoves.contains(Square.g1), isTrue);
expect(find.byKey(const Key('h1-dest')), findsNothing); expect(validMoves.contains(Square.h1), isFalse);
expect(find.byKey(const Key('c1-dest')), findsOneWidget); expect(validMoves.contains(Square.c1), isTrue);
expect(find.byKey(const Key('d1-dest')), findsOneWidget); expect(validMoves.contains(Square.d1), isTrue);
expect(find.byKey(const Key('a1-dest')), findsNothing); expect(validMoves.contains(Square.a1), isFalse);
} }
}); });
} }
@@ -1244,17 +1246,19 @@ void main() {
await tester.pumpWidget(app); 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(); await tester.pump();
// in chess960, castling is only king over rook, no matter the preference // in chess960, castling is only king over rook, no matter the preference
expect(find.byKey(const Key('f1-dest')), findsOneWidget); final validMoves = getBoardValidMoves(tester);
expect(find.byKey(const Key('g1-dest')), findsNothing); expect(validMoves.contains(Square.f1), isTrue);
expect(find.byKey(const Key('h1-dest')), findsOneWidget); expect(validMoves.contains(Square.g1), isFalse);
expect(find.byKey(const Key('c1-dest')), findsNothing); expect(validMoves.contains(Square.h1), isTrue);
expect(find.byKey(const Key('d1-dest')), findsOneWidget); expect(validMoves.contains(Square.c1), isFalse);
expect(find.byKey(const Key('a1-dest')), findsOneWidget); 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.tap(find.byKey(const Key('goto-previous')));
await tester.pump(); await tester.pump();
expect(find.byKey(const ValueKey('d4-whiteknight')), findsOneWidget); expect(boardHasPiece(tester, Square.d4, Piece.whiteKnight), isTrue);
expect(find.byKey(const ValueKey('a3-blackking')), findsOneWidget); expect(boardHasPiece(tester, Square.a3, Piece.blackKing), isTrue);
expect(find.byKey(const ValueKey('g3-whiteking')), findsNothing); expect(boardHasPiece(tester, Square.g3, Piece.whiteKing), isFalse);
// Navigate back to More tab // Navigate back to More tab
await tester.pageBack(); await tester.pageBack();
@@ -1321,9 +1325,9 @@ void main() {
expect(find.textContaining('Kg3'), findsOneWidget); expect(find.textContaining('Kg3'), findsOneWidget);
// Verify board state is correct // Verify board state is correct
expect(find.byKey(const ValueKey('d4-whiteknight')), findsOneWidget); expect(boardHasPiece(tester, Square.d4, Piece.whiteKnight), isTrue);
expect(find.byKey(const ValueKey('a3-blackking')), findsOneWidget); expect(boardHasPiece(tester, Square.a3, Piece.blackKing), isTrue);
expect(find.byKey(const ValueKey('g3-whiteking')), findsNothing); expect(boardHasPiece(tester, Square.g3, Piece.whiteKing), isFalse);
}); });
testWidgets('Clear moves clears standalone analysis', (tester) async { testWidgets('Clear moves clears standalone analysis', (tester) async {
// Open from More tab and navigate to board analysis // Open from More tab and navigate to board analysis
@@ -1350,7 +1354,7 @@ void main() {
// Verify we made the moves // Verify we made the moves
expect(find.textContaining('f4'), findsOneWidget); expect(find.textContaining('f4'), findsOneWidget);
expect(find.byKey(const ValueKey('f4-whitepawn')), findsOneWidget); expect(boardHasPiece(tester, Square.f4, Piece.whitePawn), isTrue);
//open menu //open menu
await tester.tap(find.byIcon(Icons.menu)); await tester.tap(find.byIcon(Icons.menu));
@@ -1363,7 +1367,7 @@ void main() {
//verify moves are cleared //verify moves are cleared
expect(find.textContaining('e4'), findsNothing); 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 // Navigate back to More tab
await tester.pageBack(); await tester.pageBack();
@@ -1378,7 +1382,7 @@ void main() {
// Verify moves are no longer present and analysis was cleared // Verify moves are no longer present and analysis was cleared
expect(find.textContaining('e4'), findsNothing); 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', ( testWidgets('Opening a position from board editor overwrites saved standalone analysis', (
@@ -1408,7 +1412,7 @@ void main() {
// Verify we made the moves // Verify we made the moves
expect(find.textContaining('f4'), findsOneWidget); 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 // Navigate back to More tab
await tester.pageBack(); await tester.pageBack();
@@ -1431,9 +1435,9 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Verify board state is correct and previous analysis was overwritten // Verify board state is correct and previous analysis was overwritten
expect(find.byKey(const ValueKey('d4-whitepawn')), findsOneWidget); expect(boardHasPiece(tester, Square.d4, Piece.whitePawn), isTrue);
expect(find.byKey(const ValueKey('d5-blackpawn')), findsOneWidget); expect(boardHasPiece(tester, Square.d5, Piece.blackPawn), isTrue);
expect(find.byKey(const ValueKey('f4-whitepawn')), findsNothing); expect(boardHasPiece(tester, Square.f4, Piece.whitePawn), isFalse);
}); });
group('conditional premoves', () { group('conditional premoves', () {
@@ -1794,8 +1798,8 @@ void main() {
await switchToPremoveTab(tester); await switchToPremoveTab(tester);
// Should be in starting position // Should be in starting position
expect(find.byKey(const ValueKey('e2-whitepawn')), findsOneWidget); expect(boardHasPiece(tester, Square.e2, Piece.whitePawn), isTrue);
expect(find.byKey(const ValueKey('e7-blackpawn')), findsOneWidget); expect(boardHasPiece(tester, Square.e7, Piece.blackPawn), isTrue);
await tester.tap(find.text('1. e4 e5')); await tester.tap(find.text('1. e4 e5'));
@@ -1803,10 +1807,10 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Should switch to the final position of the premove line (1. e4 e5) // Should switch to the final position of the premove line (1. e4 e5)
expect(find.byKey(const ValueKey('e2-whitepawn')), findsNothing); expect(boardHasPiece(tester, Square.e2, Piece.whitePawn), isFalse);
expect(find.byKey(const ValueKey('e4-whitepawn')), findsOneWidget); expect(boardHasPiece(tester, Square.e4, Piece.whitePawn), isTrue);
expect(find.byKey(const ValueKey('e7-blackpawn')), findsNothing); expect(boardHasPiece(tester, Square.e7, Piece.blackPawn), isFalse);
expect(find.byKey(const ValueKey('e5-blackpawn')), findsOneWidget); expect(boardHasPiece(tester, Square.e5, Piece.blackPawn), isTrue);
}); });
testWidgets('New move by opponent updates premove lines', (tester) async { 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 // Wait for socket message to arrive and board to update
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('e2-whitepawn')), findsNothing); expect(boardHasPiece(tester, Square.e2, Piece.whitePawn), isFalse);
expect(find.byKey(const ValueKey('e4-whitepawn')), findsOneWidget); expect(boardHasPiece(tester, Square.e4, Piece.whitePawn), isTrue);
// We've premoved e5, so the server will play this move for us // We've premoved e5, so the server will play this move for us
sendServerSocketMessages(AnalysisController.socketUri, [ sendServerSocketMessages(AnalysisController.socketUri, [
@@ -1882,8 +1886,8 @@ void main() {
// Wait for socket message to arrive and board to update // Wait for socket message to arrive and board to update
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('e7-blackpawn')), findsNothing); expect(boardHasPiece(tester, Square.e7, Piece.blackPawn), isFalse);
expect(find.byKey(const ValueKey('e5-blackpawn')), findsOneWidget); expect(boardHasPiece(tester, Square.e5, Piece.blackPawn), isTrue);
// Only lines that started with 1. e4 e5 should be kept // Only lines that started with 1. e4 e5 should be kept
expect(find.text('2. Nf3 Nc6'), findsOneWidget); expect(find.text('2. Nf3 Nc6'), findsOneWidget);
@@ -1978,12 +1982,12 @@ String makeCorrespondenceGameJsonWithForecast({
} }
Future<void> dragFromTo(WidgetTester tester, String from, String to) async { Future<void> dragFromTo(WidgetTester tester, String from, String to) async {
final fromOffset = squareOffset(tester, Square.fromName(from)); final fromOffset = editorSquareOffset(tester, Square.fromName(from));
await tester.dragFrom(fromOffset, squareOffset(tester, Square.fromName(to)) - fromOffset); await tester.dragFrom(fromOffset, editorSquareOffset(tester, Square.fromName(to)) - fromOffset);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
} }
Offset squareOffset(WidgetTester tester, Square square) { Offset editorSquareOffset(WidgetTester tester, Square square) {
final editor = find.byType(ChessboardEditor); final editor = find.byType(ChessboardEditor);
final squareSize = tester.getSize(editor).width / 8; final squareSize = tester.getSize(editor).width / 8;
+5 -5
View File
@@ -197,7 +197,7 @@ void main() {
await playMove(tester, 'g1', 'f3'); await playMove(tester, 'g1', 'f3');
// Wait for failure message to appear and move to be taken back // Wait for failure message to appear and move to be taken back
await tester.pump(const Duration(milliseconds: 500)); 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('You can do better'), findsOneWidget);
expect(find.text('Try another move for white'), 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 // Should not be able to interact with the board while waiting for eval
await playMove(tester, 'a7', 'a6'); await playMove(tester, 'a7', 'a6');
await tester.pump(); 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 // Pretend d4 isn't a good move either
sendServerSocketMessages(AnalysisController.socketUri, [ sendServerSocketMessages(AnalysisController.socketUri, [
@@ -219,7 +219,7 @@ void main() {
await tester.pump(); // Wait for eval to be processed await tester.pump(); // Wait for eval to be processed
// Move should be taken back // 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('You can do better'), findsOneWidget);
expect(find.text('Try another move for white'), findsOneWidget); expect(find.text('Try another move for white'), findsOneWidget);
@@ -248,7 +248,7 @@ void main() {
expect(find.text('Best was 1... c5'), findsOneWidget); expect(find.text('Best was 1... c5'), findsOneWidget);
// Correct move should be on the board // 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); expect(find.text('Next mistake'), findsOneWidget);
await tester.tap(find.text('Next mistake')); 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 // Wait for failure message to appear and move to be taken back
await tester.pumpAndSettle(const Duration(milliseconds: 500)); 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) // This one has been played multiple times, so it should be accepted as a solution (without consulting the engine)
await playMove(tester, 'd2', 'd4'); await playMove(tester, 'd2', 'd4');
@@ -1,6 +1,5 @@
import 'package:chessground/chessground.dart'; import 'package:chessground/chessground.dart';
import 'package:dartchess/dartchess.dart'; import 'package:dartchess/dartchess.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:lichess_mobile/src/model/common/game.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.tap(find.text('Start training'));
await tester.pumpAndSettle(); 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 controllerProvider = coordinateTrainingControllerProvider;
final trainingPrefsNotifier = container.read(coordinateTrainingPreferencesProvider.notifier); final trainingPrefsNotifier = container.read(coordinateTrainingPreferencesProvider.notifier);
@@ -41,7 +40,7 @@ void main() {
final app = await makeTestProviderScopeApp(tester, home: const CoordinateTrainingScreen()); final app = await makeTestProviderScopeApp(tester, home: const CoordinateTrainingScreen());
await tester.pumpWidget(app); 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); final trainingPrefsNotifier = container.read(coordinateTrainingPreferencesProvider.notifier);
trainingPrefsNotifier.setMode(TrainingMode.findSquare); trainingPrefsNotifier.setMode(TrainingMode.findSquare);
trainingPrefsNotifier.setSideChoice(SideChoice.white); trainingPrefsNotifier.setSideChoice(SideChoice.white);
@@ -56,7 +55,7 @@ void main() {
final wrongCoord = Square.values[(currentCoord! + 1) % Square.values.length]; 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(); await tester.pump();
expect(container.read(controllerProvider).score, 0); expect(container.read(controllerProvider).score, 0);
@@ -64,17 +63,17 @@ void main() {
expect(container.read(controllerProvider).nextCoord, nextCoord); expect(container.read(controllerProvider).nextCoord, nextCoord);
expect(container.read(controllerProvider).trainingActive, true); 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)); 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 { testWidgets('Tap correct square', (tester) async {
final app = await makeTestProviderScopeApp(tester, home: const CoordinateTrainingScreen()); final app = await makeTestProviderScopeApp(tester, home: const CoordinateTrainingScreen());
await tester.pumpWidget(app); 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); final trainingPrefsNotifier = container.read(coordinateTrainingPreferencesProvider.notifier);
trainingPrefsNotifier.setMode(TrainingMode.findSquare); trainingPrefsNotifier.setMode(TrainingMode.findSquare);
trainingPrefsNotifier.setSideChoice(SideChoice.white); trainingPrefsNotifier.setSideChoice(SideChoice.white);
@@ -87,18 +86,20 @@ void main() {
final currentCoord = container.read(controllerProvider).currentCoord; final currentCoord = container.read(controllerProvider).currentCoord;
final nextCoord = container.read(controllerProvider).nextCoord; 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(); 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).score, 1);
expect(container.read(controllerProvider).currentCoord, nextCoord); expect(container.read(controllerProvider).currentCoord, nextCoord);
expect(container.read(controllerProvider).trainingActive, true); expect(container.read(controllerProvider).trainingActive, true);
await tester.pumpAndSettle(const Duration(milliseconds: 300)); 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).currentCoord!.name), findsOneWidget);
expect(find.text(container.read(controllerProvider).nextCoord!.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 tester.pumpAndSettle(); // wait for analysis screen to open
await playMove(tester, 'e2', 'e4'); 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 // Go back to "more" screen and open opening explorer
await tester.pageBack(); await tester.pageBack();
@@ -215,12 +215,12 @@ void main() {
await tester.pumpAndSettle(); // wait for opening explorer screen to open await tester.pumpAndSettle(); // wait for opening explorer screen to open
// Should not use saved standalone analysis here // 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, // There was a bug where the opening explorer would partially load saved analysis,
// leading to not being to move any pieces. // leading to not being to move any pieces.
await playMove(tester, 'd2', 'd4'); 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/styles/lichess_icons.dart';
import 'package:lichess_mobile/src/view/chat/chat_screen.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/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.dart';
import 'package:lichess_mobile/src/view/game/game_screen_providers.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/bottom_bar.dart';
import 'package:lichess_mobile/src/widgets/clock.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:lichess_mobile/src/widgets/pockets.dart';
import 'package:mocktail/mocktail.dart'; import 'package:mocktail/mocktail.dart';
import 'package:qr_flutter/qr_flutter.dart'; import 'package:qr_flutter/qr_flutter.dart';
@@ -109,14 +112,14 @@ void main() {
// while loading, displays an empty board // while loading, displays an empty board
expect(find.byType(Chessboard), findsOneWidget); expect(find.byType(Chessboard), findsOneWidget);
expect(find.byType(PieceWidget), findsNothing); expect(getBoardPieces(tester), isEmpty);
final initialBoardPosition = tester.getTopLeft(find.byType(Chessboard)); final initialBoardPosition = tester.getTopLeft(find.byType(Chessboard));
// now the game controller is loading and screen doesn't have changed yet // now the game controller is loading and screen doesn't have changed yet
await tester.pump(const Duration(milliseconds: 10)); await tester.pump(const Duration(milliseconds: 10));
expect(find.byType(Chessboard), findsOneWidget); expect(find.byType(Chessboard), findsOneWidget);
expect(find.byType(PieceWidget), findsNothing); expect(getBoardPieces(tester), isEmpty);
expect( expect(
tester.getTopLeft(find.byType(Chessboard)), tester.getTopLeft(find.byType(Chessboard)),
initialBoardPosition, initialBoardPosition,
@@ -136,7 +139,7 @@ void main() {
// wait for socket message handling // wait for socket message handling
await tester.pump(); await tester.pump();
expect(find.byType(PieceWidget), findsNWidgets(32)); expect(getBoardPieces(tester).length, 32);
expect(find.text('Peter'), findsOneWidget); expect(find.text('Peter'), findsOneWidget);
expect(find.text('Steven'), findsOneWidget); expect(find.text('Steven'), findsOneWidget);
expect( expect(
@@ -163,7 +166,7 @@ void main() {
await tester.pumpWidget(app); await tester.pumpWidget(app);
expect(find.byType(Chessboard), findsOneWidget); 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('Waiting for opponent to join...'), findsOneWidget);
expect(find.text('3+2'), findsOneWidget); expect(find.text('3+2'), findsOneWidget);
expect(find.widgetWithText(BottomBarButton, 'Cancel'), findsOneWidget); expect(find.widgetWithText(BottomBarButton, 'Cancel'), findsOneWidget);
@@ -182,7 +185,7 @@ void main() {
// now the game controller is loading // now the game controller is loading
expect(find.byType(Chessboard), findsOneWidget); 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('Waiting for opponent to join...'), findsNothing);
expect(find.text('3+2'), findsNothing); expect(find.text('3+2'), findsNothing);
expect(find.widgetWithText(BottomBarButton, 'Cancel'), findsNothing); expect(find.widgetWithText(BottomBarButton, 'Cancel'), findsNothing);
@@ -206,7 +209,7 @@ void main() {
// wait for socket message handling // wait for socket message handling
await tester.pump(); await tester.pump();
expect(find.byType(PieceWidget), findsNWidgets(32)); expect(getBoardPieces(tester).length, 32);
expect(find.text('Peter'), findsOneWidget); expect(find.text('Peter'), findsOneWidget);
expect(find.text('Steven'), findsOneWidget); expect(find.text('Steven'), findsOneWidget);
expect(find.text('Waiting for opponent to join...'), findsNothing); expect(find.text('Waiting for opponent to join...'), findsNothing);
@@ -268,7 +271,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(Chessboard), findsOneWidget); 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('To invite someone to play, give this URL'), findsOneWidget);
expect(find.text('Or let your opponent scan this QR code'), findsOneWidget); expect(find.text('Or let your opponent scan this QR code'), findsOneWidget);
expect(find.byType(QrImageView), findsOneWidget); expect(find.byType(QrImageView), findsOneWidget);
@@ -615,14 +618,12 @@ void main() {
final container = ProviderScope.containerOf(tester.element(find.byType(GameScreen))); final container = ProviderScope.containerOf(tester.element(find.byType(GameScreen)));
final ctrlProvider = gameControllerProvider(const GameFullId('qVChCOTcHSeW')); final ctrlProvider = gameControllerProvider(const GameFullId('qVChCOTcHSeW'));
expect(container.read(ctrlProvider).requireValue.promotionMove, isNotNull);
expect(container.read(ctrlProvider).requireValue.moveToConfirm, isNull); expect(container.read(ctrlProvider).requireValue.moveToConfirm, isNull);
final boardRect = tester.getRect(find.byType(Chessboard)); final boardRect = tester.getRect(find.byType(Chessboard));
await tester.tapAt(squareOffset(Square.fromName('e8'), boardRect)); await tester.tapAt(squareOffset(Square.fromName('e8'), boardRect));
await tester.pump(); await tester.pump();
expect(container.read(ctrlProvider).requireValue.promotionMove, isNull);
expect(container.read(ctrlProvider).requireValue.moveToConfirm, isNotNull); expect(container.read(ctrlProvider).requireValue.moveToConfirm, isNotNull);
}); });
@@ -648,14 +649,14 @@ void main() {
), ),
); );
expect(find.byType(Chessboard), findsOneWidget); expect(find.byType(Chessboard), findsOneWidget);
expect(find.byType(PieceWidget), findsNWidgets(32)); expect(getBoardPieces(tester).length, 32);
await playMove(tester, 'g1', 'f3'); await playMove(tester, 'g1', 'f3');
// see confirmation dialog // see confirmation dialog
expect(find.text('Confirm move'), findsOneWidget); expect(find.text('Confirm move'), findsOneWidget);
// move is shown on board // 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 // move is not yet played so it doesn't appear in the move list
expect(find.text('Nf3'), findsNothing); expect(find.text('Nf3'), findsNothing);
@@ -664,129 +665,180 @@ void main() {
await tester.pump(); await tester.pump();
// move still shown on board // 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 // move appears in move list
expect(find.text('Nf3'), findsOneWidget); expect(find.text('Nf3'), findsOneWidget);
}); });
testWidgets('illegal premove is cancelled after opponent move with move confirmation', ( group('Premoves', () {
WidgetTester tester, testWidgets('premove is applied after opponent move', (WidgetTester tester) async {
) async { const gameFullId = GameFullId('qVChCOTcHSeW');
const gameFullId = GameFullId('qVChCOTcHSeW'); final gameSocketUri = GameController.socketUri(gameFullId);
final gameSocketUri = GameController.socketUri(gameFullId);
await createTestGame( // After e4 it's black's turn, white can premove
tester, await createTestGame(
pgn: 'e4 e5', tester,
clock: const ( pgn: 'e4',
running: true, clock: const (
initial: Duration(minutes: 1), running: true,
increment: Duration.zero, initial: Duration(minutes: 1),
white: Duration(seconds: 58), increment: Duration.zero,
black: Duration(seconds: 54), white: Duration(seconds: 58),
emerg: Duration(seconds: 10), black: Duration(seconds: 58),
), emerg: Duration(seconds: 10),
serverPrefs: const ServerGamePrefs( ),
showRatings: true, serverPrefs: const ServerGamePrefs(
enablePremove: true, showRatings: true,
autoQueen: .always, enablePremove: true,
confirmResign: true, autoQueen: .always,
submitMove: true, confirmResign: true,
zenMode: .no, submitMove: false,
), zenMode: .no,
); ),
expect(find.byType(Chessboard), findsOneWidget); );
expect(find.byType(PieceWidget), findsNWidgets(32));
// white plays d4 with confirmation expect(find.byType(Chessboard), findsOneWidget);
await playMove(tester, 'd2', 'd4');
expect(find.text('Confirm move'), findsOneWidget);
expect(find.byKey(const Key('d4-whitepawn')), findsOneWidget);
// white premoves d4-d5 (push the d-pawn, anticipating d5 stays free) // white premoves d2-d4
await playMove(tester, 'd4', 'd5'); await playMove(tester, 'd2', 'd4');
await tester.pump();
// premove indicators should be visible // premove indicator should be visible
expect(find.byKey(const ValueKey('d4-premove')), findsOneWidget); expect(boardHasPremove(tester, const NormalMove(from: Square.d2, to: Square.d4)), isTrue);
expect(find.byKey(const ValueKey('d5-premove')), findsOneWidget);
// confirm the move // opponent plays e7-e5 (ply 2)
await tester.tap(find.byIcon(CupertinoIcons.checkmark_rectangle_fill)); sendServerSocketMessages(gameSocketUri, [
await tester.pump(); '{"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 // let the premove microtask run
expect(find.byKey(const ValueKey('d4-premove')), findsOneWidget); await tester.pump(const Duration(milliseconds: 1));
expect(find.byKey(const ValueKey('d5-premove')), findsOneWidget); // let the board rebuild from userMove
await tester.pump();
// server acknowledges white's d4 move (ply 3) // premove should have been played
sendServerSocketMessages(gameSocketUri, [ expect(boardHasPremove(tester, const NormalMove(from: Square.d2, to: Square.d4)), isFalse);
'{"t": "move", "v": 1, "d": {"ply": 3, "uci": "d2d4", "san": "d4", "clock": {"white": 57, "black": 54}}}', expect(boardHasPiece(tester, Square.d4, Piece.whitePawn), isTrue);
]); });
await tester.pump();
// opponent plays d7-d5 (ply 4), blocking the premove testWidgets('illegal premove is cancelled after opponent move with move confirmation', (
sendServerSocketMessages(gameSocketUri, [ WidgetTester tester,
'{"t": "move", "v": 2, "d": {"ply": 4, "uci": "d7d5", "san": "d5", "clock": {"white": 57, "black": 52}}}', ) async {
]); const gameFullId = GameFullId('qVChCOTcHSeW');
await tester.pump(); final gameSocketUri = GameController.socketUri(gameFullId);
// let the premove microtask run await createTestGame(
await tester.pump(const Duration(milliseconds: 1)); 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) // white plays d4 with confirmation
expect(find.byKey(const ValueKey('d4-premove')), findsNothing); await playMove(tester, 'd2', 'd4');
expect(find.byKey(const ValueKey('d5-premove')), findsNothing); 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) // white premoves d4-d5 (push the d-pawn, anticipating d5 stays free)
expect(find.byKey(const Key('d5-blackpawn')), findsOneWidget); await playMove(tester, 'd4', 'd5');
// d4 should still have white's pawn await tester.pump();
expect(find.byKey(const Key('d4-whitepawn')), findsOneWidget);
});
testWidgets('can premove drop moves in Crazyhouse', (WidgetTester tester) async { // premove indicators should be visible
const gameFullId = GameFullId('qVChCOTcHSeW'); expect(boardHasPremove(tester, const NormalMove(from: Square.d4, to: Square.d5)), isTrue);
final gameSocketUri = GameController.socketUri(gameFullId);
await createTestGame( // confirm the move
tester, await tester.tap(find.byIcon(CupertinoIcons.checkmark_rectangle_fill));
pgn: 'e4 d5 exd5', await tester.pump();
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 indicators should still be visible after confirmation
expect(boardHasPremove(tester, const NormalMove(from: Square.d4, to: Square.d5)), isTrue);
// premove indicator should be visible // server acknowledges white's d4 move (ply 3)
expect(find.byKey(const ValueKey('a4-premove')), findsOneWidget); sendServerSocketMessages(gameSocketUri, [
'{"t": "move", "v": 1, "d": {"ply": 3, "uci": "d2d4", "san": "d4", "clock": {"white": 57, "black": 54}}}',
]);
await tester.pump();
// opponent plays Qxd5 // opponent plays d7-d5 (ply 4), blocking the premove
sendServerSocketMessages(gameSocketUri, [ sendServerSocketMessages(gameSocketUri, [
'{"t": "move", "v": 1, "d": {"ply": 4, "uci": "d8d5", "san": "Qxd5", "clock": {"white": 57, "black": 52}}}', '{"t": "move", "v": 2, "d": {"ply": 4, "uci": "d7d5", "san": "d5", "clock": {"white": 57, "black": 52}}}',
]); ]);
await tester.pump(); await tester.pump();
// let the premove microtask run // let the premove microtask run
await tester.pump(const Duration(milliseconds: 1)); await tester.pump(const Duration(milliseconds: 1));
// premove should have been played // premove should be cancelled since d4-d5 is now illegal (d5 is occupied)
expect(find.byKey(const ValueKey('a4-premove')), findsNothing); expect(boardHasPremove(tester, const NormalMove(from: Square.d4, to: Square.d5)), isFalse);
expect(find.byKey(const Key('a4-whitepawn')), findsOneWidget);
// 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 { testWidgets('takeback', (WidgetTester tester) async {
@@ -803,14 +855,14 @@ void main() {
), ),
); );
expect(find.byType(Chessboard), findsOneWidget); expect(find.byType(Chessboard), findsOneWidget);
expect(find.byType(PieceWidget), findsNWidgets(32)); expect(getBoardPieces(tester).length, 32);
// black plays // black plays
sendServerSocketMessages(testGameSocketUri, [ sendServerSocketMessages(testGameSocketUri, [
'{"t": "move", "v": 1, "d": {"ply": 4, "uci": "b8c6", "san": "Nc6", "clock": {"white": 58, "black": 52}}}', '{"t": "move", "v": 1, "d": {"ply": 4, "uci": "b8c6", "san": "Nc6", "clock": {"white": 58, "black": 52}}}',
]); ]);
await tester.pump(const Duration(milliseconds: 500)); await tester.pump(const Duration(milliseconds: 500));
expect(find.byKey(const Key('c6-blackknight')), findsOneWidget); expect(boardHasPiece(tester, Square.c6, Piece.blackKnight), isTrue);
expect( expect(
tester.widgetList<Clock>(find.byType(Clock)).last.active, tester.widgetList<Clock>(find.byType(Clock)).last.active,
true, true,
@@ -863,8 +915,8 @@ void main() {
await tester.pump(const Duration(milliseconds: 1)); await tester.pump(const Duration(milliseconds: 1));
// black move is cancelled // black move is cancelled
expect(find.byKey(const Key('c6-blackknight')), findsNothing); expect(boardHasPiece(tester, Square.c6, Piece.blackKnight), isFalse);
expect(find.byKey(const Key('b8-blackknight')), findsOneWidget); expect(boardHasPiece(tester, Square.b8, Piece.blackKnight), isTrue);
expect(tester.widget<Clock>(findClock(Side.black)).active, true); expect(tester.widget<Clock>(findClock(Side.black)).active, true);
expect(tester.widget<Clock>(findClock(Side.white)).active, false); expect(tester.widget<Clock>(findClock(Side.white)).active, false);
// black clock is ticking // black clock is ticking
@@ -892,27 +944,29 @@ void main() {
tester, 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(); await tester.pump();
final validMoves = getBoardValidMoves(tester);
switch (castlingMethod) { switch (castlingMethod) {
case CastlingMethod.kingOverRook: case CastlingMethod.kingOverRook:
// kingOverRook acts as either kingTwoSquares or kingOverRook // kingOverRook acts as either kingTwoSquares or kingOverRook
expect(find.byKey(const Key('f1-dest')), findsOneWidget); expect(validMoves.contains(Square.f1), isTrue);
expect(find.byKey(const Key('g1-dest')), findsOneWidget); expect(validMoves.contains(Square.g1), isTrue);
expect(find.byKey(const Key('h1-dest')), findsOneWidget); expect(validMoves.contains(Square.h1), isTrue);
expect(find.byKey(const Key('c1-dest')), findsOneWidget); expect(validMoves.contains(Square.c1), isTrue);
expect(find.byKey(const Key('d1-dest')), findsOneWidget); expect(validMoves.contains(Square.d1), isTrue);
expect(find.byKey(const Key('a1-dest')), findsOneWidget); expect(validMoves.contains(Square.a1), isTrue);
case CastlingMethod.kingTwoSquares: case CastlingMethod.kingTwoSquares:
expect(find.byKey(const Key('f1-dest')), findsOneWidget); expect(validMoves.contains(Square.f1), isTrue);
expect(find.byKey(const Key('g1-dest')), findsOneWidget); expect(validMoves.contains(Square.g1), isTrue);
expect(find.byKey(const Key('h1-dest')), findsNothing); expect(validMoves.contains(Square.h1), isFalse);
expect(find.byKey(const Key('c1-dest')), findsOneWidget); expect(validMoves.contains(Square.c1), isTrue);
expect(find.byKey(const Key('d1-dest')), findsOneWidget); expect(validMoves.contains(Square.d1), isTrue);
expect(find.byKey(const Key('a1-dest')), findsNothing); expect(validMoves.contains(Square.a1), isFalse);
} }
}); });
} }
@@ -931,17 +985,19 @@ void main() {
tester, 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(); await tester.pump();
// in chess960, castling is only king over rook, no matter the preference // in chess960, castling is only king over rook, no matter the preference
expect(find.byKey(const Key('f1-dest')), findsOneWidget); final validMoves = getBoardValidMoves(tester);
expect(find.byKey(const Key('g1-dest')), findsNothing); expect(validMoves.contains(Square.f1), isTrue);
expect(find.byKey(const Key('h1-dest')), findsOneWidget); expect(validMoves.contains(Square.g1), isFalse);
expect(find.byKey(const Key('c1-dest')), findsNothing); expect(validMoves.contains(Square.h1), isTrue);
expect(find.byKey(const Key('d1-dest')), findsOneWidget); expect(validMoves.contains(Square.c1), isFalse);
expect(find.byKey(const Key('a1-dest')), findsOneWidget); expect(validMoves.contains(Square.d1), isTrue);
expect(validMoves.contains(Square.a1), isTrue);
}); });
} }
}); });
@@ -1453,9 +1509,9 @@ void main() {
await tester.pump(); await tester.pump();
expect(find.byType(Chessboard), findsOneWidget); expect(find.byType(Chessboard), findsOneWidget);
expect(find.byKey(const Key('d3-whitebishop')), findsOneWidget); expect(boardHasPiece(tester, Square.d3, Piece.whiteBishop), isTrue);
expect(find.byKey(const Key('b5-lastMove')), findsOneWidget); expect(getBoardLastMove(tester)?.hasSquare(Square.b5), isTrue);
expect(find.byKey(const Key('d3-lastMove')), findsOneWidget); expect(getBoardLastMove(tester)?.hasSquare(Square.d3), isTrue);
await tester.tap(find.byIcon(Icons.menu)); await tester.tap(find.byIcon(Icons.menu));
await tester.pump(const Duration(milliseconds: 500)); await tester.pump(const Duration(milliseconds: 500));
expect(find.byType(Dialog), findsOneWidget); expect(find.byType(Dialog), findsOneWidget);
@@ -1465,10 +1521,10 @@ void main() {
find.widgetWithText(AppBar, 'Analysis board'), find.widgetWithText(AppBar, 'Analysis board'),
findsOneWidget, findsOneWidget,
); // analysis screen is now open ); // analysis screen is now open
expect(find.byKey(const Key('f3-whitequeen')), findsOneWidget); expect(boardHasPiece(tester, Square.f3, Piece.whiteQueen), isTrue);
expect(find.byKey(const Key('d3-whitebishop')), findsOneWidget); expect(boardHasPiece(tester, Square.d3, Piece.whiteBishop), isTrue);
expect(find.byKey(const Key('b5-lastMove')), findsOneWidget); expect(getBoardLastMove(tester)?.hasSquare(Square.b5), isTrue);
expect(find.byKey(const Key('d3-lastMove')), findsOneWidget); expect(getBoardLastMove(tester)?.hasSquare(Square.d3), isTrue);
expect(find.bySemanticsLabel(RegExp('Moves played')), findsOneWidget); expect(find.bySemanticsLabel(RegExp('Moves played')), findsOneWidget);
// computer analysis is not available when game is not finished // computer analysis is not available when game is not finished
expect(find.bySemanticsLabel(RegExp('Computer analysis')), findsNothing); expect(find.bySemanticsLabel(RegExp('Computer analysis')), findsNothing);
@@ -1477,9 +1533,9 @@ void main() {
testWidgets('for a finished game', (WidgetTester tester) async { testWidgets('for a finished game', (WidgetTester tester) async {
await loadFinishedTestGame(tester); await loadFinishedTestGame(tester);
expect(find.byType(Chessboard), findsOneWidget); expect(find.byType(Chessboard), findsOneWidget);
expect(find.byKey(const Key('e6-whitequeen')), findsOneWidget); expect(boardHasPiece(tester, Square.e6, Piece.whiteQueen), isTrue);
expect(find.byKey(const Key('d5-lastMove')), findsOneWidget); expect(getBoardLastMove(tester)?.hasSquare(Square.d5), isTrue);
expect(find.byKey(const Key('e6-lastMove')), findsOneWidget); expect(getBoardLastMove(tester)?.hasSquare(Square.e6), isTrue);
await tester.pump(const Duration(milliseconds: 500)); // wait for popup await tester.pump(const Duration(milliseconds: 500)); // wait for popup
await tester.tap(find.text('Analysis board')); await tester.tap(find.text('Analysis board'));
await tester.pumpAndSettle(); // wait for analysis screen to open await tester.pumpAndSettle(); // wait for analysis screen to open
@@ -1488,9 +1544,9 @@ void main() {
findsOneWidget, findsOneWidget,
); // analysis screen is now open ); // analysis screen is now open
expect(find.byType(Chessboard), findsOneWidget); expect(find.byType(Chessboard), findsOneWidget);
expect(find.byKey(const Key('e6-whitequeen')), findsOneWidget); expect(boardHasPiece(tester, Square.e6, Piece.whiteQueen), isTrue);
expect(find.byKey(const Key('d5-lastMove')), findsOneWidget); expect(getBoardLastMove(tester)?.hasSquare(Square.d5), isTrue);
expect(find.byKey(const Key('e6-lastMove')), findsOneWidget); expect(getBoardLastMove(tester)?.hasSquare(Square.e6), isTrue);
expect(find.bySemanticsLabel(RegExp('Moves played')), findsOneWidget); expect(find.bySemanticsLabel(RegExp('Moves played')), findsOneWidget);
expect( expect(
find.bySemanticsLabel(RegExp('Computer analysis')), find.bySemanticsLabel(RegExp('Computer analysis')),
@@ -1590,11 +1646,40 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Pawn should appear on c4 (transient move before server ack) // 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; 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 { 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 // 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( await createTestGame(
@@ -1612,7 +1697,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// Move should not be played since it's not our turn and the opponent's pockets should not be interactable // 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 { testWidgets('correctly handles opponent drop move received from server', (tester) async {
@@ -1637,7 +1722,107 @@ void main() {
await tester.pump(); await tester.pump();
// White pawn should appear on c4 // 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]. /// Finds the clock on the specified [side].
@@ -148,7 +148,7 @@ void main() {
await tester.pump(const Duration(milliseconds: 200)); await tester.pump(const Duration(milliseconds: 200));
await tester.pumpAndSettle(); 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 { testWidgets('Engine responds after player move', (tester) async {
@@ -537,11 +537,11 @@ void main() {
expect(find.text('Game setup'), findsNothing); expect(find.text('Game setup'), findsNothing);
// Should load the game's current position, i.e. e4 and e5 were played // Should load the game's current position, i.e. e4 and e5 were played
expect(find.byKey(const ValueKey('e2-whitepawn')), findsNothing); expect(boardHasPiece(tester, Square.e2, Piece.whitePawn), isFalse);
expect(find.byKey(const ValueKey('e4-whitepawn')), findsOneWidget); expect(boardHasPiece(tester, Square.e4, Piece.whitePawn), isTrue);
expect(find.byKey(const ValueKey('e7-blackpawn')), findsNothing); expect(boardHasPiece(tester, Square.e7, Piece.blackPawn), isFalse);
expect(find.byKey(const ValueKey('e5-blackpawn')), findsOneWidget); expect(boardHasPiece(tester, Square.e5, Piece.blackPawn), isTrue);
// Move list should show the played moves // Move list should show the played moves
expect(find.text('e4'), findsOneWidget); expect(find.text('e4'), findsOneWidget);
@@ -992,12 +992,12 @@ void main() {
expect(find.byType(Chessboard), findsOneWidget); expect(find.byType(Chessboard), findsOneWidget);
// The position should show e4 and e5 pawns // The position should show e4 and e5 pawns
expect(find.byKey(const ValueKey('e4-whitepawn')), findsOneWidget); expect(boardHasPiece(tester, Square.e4, Piece.whitePawn), isTrue);
expect(find.byKey(const ValueKey('e5-blackpawn')), findsOneWidget); expect(boardHasPiece(tester, Square.e5, Piece.blackPawn), isTrue);
// e2 and e7 should be empty // e2 and e7 should be empty
expect(find.byKey(const ValueKey('e2-whitepawn')), findsNothing); expect(boardHasPiece(tester, Square.e2, Piece.whitePawn), isFalse);
expect(find.byKey(const ValueKey('e7-blackpawn')), findsNothing); expect(boardHasPiece(tester, Square.e7, Piece.blackPawn), isFalse);
}); });
testWidgets('Engine plays first when custom position turn differs from player side', ( testWidgets('Engine plays first when custom position turn differs from player side', (
@@ -56,13 +56,10 @@ void main() {
group('Playing over the board (offline)', () { group('Playing over the board (offline)', () {
testWidgets('Checkmate and Rematch', (tester) async { 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 // Default orientation is white at the bottom
expect( expect(tester.widget<Chessboard>(find.byType(Chessboard)).orientation, Side.white);
tester.getBottomLeft(find.byKey(const ValueKey('a1-whiterook'))),
boardRect.bottomLeft,
);
await playMove(tester, 'e2', 'e4'); await playMove(tester, 'e2', 'e4');
await playMove(tester, 'f7', 'f6'); await playMove(tester, 'f7', 'f6');
@@ -82,7 +79,7 @@ void main() {
expect(gameState.game.steps.first.position, Chess.initial); expect(gameState.game.steps.first.position, Chess.initial);
// Rematch should flip orientation // 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); expect(activeClock(tester), null);
}); });
@@ -148,13 +145,13 @@ void main() {
await tester.tap(find.byTooltip('Previous')); await tester.tap(find.byTooltip('Previous'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('e7-blackpawn')), findsOneWidget); expect(boardHasPiece(tester, Square.e7, Piece.blackPawn), isTrue);
expect(activeClock(tester), Side.black); expect(activeClock(tester), Side.black);
await tester.tap(find.byTooltip('Next')); await tester.tap(find.byTooltip('Next'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('e5-blackpawn')), findsOneWidget); expect(boardHasPiece(tester, Square.e5, Piece.blackPawn), isTrue);
expect(activeClock(tester), Side.white); expect(activeClock(tester), Side.white);
@@ -165,13 +162,13 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Previous')); await tester.tap(find.byTooltip('Previous'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('e2-whitepawn')), findsOneWidget); expect(boardHasPiece(tester, Square.e2, Piece.whitePawn), isTrue);
expect(find.byKey(const ValueKey('e7-blackpawn')), findsOneWidget); expect(boardHasPiece(tester, Square.e7, Piece.blackPawn), isTrue);
expect(activeClock(tester), Side.white); expect(activeClock(tester), Side.white);
await playMove(tester, 'e2', 'e4'); 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); expect(activeClock(tester), Side.black);
}); });
@@ -293,11 +290,11 @@ void main() {
expect(find.text('Play'), findsNothing); expect(find.text('Play'), findsNothing);
// Should load the game's current position, i.e. e4 and e5 were played // Should load the game's current position, i.e. e4 and e5 were played
expect(find.byKey(const ValueKey('e2-whitepawn')), findsNothing); expect(boardHasPiece(tester, Square.e2, Piece.whitePawn), isFalse);
expect(find.byKey(const ValueKey('e4-whitepawn')), findsOneWidget); expect(boardHasPiece(tester, Square.e4, Piece.whitePawn), isTrue);
expect(find.byKey(const ValueKey('e7-blackpawn')), findsNothing); expect(boardHasPiece(tester, Square.e7, Piece.blackPawn), isFalse);
expect(find.byKey(const ValueKey('e5-blackpawn')), findsOneWidget); expect(boardHasPiece(tester, Square.e5, Piece.blackPawn), isTrue);
expect(activeClock(tester), null); expect(activeClock(tester), null);
expect(findWhiteClock(tester).timeLeft, const Duration(minutes: 2)); expect(findWhiteClock(tester).timeLeft, const Duration(minutes: 2));
@@ -452,10 +449,10 @@ void main() {
expect(gameState.game.meta.variant, Variant.fromPosition); expect(gameState.game.meta.variant, Variant.fromPosition);
expect(gameState.game.initialFen, _customFen); expect(gameState.game.initialFen, _customFen);
// Board should show the custom position (e4 pawn on e4, black pawn on e5) // Board should show the custom position (e4 pawn on e4, black pawn on e5)
expect(find.byKey(const ValueKey('e2-whitepawn')), findsNothing); expect(boardHasPiece(tester, Square.e2, Piece.whitePawn), isFalse);
expect(find.byKey(const ValueKey('e4-whitepawn')), findsOneWidget); expect(boardHasPiece(tester, Square.e4, Piece.whitePawn), isTrue);
expect(find.byKey(const ValueKey('e7-blackpawn')), findsNothing); expect(boardHasPiece(tester, Square.e7, Piece.blackPawn), isFalse);
expect(find.byKey(const ValueKey('e5-blackpawn')), findsOneWidget); expect(boardHasPiece(tester, Square.e5, Piece.blackPawn), isTrue);
}, },
); );
@@ -643,8 +640,8 @@ void main() {
expect(gameState.game.initialFen, _customFen); expect(gameState.game.initialFen, _customFen);
expect(gameState.game.meta.variant, Variant.atomic); expect(gameState.game.meta.variant, Variant.atomic);
expect(gameState.game.steps.length, 1); expect(gameState.game.steps.length, 1);
expect(find.byKey(const ValueKey('e4-whitepawn')), findsOneWidget); expect(boardHasPiece(tester, Square.e4, Piece.whitePawn), isTrue);
expect(find.byKey(const ValueKey('e5-blackpawn')), findsOneWidget); expect(boardHasPiece(tester, Square.e5, Piece.blackPawn), isTrue);
}); });
}); });
} }
+28 -27
View File
@@ -205,8 +205,9 @@ void main() {
expect(find.text('Your turn'), findsOneWidget); expect(find.text('Your turn'), findsOneWidget);
// before the first move is played, puzzle is not interactable // before the first move is played, puzzle is not interactable
expect(find.byKey(const Key('g4-blackrook')), findsOneWidget); expect(boardHasPiece(tester, Square.g4, Piece.blackRook), isTrue);
await tester.tap(find.byKey(const Key('g4-blackrook'))); final boardRect = tester.getRect(find.byType(Chessboard));
await tester.tapAt(squareOffset(Square.g4, boardRect, orientation: Side.black));
await tester.pump(); await tester.pump();
expect(find.byKey(const Key('g4-selected')), findsNothing); expect(find.byKey(const Key('g4-selected')), findsNothing);
@@ -220,23 +221,23 @@ void main() {
// in play mode we see the solution button // in play mode we see the solution button
expect(find.byIcon(Icons.flag_outlined), findsOneWidget); expect(find.byIcon(Icons.flag_outlined), findsOneWidget);
expect(find.byKey(const Key('g4-blackrook')), findsOneWidget); expect(boardHasPiece(tester, Square.g4, Piece.blackRook), isTrue);
expect(find.byKey(const Key('h8-whitequeen')), findsOneWidget); expect(boardHasPiece(tester, Square.h8, Piece.whiteQueen), isTrue);
await playMove(tester, 'g4', 'h4', orientation: orientation); 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); expect(find.text('Best move!'), findsOneWidget);
// wait for line reply and move animation // wait for line reply and move animation
await tester.pump(const Duration(milliseconds: 500)); await tester.pump(const Duration(milliseconds: 500));
await tester.pumpAndSettle(); 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); 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); expect(find.text('Success!'), findsOneWidget);
// wait for move animation // wait for move animation
@@ -317,7 +318,7 @@ void main() {
// await for first move to be played // await for first move to be played
await tester.pump(const Duration(milliseconds: 1500)); 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); await playMove(tester, 'g4', 'f4', orientation: orientation);
@@ -328,11 +329,11 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// can still play the puzzle // 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); 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); expect(find.text('Best move!'), findsOneWidget);
// wait for line reply and move animation // wait for line reply and move animation
@@ -341,7 +342,7 @@ void main() {
await playMove(tester, 'b4', 'h4', orientation: orientation); 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); expect(find.text('Puzzle complete!'), findsOneWidget);
final expectedPlayedXTimes = final expectedPlayedXTimes =
'Played ${puzzle2.puzzle.plays.toString().localizeNumbers()} times.'; 'Played ${puzzle2.puzzle.plays.toString().localizeNumbers()} times.';
@@ -414,7 +415,7 @@ void main() {
// await for first move to be played // await for first move to be played
await tester.pump(const Duration(milliseconds: 1500)); 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 // Help button should still be disabled
expect(find.byIcon(Icons.flag_outlined), findsOneWidget); expect(find.byIcon(Icons.flag_outlined), findsOneWidget);
@@ -438,8 +439,8 @@ void main() {
// wait for solution replay animation to finish // wait for solution replay animation to finish
await tester.pump(const Duration(seconds: 1)); await tester.pump(const Duration(seconds: 1));
expect(find.byKey(const Key('h4-blackrook')), findsOneWidget); expect(boardHasPiece(tester, Square.h4, Piece.blackRook), isTrue);
expect(find.byKey(const Key('h8-whitequeen')), findsOneWidget); expect(boardHasPiece(tester, Square.h8, Piece.whiteQueen), isTrue);
expect(find.text('Puzzle complete!'), findsOneWidget); expect(find.text('Puzzle complete!'), findsOneWidget);
final nextMoveBtnEnabled = find.byWidgetPredicate( final nextMoveBtnEnabled = find.byWidgetPredicate(
@@ -778,7 +779,7 @@ void main() {
// wait for previous opponent's move to be played // wait for previous opponent's move to be played
await tester.pump(const Duration(milliseconds: 1500)); 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() { bool isPrevEnabled() {
return tester return tester
@@ -816,7 +817,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
// computer replied by capturing our rook with its queen // 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); expect(isPrevEnabled(), isTrue);
@@ -825,19 +826,19 @@ void main() {
await tester.pump(); await tester.pump();
// verify we see our original bold move (black rook on h4) // 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 // tap "Previous" again to undo our first move
await tester.tap(find.byIcon(CupertinoIcons.chevron_back)); await tester.tap(find.byIcon(CupertinoIcons.chevron_back));
await tester.pump(); await tester.pump();
// verify we are back at the start (black rook on g4) // 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 // check that the user can not play a different move in the starting position
await playMove(tester, 'g4', 'g5', orientation: orientation); await playMove(tester, 'g4', 'g5', orientation: orientation);
// check that the rook stayed on g4 // 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 // check that the "Next" button is now enabled
expect(isNextEnabled(), isTrue); expect(isNextEnabled(), isTrue);
@@ -849,7 +850,7 @@ void main() {
await tester.pump(); await tester.pump();
// verify we are back to the current state (computer's white queen on h4) // 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 for first move to be played
await tester.pump(const Duration(milliseconds: 1500)); 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); await playMove(tester, 'e8', 'a8', orientation: Side.white);
@@ -1005,7 +1006,7 @@ void main() {
// await for first move to be played (Nxc3) // await for first move to be played (Nxc3)
await tester.pump(const Duration(milliseconds: 1500)); 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 // Play castling move (O-O) by moving king to g1
await playMove(tester, 'e1', 'g1', orientation: Side.white); await playMove(tester, 'e1', 'g1', orientation: Side.white);
@@ -1023,7 +1024,7 @@ void main() {
// Wait for the move animation to complete // Wait for the move animation to complete
await tester.pumpAndSettle(); 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 // wait for first move to be played
await tester.pump(const Duration(milliseconds: 1500)); 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); 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); expect(find.text('Best move!'), findsOneWidget);
// wait for line reply and move animation // wait for line reply and move animation
await tester.pump(const Duration(milliseconds: 500)); await tester.pump(const Duration(milliseconds: 500));
await tester.pumpAndSettle(); 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); 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); expect(find.text('Success!'), findsOneWidget);
// wait for move animation // wait for move animation
+8 -8
View File
@@ -76,29 +76,29 @@ void main() {
await tester.pumpWidget(app); await tester.pumpWidget(app);
// before the first move is played, puzzle is not interactable // before the first move is played, puzzle is not interactable
expect(find.byKey(const Key('h5-whiterook')), findsOneWidget); expect(boardHasPiece(tester, Square.h5, Piece.whiteRook), isTrue);
await tester.tap(find.byKey(const Key('h5-whiterook'))); await tester.tapAt(squareOffset(Square.h5, tester.getRect(find.byType(Chessboard))));
await tester.pump(); await tester.pump();
expect(find.byKey(const Key('h5-selected')), findsNothing); expect(find.byKey(const Key('h5-selected')), findsNothing);
// wait for first move to be played // wait for first move to be played
await tester.pump(const Duration(seconds: 1)); 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 playMove(tester, 'h5', 'h7', orientation: Side.white);
await tester.pump(const Duration(milliseconds: 500)); await tester.pump(const Duration(milliseconds: 500));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byKey(const Key('h7-whiterook')), findsOneWidget); expect(boardHasPiece(tester, Square.h7, Piece.whiteRook), isTrue);
expect(find.byKey(const Key('d1-blackqueen')), findsOneWidget); expect(boardHasPiece(tester, Square.d1, Piece.blackQueen), isTrue);
await playMove(tester, 'e3', 'g1', orientation: Side.white); await playMove(tester, 'e3', 'g1', orientation: Side.white);
await tester.pump(const Duration(milliseconds: 500)); await tester.pump(const Duration(milliseconds: 500));
// should have loaded next puzzle // should have loaded next puzzle
expect(find.byKey(const Key('h6-blackking')), findsOneWidget); expect(boardHasPiece(tester, Square.h6, Piece.blackKing), isTrue);
}, variant: kPlatformVariant); }, variant: kPlatformVariant);
testWidgets('shows end run result', (tester) async { testWidgets('shows end run result', (tester) async {
@@ -125,7 +125,7 @@ void main() {
await tester.pump(const Duration(milliseconds: 500)); await tester.pump(const Duration(milliseconds: 500));
// should have loaded next puzzle // 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.tap(find.text('End run'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -152,7 +152,7 @@ void main() {
await playMove(tester, 'h5', 'h6'); await playMove(tester, 'h5', 'h6');
await tester.pump(const Duration(milliseconds: 500)); 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 { 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); await playMove(tester, 'e2', 'e4', orientation: Side.black);
expect(find.byKey(const Key('e2-whitepawn')), findsNothing); expect(boardHasPiece(tester, Square.e2, Piece.whitePawn), isFalse);
expect(find.byKey(const Key('e4-whitepawn')), findsOneWidget); expect(boardHasPiece(tester, Square.e4, Piece.whitePawn), isTrue);
await playMove(tester, 'e7', 'e5', orientation: Side.black); await playMove(tester, 'e7', 'e5', orientation: Side.black);
expect(find.byKey(const Key('e5-blackpawn')), findsOneWidget); expect(boardHasPiece(tester, Square.e5, Piece.blackPawn), isTrue);
expect(find.byKey(const Key('e7-blackpawn')), findsNothing); expect(boardHasPiece(tester, Square.e7, Piece.blackPawn), isFalse);
expect(find.text('1. e4'), findsOneWidget); expect(find.text('1. e4'), findsOneWidget);
expect(find.text('e5'), findsOneWidget); expect(find.text('e5'), findsOneWidget);
@@ -695,9 +695,9 @@ void main() {
final board = tester.widget<Chessboard>(find.byType(Chessboard)); final board = tester.widget<Chessboard>(find.byType(Chessboard));
if (annotations.isEmpty) { if (annotations.isEmpty) {
expect(board.annotations, isNull); expect(board.annotations, isEmpty);
} else { } else {
expect(board.annotations!.length, annotations.length); expect(board.annotations.length, annotations.length);
expect(board.annotations, allOf(annotations)); expect(board.annotations, allOf(annotations));
} }
} }
@@ -784,7 +784,7 @@ void main() {
await tester.pumpAndSettle(); // Wait for O-O-O move to be played await tester.pumpAndSettle(); // Wait for O-O-O move to be played
final board = tester.widget<Chessboard>(find.byType(Chessboard)); final board = tester.widget<Chessboard>(find.byType(Chessboard));
expect(board.annotations!.length, 1); expect(board.annotations.length, 1);
expect( expect(
board.annotations, board.annotations,
containsPair(Square.c1, predicate<Annotation>((annotation) => annotation.symbol == '!!')), containsPair(Square.c1, predicate<Annotation>((annotation) => annotation.symbol == '!!')),
@@ -451,7 +451,10 @@ void main() {
expect(find.text('BlackFeatured'), findsOneWidget); expect(find.text('BlackFeatured'), findsOneWidget);
expect(find.text('WhiteFeatured'), 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); expect(find.byType(BoardThumbnail), findsOneWidget);
// Pretend all the pieces are gone to check that the board is updated // Pretend all the pieces are gone to check that the board is updated
@@ -463,7 +466,7 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byType(BoardThumbnail), findsOneWidget); 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 { 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/game/game_board_params.dart';
import 'package:lichess_mobile/src/model/settings/board_preferences.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/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/game_layout.dart';
import 'package:lichess_mobile/src/widgets/move_list.dart'; import 'package:lichess_mobile/src/widgets/move_list.dart';
import 'package:lichess_mobile/src/widgets/pockets.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 { testWidgets('Crazyhouse displays pockets and supports drop moves', (WidgetTester tester) async {
final playedMoves = <Move>[]; final playedMoves = <Move>[];
final app = await makeTestProviderScope( final app = await makeTestProviderScope(
@@ -266,9 +310,6 @@ void main() {
onMove: (move, {viaDragAndDrop}) { onMove: (move, {viaDragAndDrop}) {
playedMoves.add(move); playedMoves.add(move);
}, },
onPromotionSelection: (_) {},
premovable: null,
promotionMove: null,
), ),
), ),
), ),
@@ -283,4 +324,150 @@ void main() {
expect(playedMoves, [const DropMove(to: Square.a4, role: Role.pawn)]); 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,
);
});
} }