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