diff --git a/lib/src/model/board_editor/board_editor_controller.dart b/lib/src/model/board_editor/board_editor_controller.dart index 7535c5aa1d..df1d7f3559 100644 --- a/lib/src/model/board_editor/board_editor_controller.dart +++ b/lib/src/model/board_editor/board_editor_controller.dart @@ -3,26 +3,34 @@ import 'package:dartchess/dartchess.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:lichess_mobile/src/model/common/chess.dart'; +import 'package:lichess_mobile/src/model/common/chess960.dart'; part 'board_editor_controller.freezed.dart'; +typedef BoardEditorControllerParams = ({Variant initialVariant, String? initialFen}); + /// A provider for [BoardEditorController]. final boardEditorControllerProvider = NotifierProvider.autoDispose - .family( + .family( BoardEditorController.new, name: 'BoardEditorControllerProvider', ); class BoardEditorController extends Notifier { - BoardEditorController(this.initialFen); + BoardEditorController(this.params); - final String? initialFen; + final BoardEditorControllerParams? params; @override BoardEditorState build() { - final setup = Setup.parseFen(initialFen ?? kInitialFEN); - final position = Chess.fromSetup(setup, ignoreImpossibleCheck: true); - final pieces = readFen(initialFen ?? kInitialFEN).lock; + final variant = params?.initialVariant ?? Variant.standard; + final fen = + params?.initialFen ?? + (variant == Variant.chess960 ? randomChess960Position() : variant.initialPosition).fen; + final setup = Setup.parseFen(fen); + final position = Position.setupPosition(variant.rule, setup, ignoreImpossibleCheck: true); + final pieces = readFen(fen).lock; final castlingRights = IMap({ CastlingRight.whiteKing: position.castles.rookOf(Side.white, CastlingSide.king) != null, @@ -33,6 +41,7 @@ class BoardEditorController extends Notifier { return BoardEditorState( orientation: Side.white, sideToPlay: setup.turn, + variant: variant, pieces: pieces, castlingRights: castlingRights, editorPointerMode: EditorPointerMode.drag, @@ -131,6 +140,10 @@ class BoardEditorController extends Notifier { state = state.copyWith(enPassantSquare: state.enPassantSquare == square ? null : square); } + void setVariant(Variant variant) { + state = state.copyWith(variant: variant); + } + void _updatePosition(IMap pieces) { state = state.copyWith( pieces: pieces, @@ -175,6 +188,7 @@ sealed class BoardEditorState with _$BoardEditorState { const factory BoardEditorState({ required Side orientation, required Side sideToPlay, + required Variant variant, required IMap pieces, required IMap castlingRights, required EditorPointerMode editorPointerMode, @@ -261,9 +275,9 @@ sealed class BoardEditorState with _$BoardEditorState { /// Returns `null` if the position is invalid. String? get pgn { try { - final position = Chess.fromSetup(Setup.parseFen(fen)); + final position = Position.setupPosition(variant.rule, Setup.parseFen(fen)); return PgnGame( - headers: {'FEN': position.fen}, + headers: {'FEN': position.fen, 'Variant': variant.label}, moves: PgnNode(), comments: [], ).makePgn(); diff --git a/lib/src/model/common/chess.dart b/lib/src/model/common/chess.dart index 96dd0d8820..87f4aea887 100644 --- a/lib/src/model/common/chess.dart +++ b/lib/src/model/common/chess.dart @@ -98,6 +98,15 @@ enum Variant { final String label; final IconData icon; + bool sideCanCastle(Side side) { + if (this == Variant.racingKings) return false; + if (this == Variant.antichess) return false; + if (side == Side.white && this == Variant.horde) return false; + return true; + } + + bool get hasEnPassant => this != Variant.racingKings; + bool get isReadSupported => readSupportedVariants.contains(this); bool get isPlaySupported => playSupportedVariants.contains(this); diff --git a/lib/src/model/common/chess960.dart b/lib/src/model/common/chess960.dart index 6daf1bd857..d0ff26416f 100644 --- a/lib/src/model/common/chess960.dart +++ b/lib/src/model/common/chess960.dart @@ -4,14 +4,20 @@ import 'package:dartchess/dartchess.dart'; final _random = Random.secure(); -Position randomChess960Position() { - final rank8 = _positions[_random.nextInt(_positions.length)]; +Position chess960Position(int index) { + if (index < 0 || index >= _positions.length) { + throw ArgumentError('Index must be between 0 and ${_positions.length - 1}'); + } + + final rank8 = _positions[index]; return Chess.fromSetup( Setup.parseFen('$rank8/pppppppp/8/8/8/8/PPPPPPPP/${rank8.toUpperCase()} w KQkq - 0 1'), ); } +Position randomChess960Position() => chess960Position(_random.nextInt(_positions.length)); + // https://github.com/lichess-org/scalachess/blob/bd139c6dc1acdc8fff08c46e412f784d49a16578/core/src/main/scala/variant/Chess960.scala#L49 final _positions = [ 'bbqnnrkr', diff --git a/lib/src/view/analysis/analysis_screen.dart b/lib/src/view/analysis/analysis_screen.dart index cf1b2622b4..f358d5ca4a 100644 --- a/lib/src/view/analysis/analysis_screen.dart +++ b/lib/src/view/analysis/analysis_screen.dart @@ -41,9 +41,9 @@ import 'package:lichess_mobile/src/widgets/adaptive_choice_picker.dart'; import 'package:lichess_mobile/src/widgets/bottom_bar.dart'; import 'package:lichess_mobile/src/widgets/buttons.dart'; import 'package:lichess_mobile/src/widgets/feedback.dart'; -import 'package:lichess_mobile/src/widgets/misc.dart'; import 'package:lichess_mobile/src/widgets/platform_context_menu_button.dart'; import 'package:lichess_mobile/src/widgets/user.dart'; +import 'package:lichess_mobile/src/widgets/variant_app_bar_title.dart'; import 'package:logging/logging.dart'; import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -193,7 +193,7 @@ class _AnalysisScreenState extends ConsumerState<_AnalysisScreen> resizeToAvoidBottomInset: false, appBar: AppBar( centerTitle: false, - title: _Title(variant: value.variant), + title: VariantAppBarTitle(variant: value.variant, title: context.l10n.analysis), actions: appBarActions, ), body: _Body(options: widget.options, controller: _tabController), @@ -207,7 +207,7 @@ class _AnalysisScreenState extends ConsumerState<_AnalysisScreen> resizeToAvoidBottomInset: false, appBar: AppBar( centerTitle: false, - title: const _Title(variant: Variant.standard), + title: VariantAppBarTitle(variant: Variant.standard, title: context.l10n.analysis), actions: appBarActions, ), body: const Center(child: CircularProgressIndicator.adaptive()), @@ -275,25 +275,6 @@ class _AnalysisMenu extends ConsumerWidget { } } -class _Title extends StatelessWidget { - const _Title({required this.variant}); - - final Variant variant; - - static const excludedIcons = [Variant.standard, Variant.fromPosition]; - - @override - Widget build(BuildContext context) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (!excludedIcons.contains(variant)) ...[Icon(variant.icon), const SizedBox(width: 5.0)], - Flexible(child: AppBarTitleText(context.l10n.analysis)), - ], - ); - } -} - class _Body extends ConsumerWidget { const _Body({required this.options, required this.controller}); @@ -677,9 +658,12 @@ class _BottomBar extends ConsumerWidget { makeLabel: (context) => Text(context.l10n.boardEditor), onPressed: () { final boardFen = analysisState.currentPosition.fen; - Navigator.of( - context, - ).push(BoardEditorScreen.buildRoute(context, initialFen: boardFen)); + Navigator.of(context).push( + BoardEditorScreen.buildRoute(context, ( + initialVariant: analysisState.variant, + initialFen: boardFen, + )), + ); }, ), if (analysisState.isComputerAnalysisAllowed) diff --git a/lib/src/view/board_editor/board_editor_filters.dart b/lib/src/view/board_editor/board_editor_filters.dart index 8dad0241f0..1a97ce8e52 100644 --- a/lib/src/view/board_editor/board_editor_filters.dart +++ b/lib/src/view/board_editor/board_editor_filters.dart @@ -1,4 +1,5 @@ import 'package:dartchess/dartchess.dart' hide Position; +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/model/board_editor/board_editor_controller.dart'; @@ -7,15 +8,19 @@ import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/widgets/adaptive_bottom_sheet.dart'; class BoardEditorFilters extends ConsumerWidget { - const BoardEditorFilters({required this.initialFen, super.key}); + const BoardEditorFilters({required this.params, super.key}); - final String? initialFen; + final BoardEditorControllerParams? params; @override Widget build(BuildContext context, WidgetRef ref) { - final editorController = boardEditorControllerProvider(initialFen); + final editorController = boardEditorControllerProvider(params); final editorState = ref.watch(editorController); + final castlingSide = Side.values + .where((side) => editorState.variant.sideCanCastle(side)) + .toIList(); + return BottomSheetScrollableContainer( padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 8.0), children: [ @@ -36,53 +41,60 @@ class BoardEditorFilters extends ConsumerWidget { }).toList(), ), ), - Padding( - padding: Styles.bodySectionPadding, - child: Text(context.l10n.castling, style: Styles.title), - ), - ...Side.values.map((side) { - return Padding( - padding: Styles.horizontalBodyPadding, - child: Row( - spacing: 8.0, - children: [ - SizedBox( - width: 100.0, - child: Text( - side == Side.white ? context.l10n.white : context.l10n.black, - maxLines: 1, - overflow: TextOverflow.ellipsis, + if (castlingSide.isNotEmpty) ...[ + Padding( + padding: Styles.bodySectionPadding, + child: Text(context.l10n.castling, style: Styles.title), + ), + ...Side.values.where((side) => editorState.variant.sideCanCastle(side)).map((side) { + return Padding( + padding: Styles.horizontalBodyPadding, + child: Row( + spacing: 8.0, + children: [ + SizedBox( + width: 100.0, + child: Text( + side == Side.white ? context.l10n.white : context.l10n.black, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), - ), - ...[CastlingSide.king, CastlingSide.queen].map((castlingSide) { - return ChoiceChip( - label: Text(castlingSide == CastlingSide.king ? 'O-O' : 'O-O-O'), - selected: editorState.isCastlingAllowed(side, castlingSide), - onSelected: (selected) { - ref.read(editorController.notifier).setCastling(side, castlingSide, selected); - }, - ); - }), - ], - ), - ); - }), - if (editorState.enPassantOptions.isNotEmpty) ...[ + ...[CastlingSide.king, CastlingSide.queen].map((castlingSide) { + return ChoiceChip( + label: Text(castlingSide == CastlingSide.king ? 'O-O' : 'O-O-O'), + selected: editorState.isCastlingAllowed(side, castlingSide), + onSelected: (selected) { + ref + .read(editorController.notifier) + .setCastling(side, castlingSide, selected); + }, + ); + }), + ], + ), + ); + }), + ], + if (editorState.variant.hasEnPassant && editorState.enPassantOptions.isNotEmpty) ...[ const Padding( padding: Styles.bodySectionPadding, child: Text('En passant', style: Styles.subtitle), ), - Wrap( - spacing: 8.0, - children: editorState.enPassantOptions.squares.map((square) { - return ChoiceChip( - label: Text(square.name), - selected: editorState.enPassantSquare == square, - onSelected: (selected) { - ref.read(editorController.notifier).toggleEnPassantSquare(square); - }, - ); - }).toList(), + Padding( + padding: Styles.horizontalBodyPadding, + child: Wrap( + spacing: 8.0, + children: editorState.enPassantOptions.squares.map((square) { + return ChoiceChip( + label: Text(square.name), + selected: editorState.enPassantSquare == square, + onSelected: (selected) { + ref.read(editorController.notifier).toggleEnPassantSquare(square); + }, + ); + }).toList(), + ), ), ], ], diff --git a/lib/src/view/board_editor/board_editor_screen.dart b/lib/src/view/board_editor/board_editor_screen.dart index 71e4cb36dd..e9e50a95fb 100644 --- a/lib/src/view/board_editor/board_editor_screen.dart +++ b/lib/src/view/board_editor/board_editor_screen.dart @@ -22,28 +22,33 @@ import 'package:lichess_mobile/src/view/board_editor/board_editor_positions.dart import 'package:lichess_mobile/src/view/play/create_challenge_bottom_sheet.dart'; import 'package:lichess_mobile/src/view/user/search_screen.dart'; import 'package:lichess_mobile/src/widgets/adaptive_action_sheet.dart'; +import 'package:lichess_mobile/src/widgets/adaptive_choice_picker.dart'; import 'package:lichess_mobile/src/widgets/bottom_bar.dart'; import 'package:lichess_mobile/src/widgets/buttons.dart'; import 'package:lichess_mobile/src/widgets/feedback.dart'; import 'package:lichess_mobile/src/widgets/platform.dart'; +import 'package:lichess_mobile/src/widgets/variant_app_bar_title.dart'; import 'package:share_plus/share_plus.dart'; class BoardEditorScreen extends ConsumerWidget { - const BoardEditorScreen({super.key, this.initialFen}); + const BoardEditorScreen({super.key, this.params}); - final String? initialFen; + final BoardEditorControllerParams? params; - static Route buildRoute(BuildContext context, {String? initialFen}) { - return buildScreenRoute(context, screen: BoardEditorScreen(initialFen: initialFen)); + static Route buildRoute(BuildContext context, BoardEditorControllerParams? params) { + return buildScreenRoute(context, screen: BoardEditorScreen(params: params)); } @override Widget build(BuildContext context, WidgetRef ref) { - final boardEditorState = ref.watch(boardEditorControllerProvider(initialFen)); + final boardEditorState = ref.watch(boardEditorControllerProvider(params)); return Scaffold( appBar: AppBar( - title: Text(context.l10n.boardEditor), + title: VariantAppBarTitle( + variant: boardEditorState.variant, + title: context.l10n.boardEditor, + ), actions: [ IconButton( icon: const Icon(Icons.edit), @@ -52,7 +57,7 @@ class BoardEditorScreen extends ConsumerWidget { context: context, builder: (_) => _FenDialog( onFenLoaded: (fen) => - ref.read(boardEditorControllerProvider(initialFen).notifier).loadFen(fen), + ref.read(boardEditorControllerProvider(params).notifier).loadFen(fen), ), ), ), @@ -82,14 +87,14 @@ class BoardEditorScreen extends ConsumerWidget { children: [ _PieceMenu( boardSize, - initialFen: initialFen, + params: params, direction: flipAxis(direction), side: boardEditorState.orientation.opposite, isTablet: isTablet, ), _BoardEditor( boardSize, - initialFen: initialFen, + params: params, orientation: boardEditorState.orientation, isTablet: isTablet, // unlockView is safe because chessground will never modify the pieces @@ -97,7 +102,7 @@ class BoardEditorScreen extends ConsumerWidget { ), _PieceMenu( boardSize, - initialFen: initialFen, + params: params, direction: flipAxis(direction), side: boardEditorState.orientation, isTablet: isTablet, @@ -107,7 +112,7 @@ class BoardEditorScreen extends ConsumerWidget { }, ), ), - bottomNavigationBar: _BottomBar(initialFen), + bottomNavigationBar: _BottomBar(params), ); } } @@ -115,13 +120,13 @@ class BoardEditorScreen extends ConsumerWidget { class _BoardEditor extends ConsumerWidget { const _BoardEditor( this.boardSize, { - required this.initialFen, + required this.params, required this.isTablet, required this.orientation, required this.pieces, }); - final String? initialFen; + final BoardEditorControllerParams? params; final double boardSize; final bool isTablet; final Side orientation; @@ -129,7 +134,7 @@ class _BoardEditor extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final editorState = ref.watch(boardEditorControllerProvider(initialFen)); + final editorState = ref.watch(boardEditorControllerProvider(params)); final boardPrefs = ref.watch(boardPreferencesProvider); return ChessboardEditor( @@ -142,12 +147,11 @@ class _BoardEditor extends ConsumerWidget { ), pointerMode: editorState.editorPointerMode, onDiscardedPiece: (Square square) => - ref.read(boardEditorControllerProvider(initialFen).notifier).discardPiece(square), - onDroppedPiece: (Square? origin, Square dest, Piece piece) => ref - .read(boardEditorControllerProvider(initialFen).notifier) - .movePiece(origin, dest, piece), + ref.read(boardEditorControllerProvider(params).notifier).discardPiece(square), + onDroppedPiece: (Square? origin, Square dest, Piece piece) => + ref.read(boardEditorControllerProvider(params).notifier).movePiece(origin, dest, piece), onEditedSquare: (Square square) => - ref.read(boardEditorControllerProvider(initialFen).notifier).editSquare(square), + ref.read(boardEditorControllerProvider(params).notifier).editSquare(square), ); } } @@ -155,13 +159,13 @@ class _BoardEditor extends ConsumerWidget { class _PieceMenu extends ConsumerStatefulWidget { const _PieceMenu( this.boardSize, { - required this.initialFen, + required this.params, required this.direction, required this.side, required this.isTablet, }); - final String? initialFen; + final BoardEditorControllerParams? params; final double boardSize; @@ -179,7 +183,7 @@ class _PieceMenuState extends ConsumerState<_PieceMenu> { @override Widget build(BuildContext context) { final boardPrefs = ref.watch(boardPreferencesProvider); - final editorController = boardEditorControllerProvider(widget.initialFen); + final editorController = boardEditorControllerProvider(widget.params); final editorState = ref.watch(editorController); final squareSize = widget.boardSize / 8; @@ -223,7 +227,7 @@ class _PieceMenuState extends ConsumerState<_PieceMenu> { return ColoredBox( key: Key('piece-button-${piece.color.name}-${piece.role.name}'), color: - ref.read(boardEditorControllerProvider(widget.initialFen)).activePieceOnEdit == + ref.read(boardEditorControllerProvider(widget.params)).activePieceOnEdit == piece ? ColorScheme.of(context).primary : Colors.transparent, @@ -268,13 +272,13 @@ class _PieceMenuState extends ConsumerState<_PieceMenu> { } class _BottomBar extends ConsumerWidget { - const _BottomBar(this.initialFen); + const _BottomBar(this.params); - final String? initialFen; + final BoardEditorControllerParams? params; @override Widget build(BuildContext context, WidgetRef ref) { - final editorController = boardEditorControllerProvider(initialFen); + final editorController = boardEditorControllerProvider(params); final editorState = ref.watch(editorController); final pieceCount = editorState.pieces.length; @@ -286,12 +290,16 @@ class _BottomBar extends ConsumerWidget { onTap: () => showAdaptiveActionSheet( context: context, actions: [ - BottomSheetAction( - makeLabel: (context) => Text(context.l10n.startPosition), - onPressed: () { - ref.read(editorController.notifier).loadFen(kInitialEPD); - }, - ), + if (editorState.variant != Variant.chess960 && + editorState.variant != Variant.fromPosition) + BottomSheetAction( + makeLabel: (context) => Text(context.l10n.startPosition), + onPressed: () { + ref + .read(editorController.notifier) + .loadFen(editorState.variant.initialPosition.fen); + }, + ), BottomSheetAction( makeLabel: (context) => Text(context.l10n.loadPosition), onPressed: () { @@ -307,44 +315,75 @@ class _BottomBar extends ConsumerWidget { ); }, ), - BottomSheetAction( - // TODO: l10n - makeLabel: (context) => const Text('Challenge from position'), - onPressed: () { - final authUser = ref.read(authControllerProvider); - if (authUser == null) { - showSnackBar( - context, - context.l10n.challengeRegisterToSendChallenges, - type: SnackBarType.error, - ); - return; - } - Navigator.of(context).push( - SearchScreen.buildRoute( - context, - onUserTap: (user) { - if (user.id == authUser.user.id) { - showSnackBar( - context, - 'You cannot challenge yourself', - type: SnackBarType.error, + if (editorState.variant == Variant.standard) + BottomSheetAction( + // TODO: l10n + makeLabel: (context) => const Text('Challenge from position'), + onPressed: () { + final authUser = ref.read(authControllerProvider); + if (authUser == null) { + showSnackBar( + context, + context.l10n.challengeRegisterToSendChallenges, + type: SnackBarType.error, + ); + return; + } + Navigator.of(context).push( + SearchScreen.buildRoute( + context, + onUserTap: (user) { + if (user.id == authUser.user.id) { + showSnackBar( + context, + 'You cannot challenge yourself', + type: SnackBarType.error, + ); + } + showModalBottomSheet( + context: context, + isScrollControlled: true, + useRootNavigator: true, + builder: (context) { + return CreateChallengeBottomSheet(user, positionFen: editorState.fen); + }, ); - } - showModalBottomSheet( - context: context, - isScrollControlled: true, - useRootNavigator: true, - builder: (context) { - return CreateChallengeBottomSheet(user, positionFen: editorState.fen); - }, - ); - }, - // TODO: l10n - title: const Text('Challenge from position'), + }, + // TODO: l10n + title: const Text('Challenge from position'), + ), + ); + }, + ), + BottomSheetAction( + makeLabel: (context) => Text(context.l10n.variant), + onPressed: () => showChoicePicker( + context, + choices: readSupportedVariants + .where( + // TODO, for chess960 to be meaningful here, we'd need to display a dialog to load one of the starting positions + (variant) => variant != Variant.fromPosition && variant != Variant.chess960, + ) + .toList(), + selectedItem: editorState.variant, + labelBuilder: (Variant variant) => Text.rich( + TextSpan( + children: [ + WidgetSpan( + child: Icon(variant.icon), + alignment: PlaceholderAlignment.middle, + ), + const WidgetSpan(child: SizedBox(width: 8)), + TextSpan(text: variant.label), + ], ), - ); - }, + ), + onSelectedItemChanged: (Variant variant) { + if (variant != editorState.variant) { + ref.read(editorController.notifier).setVariant(variant); + } + }, + ), ), BottomSheetAction( makeLabel: (context) => Text(context.l10n.clearBoard), @@ -358,7 +397,7 @@ class _BottomBar extends ConsumerWidget { BottomBarButton( key: const Key('flip-button'), label: context.l10n.flipBoard, - onTap: ref.read(boardEditorControllerProvider(initialFen).notifier).flipBoard, + onTap: ref.read(boardEditorControllerProvider(params).notifier).flipBoard, icon: CupertinoIcons.arrow_2_squarepath, ), BottomBarButton( @@ -367,8 +406,7 @@ class _BottomBar extends ConsumerWidget { onTap: editorState.pgn != null && // 1 condition (of many) where stockfish segfaults - pieceCount > 0 && - pieceCount <= 32 + (pieceCount > 0 && (pieceCount <= 32 || editorState.variant == Variant.horde)) ? () { Navigator.of(context).push( AnalysisScreen.buildRoute( @@ -378,7 +416,9 @@ class _BottomBar extends ConsumerWidget { orientation: editorState.orientation, pgn: editorState.pgn!, isComputerAnalysisAllowed: true, - variant: Variant.fromPosition, + variant: editorState.variant.rule == Rule.chess + ? Variant.fromPosition + : editorState.variant, ), ), ); @@ -390,7 +430,7 @@ class _BottomBar extends ConsumerWidget { label: 'Filters', onTap: () => showModalBottomSheet( context: context, - builder: (BuildContext context) => BoardEditorFilters(initialFen: initialFen), + builder: (BuildContext context) => BoardEditorFilters(params: params), showDragHandle: true, constraints: BoxConstraints(minHeight: MediaQuery.sizeOf(context).height * 0.5), ), diff --git a/lib/src/view/more/more_tab_screen.dart b/lib/src/view/more/more_tab_screen.dart index 3ced3ffa1e..8bee0d446a 100644 --- a/lib/src/view/more/more_tab_screen.dart +++ b/lib/src/view/more/more_tab_screen.dart @@ -120,10 +120,12 @@ class _Body extends ConsumerWidget { ? const CupertinoListTileChevron() : null, title: Text(context.l10n.boardEditor), - onTap: () => Navigator.of( - context, - rootNavigator: true, - ).push(BoardEditorScreen.buildRoute(context)), + onTap: () => Navigator.of(context, rootNavigator: true).push( + BoardEditorScreen.buildRoute(context, ( + initialVariant: Variant.standard, + initialFen: null, + )), + ), ), ListTile( leading: const Icon(Icons.alarm_outlined), diff --git a/lib/src/widgets/variant_app_bar_title.dart b/lib/src/widgets/variant_app_bar_title.dart new file mode 100644 index 0000000000..eb0f556d27 --- /dev/null +++ b/lib/src/widgets/variant_app_bar_title.dart @@ -0,0 +1,24 @@ +import 'package:flutter/widgets.dart'; +import 'package:lichess_mobile/src/model/common/chess.dart'; +import 'package:lichess_mobile/src/widgets/misc.dart'; + +/// A widget that displays a [AppBarTitleText] preceded by an icon based on the variant type. +class VariantAppBarTitle extends StatelessWidget { + const VariantAppBarTitle({super.key, required this.variant, required this.title}); + + final Variant variant; + final String title; + + static const excludedIcons = [Variant.standard, Variant.fromPosition]; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (!excludedIcons.contains(variant)) ...[Icon(variant.icon), const SizedBox(width: 5.0)], + Flexible(child: AppBarTitleText(title)), + ], + ); + } +} diff --git a/test/view/board_editor/board_editor_screen_test.dart b/test/view/board_editor/board_editor_screen_test.dart index 151cd68144..a24d629b01 100644 --- a/test/view/board_editor/board_editor_screen_test.dart +++ b/test/view/board_editor/board_editor_screen_test.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lichess_mobile/src/model/board_editor/board_editor_controller.dart'; +import 'package:lichess_mobile/src/model/common/chess.dart'; import 'package:lichess_mobile/src/view/board_editor/board_editor_screen.dart'; import 'package:lichess_mobile/src/widgets/bottom_bar.dart'; @@ -33,7 +34,52 @@ void main() { expect(editor.orientation, Side.white); expect(editor.pointerMode, EditorPointerMode.drag); - // Legal position, so allowed top open analysis board + // Legal position, so allowed to open analysis board + expect( + tester.widget(find.byKey(const Key('analysis-board-button'))).onTap, + isNotNull, + ); + }); + + testWidgets('Opening with variant loads its starting position', (tester) async { + final app = await makeTestProviderScopeApp( + tester, + home: const BoardEditorScreen(params: (initialVariant: Variant.horde, initialFen: null)), + ); + await tester.pumpWidget(app); + + final editor = tester.widget(find.byType(ChessboardEditor)); + expect(editor.pieces, readFen(Variant.horde.initialPosition.fen)); + }); + + testWidgets('Changing variant', (tester) async { + final app = await makeTestProviderScopeApp(tester, home: const BoardEditorScreen()); + await tester.pumpWidget(app); + + 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 variant selection dialog to open + await tester.tap(find.textContaining('Horde')); + await tester.pumpAndSettle(); // wait for variant to change + + // After changing variant, pieces are not reset to that Variant's initial position + expect( + tester.widget(find.byType(ChessboardEditor)).pieces, + readFen(Chess.initial.fen), + ); + + /// But when explicitly resetting to the starting position, we now load the Horde starting position + await tester.tap(find.bySemanticsLabel('Menu')); + await tester.pumpAndSettle(); // wait for menu to open + await tester.tap(find.text('Starting position')); + await tester.pumpAndSettle(); // wait for position to reset + expect( + tester.widget(find.byType(ChessboardEditor)).pieces, + readFen(Horde.initial.fen), + ); + + // Legal position, so allowed to open analysis board expect( tester.widget(find.byKey(const Key('analysis-board-button'))).onTap, isNotNull,