From eaa0d42fd9a4c1bd0a5d1e72f1255ed5ea9fbbf8 Mon Sep 17 00:00:00 2001 From: Albert Ford Date: Tue, 17 Jan 2023 22:46:07 -0800 Subject: [PATCH 1/5] Start wip on non visual ui --- lib/src/widgets/non_visual_board.dart | 162 +++++++++++++++++++++ lib/src/widgets/non_visual_game_board.dart | 49 +++++++ 2 files changed, 211 insertions(+) create mode 100644 lib/src/widgets/non_visual_board.dart create mode 100644 lib/src/widgets/non_visual_game_board.dart diff --git a/lib/src/widgets/non_visual_board.dart b/lib/src/widgets/non_visual_board.dart new file mode 100644 index 0000000000..5d176a1216 --- /dev/null +++ b/lib/src/widgets/non_visual_board.dart @@ -0,0 +1,162 @@ +import 'package:dartchess/dartchess.dart'; +import 'package:flutter/material.dart'; + +/// Widget that provides a screen reader-friendly interface to a chess board +class NonVisualBoard extends StatefulWidget { + const NonVisualBoard({ + required this.position, + required this.lastSanMove, + required this.handleCommand, + super.key, + }); + + final Position position; + + final String lastSanMove; + + /// Callback called when a command is submitted. + /// + /// Optionally returns a message to be announced. + final String? Function(String command) handleCommand; + + @override + State createState() => _NonVisualBoardState(); +} + +class _NonVisualBoardState extends State { + final textController = TextEditingController(); + + // Is this necessary? _GameBoardLayoutState doesn't override dispose. + @override + void dispose() { + textController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + void announce(String msg) { + final snackBar = SnackBar(content: Text(msg)); + // TODO: do snackbars still get read if identical ones get read twice in a row? + // might need to delete the current snackbar + ScaffoldMessenger.of(context).showSnackBar(snackBar); + } + + return ListView( + children: [ + const Text('Current position'), + Semantics( + liveRegion: true, + // TODO: decide on a set of parameters + child: Text(renderCurrentPos(widget.lastSanMove, widget.position)), + ), + TextField( + decoration: const InputDecoration( + labelText: 'move input', + ), + controller: textController, + onSubmitted: (input) { + final message = widget.handleCommand(input); + if (message != null) { + announce(message); + } + textController.clear(); + }, + ), + ], + ); + } +} + +String renderCurrentPos(String? lastSanMove, Position position) { + if (lastSanMove == null) { + return 'Initial position'; + } + final moveNum = position.fullmoves; + // Disambiguate odd and even plies with whitespace so that consecutive + // moves with the same textual representation still get announced. + final plyWhitepsace = position.halfmoves.isOdd ? '' : ' '; + return '$moveNum $lastSanMove$plyWhitepsace'; +} + +String? handlePieceCommand(String command, Board board) { + if (command.toLowerCase().startsWith('p ')) { + final rawPiece = command.substring(2); + final piece = Piece.fromChar(rawPiece); + if (piece != null) { + return _renderLocationOfPieces(board, piece); + } else { + return 'Invalid piece command: $rawPiece is not a piece'; + } + } else { + return null; + } +} + +String? handleScanCommand(String lowercaseCommand, Board board) { + if (lowercaseCommand.startsWith('s ')) { + final rankOrFile = lowercaseCommand.substring(2); + if (_rowOrFileRegExp.hasMatch(rankOrFile)) { + return _renderPiecesByLocation(board, rankOrFile); + } else { + return 'Invalid scan command: $rankOrFile is not a rank nor a file'; + } + } else { + return null; + } +} + +String _renderLocationOfPieces(Board board, Piece piece) { + bool isCorrectPiece(Piece other) { + return piece.role == other.role && piece.color == other.color; + } + + final locations = board.pieces + .where((tuple) => isCorrectPiece(tuple.item2)) + .map((tuple) => toAlgebraic(tuple.item1)) + .join(', '); + final explicitLocations = locations.isNotEmpty ? locations : 'none'; + return '${_colorName(piece.color)} ${_roleName(piece.role)}: $explicitLocations'; +} + +String _renderPiecesByLocation(Board board, String rankOrFile) { + String renderPiece(Tuple2 tuple) { + final square = tuple.item1; + final piece = tuple.item2; + return '${toAlgebraic(square)} ${_colorName(piece.color)} ${_roleName(piece.role)}'; + } + + final pieces = board.pieces + .where((tuple) => toAlgebraic(tuple.item1).contains(rankOrFile)) + .map(renderPiece) + .join(', '); + return pieces.isNotEmpty ? pieces : 'blank'; +} + +final _rowOrFileRegExp = RegExp('^[a-h1-8]\$'); + +String _colorName(Side side) { + switch (side) { + case Side.black: + return 'black'; + case Side.white: + return 'white'; + } +} + +String _roleName(Role role) { + switch (role) { + case Role.pawn: + return 'pawn'; + case Role.knight: + return 'knight'; + case Role.bishop: + return 'bishop'; + case Role.rook: + return 'rook'; + case Role.queen: + return 'queen'; + case Role.king: + return 'king'; + } +} diff --git a/lib/src/widgets/non_visual_game_board.dart b/lib/src/widgets/non_visual_game_board.dart new file mode 100644 index 0000000000..1a83680be5 --- /dev/null +++ b/lib/src/widgets/non_visual_game_board.dart @@ -0,0 +1,49 @@ +import 'package:dartchess/dartchess.dart'; +import 'package:flutter/material.dart'; +import 'package:lichess_mobile/src/widgets/non_visual_board.dart'; + +import '../features/game/model/game_state.dart'; + +/// Wrapper around NonVisualBoard that implements commands specific to +/// playing games +class NonVisualGameBoard extends StatelessWidget { + const NonVisualGameBoard({ + required this.gameState, + required this.onMove, + super.key, + }); + + final GameState gameState; + + final void Function(Move) onMove; + + @override + Widget build(BuildContext context) { + final board = gameState.position.board; + final lastSanMove = + gameState.sanMoves.isEmpty ? null : gameState.sanMoves.last; + return NonVisualBoard( + position: gameState.position, + lastSanMove: gameState.sanMoves.last, + handleCommand: (command) { + final lowered = command.toLowerCase(); + if (lowered == 'c' || lowered == 'clock') { + return 'todo: read clocks'; + } else if (lowered == 'l' || lowered == 'last') { + return renderCurrentPos(lastSanMove, gameState.position); + } else if (lowered == 'o' || lowered == 'opponent') { + return 'todo: read player'; + } + final pieceResult = handlePieceCommand(command, board); + if (pieceResult != null) { + return pieceResult; + } + final scanResult = handleScanCommand(lowered, board); + if (scanResult != null) { + return scanResult; + } + return 'Invalid command: $command'; + }, + ); + } +} From decdb94323e87a51aed63310870ae2f72673c6a4 Mon Sep 17 00:00:00 2001 From: Albert Ford Date: Wed, 18 Jan 2023 00:10:59 -0800 Subject: [PATCH 2/5] Improve regexp legibility --- lib/src/widgets/non_visual_board.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/widgets/non_visual_board.dart b/lib/src/widgets/non_visual_board.dart index 5d176a1216..eca41cd0b2 100644 --- a/lib/src/widgets/non_visual_board.dart +++ b/lib/src/widgets/non_visual_board.dart @@ -133,7 +133,7 @@ String _renderPiecesByLocation(Board board, String rankOrFile) { return pieces.isNotEmpty ? pieces : 'blank'; } -final _rowOrFileRegExp = RegExp('^[a-h1-8]\$'); +final _rowOrFileRegExp = RegExp(r'^[a-h1-8]$'); String _colorName(Side side) { switch (side) { From 46a21288db24c9bf37c28f570d6bc7f40844b53e Mon Sep 17 00:00:00 2001 From: Albert Ford Date: Wed, 18 Jan 2023 02:09:40 -0800 Subject: [PATCH 3/5] Add non visual game board to game screen --- .../game/ui/board/playable_game_screen.dart | 168 ++++++++++-------- lib/src/widgets/non_visual_board.dart | 68 ++++--- lib/src/widgets/non_visual_game_board.dart | 31 +++- 3 files changed, 160 insertions(+), 107 deletions(-) diff --git a/lib/src/features/game/ui/board/playable_game_screen.dart b/lib/src/features/game/ui/board/playable_game_screen.dart index c218b5fb35..64dbb6e5f7 100644 --- a/lib/src/features/game/ui/board/playable_game_screen.dart +++ b/lib/src/features/game/ui/board/playable_game_screen.dart @@ -9,6 +9,7 @@ import 'package:lichess_mobile/src/common/lichess_icons.dart'; import 'package:lichess_mobile/src/utils/chessground_compat.dart'; import 'package:lichess_mobile/src/utils/async_value.dart'; import 'package:lichess_mobile/src/widgets/game_board_layout.dart'; +import 'package:lichess_mobile/src/widgets/non_visual_game_board.dart'; import 'package:lichess_mobile/src/widgets/platform.dart'; import 'package:lichess_mobile/src/widgets/feedback.dart'; import 'package:lichess_mobile/src/widgets/player.dart'; @@ -169,88 +170,105 @@ class _BoardBody extends ConsumerWidget { final isBoardTurned = ref.watch(isBoardTurnedProvider); final isReplaying = gameState != null && positionCursor < gameState.positionIndex; + final mediaQuery = MediaQuery.of(context); return gameClockStream.when( data: (clock) { - final black = Player( - key: const ValueKey('black-player'), - name: game.black.name, - rating: game.black.rating, - title: game.black.title, - active: gameState != null && - gameState.status == GameStatus.started && - gameState.position.fullmoves > 1 && - gameState.position.turn == Side.black, - clock: clock.blackTime, - ); - final white = Player( - key: const ValueKey('white-player'), - name: game.white.name, - rating: game.white.rating, - title: game.white.title, - active: gameState != null && - gameState.status == GameStatus.started && - gameState.position.fullmoves > 1 && - gameState.position.turn == Side.white, - clock: clock.whiteTime, - ); - final topPlayer = game.orientation == Side.white ? black : white; - final bottomPlayer = game.orientation == Side.white ? white : black; + if (mediaQuery.accessibleNavigation) { + return NonVisualGameBoard( + gameState: gameState, + onMove: (Move move) => + ref.read(gameStateProvider.notifier).onUserMove(game.id, move), + ); + } else { + final black = Player( + key: const ValueKey('black-player'), + name: game.black.name, + rating: game.black.rating, + title: game.black.title, + active: gameState != null && + gameState.status == GameStatus.started && + gameState.position.fullmoves > 1 && + gameState.position.turn == Side.black, + clock: clock.blackTime, + ); + final white = Player( + key: const ValueKey('white-player'), + name: game.white.name, + rating: game.white.rating, + title: game.white.title, + active: gameState != null && + gameState.status == GameStatus.started && + gameState.position.fullmoves > 1 && + gameState.position.turn == Side.white, + clock: clock.whiteTime, + ); + final topPlayer = game.orientation == Side.white ? black : white; + final bottomPlayer = game.orientation == Side.white ? white : black; - return GameBoardLayout( - boardData: cg.BoardData( - interactableSide: - gameState == null || !gameState.playing || isReplaying - ? cg.InteractableSide.none - : game.orientation == Side.white - ? cg.InteractableSide.white - : cg.InteractableSide.black, - orientation: - (isBoardTurned ? game.orientation.opposite : game.orientation) - .cg, - fen: gameState?.positions[positionCursor].fen ?? game.initialFen, - validMoves: gameState?.validMoves, - lastMove: gameState != null && gameState.gameOver - ? positionCursor > 0 - ? gameState.moveAtPly(positionCursor - 1)?.cg - : null - : gameState?.lastMove?.cg, - sideToMove: gameState?.position.turn.cg ?? game.orientation.cg, - onMove: (cg.Move move, {bool? isPremove}) => ref - .read(gameStateProvider.notifier) - .onUserMove(game.id, Move.fromUci(move.uci)!), - ), - topPlayer: topPlayer, - bottomPlayer: bottomPlayer, - moves: gameState?.sanMoves, - currentMoveIndex: positionCursor, - ); + return GameBoardLayout( + boardData: cg.BoardData( + interactableSide: + gameState == null || !gameState.playing || isReplaying + ? cg.InteractableSide.none + : game.orientation == Side.white + ? cg.InteractableSide.white + : cg.InteractableSide.black, + orientation: + (isBoardTurned ? game.orientation.opposite : game.orientation) + .cg, + fen: gameState?.positions[positionCursor].fen ?? game.initialFen, + validMoves: gameState?.validMoves, + lastMove: gameState != null && gameState.gameOver + ? positionCursor > 0 + ? gameState.moveAtPly(positionCursor - 1)?.cg + : null + : gameState?.lastMove?.cg, + sideToMove: gameState?.position.turn.cg ?? game.orientation.cg, + onMove: (cg.Move move, {bool? isPremove}) => ref + .read(gameStateProvider.notifier) + .onUserMove(game.id, Move.fromUci(move.uci)!), + ), + topPlayer: topPlayer, + bottomPlayer: bottomPlayer, + moves: gameState?.sanMoves, + currentMoveIndex: positionCursor, + ); + } }, loading: () { - final player = Player( - name: game.player.name, - rating: game.player.rating, - title: game.player.title, - active: false, - clock: Duration.zero, - ); - final opponent = Player( - name: game.opponent.name, - rating: game.opponent.rating, - title: game.opponent.title, - active: false, - clock: Duration.zero, - ); + if (mediaQuery.accessibleNavigation) { + return NonVisualGameBoard( + gameState: null, + onMove: (move) {}, + isLoading: true, + ); + } else { + final player = Player( + name: game.player.name, + rating: game.player.rating, + title: game.player.title, + active: false, + clock: Duration.zero, + ); + final opponent = Player( + name: game.opponent.name, + rating: game.opponent.rating, + title: game.opponent.title, + active: false, + clock: Duration.zero, + ); - return GameBoardLayout( - topPlayer: opponent, - bottomPlayer: player, - boardData: cg.BoardData( - interactableSide: cg.InteractableSide.none, - orientation: game.orientation.cg, - fen: game.initialFen, - ), - ); + return GameBoardLayout( + topPlayer: opponent, + bottomPlayer: player, + boardData: cg.BoardData( + interactableSide: cg.InteractableSide.none, + orientation: game.orientation.cg, + fen: game.initialFen, + ), + ); + } }, error: (err, stackTrace) { debugPrint( diff --git a/lib/src/widgets/non_visual_board.dart b/lib/src/widgets/non_visual_board.dart index eca41cd0b2..345e2c177e 100644 --- a/lib/src/widgets/non_visual_board.dart +++ b/lib/src/widgets/non_visual_board.dart @@ -7,12 +7,18 @@ class NonVisualBoard extends StatefulWidget { required this.position, required this.lastSanMove, required this.handleCommand, + this.isLoading = false, + this.loadCompleteMsg = 'Loading complete', super.key, }); final Position position; - final String lastSanMove; + final String? lastSanMove; + + final bool isLoading; + + final String loadCompleteMsg; /// Callback called when a command is submitted. /// @@ -42,29 +48,45 @@ class _NonVisualBoardState extends State { ScaffoldMessenger.of(context).showSnackBar(snackBar); } - return ListView( - children: [ - const Text('Current position'), - Semantics( - liveRegion: true, - // TODO: decide on a set of parameters - child: Text(renderCurrentPos(widget.lastSanMove, widget.position)), - ), - TextField( - decoration: const InputDecoration( - labelText: 'move input', + if (widget.isLoading) { + return ListView( + children: [ + Semantics( + key: const ValueKey('loading-status'), + liveRegion: true, + child: const Text('Loading'), + ), + ], + ); + } else { + return ListView( + children: [ + Semantics( + key: const ValueKey('loading-status'), + liveRegion: true, + child: Text(widget.loadCompleteMsg), ), - controller: textController, - onSubmitted: (input) { - final message = widget.handleCommand(input); - if (message != null) { - announce(message); - } - textController.clear(); - }, - ), - ], - ); + const Text('Current position'), + Semantics( + liveRegion: true, + child: Text(renderCurrentPos(widget.lastSanMove, widget.position)), + ), + TextField( + decoration: const InputDecoration( + labelText: 'move input', + ), + controller: textController, + onSubmitted: (input) { + final message = widget.handleCommand(input); + if (message != null) { + announce(message); + } + textController.clear(); + }, + ), + ], + ); + } } } diff --git a/lib/src/widgets/non_visual_game_board.dart b/lib/src/widgets/non_visual_game_board.dart index 1a83680be5..94f040f7bf 100644 --- a/lib/src/widgets/non_visual_game_board.dart +++ b/lib/src/widgets/non_visual_game_board.dart @@ -10,38 +10,51 @@ class NonVisualGameBoard extends StatelessWidget { const NonVisualGameBoard({ required this.gameState, required this.onMove, + this.isLoading = false, super.key, }); - final GameState gameState; + final GameState? gameState; final void Function(Move) onMove; + final bool isLoading; + @override Widget build(BuildContext context) { - final board = gameState.position.board; - final lastSanMove = - gameState.sanMoves.isEmpty ? null : gameState.sanMoves.last; + final position = gameState?.position ?? Chess.initial; + final lastSanMove = gameState?.sanMoves.isNotEmpty == true + ? gameState!.sanMoves.last + : null; return NonVisualBoard( - position: gameState.position, - lastSanMove: gameState.sanMoves.last, + position: position, + lastSanMove: lastSanMove, + isLoading: isLoading, + loadCompleteMsg: 'Game loaded', handleCommand: (command) { final lowered = command.toLowerCase(); if (lowered == 'c' || lowered == 'clock') { return 'todo: read clocks'; } else if (lowered == 'l' || lowered == 'last') { - return renderCurrentPos(lastSanMove, gameState.position); + return renderCurrentPos(lastSanMove, position); } else if (lowered == 'o' || lowered == 'opponent') { return 'todo: read player'; } - final pieceResult = handlePieceCommand(command, board); + final pieceResult = handlePieceCommand(command, position.board); if (pieceResult != null) { return pieceResult; } - final scanResult = handleScanCommand(lowered, board); + final scanResult = handleScanCommand(lowered, position.board); if (scanResult != null) { return scanResult; } + // TODO: uncomment when parseSan is available + final move = + Move.fromUci(command) /*?? gameState.position.parseSan(command)*/; + if (move != null) { + onMove(move); + return null; + } return 'Invalid command: $command'; }, ); From 9d7b2b2eae6a782cc20b82db3195fa7e51118edd Mon Sep 17 00:00:00 2001 From: Albert Ford Date: Sun, 22 Jan 2023 22:40:23 -0800 Subject: [PATCH 4/5] Implement m (possible moves) nvui command --- lib/src/widgets/non_visual_board.dart | 51 ++++++++++++++++++++++ lib/src/widgets/non_visual_game_board.dart | 4 +- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/lib/src/widgets/non_visual_board.dart b/lib/src/widgets/non_visual_board.dart index 345e2c177e..92893d1172 100644 --- a/lib/src/widgets/non_visual_board.dart +++ b/lib/src/widgets/non_visual_board.dart @@ -128,6 +128,20 @@ String? handleScanCommand(String lowercaseCommand, Board board) { } } +String? handlePossibleMovesCommand(String lowercaseCommand, Position position) { + if (lowercaseCommand.startsWith('m ')) { + final rawSquare = lowercaseCommand.substring(2); + final square = parseSquare(rawSquare); + if (square != null) { + return _renderPossibleMoves(square, position); + } else { + return 'Invalid moves command: $rawSquare is not a square'; + } + } else { + return null; + } +} + String _renderLocationOfPieces(Board board, Piece piece) { bool isCorrectPiece(Piece other) { return piece.role == other.role && piece.color == other.color; @@ -155,6 +169,43 @@ String _renderPiecesByLocation(Board board, String rankOrFile) { return pieces.isNotEmpty ? pieces : 'blank'; } +String _renderPossibleMoves(Square square, Position position, + {bool capturesOnly = false, Side? perspective}) { + String renderPossibleMove(Square square) { + final captureTarget = position.board.pieceAt(square); + var dest = toAlgebraic(square); + if (captureTarget != null && captureTarget.color != perspective) { + dest += ' captures ${_roleName(captureTarget.role)}'; + } + return dest; + } + + bool didFilterOut = false; + + bool isCapture(String renderedMove) { + final isCapture = renderedMove.contains('captures'); + if (!isCapture) { + didFilterOut = true; + } + return isCapture; + } + + // TODO: uncomment once dartchess has a way to switch the turn color of a position + final povPosition = perspective == position.turn.opposite + ? position /*.nullMove()*/ : position; + final possibleDests = povPosition + .legalMovesOf(square) + .squares + .map(renderPossibleMove) + .where((renderedMove) => !capturesOnly && isCapture(renderedMove)) + .join(', '); + if (possibleDests == '') { + return didFilterOut ? 'No captures' : 'None'; + } else { + return possibleDests; + } +} + final _rowOrFileRegExp = RegExp(r'^[a-h1-8]$'); String _colorName(Side side) { diff --git a/lib/src/widgets/non_visual_game_board.dart b/lib/src/widgets/non_visual_game_board.dart index 94f040f7bf..28032d271e 100644 --- a/lib/src/widgets/non_visual_game_board.dart +++ b/lib/src/widgets/non_visual_game_board.dart @@ -48,9 +48,7 @@ class NonVisualGameBoard extends StatelessWidget { if (scanResult != null) { return scanResult; } - // TODO: uncomment when parseSan is available - final move = - Move.fromUci(command) /*?? gameState.position.parseSan(command)*/; + final move = Move.fromUci(command) ?? position.parseSan(command); if (move != null) { onMove(move); return null; From 4fc52040e863cd3c12a76bf1babcb80d7b585d29 Mon Sep 17 00:00:00 2001 From: Albert Ford Date: Sun, 22 Jan 2023 22:45:57 -0800 Subject: [PATCH 5/5] Refactor to simplify nvui command handling --- lib/src/widgets/non_visual_game_board.dart | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/lib/src/widgets/non_visual_game_board.dart b/lib/src/widgets/non_visual_game_board.dart index 28032d271e..0add15cc69 100644 --- a/lib/src/widgets/non_visual_game_board.dart +++ b/lib/src/widgets/non_visual_game_board.dart @@ -40,20 +40,15 @@ class NonVisualGameBoard extends StatelessWidget { } else if (lowered == 'o' || lowered == 'opponent') { return 'todo: read player'; } - final pieceResult = handlePieceCommand(command, position.board); - if (pieceResult != null) { - return pieceResult; - } - final scanResult = handleScanCommand(lowered, position.board); - if (scanResult != null) { - return scanResult; - } final move = Move.fromUci(command) ?? position.parseSan(command); if (move != null) { onMove(move); return null; } - return 'Invalid command: $command'; + return handlePieceCommand(command, position.board) ?? + handleScanCommand(lowered, position.board) ?? + handlePossibleMovesCommand(lowered, position) ?? + 'Invalid command: $command'; }, ); }