diff --git a/lib/src/constants.dart b/lib/src/constants.dart index 9a5abebcde..addac519de 100644 --- a/lib/src/constants.dart +++ b/lib/src/constants.dart @@ -61,8 +61,10 @@ final List boardShadows = defaultTargetPlatform == TargetPlatform.iOS const kMaxClockTextScaleFactor = 1.94; const kEmptyWidget = SizedBox.shrink(); -const kEmptyFen = '8/8/8/8/8/8/8/8 w - - 0 1'; const kTabletBoardTableSidePadding = 16.0; + +/// In crazyhouse, when displaying pockets above/below the board, add this much additional side padding to make the board smaller and avoid overflows. +const kAdditionalBoardSidePaddingForPockets = 70.0; const kBottomBarHeight = 56.0; const kMaterialPopupMenuMaxWidth = 500.0; diff --git a/lib/src/model/common/chess.dart b/lib/src/model/common/chess.dart index 12cbb0da68..9e56782a75 100644 --- a/lib/src/model/common/chess.dart +++ b/lib/src/model/common/chess.dart @@ -94,6 +94,7 @@ const ISet readSupportedVariants = ISetConst({ Variant.threeCheck, Variant.racingKings, Variant.horde, + Variant.crazyhouse, }); /// Set of supported variants for playing a game. @@ -133,6 +134,8 @@ enum Variant { bool get isPlaySupported => playSupportedVariants.contains(this); + bool get hasDropMoves => this == Variant.crazyhouse; + static final IMap nameMap = IMap(values.asNameMap()); static Variant fromRule(Rule rule) { diff --git a/lib/src/model/game/game_board_params.dart b/lib/src/model/game/game_board_params.dart new file mode 100644 index 0000000000..6696f37ca2 --- /dev/null +++ b/lib/src/model/game/game_board_params.dart @@ -0,0 +1,43 @@ +import 'package:chessground/chessground.dart'; +import 'package:dartchess/dartchess.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:lichess_mobile/src/model/common/chess.dart'; + +part 'game_board_params.freezed.dart'; + +@freezed +sealed class GameBoardParams with _$GameBoardParams { + const GameBoardParams._(); + + const factory GameBoardParams.readonly({ + required String fen, + required Variant variant, + required Pockets? pockets, + }) = ReadonlyBoardParams; + + const factory GameBoardParams.interactive({ + 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, + }) = InteractiveBoardParams; + + static const emptyBoard = ReadonlyBoardParams( + fen: kEmptyFEN, + variant: Variant.standard, + pockets: null, + ); + + String get fen => switch (this) { + ReadonlyBoardParams(:final fen) => fen, + InteractiveBoardParams(:final position) => position.fen, + }; + + Pockets? get pockets => switch (this) { + ReadonlyBoardParams(:final pockets) => pockets, + InteractiveBoardParams(:final position) => position.pockets, + }; +} diff --git a/lib/src/model/tv/tv_channel.dart b/lib/src/model/tv/tv_channel.dart index 72aa16e279..68492f7095 100644 --- a/lib/src/model/tv/tv_channel.dart +++ b/lib/src/model/tv/tv_channel.dart @@ -16,7 +16,7 @@ enum TvChannel { // atomic('Atomic', LichessIcons.atom), horde('Horde', LichessIcons.horde), racingKings('Racing Kings', LichessIcons.racing_kings), - // crazyhouse('Crazyhouse', LichessIcons.h_square), + crazyhouse('Crazyhouse', LichessIcons.h_square), ultraBullet('UltraBullet', LichessIcons.ultrabullet), bot('Bot', LichessIcons.cogs), computer('Computer', LichessIcons.cogs); diff --git a/lib/src/utils/screen.dart b/lib/src/utils/screen.dart index 1699d0a2af..9d05eff95d 100644 --- a/lib/src/utils/screen.dart +++ b/lib/src/utils/screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:lichess_mobile/src/constants.dart'; +import 'package:lichess_mobile/src/widgets/pockets.dart'; /// Returns the estimated height of what is left after removing the height of the board from the screen. double estimateHeightMinusBoard(MediaQueryData mediaQuery) { @@ -44,6 +45,14 @@ bool isTabletOrLarger(BuildContext context) { return getScreenType(context) >= ScreenType.tablet; } +/// How big a square in the [PocketsMenu] should be, based on the size of the board and whether the device is a tablet. +double pocketSquareSize({required double boardSize, required bool isTablet}) { + final squareSize = boardSize / 8; + // On tablets, displaying the pockets at the same size as regular pieces + // can lead to overflows and looks weird, so reduce the size a bit. + return isTablet ? 0.7 * squareSize : squareSize; +} + enum ScreenType { watch, handset, tablet, desktop } extension ScreenTypeComparisonOperators on ScreenType { diff --git a/lib/src/view/analysis/analysis_board.dart b/lib/src/view/analysis/analysis_board.dart index 7ed5b5f167..f3a49a532f 100644 --- a/lib/src/view/analysis/analysis_board.dart +++ b/lib/src/view/analysis/analysis_board.dart @@ -166,6 +166,7 @@ abstract class AnalysisBoardState< onClearShapes: _onClearShapes, newShapeColor: boardPrefs.shapeColor.color, ), + enableDropMoves: analysisState.variant.hasDropMoves, ), ); } diff --git a/lib/src/view/analysis/analysis_layout.dart b/lib/src/view/analysis/analysis_layout.dart index 626324d099..5545bfc909 100644 --- a/lib/src/view/analysis/analysis_layout.dart +++ b/lib/src/view/analysis/analysis_layout.dart @@ -9,6 +9,7 @@ import 'package:lichess_mobile/src/utils/screen.dart'; import 'package:lichess_mobile/src/view/engine/engine_gauge.dart'; import 'package:lichess_mobile/src/widgets/adaptive_action_sheet.dart'; import 'package:lichess_mobile/src/widgets/buttons.dart'; +import 'package:lichess_mobile/src/widgets/pockets.dart'; /// The height of the board header or footer in the analysis layout. const kAnalysisBoardHeaderOrFooterHeight = 26.0; @@ -129,11 +130,13 @@ class AnalysisLayout extends StatelessWidget { required this.boardBuilder, required this.children, required this.pov, + required this.sideToMove, this.boardHeader, this.boardFooter, this.engineGaugeBuilder, this.engineLines, this.bottomBar, + this.pockets, super.key, }); @@ -146,6 +149,9 @@ class AnalysisLayout extends StatelessWidget { /// The side the board is displayed from. final Side pov; + /// The side to move. In crazyhouse, this enables the [PocketsMenu] of this side. + final Side? sideToMove; + /// A widget to show above the board. /// /// The widget will included in a parent container with a height of @@ -173,6 +179,11 @@ class AnalysisLayout extends StatelessWidget { /// A widget to show at the bottom of the screen. final Widget? bottomBar; + /// Current state of the pockets, in variants like crazyhouse. + /// + /// If not null, will render a [PocketsMenu] for each player. + final Pockets? pockets; + @override Widget build(BuildContext context) { return Column( @@ -202,6 +213,7 @@ class AnalysisLayout extends StatelessWidget { : constraints.biggest.longestSide / kGoldenRatio - (kTabletBoardTableSidePadding * 2)) - headerAndFooterHeight; + return Padding( padding: const EdgeInsets.all(kTabletBoardTableSidePadding), child: Row( @@ -269,6 +281,19 @@ class AnalysisLayout extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (engineLines != null) engineLines!, + if (pockets != null) + Align( + alignment: Alignment.center, + child: PocketsMenu( + side: pov.opposite, + sideToMove: sideToMove, + pockets: pockets!, + squareSize: pocketSquareSize( + boardSize: boardSize, + isTablet: isTablet, + ), + ), + ), Expanded( child: Card( clipBehavior: Clip.hardEdge, @@ -276,6 +301,19 @@ class AnalysisLayout extends StatelessWidget { child: TabBarView(controller: tabController, children: children), ), ), + if (pockets != null) + Align( + alignment: Alignment.center, + child: PocketsMenu( + side: pov, + sideToMove: sideToMove, + pockets: pockets!, + squareSize: pocketSquareSize( + boardSize: boardSize, + isTablet: isTablet, + ), + ), + ), ], ), ), @@ -289,7 +327,10 @@ class AnalysisLayout extends StatelessWidget { final isSmallScreen = remainingHeight < kSmallHeightMinusBoard; final evalGaugeSize = engineGaugeBuilder != null ? evalGaugeWidth : 0.0; final boardSize = isTablet || isSmallScreen - ? defaultBoardSize - evalGaugeSize - kTabletBoardTableSidePadding * 2 + ? defaultBoardSize - + evalGaugeSize - + kTabletBoardTableSidePadding * 2 - + (pockets != null ? kAdditionalBoardSidePaddingForPockets : 0.0) : defaultBoardSize - evalGaugeSize; return Column( @@ -304,6 +345,16 @@ class AnalysisLayout extends StatelessWidget { : EdgeInsets.zero, child: Column( children: [ + if (pockets != null) + PocketsMenu( + side: pov.opposite, + sideToMove: sideToMove, + pockets: pockets!, + squareSize: pocketSquareSize( + boardSize: boardSize, + isTablet: isTablet, + ), + ), if (boardHeader != null) // This key is used to preserve the state of the board header when the pov changes Container( @@ -321,6 +372,7 @@ class AnalysisLayout extends StatelessWidget { child: boardHeader, ), Row( + mainAxisAlignment: MainAxisAlignment.center, children: [ boardBuilder( context, @@ -349,6 +401,16 @@ class AnalysisLayout extends StatelessWidget { height: kAnalysisBoardHeaderOrFooterHeight, child: boardFooter, ), + if (pockets != null) + PocketsMenu( + side: pov, + sideToMove: sideToMove, + pockets: pockets!, + squareSize: pocketSquareSize( + boardSize: boardSize, + isTablet: isTablet, + ), + ), ], ), ), diff --git a/lib/src/view/analysis/analysis_screen.dart b/lib/src/view/analysis/analysis_screen.dart index 8274f568a9..71efb075d9 100644 --- a/lib/src/view/analysis/analysis_screen.dart +++ b/lib/src/view/analysis/analysis_screen.dart @@ -361,6 +361,7 @@ class _Body extends ConsumerWidget { child: AnalysisLayout( tabController: controller, pov: pov, + sideToMove: analysisState.currentPosition.turn, boardBuilder: (context, boardSize, borderRadius) => GameAnalysisBoard(options: options, boardSize: boardSize, boardRadius: borderRadius), boardHeader: boardHeader, @@ -379,6 +380,7 @@ class _Body extends ConsumerWidget { ) : null, bottomBar: _BottomBar(options: options), + pockets: analysisState.currentPosition.pockets, children: [ ExplorerView( pov: pov, diff --git a/lib/src/view/analysis/retro_screen.dart b/lib/src/view/analysis/retro_screen.dart index 5c808d2514..9a4743bc3f 100644 --- a/lib/src/view/analysis/retro_screen.dart +++ b/lib/src/view/analysis/retro_screen.dart @@ -125,6 +125,7 @@ class _RetroScreen extends ConsumerWidget { return AnalysisLayout( pov: state.pov, + sideToMove: state.currentPosition.turn, boardBuilder: (context, boardSize, borderRadius) => RetroAnalysisBoard(options, boardSize: boardSize, boardRadius: borderRadius), engineGaugeBuilder: (context) { diff --git a/lib/src/view/board_editor/board_editor_screen.dart b/lib/src/view/board_editor/board_editor_screen.dart index e87dfbf350..62933f6adf 100644 --- a/lib/src/view/board_editor/board_editor_screen.dart +++ b/lib/src/view/board_editor/board_editor_screen.dart @@ -395,7 +395,7 @@ class _BottomBar extends ConsumerWidget { BottomSheetAction( makeLabel: (context) => Text(context.l10n.clearBoard), onPressed: () { - ref.read(editorController.notifier).loadFen(kEmptyFen); + ref.read(editorController.notifier).loadFen(kEmptyFEN); }, ), ], diff --git a/lib/src/view/broadcast/broadcast_game_screen.dart b/lib/src/view/broadcast/broadcast_game_screen.dart index baa57fd24c..009a4a9ddc 100644 --- a/lib/src/view/broadcast/broadcast_game_screen.dart +++ b/lib/src/view/broadcast/broadcast_game_screen.dart @@ -271,6 +271,7 @@ class _Body extends ConsumerWidget { return AnalysisLayout( pov: pov, + sideToMove: state.currentPosition.turn, tabController: tabController, boardBuilder: (context, boardSize, borderRadius) => BroadcastAnalysisBoard( roundId: roundId, diff --git a/lib/src/view/correspondence/offline_correspondence_game_screen.dart b/lib/src/view/correspondence/offline_correspondence_game_screen.dart index db1d739627..3b378eb714 100644 --- a/lib/src/view/correspondence/offline_correspondence_game_screen.dart +++ b/lib/src/view/correspondence/offline_correspondence_game_screen.dart @@ -11,6 +11,7 @@ import 'package:lichess_mobile/src/model/common/service/sound_service.dart'; import 'package:lichess_mobile/src/model/correspondence/correspondence_game_storage.dart'; import 'package:lichess_mobile/src/model/correspondence/offline_correspondence_game.dart'; import 'package:lichess_mobile/src/model/game/game.dart'; +import 'package:lichess_mobile/src/model/game/game_board_params.dart'; import 'package:lichess_mobile/src/model/game/game_status.dart'; import 'package:lichess_mobile/src/model/game/material_diff.dart'; import 'package:lichess_mobile/src/model/settings/board_preferences.dart'; @@ -189,7 +190,7 @@ class _BodyState extends ConsumerState<_Body> { isBoardTurned: isBoardTurned, ), lastMove: game.moveAt(stepCursor), - interactiveBoardParams: ( + boardParams: GameBoardParams.interactive( variant: game.meta.variant, position: position, playerSide: game.playable && !isReplaying diff --git a/lib/src/view/game/game_body.dart b/lib/src/view/game/game_body.dart index 9a2a7d05a6..7ee2848c31 100644 --- a/lib/src/view/game/game_body.dart +++ b/lib/src/view/game/game_body.dart @@ -10,6 +10,7 @@ import 'package:lichess_mobile/src/model/account/account_repository.dart'; import 'package:lichess_mobile/src/model/account/ongoing_game.dart'; import 'package:lichess_mobile/src/model/common/id.dart'; import 'package:lichess_mobile/src/model/common/speed.dart'; +import 'package:lichess_mobile/src/model/game/game_board_params.dart'; import 'package:lichess_mobile/src/model/game/game_controller.dart'; import 'package:lichess_mobile/src/model/game/game_preferences.dart'; import 'package:lichess_mobile/src/model/game/playable_game.dart'; @@ -258,7 +259,7 @@ class GameBody extends ConsumerWidget { isBoardTurned: isBoardTurned, ), lastMove: gameState.game.moveAt(gameState.stepCursor), - interactiveBoardParams: ( + boardParams: GameBoardParams.interactive( variant: gameState.game.meta.variant, position: gameState.currentPosition, playerSide: gameState.game.playable && !gameState.isReplaying diff --git a/lib/src/view/game/game_loading_board.dart b/lib/src/view/game/game_loading_board.dart index 189545e79a..068e1f4455 100644 --- a/lib/src/view/game/game_loading_board.dart +++ b/lib/src/view/game/game_loading_board.dart @@ -2,8 +2,9 @@ import 'package:dartchess/dartchess.dart'; import 'package:flutter/cupertino.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/challenge/challenge.dart'; +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/lobby/game_seek.dart'; import 'package:lichess_mobile/src/model/lobby/lobby_numbers.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; @@ -34,9 +35,7 @@ class _LobbyScreenLoadingContentState extends State { return SafeArea( child: GameLayout( orientation: Side.white, - fen: kEmptyFen, - topTable: const SizedBox.shrink(), - bottomTable: const SizedBox.shrink(), + boardParams: GameBoardParams.emptyBoard, moves: const [], boardOverlay: Card( color: Theme.of(context).dialogTheme.backgroundColor, @@ -136,9 +135,7 @@ class _ChallengeLoadingContentState extends State { return SafeArea( child: GameLayout( orientation: Side.white, - fen: kEmptyFen, - topTable: const SizedBox.shrink(), - bottomTable: const SizedBox.shrink(), + boardParams: GameBoardParams.emptyBoard, moves: const [], boardOverlay: Card( color: Theme.of(context).dialogTheme.backgroundColor, @@ -227,7 +224,11 @@ class StandaloneGameLoadingContent extends StatelessWidget { child: SafeArea( child: GameLayout( orientation: position?.orientation ?? Side.white, - fen: position?.fen ?? kEmptyFen, + boardParams: GameBoardParams.readonly( + fen: position?.fen ?? kEmptyFEN, + variant: Variant.standard, + pockets: null, + ), lastMove: position?.lastMove, topTable: const LoadingPlayerWidget(), bottomTable: const LoadingPlayerWidget(), @@ -300,9 +301,7 @@ class LoadGameError extends StatelessWidget { child: SafeArea( child: GameLayout( orientation: Side.white, - fen: kEmptyFen, - topTable: const SizedBox.shrink(), - bottomTable: const SizedBox.shrink(), + boardParams: GameBoardParams.emptyBoard, moves: const [], errorMessage: errorMessage, ), @@ -341,9 +340,7 @@ class ChallengeDeclinedBoard extends StatelessWidget { child: SafeArea( child: GameLayout( orientation: Side.white, - fen: kEmptyFen, - topTable: const SizedBox.shrink(), - bottomTable: const SizedBox.shrink(), + boardParams: GameBoardParams.emptyBoard, moves: const [], boardOverlay: Card( color: Theme.of(context).dialogTheme.backgroundColor, diff --git a/lib/src/view/offline_computer/offline_computer_game_screen.dart b/lib/src/view/offline_computer/offline_computer_game_screen.dart index e646eb8968..b0104f69ca 100644 --- a/lib/src/view/offline_computer/offline_computer_game_screen.dart +++ b/lib/src/view/offline_computer/offline_computer_game_screen.dart @@ -9,6 +9,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lichess_mobile/src/model/analysis/analysis_controller.dart'; import 'package:lichess_mobile/src/model/common/chess.dart'; +import 'package:lichess_mobile/src/model/game/game_board_params.dart'; import 'package:lichess_mobile/src/model/game/game_status.dart'; import 'package:lichess_mobile/src/model/game/offline_computer_game.dart'; import 'package:lichess_mobile/src/model/offline_computer/offline_computer_game_controller.dart'; @@ -240,10 +241,9 @@ class _BodyState extends ConsumerState<_Body> { youAre: orientation, isBoardTurned: isBoardFlipped, ), - fen: gameState.currentPosition.fen, lastMove: gameState.lastMove, shapes: _buildBoardShapes(gameState, boardColorScheme), - interactiveBoardParams: ( + boardParams: GameBoardParams.interactive( variant: Variant.standard, position: gameState.currentPosition, playerSide: gameState.game.finished diff --git a/lib/src/view/over_the_board/over_the_board_screen.dart b/lib/src/view/over_the_board/over_the_board_screen.dart index 53d73f3d4c..10a2860f5f 100644 --- a/lib/src/view/over_the_board/over_the_board_screen.dart +++ b/lib/src/view/over_the_board/over_the_board_screen.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lichess_mobile/src/model/analysis/analysis_controller.dart'; import 'package:lichess_mobile/src/model/common/id.dart'; +import 'package:lichess_mobile/src/model/game/game_board_params.dart'; import 'package:lichess_mobile/src/model/over_the_board/over_the_board_clock.dart'; import 'package:lichess_mobile/src/model/over_the_board/over_the_board_game_controller.dart'; import 'package:lichess_mobile/src/model/over_the_board/over_the_board_game_storage.dart'; @@ -223,20 +224,19 @@ class _BodyState extends ConsumerState<_Body> { key: _boardKey, topTable: _Player( side: orientation.opposite, - upsideDown: - !overTheBoardPrefs.flipPiecesAfterMove || orientation != gameState.turn, clockKey: const ValueKey('topClock'), ), + topTableUpsideDown: + !overTheBoardPrefs.flipPiecesAfterMove || orientation != gameState.turn, bottomTable: _Player( side: orientation, - upsideDown: - overTheBoardPrefs.flipPiecesAfterMove && orientation != gameState.turn, clockKey: const ValueKey('bottomClock'), ), + bottomTableUpsideDown: + overTheBoardPrefs.flipPiecesAfterMove && orientation != gameState.turn, orientation: orientation, - fen: gameState.currentPosition.fen, lastMove: gameState.lastMove, - interactiveBoardParams: ( + boardParams: GameBoardParams.interactive( variant: gameState.game.meta.variant, position: gameState.currentPosition, playerSide: gameState.game.finished @@ -437,11 +437,10 @@ class _BottomBar extends ConsumerWidget { } class _Player extends ConsumerWidget { - const _Player({required this.clockKey, required this.side, required this.upsideDown}); + const _Player({required this.clockKey, required this.side}); final Side side; final Key clockKey; - final bool upsideDown; @override Widget build(BuildContext context, WidgetRef ref) { @@ -449,27 +448,24 @@ class _Player extends ConsumerWidget { final boardPreferences = ref.watch(boardPreferencesProvider); final clock = ref.watch(overTheBoardClockProvider); - return RotatedBox( - quarterTurns: upsideDown ? 2 : 0, - child: GamePlayer( - game: gameState.game, - side: side, - materialDiff: boardPreferences.materialDifferenceFormat.visible - ? gameState.currentMaterialDiff(side) - : null, - materialDifferenceFormat: boardPreferences.materialDifferenceFormat, - shouldLinkToUserProfile: false, - clock: clock.timeIncrement.isInfinite - ? null - : Clock( - timeLeft: Duration(milliseconds: max(0, clock.timeLeft(side)!.inMilliseconds)), - key: clockKey, - active: clock.activeClock == side, - emergencyThreshold: Duration( - seconds: (clock.timeIncrement.time * 0.125).clamp(10, 60).toInt(), - ), + return GamePlayer( + game: gameState.game, + side: side, + materialDiff: boardPreferences.materialDifferenceFormat.visible + ? gameState.currentMaterialDiff(side) + : null, + materialDifferenceFormat: boardPreferences.materialDifferenceFormat, + shouldLinkToUserProfile: false, + clock: clock.timeIncrement.isInfinite + ? null + : Clock( + timeLeft: Duration(milliseconds: max(0, clock.timeLeft(side)!.inMilliseconds)), + key: clockKey, + active: clock.activeClock == side, + emergencyThreshold: Duration( + seconds: (clock.timeIncrement.time * 0.125).clamp(10, 60).toInt(), ), - ), + ), ); } } diff --git a/lib/src/view/play/create_challenge_bottom_sheet.dart b/lib/src/view/play/create_challenge_bottom_sheet.dart index 14ddcb6ca8..768e81d0d3 100644 --- a/lib/src/view/play/create_challenge_bottom_sheet.dart +++ b/lib/src/view/play/create_challenge_bottom_sheet.dart @@ -4,7 +4,6 @@ import 'package:dartchess/dartchess.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:lichess_mobile/src/constants.dart'; import 'package:lichess_mobile/src/model/account/account_repository.dart'; import 'package:lichess_mobile/src/model/challenge/challenge.dart'; import 'package:lichess_mobile/src/model/challenge/challenge_preferences.dart'; @@ -253,7 +252,7 @@ class _CreateChallengeBottomSheetState extends ConsumerState Chessboard.fixed( size: boardSize, settings: boardPrefs.toBoardSettings().copyWith( @@ -113,6 +115,7 @@ class _StudyScreenLoader extends ConsumerWidget { length: 1, child: AnalysisLayout( pov: Side.white, + sideToMove: null, boardBuilder: (context, boardSize, borderRadius) => Chessboard.fixed( size: boardSize, settings: boardPrefs.toBoardSettings().copyWith( @@ -182,9 +185,19 @@ class _StudyScreenState extends ConsumerState<_StudyScreen> with TickerProviderS @override Widget build(BuildContext context) { + final variant = widget.studyState.variant; return Scaffold( appBar: AppBar( - title: AppBarTitleText(widget.studyState.currentChapterTitle, maxLines: 2), + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (variant != Variant.standard && variant != Variant.fromPosition) ...[ + Icon(variant.icon), + const SizedBox(width: 5.0), + ], + Flexible(child: AppBarTitleText(widget.studyState.currentChapterTitle)), + ], + ), actions: [ if (tabs.length > 1) AppBarAnalysisTabIndicator(tabs: tabs, controller: _tabController), _StudyMenu(id: widget.id), @@ -399,6 +412,7 @@ class _Body extends ConsumerWidget { length: 1, child: AnalysisLayout( pov: Side.white, + sideToMove: null, boardBuilder: (context, boardSize, borderRadius) => SizedBox.square( dimension: boardSize, child: Center(child: Text('${variant.label} is not supported yet.')), @@ -423,6 +437,7 @@ class _Body extends ConsumerWidget { return AnalysisLayout( tabController: tabController, pov: pov, + sideToMove: studyState.currentPosition?.turn, boardBuilder: (context, boardSize, borderRadius) => StudyAnalysisBoard(id: id, boardSize: boardSize, boardRadius: borderRadius), engineGaugeBuilder: @@ -444,6 +459,7 @@ class _Body extends ConsumerWidget { ) : null, bottomBar: StudyBottomBar(id: id), + pockets: studyState.currentPosition?.pockets, children: tabs.map((tab) { switch (tab) { case AnalysisTab.explorer: diff --git a/lib/src/view/watch/live_tv_channels_screen.dart b/lib/src/view/watch/live_tv_channels_screen.dart index 1bc3414e9a..9ab6dae5a5 100644 --- a/lib/src/view/watch/live_tv_channels_screen.dart +++ b/lib/src/view/watch/live_tv_channels_screen.dart @@ -1,6 +1,6 @@ +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/tv/live_tv_channels.dart'; import 'package:lichess_mobile/src/model/tv/tv_channel.dart'; import 'package:lichess_mobile/src/styles/styles.dart'; @@ -65,7 +65,7 @@ class _Body extends ConsumerWidget { ); }, orientation: game.orientation, - fen: game.fen ?? kEmptyFen, + fen: game.fen ?? kEmptyFEN, lastMove: game.lastMove, description: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/src/view/watch/tv_screen.dart b/lib/src/view/watch/tv_screen.dart index 93322d3e16..f13a5ded0b 100644 --- a/lib/src/view/watch/tv_screen.dart +++ b/lib/src/view/watch/tv_screen.dart @@ -2,8 +2,8 @@ import 'package:dartchess/dartchess.dart'; import 'package:flutter/cupertino.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/id.dart'; +import 'package:lichess_mobile/src/model/game/game_board_params.dart'; import 'package:lichess_mobile/src/model/tv/tv_channel.dart'; import 'package:lichess_mobile/src/model/tv/tv_controller.dart'; import 'package:lichess_mobile/src/model/user/user.dart'; @@ -158,7 +158,11 @@ class _TvScreenState extends ConsumerState { youAre: gameState.orientation, isBoardTurned: false, ), - fen: position.fen, + boardParams: GameBoardParams.readonly( + fen: position.fen, + variant: gameState.game.meta.variant, + pockets: position.pockets, + ), boardSettingsOverrides: const BoardSettingsOverrides( animationDuration: Duration.zero, ), @@ -218,7 +222,7 @@ class _TvScreenState extends ConsumerState { topTable: LoadingPlayerWidget(), bottomTable: LoadingPlayerWidget(), orientation: Side.white, - fen: kEmptyFEN, + boardParams: GameBoardParams.emptyBoard, moves: [], userActionsBar: BottomBar.empty(), ), @@ -226,10 +230,8 @@ class _TvScreenState extends ConsumerState { error: (err, stackTrace) { debugPrint('SEVERE: [TvScreen] could not load stream; $err\n$stackTrace'); return const GameLayout( - topTable: kEmptyWidget, - bottomTable: kEmptyWidget, orientation: Side.white, - fen: kEmptyFEN, + boardParams: GameBoardParams.emptyBoard, errorMessage: 'Could not load TV stream.', moves: [], ); diff --git a/lib/src/widgets/game_layout.dart b/lib/src/widgets/game_layout.dart index 18240af802..3b141fc817 100644 --- a/lib/src/widgets/game_layout.dart +++ b/lib/src/widgets/game_layout.dart @@ -6,21 +6,13 @@ 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/game/game_board_params.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'; import 'package:lichess_mobile/src/widgets/move_list.dart'; - -typedef InteractiveBoardParams = ({ - Variant variant, - Position position, - PlayerSide playerSide, - NormalMove? promotionMove, - void Function(Move, {bool? viaDragAndDrop}) onMove, - void Function(Role? role) onPromotionSelection, - Premovable? premovable, -}); +import 'package:lichess_mobile/src/widgets/pockets.dart'; Side variantBoardOrientation({ required Variant variant, @@ -50,12 +42,13 @@ class GameLayout extends ConsumerStatefulWidget { /// Creates a game layout with the given values. const GameLayout({ required this.orientation, - this.fen, - this.interactiveBoardParams, + required this.boardParams, this.lastMove, this.boardSettingsOverrides, this.topTable = const SizedBox.shrink(), this.bottomTable = const SizedBox.shrink(), + this.topTableUpsideDown = false, + this.bottomTableUpsideDown = false, this.topTableFlex = 1, this.bottomTableFlex = 1, this.shapes, @@ -68,20 +61,18 @@ class GameLayout extends ConsumerStatefulWidget { this.zenMode = false, this.userActionsBar, super.key, - }) : assert( - fen != null || interactiveBoardParams != null, - 'Either a fen or interactiveBoardParams must be provided', - ); + }); /// Creates an empty game layout (useful for loading). const GameLayout.empty({this.moves, this.errorMessage}) : orientation = Side.white, - fen = kEmptyFen, - interactiveBoardParams = null, + boardParams = GameBoardParams.emptyBoard, lastMove = null, boardSettingsOverrides = null, topTable = const SizedBox.shrink(), bottomTable = const SizedBox.shrink(), + topTableUpsideDown = false, + bottomTableUpsideDown = false, topTableFlex = 1, bottomTableFlex = 1, shapes = null, @@ -92,9 +83,7 @@ class GameLayout extends ConsumerStatefulWidget { zenMode = false, userActionsBar = null; - final String? fen; - - final InteractiveBoardParams? interactiveBoardParams; + final GameBoardParams boardParams; final Side orientation; @@ -115,6 +104,12 @@ class GameLayout extends ConsumerStatefulWidget { /// Widget that will appear at the bottom of the board. final Widget bottomTable; + /// Whether to render the [topTable] upside down. Used for OTB games. + final bool topTableUpsideDown; + + /// Whether to render the [bottomTable] upside down. Used for OTB games. + final bool bottomTableUpsideDown; + /// Flex factor for the top table in portrait mode (default: 1). final int topTableFlex; @@ -170,6 +165,7 @@ class _GameLayoutState extends ConsumerState { onClearShapes: _onClearShapes, newShapeColor: boardPrefs.shapeColor.color, ), + enableDropMoves: widget.boardParams.variant.hasDropMoves == true, ); final settings = widget.boardSettingsOverrides != null @@ -179,18 +175,69 @@ class _GameLayoutState extends ConsumerState { final shapes = userShapes.union(widget.shapes ?? ISet()); final slicedMoves = widget.moves?.asMap().entries.slices(2); - final fen = widget.interactiveBoardParams?.position.fen ?? widget.fen!; - final gameData = widget.interactiveBoardParams != null - ? boardPrefs.toGameData( - variant: widget.interactiveBoardParams!.variant, - position: widget.interactiveBoardParams!.position, - playerSide: widget.interactiveBoardParams!.playerSide, - promotionMove: widget.interactiveBoardParams!.promotionMove, - onMove: widget.interactiveBoardParams!.onMove, - onPromotionSelection: widget.interactiveBoardParams!.onPromotionSelection, - premovable: widget.interactiveBoardParams!.premovable, - ) - : null; + 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; + + Widget topTable({required double boardSize}) => RotatedBox( + quarterTurns: widget.topTableUpsideDown ? 2 : 0, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + verticalDirection: widget.topTableUpsideDown + ? VerticalDirection.up + : VerticalDirection.down, + children: [ + if (pockets != null) + PocketsMenu( + side: widget.orientation.opposite, + sideToMove: sideToMove, + pockets: pockets, + squareSize: pocketSquareSize(boardSize: boardSize, isTablet: isTablet), + isUpsideDown: widget.topTableUpsideDown, + ), + widget.topTable, + ], + ), + ); + + Widget bottomTable({required double boardSize}) => RotatedBox( + quarterTurns: widget.bottomTableUpsideDown ? 2 : 0, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + verticalDirection: widget.bottomTableUpsideDown + ? VerticalDirection.up + : VerticalDirection.down, + children: [ + widget.bottomTable, + if (pockets != null) + PocketsMenu( + side: widget.orientation, + sideToMove: sideToMove, + pockets: pockets, + squareSize: pocketSquareSize(boardSize: boardSize, isTablet: isTablet), + isUpsideDown: widget.bottomTableUpsideDown, + ), + ], + ), + ); if (orientation == Orientation.landscape) { final defaultBoardSize = @@ -199,6 +246,7 @@ class _GameLayoutState extends ConsumerState { final boardSize = sideWidth >= 250 ? defaultBoardSize : constraints.biggest.longestSide / kGoldenRatio - (kTabletBoardTableSidePadding * 2); + return Padding( padding: const EdgeInsets.all(kTabletBoardTableSidePadding), child: Row( @@ -222,7 +270,7 @@ class _GameLayoutState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - widget.topTable, + topTable(boardSize: boardSize), if (boardPrefs.moveListDisplay && !widget.zenMode && slicedMoves != null) Expanded( child: Padding( @@ -244,7 +292,7 @@ class _GameLayoutState extends ConsumerState { child: widget.userActionsBar, ), - widget.bottomTable, + bottomTable(boardSize: boardSize), ], ), ), @@ -253,23 +301,29 @@ class _GameLayoutState extends ConsumerState { ); } else { final defaultBoardSize = constraints.biggest.shortestSide; - double effectiveBoarSize = isTablet - ? defaultBoardSize - kTabletBoardTableSidePadding * 2 - : defaultBoardSize; - // vertical space left on portrait mode to check if we can display the - // move list final isShortScreen = isShortVerticalScreen(context); + final pocketsPadding = (pockets != null && (isTablet || isShortScreen)) + ? kAdditionalBoardSidePaddingForPockets + : 0; + + double effectiveBoardSize = + (isTablet ? defaultBoardSize - kTabletBoardTableSidePadding * 2 : defaultBoardSize) - + pocketsPadding; + if (isShortScreen) { - effectiveBoarSize -= 16; + effectiveBoardSize -= 16; } return Column( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.center, children: [ - if (boardPrefs.moveListDisplay && slicedMoves != null && !isShortScreen) + if (boardPrefs.moveListDisplay && + slicedMoves != null && + !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) @@ -286,7 +340,7 @@ class _GameLayoutState extends ConsumerState { padding: EdgeInsets.symmetric( horizontal: isTablet ? kTabletBoardTableSidePadding : 12.0, ), - child: widget.topTable, + child: topTable(boardSize: effectiveBoardSize), ), ), Padding( @@ -294,7 +348,7 @@ class _GameLayoutState extends ConsumerState { ? const EdgeInsets.symmetric(horizontal: kTabletBoardTableSidePadding) : EdgeInsets.zero, child: BoardWidget( - size: effectiveBoarSize, + size: effectiveBoardSize, fen: fen, orientation: widget.orientation, gameData: gameData, @@ -312,7 +366,7 @@ class _GameLayoutState extends ConsumerState { padding: EdgeInsets.symmetric( horizontal: isTablet ? kTabletBoardTableSidePadding : 12.0, ), - child: widget.bottomTable, + child: bottomTable(boardSize: effectiveBoardSize), ), ), if (widget.userActionsBar != null) widget.userActionsBar!, diff --git a/lib/src/widgets/pgn.dart b/lib/src/widgets/pgn.dart index b30b7d040b..897da384dc 100644 --- a/lib/src/widgets/pgn.dart +++ b/lib/src/widgets/pgn.dart @@ -1238,8 +1238,13 @@ class InlineMove extends ConsumerWidget { : null) : null; + // In some crazyhouse PGNs (e.g. lichess studies), pawn drops include the `P` prefix, + // but we don't want to display it in the move list. + final san = branch.sanMove.san.startsWith('P') + ? branch.sanMove.san.substring(1) + : branch.sanMove.san; final moveWithNag = - branch.sanMove.san + + san + (branch.nags != null && params.shouldShowAnnotations ? moveAnnotationChar(branch.nags!) : ''); diff --git a/lib/src/widgets/pockets.dart b/lib/src/widgets/pockets.dart new file mode 100644 index 0000000000..127aa65082 --- /dev/null +++ b/lib/src/widgets/pockets.dart @@ -0,0 +1,132 @@ +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/model/settings/board_preferences.dart'; +import 'package:lichess_mobile/src/styles/styles.dart'; + +/// Visualization of captured pieces in variants like Crazyhouse. +class PocketsMenu extends ConsumerWidget { + const PocketsMenu({ + required this.pockets, + required this.side, + required this.sideToMove, + required this.squareSize, + this.isUpsideDown = false, + this.pieceAssets, + }); + + final Pockets pockets; + + final Side side; + + /// If this is equal to [side], pieces from the pockets are can be dragged onto the board to make a move. + final Side? sideToMove; + + /// Size of a square on the chessboard. + /// + /// Pieces in the pockets are rendered at the same size as pieces on the board. + final double squareSize; + + /// Whether the menu is currently rendered upside down by the parent widget. + /// + /// This is used to also flip the drag feedback widget when dragging a piece onto the board. + final bool isUpsideDown; + + /// Optionally overrides pieces assets used to render the pieces in the pockets. + /// + /// If null, the piece assets from the current board preferences are used. + final PieceAssets? pieceAssets; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final boardPrefs = ref.watch(boardPreferencesProvider); + + return Padding( + padding: const EdgeInsets.all(8.0), + child: Container( + clipBehavior: Clip.hardEdge, + decoration: const BoxDecoration(borderRadius: Styles.boardBorderRadius), + child: ColoredBox( + color: Theme.of(context).disabledColor, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: Role.values + .where((role) => role != Role.king) + .map( + (role) => _Pocket( + count: pockets.of(side, role), + role: role, + interactive: side == sideToMove, + side: side, + squareSize: squareSize, + pieceAssets: pieceAssets ?? boardPrefs.pieceSet.assets, + isUpsideDown: isUpsideDown, + ), + ) + .toList(growable: false), + ), + ), + ), + ); + } +} + +class _Pocket extends StatelessWidget { + const _Pocket({ + required this.role, + required this.count, + required this.interactive, + required this.side, + required this.squareSize, + required this.pieceAssets, + required this.isUpsideDown, + }); + + final Role role; + final int count; + final bool interactive; + final Side side; + final double squareSize; + final PieceAssets pieceAssets; + final bool isUpsideDown; + + @override + Widget build(BuildContext context) { + final piece = Piece(role: role, color: side); + + return IgnorePointer( + ignoring: !interactive || count == 0, + child: Draggable( + key: ValueKey('pocket-${side.name}${role.name}'), + data: Piece(role: role, color: side), + feedback: RotatedBox( + quarterTurns: isUpsideDown ? 2 : 0, + child: PieceDragFeedback( + piece: piece, + squareSize: squareSize, + pieceAssets: pieceAssets, + offset: isUpsideDown ? const Offset(1, 0) : const Offset(0, -1), + ), + ), + child: Badge( + offset: Offset.zero, + backgroundColor: ColorScheme.of(context).secondary, + textStyle: TextStyle( + color: ColorScheme.of(context).onSecondary, + fontWeight: FontWeight.bold, + ), + isLabelVisible: count > 0, + label: count > 0 ? Text('$count') : null, + child: PieceWidget( + piece: piece, + size: squareSize, + pieceAssets: pieceAssets, + opacity: count == 0 ? const AlwaysStoppedAnimation(0.3) : null, + ), + ), + ), + ); + } +} diff --git a/test/model/tv/tv_repository_test.dart b/test/model/tv/tv_repository_test.dart index fb9e5497d2..42e47eaf8f 100644 --- a/test/model/tv/tv_repository_test.dart +++ b/test/model/tv/tv_repository_test.dart @@ -24,7 +24,7 @@ void main() { expect(result, isA()); // supported channels only - expect(result.length, 13); + expect(result.length, 14); expect(result[TvChannel.best]?.user.name, 'Chessisnotfair'); }); diff --git a/test/test_helpers.dart b/test/test_helpers.dart index ad38e32ace..01841e592b 100644 --- a/test/test_helpers.dart +++ b/test/test_helpers.dart @@ -111,6 +111,24 @@ Future playMove( await tester.pump(); } +/// Plays a drop move on the board. +Future playDropMove( + WidgetTester tester, + Side side, + Role role, + String to, { + Rect? boardRect, + Side orientation = Side.white, +}) async { + final rect = boardRect ?? tester.getRect(find.byType(Chessboard)); + 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.pumpAndSettle(); +} + // -- class _SameRequest extends Matcher { diff --git a/test/view/analysis/analysis_layout_test.dart b/test/view/analysis/analysis_layout_test.dart index 92cbe0e944..58583379dc 100644 --- a/test/view/analysis/analysis_layout_test.dart +++ b/test/view/analysis/analysis_layout_test.dart @@ -23,6 +23,7 @@ void main() { length: 1, child: AnalysisLayout( pov: Side.white, + sideToMove: Side.white, boardBuilder: (context, boardSize, boardRadius) { return Chessboard.fixed( size: boardSize, @@ -71,6 +72,7 @@ void main() { length: 1, child: AnalysisLayout( pov: Side.white, + sideToMove: Side.white, boardBuilder: (context, boardSize, boardRadius) { return Chessboard.fixed( size: boardSize, diff --git a/test/view/analysis/analysis_screen_test.dart b/test/view/analysis/analysis_screen_test.dart index 1bbcbcd81b..42213ddc97 100644 --- a/test/view/analysis/analysis_screen_test.dart +++ b/test/view/analysis/analysis_screen_test.dart @@ -27,6 +27,7 @@ import 'package:lichess_mobile/src/view/engine/engine_lines.dart'; import 'package:lichess_mobile/src/view/more/more_tab_screen.dart'; import 'package:lichess_mobile/src/widgets/bottom_bar.dart'; import 'package:lichess_mobile/src/widgets/pgn.dart'; +import 'package:lichess_mobile/src/widgets/pockets.dart'; import 'package:multistockfish/multistockfish.dart'; import '../../model/engine/fake_stockfish.dart'; @@ -127,6 +128,8 @@ void main() { await tester.pumpWidget(app); + expect(find.byType(PocketsMenu), findsNothing); + await tester.tap(find.bySemanticsLabel('Menu')); await tester.pumpAndSettle(); // wait for menu to open @@ -146,16 +149,62 @@ void main() { expect(find.textContaining('Atomic'), findsNothing); expect(find.textContaining('Horde'), findsOneWidget); expect(find.textContaining('Racing Kings'), findsOneWidget); - expect(find.textContaining('Crazyhouse'), findsNothing); + expect(find.textContaining('Crazyhouse'), findsOneWidget); await tester.tap(find.textContaining('Horde')); await tester.pumpAndSettle(); // wait for dialog to close and new variant to be loaded + expect(find.byType(PocketsMenu), findsNothing); + // Horde starting position should be loaded: expect(find.byKey(const ValueKey('b5-whitepawn')), findsOneWidget); + + // Change to crazhouse, pockets should be displayed: + await tester.tap(find.bySemanticsLabel('Menu')); + await tester.pumpAndSettle(); // wait for menu to open + await tester.tap(find.text('Variant')); + await tester.pumpAndSettle(); // wait for dialog to open + await tester.tap(find.textContaining('Crazyhouse')); + await tester.pumpAndSettle(); // wait for dialog to close and new variant to be loaded + + // One for white, one for black + expect(find.byType(PocketsMenu), findsNWidgets(2)); } }); } + + testWidgets('Crazyhouse support DropMoves for both sides', (tester) async { + final app = await makeTestProviderScopeApp( + tester, + home: const AnalysisScreen( + options: AnalysisOptions.pgn( + id: StringId('standalone'), + orientation: Side.white, + pgn: ''' + [Variant "Crazyhouse"] + [FEN "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR[] w KQkq - 0 1"] + 1. e4 d5 2. exd5 Qxd5 + ''', + isComputerAnalysisAllowed: false, + variant: Variant.crazyhouse, + ), + ), + ); + + await tester.pumpWidget(app); + + expect(find.byType(PocketsMenu), findsNWidgets(2)); + + await playDropMove(tester, Side.white, Role.pawn, 'a4'); + expect(find.byKey(const ValueKey('a4-whitepawn')), findsOneWidget); + + // 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); + + await playDropMove(tester, Side.black, Role.pawn, 'h5'); + expect(find.byKey(const ValueKey('h5-blackpawn')), findsOneWidget); + }); }); group('Analysis Tree View', () { diff --git a/test/widgets/game_layout_test.dart b/test/widgets/game_layout_test.dart index 035a08838d..f2a2318a30 100644 --- a/test/widgets/game_layout_test.dart +++ b/test/widgets/game_layout_test.dart @@ -6,7 +6,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lichess_mobile/src/constants.dart'; 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/widgets/game_layout.dart'; +import 'package:lichess_mobile/src/widgets/pockets.dart'; import '../test_helpers.dart'; import '../test_provider_scope.dart'; @@ -22,7 +24,11 @@ void main() { child: const MaterialApp( home: GameLayout( orientation: Side.white, - fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR', + boardParams: GameBoardParams.readonly( + fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR', + variant: Variant.standard, + pockets: null, + ), topTable: Row( mainAxisSize: MainAxisSize.max, key: ValueKey('top_table'), @@ -69,7 +75,11 @@ void main() { child: const MaterialApp( home: GameLayout( orientation: Side.white, - fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR', + boardParams: GameBoardParams.readonly( + fen: 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR', + variant: Variant.standard, + pockets: null, + ), topTable: Row( mainAxisSize: MainAxisSize.max, key: ValueKey('top_table'), @@ -187,4 +197,38 @@ void main() { Side.black, ); }); + + testWidgets('Crazyhouse displays pockets and supports drop moves', (WidgetTester tester) async { + final playedMoves = []; + final app = await makeTestProviderScope( + tester, + child: MaterialApp( + home: GameLayout( + orientation: Side.white, + boardParams: GameBoardParams.interactive( + variant: Variant.crazyhouse, + position: Crazyhouse.fromSetup( + Setup.parseFen('rnb1kbnr/ppp1pppp/8/3q4/8/8/PPPP1PPP/RNBQKBNR[Pp] w KQkq - 0 3'), + ), + playerSide: PlayerSide.white, + onMove: (move, {viaDragAndDrop}) { + playedMoves.add(move); + }, + onPromotionSelection: (_) {}, + premovable: null, + promotionMove: null, + ), + ), + ), + ); + await tester.pumpWidget(app); + + expect(find.byType(PocketsMenu), findsNWidgets(2)); + + // Only the pockets of the player side should be interactive. + await playDropMove(tester, Side.white, Role.pawn, 'a4'); + await playDropMove(tester, Side.black, Role.pawn, 'a3'); + + expect(playedMoves, [const DropMove(to: Square.a4, role: Role.pawn)]); + }); }