diff --git a/lib/src/model/settings/board_preferences.dart b/lib/src/model/settings/board_preferences.dart index 7ebb717683..422c350ce0 100644 --- a/lib/src/model/settings/board_preferences.dart +++ b/lib/src/model/settings/board_preferences.dart @@ -5,6 +5,7 @@ import 'package:lichess_mobile/l10n/l10n.dart'; import 'package:lichess_mobile/src/model/settings/preferences_storage.dart'; import 'package:lichess_mobile/src/styles/styles.dart'; import 'package:lichess_mobile/src/utils/color_palette.dart'; +import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'board_preferences.freezed.dart'; @@ -43,6 +44,10 @@ class BoardPreferences extends _$BoardPreferences with PreferencesStorage setCastlingMethod(CastlingMethod castlingMethod) { + return save(state.copyWith(castlingMethod: castlingMethod)); + } + Future toggleHapticFeedback() { return save(state.copyWith(hapticFeedback: !state.hapticFeedback)); } @@ -125,6 +130,7 @@ class BoardPrefs with _$BoardPrefs implements Serializable { required ClockPosition clockPosition, @JsonKey(defaultValue: PieceShiftMethod.either, unknownEnumValue: PieceShiftMethod.either) required PieceShiftMethod pieceShiftMethod, + required CastlingMethod castlingMethod, /// Whether to enable shape drawings on the board for games and puzzles. @JsonKey(defaultValue: true) required bool enableShapeDrawings, @@ -150,6 +156,7 @@ class BoardPrefs with _$BoardPrefs implements Serializable { materialDifferenceFormat: MaterialDifferenceFormat.materialDifference, clockPosition: ClockPosition.right, pieceShiftMethod: PieceShiftMethod.either, + castlingMethod: CastlingMethod.either, enableShapeDrawings: true, magnifyDraggedPiece: true, dragTargetKind: DragTargetKind.circle, @@ -347,6 +354,19 @@ enum ClockPosition { }; } +enum CastlingMethod { + kingOverRook, + kingTwoSquares, + either; + + String castlingMethodl10n(BuildContext context, CastlingMethod castlingMethod) => + switch (castlingMethod) { + CastlingMethod.kingOverRook => context.l10n.preferencesCastleByMovingOntoTheRook, + CastlingMethod.kingTwoSquares => context.l10n.preferencesCastleByMovingTwoSquares, + CastlingMethod.either => 'Either', //TODO l10n string + }; +} + String dragTargetKindLabel(DragTargetKind kind) => switch (kind) { DragTargetKind.circle => 'Circle', DragTargetKind.square => 'Square', diff --git a/lib/src/view/analysis/analysis_board.dart b/lib/src/view/analysis/analysis_board.dart index d454dd8ecc..1976e4b362 100644 --- a/lib/src/view/analysis/analysis_board.dart +++ b/lib/src/view/analysis/analysis_board.dart @@ -11,6 +11,7 @@ import 'package:lichess_mobile/src/model/common/chess.dart'; import 'package:lichess_mobile/src/model/common/eval.dart'; import 'package:lichess_mobile/src/model/engine/evaluation_service.dart'; import 'package:lichess_mobile/src/model/settings/board_preferences.dart'; +import 'package:lichess_mobile/src/widgets/interactive_board.dart'; import 'package:lichess_mobile/src/widgets/pgn.dart'; class AnalysisBoard extends ConsumerStatefulWidget { @@ -66,12 +67,13 @@ class AnalysisBoardState extends ConsumerState { ) : ISet(); - return Chessboard( + return InteractiveBoardWidget( size: widget.boardSize, + boardPrefs: boardPrefs, fen: analysisState.position.fen, lastMove: analysisState.lastMove as NormalMove?, orientation: analysisState.pov, - game: GameData( + gameData: GameData( playerSide: analysisState.position.isGameOver ? PlayerSide.none diff --git a/lib/src/view/broadcast/broadcast_game_screen.dart b/lib/src/view/broadcast/broadcast_game_screen.dart index b5628d0c7e..05fb6039ed 100644 --- a/lib/src/view/broadcast/broadcast_game_screen.dart +++ b/lib/src/view/broadcast/broadcast_game_screen.dart @@ -25,6 +25,7 @@ import 'package:lichess_mobile/src/view/engine/engine_lines.dart'; import 'package:lichess_mobile/src/view/opening_explorer/opening_explorer_view.dart'; import 'package:lichess_mobile/src/widgets/buttons.dart'; import 'package:lichess_mobile/src/widgets/clock.dart'; +import 'package:lichess_mobile/src/widgets/interactive_board.dart'; import 'package:lichess_mobile/src/widgets/pgn.dart'; import 'package:lichess_mobile/src/widgets/platform_scaffold.dart'; @@ -320,12 +321,13 @@ class _BroadcastBoardState extends ConsumerState<_BroadcastBoard> { ) : ISet(); - return Chessboard( + return InteractiveBoardWidget( size: widget.boardSize, + boardPrefs: boardPrefs, fen: broadcastAnalysisState.position.fen, lastMove: broadcastAnalysisState.lastMove as NormalMove?, orientation: broadcastAnalysisState.pov, - game: GameData( + gameData: GameData( playerSide: broadcastAnalysisState.position.isGameOver ? PlayerSide.none diff --git a/lib/src/view/settings/board_settings_screen.dart b/lib/src/view/settings/board_settings_screen.dart index 3c7d79eeb7..56bfec9e79 100644 --- a/lib/src/view/settings/board_settings_screen.dart +++ b/lib/src/view/settings/board_settings_screen.dart @@ -71,6 +71,30 @@ class _Body extends ConsumerWidget { } }, ), + SettingsListTile( + settingsLabel: Text( + context.l10n.preferencesCastleByMovingTheKingTwoSquaresOrOntoTheRook, + ), + settingsValue: boardPrefs.castlingMethod.name, + showCupertinoTrailingValue: false, + onTap: () { + if (Theme.of(context).platform == TargetPlatform.android) { + showChoicePicker( + context, + choices: CastlingMethod.values, + selectedItem: boardPrefs.castlingMethod, + labelBuilder: (t) => Text(t.castlingMethodl10n(context, t)), + onSelectedItemChanged: (CastlingMethod? value) { + ref + .read(boardPreferencesProvider.notifier) + .setCastlingMethod(value ?? CastlingMethod.either); + }, + ); + } else { + Navigator.of(context).push(CastlingMethodSettingsScreen.buildRoute(context)); + } + }, + ), SwitchSettingTile( title: Text(context.l10n.mobilePrefMagnifyDraggedPiece), value: boardPrefs.magnifyDraggedPiece, @@ -263,6 +287,44 @@ class PieceShiftMethodSettingsScreen extends ConsumerWidget { } } +class CastlingMethodSettingsScreen extends ConsumerWidget { + const CastlingMethodSettingsScreen({super.key}); + + static Route buildRoute(BuildContext context) { + return buildScreenRoute( + context, + screen: const CastlingMethodSettingsScreen(), + title: 'Castling method', + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final castlingMethod = ref.watch( + boardPreferencesProvider.select((state) => state.castlingMethod), + ); + + void onChanged(CastlingMethod? value) { + ref.read(boardPreferencesProvider.notifier).setCastlingMethod(value ?? CastlingMethod.either); + } + + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar(), + child: ListView( + children: [ + ChoicePicker( + notchedTile: true, + choices: CastlingMethod.values, + selectedItem: castlingMethod, + titleBuilder: (t) => Text(t.castlingMethodl10n(context, t)), + onSelectedItemChanged: onChanged, + ), + ], + ), + ); + } +} + class BoardClockPositionScreen extends ConsumerWidget { const BoardClockPositionScreen({super.key}); diff --git a/lib/src/view/study/study_screen.dart b/lib/src/view/study/study_screen.dart index 65a885aacd..1c2fe55aec 100644 --- a/lib/src/view/study/study_screen.dart +++ b/lib/src/view/study/study_screen.dart @@ -32,6 +32,7 @@ import 'package:lichess_mobile/src/view/study/study_tree_view.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/feedback.dart'; +import 'package:lichess_mobile/src/widgets/interactive_board.dart'; import 'package:lichess_mobile/src/widgets/pgn.dart'; import 'package:lichess_mobile/src/widgets/platform_scaffold.dart'; import 'package:lichess_mobile/src/widgets/shimmer.dart'; @@ -530,8 +531,9 @@ class _StudyBoardState extends ConsumerState<_StudyBoard> { final sanMove = currentNode.sanMove; final annotation = makeAnnotation(studyState.currentNode.nags); - return Chessboard( + return InteractiveBoardWidget( size: widget.boardSize, + boardPrefs: boardPrefs, settings: boardPrefs.toBoardSettings().copyWith( borderRadius: widget.borderRadius, boxShadow: widget.borderRadius != null ? boardShadows : const [], @@ -552,7 +554,7 @@ class _StudyBoardState extends ConsumerState<_StudyBoard> { ? IMap({Move.parse(altCastles[sanMove.move.uci]!)!.to: annotation}) : IMap({sanMove.move.to: annotation}) : null, - game: + gameData: position != null ? GameData( playerSide: studyState.playerSide, diff --git a/lib/src/widgets/board_table.dart b/lib/src/widgets/board_table.dart index 2c10db0900..01843d5845 100644 --- a/lib/src/widgets/board_table.dart +++ b/lib/src/widgets/board_table.dart @@ -10,6 +10,7 @@ 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/view/engine/engine_gauge.dart'; +import 'package:lichess_mobile/src/widgets/interactive_board.dart'; import 'package:lichess_mobile/src/widgets/move_list.dart'; /// Board layout that adapts to screen size and aspect ratio. @@ -172,7 +173,7 @@ class _BoardTableState extends ConsumerState { child: Row( mainAxisSize: MainAxisSize.max, children: [ - _BoardWidget( + InteractiveBoardWidget( size: boardSize, boardPrefs: boardPrefs, fen: widget.fen, @@ -270,7 +271,7 @@ class _BoardTableState extends ConsumerState { isTablet ? const EdgeInsets.symmetric(horizontal: kTabletBoardTableSidePadding) : EdgeInsets.zero, - child: _BoardWidget( + child: InteractiveBoardWidget( size: boardSize, boardPrefs: boardPrefs, fen: widget.fen, @@ -319,104 +320,6 @@ class _BoardTableState extends ConsumerState { } } -class _BoardWidget extends StatelessWidget { - const _BoardWidget({ - required this.size, - required this.boardPrefs, - required this.fen, - required this.orientation, - required this.gameData, - required this.lastMove, - required this.shapes, - required this.settings, - required this.boardOverlay, - required this.error, - this.boardKey, - }); - - final double size; - final BoardPrefs boardPrefs; - final String fen; - final Side orientation; - final GameData? gameData; - final Move? lastMove; - final ISet shapes; - final ChessboardSettings settings; - final String? error; - final Widget? boardOverlay; - final GlobalKey? boardKey; - - @override - Widget build(BuildContext context) { - final board = Chessboard( - key: boardKey, - size: size, - fen: fen, - orientation: orientation, - game: gameData, - lastMove: lastMove, - shapes: shapes, - settings: settings, - ); - - if (boardOverlay != null) { - return SizedBox.square( - dimension: size, - child: Stack( - children: [ - board, - SizedBox.square( - dimension: size, - child: Center( - child: SizedBox( - width: (size / 8) * 6.6, - height: (size / 8) * 4.6, - child: boardOverlay, - ), - ), - ), - ], - ), - ); - } else if (error != null) { - return SizedBox.square( - dimension: size, - child: Stack(children: [board, _ErrorWidget(errorMessage: error!, boardSize: size)]), - ); - } - - return board; - } -} - -class _ErrorWidget extends StatelessWidget { - const _ErrorWidget({required this.errorMessage, required this.boardSize}); - final double boardSize; - final String errorMessage; - - @override - Widget build(BuildContext context) { - return SizedBox.square( - dimension: boardSize, - child: Center( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Container( - decoration: BoxDecoration( - color: - Theme.of(context).platform == TargetPlatform.iOS - ? CupertinoColors.secondarySystemBackground.resolveFrom(context) - : ColorScheme.of(context).surface, - borderRadius: const BorderRadius.all(Radius.circular(10.0)), - ), - child: Padding(padding: const EdgeInsets.all(10.0), child: Text(errorMessage)), - ), - ), - ), - ); - } -} - class BoardSettingsOverrides { const BoardSettingsOverrides({ this.animationDuration, diff --git a/lib/src/widgets/interactive_board.dart b/lib/src/widgets/interactive_board.dart new file mode 100644 index 0000000000..a7dbf48e20 --- /dev/null +++ b/lib/src/widgets/interactive_board.dart @@ -0,0 +1,143 @@ +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/settings/board_preferences.dart'; + +/// A stateless widget that displays an interactive chess board. +class InteractiveBoardWidget extends StatelessWidget { + InteractiveBoardWidget({ + required this.size, + required this.boardPrefs, + required this.fen, + required this.orientation, + required this.gameData, + this.lastMove, + this.shapes, + required this.settings, + this.boardOverlay, + this.error, + this.annotations, + this.boardKey, + }) : setup = Setup.parseFen(fen); + + final double size; + final BoardPrefs boardPrefs; + final String fen; + final Side orientation; + final GameData? gameData; + final Move? lastMove; + final ISet? shapes; + final ChessboardSettings settings; + final String? error; + final Widget? boardOverlay; + final IMap? annotations; + final GlobalKey? boardKey; + final Setup setup; + + @override + Widget build(BuildContext context) { + const Map castlingMap = { + Square.a1: Square.c1, + Square.a8: Square.c8, + Square.h1: Square.g1, + Square.h8: Square.g8, + }; + + GameData? modifiedGameData; + + MapEntry> mapper(Square sq, ISet moves) { + return MapEntry( + sq, + setup.board.kings.squares.contains(sq) && //king move + setup.castlingRights.squares.intersectsWith( + moves, //king can castle + ) + ? (switch (boardPrefs.castlingMethod) { + CastlingMethod.kingOverRook => moves.removeAll(castlingMap.values), + CastlingMethod.kingTwoSquares => moves.removeAll(castlingMap.keys), + _ => moves, + }) + : moves, + ); + } + + if (gameData != null) { + modifiedGameData = GameData( + playerSide: gameData!.playerSide, + sideToMove: gameData!.sideToMove, + validMoves: gameData!.validMoves.map(mapper), + promotionMove: gameData!.promotionMove, + onMove: gameData!.onMove, + onPromotionSelection: gameData!.onPromotionSelection, + premovable: gameData?.premovable, + isCheck: gameData?.isCheck, + ); + } + + final board = Chessboard( + key: boardKey, + size: size, + fen: fen, + orientation: orientation, + game: modifiedGameData ?? gameData, + lastMove: lastMove, + shapes: shapes, + settings: settings, + annotations: annotations, + ); + + if (boardOverlay != null) { + return SizedBox.square( + dimension: size, + child: Stack( + children: [ + board, + SizedBox.square( + dimension: size, + child: Center( + child: SizedBox( + width: (size / 8) * 6.6, + height: (size / 8) * 4.6, + child: boardOverlay, + ), + ), + ), + ], + ), + ); + } else if (error != null) { + return SizedBox.square( + dimension: size, + child: Stack(children: [board, _ErrorWidget(errorMessage: error!, boardSize: size)]), + ); + } + + return board; + } +} + +class _ErrorWidget extends StatelessWidget { + const _ErrorWidget({required this.errorMessage, required this.boardSize}); + final double boardSize; + final String errorMessage; + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: boardSize, + child: Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.all(Radius.circular(10.0)), + ), + child: Padding(padding: const EdgeInsets.all(10.0), child: Text(errorMessage)), + ), + ), + ), + ); + } +}