From afd2afcb3f5e79b12fd911f8243a1fd7c3ee8947 Mon Sep 17 00:00:00 2001 From: Jimima Date: Fri, 8 Nov 2024 11:51:37 +0000 Subject: [PATCH 01/25] WIP --- lib/src/model/game/material_diff.dart | 21 ++++++++++++++++++--- lib/src/view/game/game_player.dart | 24 +++++++++++++++++++++++- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/lib/src/model/game/material_diff.dart b/lib/src/model/game/material_diff.dart index 6fe688f4de..7b4373fe93 100644 --- a/lib/src/model/game/material_diff.dart +++ b/lib/src/model/game/material_diff.dart @@ -9,6 +9,8 @@ class MaterialDiffSide with _$MaterialDiffSide { const factory MaterialDiffSide({ required IMap pieces, required int score, + required IMap capturedPieces, + }) = _MaterialDiffSide; } @@ -34,6 +36,19 @@ class MaterialDiff with _$MaterialDiff { int score = 0; final IMap blackCount = board.materialCount(Side.black); final IMap whiteCount = board.materialCount(Side.white); + + final IMap blackStartingCount = Board.standard.materialCount(Side.black); + final IMap whiteStartingCount = Board.standard.materialCount(Side.white); + // TODO: parameterise starting position maybe so it can be passed in + + IMap subtractPieceCounts(IMap startingCount, IMap subtractCount){ + IMap capturedPieces = IMap(); + startingCount.forEach((role,count){capturedPieces = capturedPieces.add(role, count - (subtractCount.get(role) ?? 0) ) ;}); + return capturedPieces; + } + + final IMap blackCapturedPieces = subtractPieceCounts(whiteStartingCount, whiteCount); + final IMap whiteCapturedPieces = subtractPieceCounts(blackStartingCount, blackCount); Map count; Map black; @@ -80,9 +95,9 @@ class MaterialDiff with _$MaterialDiff { }); return MaterialDiff( - black: MaterialDiffSide(pieces: black.toIMap(), score: -score), - white: MaterialDiffSide(pieces: white.toIMap(), score: score), - ); + black: MaterialDiffSide(pieces: black.toIMap(), score: -score, capturedPieces: blackCapturedPieces), + white: MaterialDiffSide(pieces: white.toIMap(), score: score, capturedPieces: whiteCapturedPieces) + ,); } MaterialDiffSide bySide(Side side) => side == Side.black ? black : white; diff --git a/lib/src/view/game/game_player.dart b/lib/src/view/game/game_player.dart index 619f583af3..c5b41cb5eb 100644 --- a/lib/src/view/game/game_player.dart +++ b/lib/src/view/game/game_player.dart @@ -140,7 +140,29 @@ class GamePlayer extends StatelessWidget { ), if (timeToMove != null) MoveExpiration(timeToMove: timeToMove!, mePlaying: mePlaying) - else if (materialDiff != null) + else if (materialDiff != null && true) //TODO put this as a pref + Row( + children: [ + for (final role in Role.values) + for (int i = 0; i < materialDiff!.capturedPieces[role]!; i++) + Icon( + _iconByRole[role], + size: 13, + color: Colors.grey, + ), + const SizedBox(width: 3), + Text( + style: const TextStyle( + fontSize: 13, + color: Colors.grey, + ), + materialDiff != null && materialDiff!.score > 0 + ? '+${materialDiff!.score}' + : '', + ), + ], + ) + else if (materialDiff != null && false) Row( children: [ for (final role in Role.values) From ae16f7987e75e2fe5f58b41f55c8e50939202ee0 Mon Sep 17 00:00:00 2001 From: Jimima Date: Fri, 8 Nov 2024 14:58:55 +0000 Subject: [PATCH 02/25] WIP --- lib/src/model/settings/board_preferences.dart | 20 +++++++++++++++ lib/src/view/game/game_body.dart | 2 ++ lib/src/view/game/game_player.dart | 14 ++++++++++- .../view/settings/board_settings_screen.dart | 25 +++++++++++++++++++ 4 files changed, 60 insertions(+), 1 deletion(-) diff --git a/lib/src/model/settings/board_preferences.dart b/lib/src/model/settings/board_preferences.dart index ca5e4cb208..636af7e3f4 100644 --- a/lib/src/model/settings/board_preferences.dart +++ b/lib/src/model/settings/board_preferences.dart @@ -81,6 +81,12 @@ class BoardPreferences extends _$BoardPreferences ); } + Future setMaterialDifferenceFormat(MaterialDifferenceFormat materialDifferenceFormat) { + return save( + state.copyWith(materialDifferenceFormat: materialDifferenceFormat), + ); + } + Future toggleEnableShapeDrawings() { return save( state.copyWith(enableShapeDrawings: !state.enableShapeDrawings), @@ -106,6 +112,7 @@ class BoardPrefs with _$BoardPrefs implements Serializable { required bool coordinates, required bool pieceAnimation, required bool showMaterialDifference, + required MaterialDifferenceFormat materialDifferenceFormat, @JsonKey( defaultValue: PieceShiftMethod.either, unknownEnumValue: PieceShiftMethod.either, @@ -132,6 +139,7 @@ class BoardPrefs with _$BoardPrefs implements Serializable { coordinates: true, pieceAnimation: true, showMaterialDifference: true, + materialDifferenceFormat : MaterialDifferenceFormat.difference, pieceShiftMethod: PieceShiftMethod.either, enableShapeDrawings: true, magnifyDraggedPiece: true, @@ -287,3 +295,15 @@ enum BoardTheme { errorBuilder: (context, o, st) => const SizedBox.shrink(), ); } + +enum MaterialDifferenceFormat { + difference(description: 'Material difference'), + pieces(description: 'Captured pieces'); + + const MaterialDifferenceFormat({ + required this.description, + }); + + final String description; + +} diff --git a/lib/src/view/game/game_body.dart b/lib/src/view/game/game_body.dart index 790d2f472a..6bcc335cc3 100644 --- a/lib/src/view/game/game_body.dart +++ b/lib/src/view/game/game_body.dart @@ -131,6 +131,7 @@ class GameBody extends ConsumerWidget { materialDiff: boardPreferences.showMaterialDifference ? gameState.game.materialDiffAt(gameState.stepCursor, Side.black) : null, + materialDifferenceFormat: boardPreferences.materialDifferenceFormat, timeToMove: gameState.game.sideToMove == Side.black ? gameState.timeToMove : null, @@ -171,6 +172,7 @@ class GameBody extends ConsumerWidget { materialDiff: boardPreferences.showMaterialDifference ? gameState.game.materialDiffAt(gameState.stepCursor, Side.white) : null, + materialDifferenceFormat: boardPreferences.materialDifferenceFormat, timeToMove: gameState.game.sideToMove == Side.white ? gameState.timeToMove : null, diff --git a/lib/src/view/game/game_player.dart b/lib/src/view/game/game_player.dart index c5b41cb5eb..7777f8f492 100644 --- a/lib/src/view/game/game_player.dart +++ b/lib/src/view/game/game_player.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:lichess_mobile/src/constants.dart'; import 'package:lichess_mobile/src/model/game/material_diff.dart'; import 'package:lichess_mobile/src/model/game/player.dart'; +import 'package:lichess_mobile/src/model/settings/board_preferences.dart'; import 'package:lichess_mobile/src/styles/lichess_colors.dart'; import 'package:lichess_mobile/src/styles/lichess_icons.dart'; import 'package:lichess_mobile/src/styles/styles.dart'; @@ -25,6 +26,7 @@ class GamePlayer extends StatelessWidget { required this.player, this.clock, this.materialDiff, + this.materialDifferenceFormat, this.confirmMoveCallbacks, this.timeToMove, this.shouldLinkToUserProfile = true, @@ -36,6 +38,7 @@ class GamePlayer extends StatelessWidget { final Player player; final Widget? clock; final MaterialDiffSide? materialDiff; + final MaterialDifferenceFormat? materialDifferenceFormat; /// if confirm move preference is enabled, used to display confirmation buttons final ({VoidCallback confirm, VoidCallback cancel})? confirmMoveCallbacks; @@ -140,9 +143,10 @@ class GamePlayer extends StatelessWidget { ), if (timeToMove != null) MoveExpiration(timeToMove: timeToMove!, mePlaying: mePlaying) - else if (materialDiff != null && true) //TODO put this as a pref + else if (materialDiff != null) //TODO put this as a pref Row( children: [ + if (materialDifferenceFormat == MaterialDifferenceFormat.pieces) for (final role in Role.values) for (int i = 0; i < materialDiff!.capturedPieces[role]!; i++) Icon( @@ -150,6 +154,14 @@ class GamePlayer extends StatelessWidget { size: 13, color: Colors.grey, ), + if (materialDifferenceFormat == MaterialDifferenceFormat.difference) + for (final role in Role.values) + for (int i = 0; i < materialDiff!.pieces[role]!; i++) + Icon( + _iconByRole[role], + size: 13, + color: Colors.grey, + ), const SizedBox(width: 3), Text( style: const TextStyle( diff --git a/lib/src/view/settings/board_settings_screen.dart b/lib/src/view/settings/board_settings_screen.dart index eefd82f4f2..82d4a1eb19 100644 --- a/lib/src/view/settings/board_settings_screen.dart +++ b/lib/src/view/settings/board_settings_screen.dart @@ -195,6 +195,31 @@ class _Body extends ConsumerWidget { .toggleShowMaterialDifference(); }, ), + SettingsListTile( + settingsLabel: const Text('Material difference format'), + settingsValue: boardPrefs.materialDifferenceFormat.description, + onTap: () { + // if (Theme.of(context).platform == TargetPlatform.android) { + if (true) { + showChoicePicker( + context, + choices: MaterialDifferenceFormat.values, + selectedItem: boardPrefs.materialDifferenceFormat, + labelBuilder: (t) => + Text(t.description), + onSelectedItemChanged: (MaterialDifferenceFormat? value) => ref + .read(boardPreferencesProvider.notifier) + .setMaterialDifferenceFormat(value ?? MaterialDifferenceFormat.difference), + ); + } else { + // pushPlatformRoute( + // context, + // title: 'Clock position', + // builder: (context) => const BoardClockPositionScreen(), + // ); + } + }, + ), ], ), ], From f4678228736edf06ddeae43c14037b78eec532d7 Mon Sep 17 00:00:00 2001 From: Jimima Date: Fri, 8 Nov 2024 15:12:05 +0000 Subject: [PATCH 03/25] Format fix --- lib/src/model/game/material_diff.dart | 35 ++++++++----- lib/src/model/settings/board_preferences.dart | 6 +-- lib/src/view/game/game_player.dart | 19 +++---- .../view/settings/board_settings_screen.dart | 49 ++++++++++--------- 4 files changed, 63 insertions(+), 46 deletions(-) diff --git a/lib/src/model/game/material_diff.dart b/lib/src/model/game/material_diff.dart index 7b4373fe93..122d7c7477 100644 --- a/lib/src/model/game/material_diff.dart +++ b/lib/src/model/game/material_diff.dart @@ -10,7 +10,6 @@ class MaterialDiffSide with _$MaterialDiffSide { required IMap pieces, required int score, required IMap capturedPieces, - }) = _MaterialDiffSide; } @@ -36,19 +35,27 @@ class MaterialDiff with _$MaterialDiff { int score = 0; final IMap blackCount = board.materialCount(Side.black); final IMap whiteCount = board.materialCount(Side.white); - - final IMap blackStartingCount = Board.standard.materialCount(Side.black); - final IMap whiteStartingCount = Board.standard.materialCount(Side.white); + + final IMap blackStartingCount = + Board.standard.materialCount(Side.black); + final IMap whiteStartingCount = + Board.standard.materialCount(Side.white); // TODO: parameterise starting position maybe so it can be passed in - IMap subtractPieceCounts(IMap startingCount, IMap subtractCount){ + IMap subtractPieceCounts( + IMap startingCount, IMap subtractCount) { IMap capturedPieces = IMap(); - startingCount.forEach((role,count){capturedPieces = capturedPieces.add(role, count - (subtractCount.get(role) ?? 0) ) ;}); + startingCount.forEach((role, count) { + capturedPieces = + capturedPieces.add(role, count - (subtractCount.get(role) ?? 0)); + }); return capturedPieces; } - final IMap blackCapturedPieces = subtractPieceCounts(whiteStartingCount, whiteCount); - final IMap whiteCapturedPieces = subtractPieceCounts(blackStartingCount, blackCount); + final IMap blackCapturedPieces = + subtractPieceCounts(whiteStartingCount, whiteCount); + final IMap whiteCapturedPieces = + subtractPieceCounts(blackStartingCount, blackCount); Map count; Map black; @@ -95,9 +102,15 @@ class MaterialDiff with _$MaterialDiff { }); return MaterialDiff( - black: MaterialDiffSide(pieces: black.toIMap(), score: -score, capturedPieces: blackCapturedPieces), - white: MaterialDiffSide(pieces: white.toIMap(), score: score, capturedPieces: whiteCapturedPieces) - ,); + black: MaterialDiffSide( + pieces: black.toIMap(), + score: -score, + capturedPieces: blackCapturedPieces), + white: MaterialDiffSide( + pieces: white.toIMap(), + score: score, + capturedPieces: whiteCapturedPieces), + ); } MaterialDiffSide bySide(Side side) => side == Side.black ? black : white; diff --git a/lib/src/model/settings/board_preferences.dart b/lib/src/model/settings/board_preferences.dart index 636af7e3f4..2a72fa8a7a 100644 --- a/lib/src/model/settings/board_preferences.dart +++ b/lib/src/model/settings/board_preferences.dart @@ -81,7 +81,8 @@ class BoardPreferences extends _$BoardPreferences ); } - Future setMaterialDifferenceFormat(MaterialDifferenceFormat materialDifferenceFormat) { + Future setMaterialDifferenceFormat( + MaterialDifferenceFormat materialDifferenceFormat) { return save( state.copyWith(materialDifferenceFormat: materialDifferenceFormat), ); @@ -139,7 +140,7 @@ class BoardPrefs with _$BoardPrefs implements Serializable { coordinates: true, pieceAnimation: true, showMaterialDifference: true, - materialDifferenceFormat : MaterialDifferenceFormat.difference, + materialDifferenceFormat: MaterialDifferenceFormat.difference, pieceShiftMethod: PieceShiftMethod.either, enableShapeDrawings: true, magnifyDraggedPiece: true, @@ -305,5 +306,4 @@ enum MaterialDifferenceFormat { }); final String description; - } diff --git a/lib/src/view/game/game_player.dart b/lib/src/view/game/game_player.dart index 7777f8f492..35e7c713ab 100644 --- a/lib/src/view/game/game_player.dart +++ b/lib/src/view/game/game_player.dart @@ -143,18 +143,19 @@ class GamePlayer extends StatelessWidget { ), if (timeToMove != null) MoveExpiration(timeToMove: timeToMove!, mePlaying: mePlaying) - else if (materialDiff != null) //TODO put this as a pref + else if (materialDiff != null) Row( children: [ if (materialDifferenceFormat == MaterialDifferenceFormat.pieces) - for (final role in Role.values) - for (int i = 0; i < materialDiff!.capturedPieces[role]!; i++) - Icon( - _iconByRole[role], - size: 13, - color: Colors.grey, - ), - if (materialDifferenceFormat == MaterialDifferenceFormat.difference) + for (final role in Role.values) + for (int i = 0; i < materialDiff!.capturedPieces[role]!; i++) + Icon( + _iconByRole[role], + size: 13, + color: Colors.grey, + ), + if (materialDifferenceFormat == + MaterialDifferenceFormat.difference) for (final role in Role.values) for (int i = 0; i < materialDiff!.pieces[role]!; i++) Icon( diff --git a/lib/src/view/settings/board_settings_screen.dart b/lib/src/view/settings/board_settings_screen.dart index 82d4a1eb19..f550292a4f 100644 --- a/lib/src/view/settings/board_settings_screen.dart +++ b/lib/src/view/settings/board_settings_screen.dart @@ -196,29 +196,32 @@ class _Body extends ConsumerWidget { }, ), SettingsListTile( - settingsLabel: const Text('Material difference format'), - settingsValue: boardPrefs.materialDifferenceFormat.description, - onTap: () { - // if (Theme.of(context).platform == TargetPlatform.android) { - if (true) { - showChoicePicker( - context, - choices: MaterialDifferenceFormat.values, - selectedItem: boardPrefs.materialDifferenceFormat, - labelBuilder: (t) => - Text(t.description), - onSelectedItemChanged: (MaterialDifferenceFormat? value) => ref - .read(boardPreferencesProvider.notifier) - .setMaterialDifferenceFormat(value ?? MaterialDifferenceFormat.difference), - ); - } else { - // pushPlatformRoute( - // context, - // title: 'Clock position', - // builder: (context) => const BoardClockPositionScreen(), - // ); - } - }, + settingsLabel: const Text('Material difference format'), + settingsValue: boardPrefs.materialDifferenceFormat.description, + onTap: () { + //TODO: implement different handling to android/ios + // if (Theme.of(context).platform == TargetPlatform.android) { + + if (true) { + showChoicePicker( + context, + choices: MaterialDifferenceFormat.values, + selectedItem: boardPrefs.materialDifferenceFormat, + labelBuilder: (t) => Text(t.description), + onSelectedItemChanged: + (MaterialDifferenceFormat? value) => ref + .read(boardPreferencesProvider.notifier) + .setMaterialDifferenceFormat( + value ?? MaterialDifferenceFormat.difference), + ); + } else { + // pushPlatformRoute( + // context, + // title: 'Clock position', + // builder: (context) => const BoardClockPositionScreen(), + // ); + } + }, ), ], ), From 7691710a3132c7457785b2553ec4a72ede02850c Mon Sep 17 00:00:00 2001 From: Jimima Date: Mon, 11 Nov 2024 14:03:50 +0000 Subject: [PATCH 04/25] WIP with settings option --- lib/src/model/game/material_diff.dart | 18 +++++++++++------- lib/src/model/settings/board_preferences.dart | 3 ++- .../view/settings/board_settings_screen.dart | 3 ++- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/lib/src/model/game/material_diff.dart b/lib/src/model/game/material_diff.dart index 122d7c7477..a661d1fa2d 100644 --- a/lib/src/model/game/material_diff.dart +++ b/lib/src/model/game/material_diff.dart @@ -43,7 +43,9 @@ class MaterialDiff with _$MaterialDiff { // TODO: parameterise starting position maybe so it can be passed in IMap subtractPieceCounts( - IMap startingCount, IMap subtractCount) { + IMap startingCount, + IMap subtractCount, + ) { IMap capturedPieces = IMap(); startingCount.forEach((role, count) { capturedPieces = @@ -103,13 +105,15 @@ class MaterialDiff with _$MaterialDiff { return MaterialDiff( black: MaterialDiffSide( - pieces: black.toIMap(), - score: -score, - capturedPieces: blackCapturedPieces), + pieces: black.toIMap(), + score: -score, + capturedPieces: blackCapturedPieces, + ), white: MaterialDiffSide( - pieces: white.toIMap(), - score: score, - capturedPieces: whiteCapturedPieces), + pieces: white.toIMap(), + score: score, + capturedPieces: whiteCapturedPieces, + ), ); } diff --git a/lib/src/model/settings/board_preferences.dart b/lib/src/model/settings/board_preferences.dart index 2a72fa8a7a..033e99871a 100644 --- a/lib/src/model/settings/board_preferences.dart +++ b/lib/src/model/settings/board_preferences.dart @@ -82,7 +82,8 @@ class BoardPreferences extends _$BoardPreferences } Future setMaterialDifferenceFormat( - MaterialDifferenceFormat materialDifferenceFormat) { + MaterialDifferenceFormat materialDifferenceFormat, + ) { return save( state.copyWith(materialDifferenceFormat: materialDifferenceFormat), ); diff --git a/lib/src/view/settings/board_settings_screen.dart b/lib/src/view/settings/board_settings_screen.dart index f550292a4f..3bcad626f8 100644 --- a/lib/src/view/settings/board_settings_screen.dart +++ b/lib/src/view/settings/board_settings_screen.dart @@ -212,7 +212,8 @@ class _Body extends ConsumerWidget { (MaterialDifferenceFormat? value) => ref .read(boardPreferencesProvider.notifier) .setMaterialDifferenceFormat( - value ?? MaterialDifferenceFormat.difference), + value ?? MaterialDifferenceFormat.difference, + ), ); } else { // pushPlatformRoute( From 9f528bf535b5a9a2867a1cd47813a0ea8c3afc8b Mon Sep 17 00:00:00 2001 From: Jimima Date: Tue, 12 Nov 2024 16:15:35 +0000 Subject: [PATCH 05/25] Version with single option with 3 choices --- lib/src/model/settings/board_preferences.dart | 40 ++++++++------ .../offline_correspondence_game_screen.dart | 10 ++-- lib/src/view/game/game_body.dart | 8 +-- lib/src/view/game/game_player.dart | 33 ++--------- lib/src/view/game/game_settings.dart | 22 ++++---- .../over_the_board/over_the_board_screen.dart | 3 +- .../view/settings/board_settings_screen.dart | 55 ++++++++----------- 7 files changed, 74 insertions(+), 97 deletions(-) diff --git a/lib/src/model/settings/board_preferences.dart b/lib/src/model/settings/board_preferences.dart index 033e99871a..6083e1073f 100644 --- a/lib/src/model/settings/board_preferences.dart +++ b/lib/src/model/settings/board_preferences.dart @@ -1,4 +1,5 @@ import 'package:chessground/chessground.dart'; +import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:lichess_mobile/src/model/settings/preferences_storage.dart'; @@ -75,17 +76,17 @@ class BoardPreferences extends _$BoardPreferences ); } - Future toggleShowMaterialDifference() { - return save( - state.copyWith(showMaterialDifference: !state.showMaterialDifference), - ); - } + // Future toggleShowMaterialDifference() { + // return save( + // state.copyWith(showMaterialDifference: !state.showMaterialDifference), + // ); + // } Future setMaterialDifferenceFormat( - MaterialDifferenceFormat materialDifferenceFormat, + MaterialDifference materialDifference, ) { return save( - state.copyWith(materialDifferenceFormat: materialDifferenceFormat), + state.copyWith(materialDifference: materialDifference), ); } @@ -113,8 +114,9 @@ class BoardPrefs with _$BoardPrefs implements Serializable { required bool boardHighlights, required bool coordinates, required bool pieceAnimation, - required bool showMaterialDifference, - required MaterialDifferenceFormat materialDifferenceFormat, + // required bool showMaterialDifference, + required MaterialDifference materialDifference, + // required MaterialDifferenceFormat materialDifferenceFormat, @JsonKey( defaultValue: PieceShiftMethod.either, unknownEnumValue: PieceShiftMethod.either, @@ -140,8 +142,9 @@ class BoardPrefs with _$BoardPrefs implements Serializable { boardHighlights: true, coordinates: true, pieceAnimation: true, - showMaterialDifference: true, - materialDifferenceFormat: MaterialDifferenceFormat.difference, + materialDifference: MaterialDifference.materialDifference, + // showMaterialDifference: true, + // materialDifferenceFormat: MaterialDifferenceFormat.difference, pieceShiftMethod: PieceShiftMethod.either, enableShapeDrawings: true, magnifyDraggedPiece: true, @@ -298,13 +301,16 @@ enum BoardTheme { ); } -enum MaterialDifferenceFormat { - difference(description: 'Material difference'), - pieces(description: 'Captured pieces'); +enum MaterialDifference { + materialDifference(label: 'Material difference', visible: false), + capturedPieces(label: 'Captured pieces', visible: true), + hidden(label: 'Hidden', visible: false); - const MaterialDifferenceFormat({ - required this.description, + const MaterialDifference({ + required this.label, + required this.visible, }); - final String description; + final String label; + final bool visible; } diff --git a/lib/src/view/correspondence/offline_correspondence_game_screen.dart b/lib/src/view/correspondence/offline_correspondence_game_screen.dart index e2f181ebd3..26cf810243 100644 --- a/lib/src/view/correspondence/offline_correspondence_game_screen.dart +++ b/lib/src/view/correspondence/offline_correspondence_game_screen.dart @@ -142,9 +142,9 @@ class _BodyState extends ConsumerState<_Body> { @override Widget build(BuildContext context) { - final shouldShowMaterialDiff = ref.watch( + final materialDifference = ref.watch( boardPreferencesProvider.select( - (prefs) => prefs.showMaterialDifference, + (prefs) => prefs.materialDifference, ), ); @@ -157,9 +157,10 @@ class _BodyState extends ConsumerState<_Body> { final black = GamePlayer( player: game.black, - materialDiff: shouldShowMaterialDiff + materialDiff: materialDifference.visible ? game.materialDiffAt(stepCursor, Side.black) : null, + materialDifference: materialDifference, shouldLinkToUserProfile: false, mePlaying: youAre == Side.black, confirmMoveCallbacks: youAre == Side.black && moveToConfirm != null @@ -179,9 +180,10 @@ class _BodyState extends ConsumerState<_Body> { ); final white = GamePlayer( player: game.white, - materialDiff: shouldShowMaterialDiff + materialDiff: materialDifference.visible ? game.materialDiffAt(stepCursor, Side.white) : null, + materialDifference: materialDifference, shouldLinkToUserProfile: false, mePlaying: youAre == Side.white, confirmMoveCallbacks: youAre == Side.white && moveToConfirm != null diff --git a/lib/src/view/game/game_body.dart b/lib/src/view/game/game_body.dart index 6bcc335cc3..928051b1ec 100644 --- a/lib/src/view/game/game_body.dart +++ b/lib/src/view/game/game_body.dart @@ -128,10 +128,10 @@ class GameBody extends ConsumerWidget { final black = GamePlayer( player: gameState.game.black, - materialDiff: boardPreferences.showMaterialDifference + materialDiff: boardPreferences.materialDifference.visible ? gameState.game.materialDiffAt(gameState.stepCursor, Side.black) : null, - materialDifferenceFormat: boardPreferences.materialDifferenceFormat, + materialDifference: boardPreferences.materialDifference, timeToMove: gameState.game.sideToMove == Side.black ? gameState.timeToMove : null, @@ -169,10 +169,10 @@ class GameBody extends ConsumerWidget { ); final white = GamePlayer( player: gameState.game.white, - materialDiff: boardPreferences.showMaterialDifference + materialDiff: boardPreferences.materialDifference.visible ? gameState.game.materialDiffAt(gameState.stepCursor, Side.white) : null, - materialDifferenceFormat: boardPreferences.materialDifferenceFormat, + materialDifference: boardPreferences.materialDifference, timeToMove: gameState.game.sideToMove == Side.white ? gameState.timeToMove : null, diff --git a/lib/src/view/game/game_player.dart b/lib/src/view/game/game_player.dart index 35e7c713ab..f6bf1b59a0 100644 --- a/lib/src/view/game/game_player.dart +++ b/lib/src/view/game/game_player.dart @@ -26,7 +26,7 @@ class GamePlayer extends StatelessWidget { required this.player, this.clock, this.materialDiff, - this.materialDifferenceFormat, + this.materialDifference, this.confirmMoveCallbacks, this.timeToMove, this.shouldLinkToUserProfile = true, @@ -38,7 +38,7 @@ class GamePlayer extends StatelessWidget { final Player player; final Widget? clock; final MaterialDiffSide? materialDiff; - final MaterialDifferenceFormat? materialDifferenceFormat; + final MaterialDifference? materialDifference; /// if confirm move preference is enabled, used to display confirmation buttons final ({VoidCallback confirm, VoidCallback cancel})? confirmMoveCallbacks; @@ -143,10 +143,10 @@ class GamePlayer extends StatelessWidget { ), if (timeToMove != null) MoveExpiration(timeToMove: timeToMove!, mePlaying: mePlaying) - else if (materialDiff != null) + else if (materialDiff != null && materialDifference?.visible == true) Row( children: [ - if (materialDifferenceFormat == MaterialDifferenceFormat.pieces) + if (materialDifference == MaterialDifference.capturedPieces) for (final role in Role.values) for (int i = 0; i < materialDiff!.capturedPieces[role]!; i++) Icon( @@ -154,8 +154,7 @@ class GamePlayer extends StatelessWidget { size: 13, color: Colors.grey, ), - if (materialDifferenceFormat == - MaterialDifferenceFormat.difference) + if (materialDifference == MaterialDifference.materialDifference) for (final role in Role.values) for (int i = 0; i < materialDiff!.pieces[role]!; i++) Icon( @@ -175,28 +174,6 @@ class GamePlayer extends StatelessWidget { ), ], ) - else if (materialDiff != null && false) - Row( - children: [ - for (final role in Role.values) - for (int i = 0; i < materialDiff!.pieces[role]!; i++) - Icon( - _iconByRole[role], - size: 13, - color: Colors.grey, - ), - const SizedBox(width: 3), - Text( - style: const TextStyle( - fontSize: 13, - color: Colors.grey, - ), - materialDiff != null && materialDiff!.score > 0 - ? '+${materialDiff!.score}' - : '', - ), - ], - ) else // to avoid shifts use an empty text widget const Text('', style: TextStyle(fontSize: 13)), diff --git a/lib/src/view/game/game_settings.dart b/lib/src/view/game/game_settings.dart index 3785e3333a..e2e1d616f4 100644 --- a/lib/src/view/game/game_settings.dart +++ b/lib/src/view/game/game_settings.dart @@ -109,17 +109,17 @@ class GameSettings extends ConsumerWidget { ref.read(boardPreferencesProvider.notifier).togglePieceAnimation(); }, ), - SwitchSettingTile( - title: Text( - context.l10n.preferencesMaterialDifference, - ), - value: boardPrefs.showMaterialDifference, - onChanged: (value) { - ref - .read(boardPreferencesProvider.notifier) - .toggleShowMaterialDifference(); - }, - ), + // SwitchSettingTile( + // title: Text( + // context.l10n.preferencesMaterialDifference, + // ), + // value: boardPrefs.showMaterialDifference, + // onChanged: (value) { + // ref + // .read(boardPreferencesProvider.notifier) + // .toggleShowMaterialDifference(); + // }, + // ), SwitchSettingTile( title: Text( context.l10n.toggleTheChat, 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 c1a7ecaaec..d563feb605 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 @@ -301,9 +301,10 @@ class _Player extends ConsumerWidget { name: side.name.capitalize(), ), ), - materialDiff: boardPreferences.showMaterialDifference + materialDiff: boardPreferences.materialDifference.visible ? gameState.currentMaterialDiff(side) : null, + materialDifference: boardPreferences.materialDifference, shouldLinkToUserProfile: false, clock: clock.timeIncrement.isInfinite ? null diff --git a/lib/src/view/settings/board_settings_screen.dart b/lib/src/view/settings/board_settings_screen.dart index 3bcad626f8..dc917a4950 100644 --- a/lib/src/view/settings/board_settings_screen.dart +++ b/lib/src/view/settings/board_settings_screen.dart @@ -184,44 +184,35 @@ class _Body extends ConsumerWidget { .togglePieceAnimation(); }, ), - SwitchSettingTile( - title: Text( - context.l10n.preferencesMaterialDifference, - ), - value: boardPrefs.showMaterialDifference, - onChanged: (value) { - ref - .read(boardPreferencesProvider.notifier) - .toggleShowMaterialDifference(); - }, - ), + // SwitchSettingTile( + // title: Text( + // context.l10n.preferencesMaterialDifference, + // ), + // value: boardPrefs.showMaterialDifference, + // onChanged: (value) { + // ref + // .read(boardPreferencesProvider.notifier) + // .toggleShowMaterialDifference(); + // }, + // ), SettingsListTile( settingsLabel: const Text('Material difference format'), - settingsValue: boardPrefs.materialDifferenceFormat.description, + settingsValue: boardPrefs.materialDifference.label, onTap: () { //TODO: implement different handling to android/ios // if (Theme.of(context).platform == TargetPlatform.android) { - if (true) { - showChoicePicker( - context, - choices: MaterialDifferenceFormat.values, - selectedItem: boardPrefs.materialDifferenceFormat, - labelBuilder: (t) => Text(t.description), - onSelectedItemChanged: - (MaterialDifferenceFormat? value) => ref - .read(boardPreferencesProvider.notifier) - .setMaterialDifferenceFormat( - value ?? MaterialDifferenceFormat.difference, - ), - ); - } else { - // pushPlatformRoute( - // context, - // title: 'Clock position', - // builder: (context) => const BoardClockPositionScreen(), - // ); - } + showChoicePicker( + context, + choices: MaterialDifference.values, + selectedItem: boardPrefs.materialDifference, + labelBuilder: (t) => Text(t.label), + onSelectedItemChanged: (MaterialDifference? value) => ref + .read(boardPreferencesProvider.notifier) + .setMaterialDifferenceFormat( + value ?? MaterialDifference.materialDifference, + ), + ); }, ), ], From 985c69f20490d4011890099d1068339fe4478309 Mon Sep 17 00:00:00 2001 From: Jimima Date: Thu, 14 Nov 2024 08:57:35 +0000 Subject: [PATCH 06/25] Added in-game preference option --- lib/src/model/settings/board_preferences.dart | 2 +- lib/src/view/game/game_settings.dart | 29 ++++++++++++------- .../view/settings/board_settings_screen.dart | 16 +--------- 3 files changed, 20 insertions(+), 27 deletions(-) diff --git a/lib/src/model/settings/board_preferences.dart b/lib/src/model/settings/board_preferences.dart index 6083e1073f..4ca47608c2 100644 --- a/lib/src/model/settings/board_preferences.dart +++ b/lib/src/model/settings/board_preferences.dart @@ -302,7 +302,7 @@ enum BoardTheme { } enum MaterialDifference { - materialDifference(label: 'Material difference', visible: false), + materialDifference(label: 'Material difference', visible: true), capturedPieces(label: 'Captured pieces', visible: true), hidden(label: 'Hidden', visible: false); diff --git a/lib/src/view/game/game_settings.dart b/lib/src/view/game/game_settings.dart index e2e1d616f4..d35ced93e0 100644 --- a/lib/src/view/game/game_settings.dart +++ b/lib/src/view/game/game_settings.dart @@ -11,6 +11,7 @@ import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/widgets/adaptive_bottom_sheet.dart'; import 'package:lichess_mobile/src/widgets/settings.dart'; +import '../../widgets/adaptive_choice_picker.dart'; import 'game_screen_providers.dart'; class GameSettings extends ConsumerWidget { @@ -109,17 +110,6 @@ class GameSettings extends ConsumerWidget { ref.read(boardPreferencesProvider.notifier).togglePieceAnimation(); }, ), - // SwitchSettingTile( - // title: Text( - // context.l10n.preferencesMaterialDifference, - // ), - // value: boardPrefs.showMaterialDifference, - // onChanged: (value) { - // ref - // .read(boardPreferencesProvider.notifier) - // .toggleShowMaterialDifference(); - // }, - // ), SwitchSettingTile( title: Text( context.l10n.toggleTheChat, @@ -137,6 +127,23 @@ class GameSettings extends ConsumerWidget { ref.read(gamePreferencesProvider.notifier).toggleBlindfoldMode(); }, ), + SettingsListTile( + settingsLabel: const Text('Captured pieces'), + settingsValue: boardPrefs.materialDifference.label, + onTap: () { + showChoicePicker( + context, + choices: MaterialDifference.values, + selectedItem: boardPrefs.materialDifference, + labelBuilder: (t) => Text(t.label), + onSelectedItemChanged: (MaterialDifference? value) => ref + .read(boardPreferencesProvider.notifier) + .setMaterialDifferenceFormat( + value ?? MaterialDifference.materialDifference, + ), + ); + }, + ), ], ); } diff --git a/lib/src/view/settings/board_settings_screen.dart b/lib/src/view/settings/board_settings_screen.dart index dc917a4950..1bbb760750 100644 --- a/lib/src/view/settings/board_settings_screen.dart +++ b/lib/src/view/settings/board_settings_screen.dart @@ -184,24 +184,10 @@ class _Body extends ConsumerWidget { .togglePieceAnimation(); }, ), - // SwitchSettingTile( - // title: Text( - // context.l10n.preferencesMaterialDifference, - // ), - // value: boardPrefs.showMaterialDifference, - // onChanged: (value) { - // ref - // .read(boardPreferencesProvider.notifier) - // .toggleShowMaterialDifference(); - // }, - // ), SettingsListTile( - settingsLabel: const Text('Material difference format'), + settingsLabel: const Text('Captured pieces'), settingsValue: boardPrefs.materialDifference.label, onTap: () { - //TODO: implement different handling to android/ios - // if (Theme.of(context).platform == TargetPlatform.android) { - showChoicePicker( context, choices: MaterialDifference.values, From c6ff78b3da16368ff105c8933d53e5b38800a9d7 Mon Sep 17 00:00:00 2001 From: Jimima Date: Thu, 14 Nov 2024 09:01:19 +0000 Subject: [PATCH 07/25] Fix format --- lib/src/view/game/game_settings.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/src/view/game/game_settings.dart b/lib/src/view/game/game_settings.dart index d35ced93e0..aebe1270da 100644 --- a/lib/src/view/game/game_settings.dart +++ b/lib/src/view/game/game_settings.dart @@ -139,8 +139,8 @@ class GameSettings extends ConsumerWidget { onSelectedItemChanged: (MaterialDifference? value) => ref .read(boardPreferencesProvider.notifier) .setMaterialDifferenceFormat( - value ?? MaterialDifference.materialDifference, - ), + value ?? MaterialDifference.materialDifference, + ), ); }, ), From f6f7f814182d54d6a732ccc612b5bae6d2f0686b Mon Sep 17 00:00:00 2001 From: Jimima Date: Fri, 15 Nov 2024 13:23:02 +0000 Subject: [PATCH 08/25] Removed comments --- lib/src/model/settings/board_preferences.dart | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/lib/src/model/settings/board_preferences.dart b/lib/src/model/settings/board_preferences.dart index 337130c2ae..e71d0d7e44 100644 --- a/lib/src/model/settings/board_preferences.dart +++ b/lib/src/model/settings/board_preferences.dart @@ -81,12 +81,6 @@ class BoardPreferences extends _$BoardPreferences ); } - // Future toggleShowMaterialDifference() { - // return save( - // state.copyWith(showMaterialDifference: !state.showMaterialDifference), - // ); - // } - Future setMaterialDifferenceFormat( MaterialDifference materialDifference, ) { @@ -119,9 +113,7 @@ class BoardPrefs with _$BoardPrefs implements Serializable { required bool boardHighlights, required bool coordinates, required bool pieceAnimation, - // required bool showMaterialDifference, required MaterialDifference materialDifference, - // required MaterialDifferenceFormat materialDifferenceFormat, @JsonKey( defaultValue: PieceShiftMethod.either, unknownEnumValue: PieceShiftMethod.either, @@ -149,8 +141,6 @@ class BoardPrefs with _$BoardPrefs implements Serializable { coordinates: true, pieceAnimation: true, materialDifference: MaterialDifference.materialDifference, - // showMaterialDifference: true, - // materialDifferenceFormat: MaterialDifferenceFormat.difference, pieceShiftMethod: PieceShiftMethod.either, enableShapeDrawings: true, magnifyDraggedPiece: true, From 3fe1a77256acaadb3ebcd013a225599fc30e5866 Mon Sep 17 00:00:00 2001 From: Jimima Date: Fri, 15 Nov 2024 13:42:39 +0000 Subject: [PATCH 09/25] Refactored enum --- lib/src/model/settings/board_preferences.dart | 20 +++++++++---------- lib/src/view/game/game_player.dart | 6 +++--- lib/src/view/game/game_settings.dart | 6 +++--- .../view/settings/board_settings_screen.dart | 6 +++--- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/lib/src/model/settings/board_preferences.dart b/lib/src/model/settings/board_preferences.dart index e71d0d7e44..482e2a0e22 100644 --- a/lib/src/model/settings/board_preferences.dart +++ b/lib/src/model/settings/board_preferences.dart @@ -82,7 +82,7 @@ class BoardPreferences extends _$BoardPreferences } Future setMaterialDifferenceFormat( - MaterialDifference materialDifference, + MaterialDifferenceFormat materialDifference, ) { return save( state.copyWith(materialDifference: materialDifference), @@ -113,7 +113,7 @@ class BoardPrefs with _$BoardPrefs implements Serializable { required bool boardHighlights, required bool coordinates, required bool pieceAnimation, - required MaterialDifference materialDifference, + required MaterialDifferenceFormat materialDifference, @JsonKey( defaultValue: PieceShiftMethod.either, unknownEnumValue: PieceShiftMethod.either, @@ -140,7 +140,7 @@ class BoardPrefs with _$BoardPrefs implements Serializable { boardHighlights: true, coordinates: true, pieceAnimation: true, - materialDifference: MaterialDifference.materialDifference, + materialDifference: MaterialDifferenceFormat.materialDifference, pieceShiftMethod: PieceShiftMethod.either, enableShapeDrawings: true, magnifyDraggedPiece: true, @@ -304,16 +304,16 @@ enum BoardTheme { ); } -enum MaterialDifference { - materialDifference(label: 'Material difference', visible: true), - capturedPieces(label: 'Captured pieces', visible: true), - hidden(label: 'Hidden', visible: false); +enum MaterialDifferenceFormat { + materialDifference(label: 'Material difference'), + capturedPieces(label: 'Captured pieces'), + hidden(label: 'Hidden'); - const MaterialDifference({ + const MaterialDifferenceFormat({ required this.label, - required this.visible, }); final String label; - final bool visible; + + bool get visible => this != MaterialDifferenceFormat.hidden; } diff --git a/lib/src/view/game/game_player.dart b/lib/src/view/game/game_player.dart index df4376bda2..639f2dd966 100644 --- a/lib/src/view/game/game_player.dart +++ b/lib/src/view/game/game_player.dart @@ -38,7 +38,7 @@ class GamePlayer extends StatelessWidget { final Player player; final Widget? clock; final MaterialDiffSide? materialDiff; - final MaterialDifference? materialDifference; + final MaterialDifferenceFormat? materialDifference; /// if confirm move preference is enabled, used to display confirmation buttons final ({VoidCallback confirm, VoidCallback cancel})? confirmMoveCallbacks; @@ -146,7 +146,7 @@ class GamePlayer extends StatelessWidget { else if (materialDiff != null && materialDifference?.visible == true) Row( children: [ - if (materialDifference == MaterialDifference.capturedPieces) + if (materialDifference == MaterialDifferenceFormat.capturedPieces) for (final role in Role.values) for (int i = 0; i < materialDiff!.capturedPieces[role]!; i++) Icon( @@ -154,7 +154,7 @@ class GamePlayer extends StatelessWidget { size: 13, color: Colors.grey, ), - if (materialDifference == MaterialDifference.materialDifference) + if (materialDifference == MaterialDifferenceFormat.materialDifference) for (final role in Role.values) for (int i = 0; i < materialDiff!.pieces[role]!; i++) Icon( diff --git a/lib/src/view/game/game_settings.dart b/lib/src/view/game/game_settings.dart index aebe1270da..2645e85973 100644 --- a/lib/src/view/game/game_settings.dart +++ b/lib/src/view/game/game_settings.dart @@ -133,13 +133,13 @@ class GameSettings extends ConsumerWidget { onTap: () { showChoicePicker( context, - choices: MaterialDifference.values, + choices: MaterialDifferenceFormat.values, selectedItem: boardPrefs.materialDifference, labelBuilder: (t) => Text(t.label), - onSelectedItemChanged: (MaterialDifference? value) => ref + onSelectedItemChanged: (MaterialDifferenceFormat? value) => ref .read(boardPreferencesProvider.notifier) .setMaterialDifferenceFormat( - value ?? MaterialDifference.materialDifference, + value ?? MaterialDifferenceFormat.materialDifference, ), ); }, diff --git a/lib/src/view/settings/board_settings_screen.dart b/lib/src/view/settings/board_settings_screen.dart index 1bbb760750..9a5d8dde24 100644 --- a/lib/src/view/settings/board_settings_screen.dart +++ b/lib/src/view/settings/board_settings_screen.dart @@ -190,13 +190,13 @@ class _Body extends ConsumerWidget { onTap: () { showChoicePicker( context, - choices: MaterialDifference.values, + choices: MaterialDifferenceFormat.values, selectedItem: boardPrefs.materialDifference, labelBuilder: (t) => Text(t.label), - onSelectedItemChanged: (MaterialDifference? value) => ref + onSelectedItemChanged: (MaterialDifferenceFormat? value) => ref .read(boardPreferencesProvider.notifier) .setMaterialDifferenceFormat( - value ?? MaterialDifference.materialDifference, + value ?? MaterialDifferenceFormat.materialDifference, ), ); }, From 21fa1e936ddc6778323a627fb3e2d11e27cbdb63 Mon Sep 17 00:00:00 2001 From: Jimima Date: Fri, 15 Nov 2024 14:26:56 +0000 Subject: [PATCH 10/25] Added test for captured pieces --- test/model/game/material_diff_test.dart | 26 +++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/test/model/game/material_diff_test.dart b/test/model/game/material_diff_test.dart index 7a1e7c6a79..a6391a6b18 100644 --- a/test/model/game/material_diff_test.dart +++ b/test/model/game/material_diff_test.dart @@ -38,6 +38,32 @@ void main() { }), ), ); + expect( + diff.bySide(Side.black).capturedPieces, + equals( + IMap(const { + Role.king: 0, + Role.queen: 0, + Role.rook: 0, + Role.bishop: 2, + Role.knight: 2, + Role.pawn: 4, + }), + ), + ); + expect( + diff.bySide(Side.white).capturedPieces, + equals( + IMap(const { + Role.king: 0, + Role.queen: 1, + Role.rook: 1, + Role.bishop: 1, + Role.knight: 2, + Role.pawn: 3, + }), + ), + ); }); }); } From c5e81eed527609b088323b77baa0c62c4c7e88c0 Mon Sep 17 00:00:00 2001 From: Jimima Date: Fri, 15 Nov 2024 14:28:48 +0000 Subject: [PATCH 11/25] Format --- lib/src/view/game/game_player.dart | 3 ++- lib/src/view/settings/board_settings_screen.dart | 12 +++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/lib/src/view/game/game_player.dart b/lib/src/view/game/game_player.dart index 639f2dd966..134756fb9f 100644 --- a/lib/src/view/game/game_player.dart +++ b/lib/src/view/game/game_player.dart @@ -154,7 +154,8 @@ class GamePlayer extends StatelessWidget { size: 13, color: Colors.grey, ), - if (materialDifference == MaterialDifferenceFormat.materialDifference) + if (materialDifference == + MaterialDifferenceFormat.materialDifference) for (final role in Role.values) for (int i = 0; i < materialDiff!.pieces[role]!; i++) Icon( diff --git a/lib/src/view/settings/board_settings_screen.dart b/lib/src/view/settings/board_settings_screen.dart index 9a5d8dde24..0e88ed4c55 100644 --- a/lib/src/view/settings/board_settings_screen.dart +++ b/lib/src/view/settings/board_settings_screen.dart @@ -193,11 +193,13 @@ class _Body extends ConsumerWidget { choices: MaterialDifferenceFormat.values, selectedItem: boardPrefs.materialDifference, labelBuilder: (t) => Text(t.label), - onSelectedItemChanged: (MaterialDifferenceFormat? value) => ref - .read(boardPreferencesProvider.notifier) - .setMaterialDifferenceFormat( - value ?? MaterialDifferenceFormat.materialDifference, - ), + onSelectedItemChanged: (MaterialDifferenceFormat? value) => + ref + .read(boardPreferencesProvider.notifier) + .setMaterialDifferenceFormat( + value ?? + MaterialDifferenceFormat.materialDifference, + ), ); }, ), From 3dfd6b3ac1c3e5d7f7d334bdee047fa7bc1b576b Mon Sep 17 00:00:00 2001 From: Jimima Date: Fri, 15 Nov 2024 14:52:12 +0000 Subject: [PATCH 12/25] Parameterised starting position --- lib/src/model/game/material_diff.dart | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/src/model/game/material_diff.dart b/lib/src/model/game/material_diff.dart index a661d1fa2d..30cd29cf33 100644 --- a/lib/src/model/game/material_diff.dart +++ b/lib/src/model/game/material_diff.dart @@ -31,16 +31,17 @@ class MaterialDiff with _$MaterialDiff { required MaterialDiffSide white, }) = _MaterialDiff; - factory MaterialDiff.fromBoard(Board board) { + factory MaterialDiff.fromBoard(Board board, {Board? startingPosition}) { int score = 0; final IMap blackCount = board.materialCount(Side.black); final IMap whiteCount = board.materialCount(Side.white); final IMap blackStartingCount = - Board.standard.materialCount(Side.black); + startingPosition?.materialCount(Side.black) ?? + Board.standard.materialCount(Side.black); final IMap whiteStartingCount = - Board.standard.materialCount(Side.white); - // TODO: parameterise starting position maybe so it can be passed in + startingPosition?.materialCount(Side.white) ?? + Board.standard.materialCount(Side.white); IMap subtractPieceCounts( IMap startingCount, From 913d4a19518e33f0faff0b20cd20c98307a95e8e Mon Sep 17 00:00:00 2001 From: Jimima Date: Fri, 15 Nov 2024 16:36:36 +0000 Subject: [PATCH 13/25] Refactor --- lib/src/model/settings/board_preferences.dart | 8 +- .../offline_correspondence_game_screen.dart | 6 +- lib/src/view/game/game_body.dart | 8 +- lib/src/view/game/game_player.dart | 80 +++++++++++-------- lib/src/view/game/game_settings.dart | 6 +- .../over_the_board/over_the_board_screen.dart | 4 +- .../view/settings/board_settings_screen.dart | 6 +- 7 files changed, 66 insertions(+), 52 deletions(-) diff --git a/lib/src/model/settings/board_preferences.dart b/lib/src/model/settings/board_preferences.dart index 482e2a0e22..9dbc7bbeac 100644 --- a/lib/src/model/settings/board_preferences.dart +++ b/lib/src/model/settings/board_preferences.dart @@ -82,10 +82,10 @@ class BoardPreferences extends _$BoardPreferences } Future setMaterialDifferenceFormat( - MaterialDifferenceFormat materialDifference, + MaterialDifferenceFormat materialDifferenceFormat, ) { return save( - state.copyWith(materialDifference: materialDifference), + state.copyWith(materialDifferenceFormat: materialDifferenceFormat), ); } @@ -113,7 +113,7 @@ class BoardPrefs with _$BoardPrefs implements Serializable { required bool boardHighlights, required bool coordinates, required bool pieceAnimation, - required MaterialDifferenceFormat materialDifference, + required MaterialDifferenceFormat materialDifferenceFormat, @JsonKey( defaultValue: PieceShiftMethod.either, unknownEnumValue: PieceShiftMethod.either, @@ -140,7 +140,7 @@ class BoardPrefs with _$BoardPrefs implements Serializable { boardHighlights: true, coordinates: true, pieceAnimation: true, - materialDifference: MaterialDifferenceFormat.materialDifference, + materialDifferenceFormat: MaterialDifferenceFormat.materialDifference, pieceShiftMethod: PieceShiftMethod.either, enableShapeDrawings: true, magnifyDraggedPiece: true, diff --git a/lib/src/view/correspondence/offline_correspondence_game_screen.dart b/lib/src/view/correspondence/offline_correspondence_game_screen.dart index 26cf810243..8f7c6b6b08 100644 --- a/lib/src/view/correspondence/offline_correspondence_game_screen.dart +++ b/lib/src/view/correspondence/offline_correspondence_game_screen.dart @@ -144,7 +144,7 @@ class _BodyState extends ConsumerState<_Body> { Widget build(BuildContext context) { final materialDifference = ref.watch( boardPreferencesProvider.select( - (prefs) => prefs.materialDifference, + (prefs) => prefs.materialDifferenceFormat, ), ); @@ -160,7 +160,7 @@ class _BodyState extends ConsumerState<_Body> { materialDiff: materialDifference.visible ? game.materialDiffAt(stepCursor, Side.black) : null, - materialDifference: materialDifference, + materialDifferenceFormat: materialDifference, shouldLinkToUserProfile: false, mePlaying: youAre == Side.black, confirmMoveCallbacks: youAre == Side.black && moveToConfirm != null @@ -183,7 +183,7 @@ class _BodyState extends ConsumerState<_Body> { materialDiff: materialDifference.visible ? game.materialDiffAt(stepCursor, Side.white) : null, - materialDifference: materialDifference, + materialDifferenceFormat: materialDifference, shouldLinkToUserProfile: false, mePlaying: youAre == Side.white, confirmMoveCallbacks: youAre == Side.white && moveToConfirm != null diff --git a/lib/src/view/game/game_body.dart b/lib/src/view/game/game_body.dart index 928051b1ec..eea37bfced 100644 --- a/lib/src/view/game/game_body.dart +++ b/lib/src/view/game/game_body.dart @@ -128,10 +128,10 @@ class GameBody extends ConsumerWidget { final black = GamePlayer( player: gameState.game.black, - materialDiff: boardPreferences.materialDifference.visible + materialDiff: boardPreferences.materialDifferenceFormat.visible ? gameState.game.materialDiffAt(gameState.stepCursor, Side.black) : null, - materialDifference: boardPreferences.materialDifference, + materialDifferenceFormat: boardPreferences.materialDifferenceFormat, timeToMove: gameState.game.sideToMove == Side.black ? gameState.timeToMove : null, @@ -169,10 +169,10 @@ class GameBody extends ConsumerWidget { ); final white = GamePlayer( player: gameState.game.white, - materialDiff: boardPreferences.materialDifference.visible + materialDiff: boardPreferences.materialDifferenceFormat.visible ? gameState.game.materialDiffAt(gameState.stepCursor, Side.white) : null, - materialDifference: boardPreferences.materialDifference, + materialDifferenceFormat: boardPreferences.materialDifferenceFormat, timeToMove: gameState.game.sideToMove == Side.white ? gameState.timeToMove : null, diff --git a/lib/src/view/game/game_player.dart b/lib/src/view/game/game_player.dart index 134756fb9f..35c4a00f40 100644 --- a/lib/src/view/game/game_player.dart +++ b/lib/src/view/game/game_player.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:dartchess/dartchess.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:lichess_mobile/src/constants.dart'; @@ -26,7 +27,7 @@ class GamePlayer extends StatelessWidget { required this.player, this.clock, this.materialDiff, - this.materialDifference, + this.materialDifferenceFormat, this.confirmMoveCallbacks, this.timeToMove, this.shouldLinkToUserProfile = true, @@ -38,7 +39,7 @@ class GamePlayer extends StatelessWidget { final Player player; final Widget? clock; final MaterialDiffSide? materialDiff; - final MaterialDifferenceFormat? materialDifference; + final MaterialDifferenceFormat? materialDifferenceFormat; /// if confirm move preference is enabled, used to display confirmation buttons final ({VoidCallback confirm, VoidCallback cancel})? confirmMoveCallbacks; @@ -143,37 +144,12 @@ class GamePlayer extends StatelessWidget { ), if (timeToMove != null) MoveExpiration(timeToMove: timeToMove!, mePlaying: mePlaying) - else if (materialDiff != null && materialDifference?.visible == true) - Row( - children: [ - if (materialDifference == MaterialDifferenceFormat.capturedPieces) - for (final role in Role.values) - for (int i = 0; i < materialDiff!.capturedPieces[role]!; i++) - Icon( - _iconByRole[role], - size: 13, - color: Colors.grey, - ), - if (materialDifference == - MaterialDifferenceFormat.materialDifference) - for (final role in Role.values) - for (int i = 0; i < materialDiff!.pieces[role]!; i++) - Icon( - _iconByRole[role], - size: 13, - color: Colors.grey, - ), - const SizedBox(width: 3), - Text( - style: const TextStyle( - fontSize: 13, - color: Colors.grey, - ), - materialDiff != null && materialDiff!.score > 0 - ? '+${materialDiff!.score}' - : '', - ), - ], + else if (materialDiff != null && + materialDifferenceFormat?.visible == true) + //MaterialDifferenceDisplay + MaterialDifferenceDisplay( + materialDiff: materialDiff!, + materialDifferenceFormat: materialDifferenceFormat!, ) else // to avoid shifts use an empty text widget @@ -338,6 +314,44 @@ class _MoveExpirationState extends State { } } +class MaterialDifferenceDisplay extends StatelessWidget { + const MaterialDifferenceDisplay({ + required this.materialDiff, + this.materialDifferenceFormat = MaterialDifferenceFormat.materialDifference, + }); + + final MaterialDiffSide materialDiff; + final MaterialDifferenceFormat materialDifferenceFormat; + + @override + Widget build(BuildContext context) { + final IMap piecesToRender = + (materialDifferenceFormat == MaterialDifferenceFormat.capturedPieces + ? materialDiff.capturedPieces + : materialDiff.pieces); + + return Row( + children: [ + for (final role in Role.values) + for (int i = 0; i < piecesToRender[role]!; i++) + Icon( + _iconByRole[role], + size: 13, + color: Colors.grey, + ), + const SizedBox(width: 3), + Text( + style: const TextStyle( + fontSize: 13, + color: Colors.grey, + ), + materialDiff.score > 0 ? '+${materialDiff.score}' : '', + ), + ], + ); + } +} + const Map _iconByRole = { Role.king: LichessIcons.chess_king, Role.queen: LichessIcons.chess_queen, diff --git a/lib/src/view/game/game_settings.dart b/lib/src/view/game/game_settings.dart index 2645e85973..4e3095c252 100644 --- a/lib/src/view/game/game_settings.dart +++ b/lib/src/view/game/game_settings.dart @@ -128,13 +128,13 @@ class GameSettings extends ConsumerWidget { }, ), SettingsListTile( - settingsLabel: const Text('Captured pieces'), - settingsValue: boardPrefs.materialDifference.label, + settingsLabel: const Text('Material'), //TODO: l10n + settingsValue: boardPrefs.materialDifferenceFormat.label, onTap: () { showChoicePicker( context, choices: MaterialDifferenceFormat.values, - selectedItem: boardPrefs.materialDifference, + selectedItem: boardPrefs.materialDifferenceFormat, labelBuilder: (t) => Text(t.label), onSelectedItemChanged: (MaterialDifferenceFormat? value) => ref .read(boardPreferencesProvider.notifier) 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 d563feb605..5478945df8 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 @@ -301,10 +301,10 @@ class _Player extends ConsumerWidget { name: side.name.capitalize(), ), ), - materialDiff: boardPreferences.materialDifference.visible + materialDiff: boardPreferences.materialDifferenceFormat.visible ? gameState.currentMaterialDiff(side) : null, - materialDifference: boardPreferences.materialDifference, + materialDifferenceFormat: boardPreferences.materialDifferenceFormat, shouldLinkToUserProfile: false, clock: clock.timeIncrement.isInfinite ? null diff --git a/lib/src/view/settings/board_settings_screen.dart b/lib/src/view/settings/board_settings_screen.dart index 0e88ed4c55..27b468fdc2 100644 --- a/lib/src/view/settings/board_settings_screen.dart +++ b/lib/src/view/settings/board_settings_screen.dart @@ -185,13 +185,13 @@ class _Body extends ConsumerWidget { }, ), SettingsListTile( - settingsLabel: const Text('Captured pieces'), - settingsValue: boardPrefs.materialDifference.label, + settingsLabel: const Text('Material'), + settingsValue: boardPrefs.materialDifferenceFormat.label, onTap: () { showChoicePicker( context, choices: MaterialDifferenceFormat.values, - selectedItem: boardPrefs.materialDifference, + selectedItem: boardPrefs.materialDifferenceFormat, labelBuilder: (t) => Text(t.label), onSelectedItemChanged: (MaterialDifferenceFormat? value) => ref From b784f93948bc4f9a10a6cd60ab93d7117b49f4e2 Mon Sep 17 00:00:00 2001 From: Jimima Date: Fri, 15 Nov 2024 16:44:18 +0000 Subject: [PATCH 14/25] Removed comment --- lib/src/view/game/game_player.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/src/view/game/game_player.dart b/lib/src/view/game/game_player.dart index 35c4a00f40..87af26cf8d 100644 --- a/lib/src/view/game/game_player.dart +++ b/lib/src/view/game/game_player.dart @@ -146,7 +146,6 @@ class GamePlayer extends StatelessWidget { MoveExpiration(timeToMove: timeToMove!, mePlaying: mePlaying) else if (materialDiff != null && materialDifferenceFormat?.visible == true) - //MaterialDifferenceDisplay MaterialDifferenceDisplay( materialDiff: materialDiff!, materialDifferenceFormat: materialDifferenceFormat!, From 46a74804c184ca6f9d0f869368379e1a3187c99d Mon Sep 17 00:00:00 2001 From: Jimima Date: Sat, 16 Nov 2024 12:45:14 +0000 Subject: [PATCH 15/25] Placeholder l10n implementation --- lib/src/model/settings/board_preferences.dart | 8 ++++++++ lib/src/view/game/game_settings.dart | 6 ++++-- lib/src/view/settings/board_settings_screen.dart | 9 ++++++--- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/lib/src/model/settings/board_preferences.dart b/lib/src/model/settings/board_preferences.dart index 9dbc7bbeac..e07555b201 100644 --- a/lib/src/model/settings/board_preferences.dart +++ b/lib/src/model/settings/board_preferences.dart @@ -2,6 +2,7 @@ import 'package:chessground/chessground.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +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'; @@ -316,4 +317,11 @@ enum MaterialDifferenceFormat { final String label; bool get visible => this != MaterialDifferenceFormat.hidden; + + String l10n(AppLocalizations l10n) => switch (this) { + //TODO: Add l10n + MaterialDifferenceFormat.materialDifference => materialDifference.label, + MaterialDifferenceFormat.capturedPieces => capturedPieces.label, + MaterialDifferenceFormat.hidden => hidden.label, + }; } diff --git a/lib/src/view/game/game_settings.dart b/lib/src/view/game/game_settings.dart index 4e3095c252..25e9c2b1f7 100644 --- a/lib/src/view/game/game_settings.dart +++ b/lib/src/view/game/game_settings.dart @@ -1,6 +1,7 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lichess_mobile/l10n/l10n.dart'; import 'package:lichess_mobile/src/model/account/account_preferences.dart'; import 'package:lichess_mobile/src/model/common/id.dart'; import 'package:lichess_mobile/src/model/game/game_controller.dart'; @@ -129,13 +130,14 @@ class GameSettings extends ConsumerWidget { ), SettingsListTile( settingsLabel: const Text('Material'), //TODO: l10n - settingsValue: boardPrefs.materialDifferenceFormat.label, + settingsValue: boardPrefs.materialDifferenceFormat + .l10n(AppLocalizations.of(context)), onTap: () { showChoicePicker( context, choices: MaterialDifferenceFormat.values, selectedItem: boardPrefs.materialDifferenceFormat, - labelBuilder: (t) => Text(t.label), + labelBuilder: (t) => Text(t.l10n(AppLocalizations.of(context))), onSelectedItemChanged: (MaterialDifferenceFormat? value) => ref .read(boardPreferencesProvider.notifier) .setMaterialDifferenceFormat( diff --git a/lib/src/view/settings/board_settings_screen.dart b/lib/src/view/settings/board_settings_screen.dart index 27b468fdc2..e2bc8b5ccd 100644 --- a/lib/src/view/settings/board_settings_screen.dart +++ b/lib/src/view/settings/board_settings_screen.dart @@ -2,6 +2,7 @@ import 'package:chessground/chessground.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lichess_mobile/l10n/l10n.dart'; import 'package:lichess_mobile/src/model/settings/board_preferences.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/utils/navigation.dart'; @@ -185,14 +186,16 @@ class _Body extends ConsumerWidget { }, ), SettingsListTile( - settingsLabel: const Text('Material'), - settingsValue: boardPrefs.materialDifferenceFormat.label, + settingsLabel: const Text('Material'), //TODO: l10n + settingsValue: boardPrefs.materialDifferenceFormat + .l10n(AppLocalizations.of(context)), onTap: () { showChoicePicker( context, choices: MaterialDifferenceFormat.values, selectedItem: boardPrefs.materialDifferenceFormat, - labelBuilder: (t) => Text(t.label), + labelBuilder: (t) => + Text(t.l10n(AppLocalizations.of(context))), onSelectedItemChanged: (MaterialDifferenceFormat? value) => ref .read(boardPreferencesProvider.notifier) From b8e7a04eefa1b32222d17e129b8bdab2e77affdd Mon Sep 17 00:00:00 2001 From: Jimima Date: Wed, 20 Nov 2024 15:40:40 +0000 Subject: [PATCH 16/25] Refactor --- lib/src/view/game/game_player.dart | 43 +++++++++++++++--------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/lib/src/view/game/game_player.dart b/lib/src/view/game/game_player.dart index 87af26cf8d..394ebcd672 100644 --- a/lib/src/view/game/game_player.dart +++ b/lib/src/view/game/game_player.dart @@ -144,8 +144,7 @@ class GamePlayer extends StatelessWidget { ), if (timeToMove != null) MoveExpiration(timeToMove: timeToMove!, mePlaying: mePlaying) - else if (materialDiff != null && - materialDifferenceFormat?.visible == true) + else if (materialDiff != null) MaterialDifferenceDisplay( materialDiff: materialDiff!, materialDifferenceFormat: materialDifferenceFormat!, @@ -329,25 +328,27 @@ class MaterialDifferenceDisplay extends StatelessWidget { ? materialDiff.capturedPieces : materialDiff.pieces); - return Row( - children: [ - for (final role in Role.values) - for (int i = 0; i < piecesToRender[role]!; i++) - Icon( - _iconByRole[role], - size: 13, - color: Colors.grey, - ), - const SizedBox(width: 3), - Text( - style: const TextStyle( - fontSize: 13, - color: Colors.grey, - ), - materialDiff.score > 0 ? '+${materialDiff.score}' : '', - ), - ], - ); + return !materialDifferenceFormat.visible + ? const SizedBox.shrink() + : Row( + children: [ + for (final role in Role.values) + for (int i = 0; i < piecesToRender[role]!; i++) + Icon( + _iconByRole[role], + size: 13, + color: Colors.grey, + ), + const SizedBox(width: 3), + Text( + style: const TextStyle( + fontSize: 13, + color: Colors.grey, + ), + materialDiff.score > 0 ? '+${materialDiff.score}' : '', + ), + ], + ); } } From a4716f7523a8b79dc7f27b27027844de4e2c43f7 Mon Sep 17 00:00:00 2001 From: Jimima Date: Wed, 20 Nov 2024 16:44:11 +0000 Subject: [PATCH 17/25] Syntax fix(?) --- .../model/account/account_preferences.dart | 16 +- lib/src/model/clock/chess_clock.dart | 184 +++++++ lib/src/model/clock/clock_controller.dart | 191 ------- .../model/clock/clock_tool_controller.dart | 231 ++++++++ .../offline_correspondence_game.dart | 1 - lib/src/model/game/chat_controller.dart | 59 ++- lib/src/model/game/game.dart | 9 - lib/src/model/game/game_controller.dart | 135 ++++- lib/src/model/game/game_socket_events.dart | 9 +- lib/src/model/game/game_status.dart | 15 + lib/src/model/game/playable_game.dart | 26 +- lib/src/model/settings/board_preferences.dart | 38 +- lib/src/model/study/study_controller.dart | 80 ++- lib/src/model/tv/tv_controller.dart | 21 + lib/src/network/socket.dart | 3 +- lib/src/view/clock/clock_settings.dart | 18 +- ...ock_screen.dart => clock_tool_screen.dart} | 83 +-- lib/src/view/clock/custom_clock_settings.dart | 6 +- .../offline_correspondence_game_screen.dart | 2 +- lib/src/view/game/archived_game_screen.dart | 16 +- .../game/correspondence_clock_widget.dart | 2 +- lib/src/view/game/game_body.dart | 96 ++-- lib/src/view/game/game_common_widgets.dart | 5 + lib/src/view/game/game_player.dart | 56 +- lib/src/view/game/game_settings.dart | 38 +- lib/src/view/game/message_screen.dart | 51 +- .../over_the_board/over_the_board_screen.dart | 2 +- lib/src/view/puzzle/puzzle_screen.dart | 5 + .../view/puzzle/puzzle_settings_screen.dart | 49 +- .../view/settings/board_settings_screen.dart | 229 +++++++- .../piece_shift_method_settings_screen.dart | 79 --- lib/src/view/settings/theme_screen.dart | 13 +- lib/src/view/study/study_bottom_bar.dart | 116 ++++ lib/src/view/study/study_gamebook.dart | 174 ++++++ lib/src/view/study/study_screen.dart | 14 +- lib/src/view/tools/tools_tab_screen.dart | 4 +- lib/src/view/watch/tv_screen.dart | 28 +- lib/src/widgets/board_table.dart | 12 +- .../{countdown_clock.dart => clock.dart} | 310 +++++------ lib/src/widgets/rating.dart | 4 +- .../challenge/challenge_service_test.dart | 6 +- test/model/clock/chess_clock_test.dart | 231 ++++++++ test/model/game/game_socket_events_test.dart | 17 +- test/model/game/game_socket_example_data.dart | 103 ++++ test/network/fake_websocket_channel.dart | 38 +- test/network/socket_test.dart | 13 +- test/test_container.dart | 2 +- test/test_helpers.dart | 8 +- test/test_provider_scope.dart | 2 +- test/view/game/game_screen_test.dart | 497 ++++++++++++++++++ .../over_the_board_screen_test.dart | 40 +- test/view/puzzle/puzzle_screen_test.dart | 14 +- test/view/puzzle/storm_screen_test.dart | 11 +- test/view/study/study_screen_test.dart | 233 +++++++- test/view/user/perf_stats_screen_test.dart | 2 +- test/widgets/clock_test.dart | 249 +++++++++ 56 files changed, 3113 insertions(+), 783 deletions(-) create mode 100644 lib/src/model/clock/chess_clock.dart delete mode 100644 lib/src/model/clock/clock_controller.dart create mode 100644 lib/src/model/clock/clock_tool_controller.dart rename lib/src/view/clock/{clock_screen.dart => clock_tool_screen.dart} (73%) delete mode 100644 lib/src/view/settings/piece_shift_method_settings_screen.dart create mode 100644 lib/src/view/study/study_gamebook.dart rename lib/src/widgets/{countdown_clock.dart => clock.dart} (64%) create mode 100644 test/model/clock/chess_clock_test.dart create mode 100644 test/model/game/game_socket_example_data.dart create mode 100644 test/view/game/game_screen_test.dart create mode 100644 test/widgets/clock_test.dart diff --git a/lib/src/model/account/account_preferences.dart b/lib/src/model/account/account_preferences.dart index ee6950f089..343843f1d7 100644 --- a/lib/src/model/account/account_preferences.dart +++ b/lib/src/model/account/account_preferences.dart @@ -1,5 +1,6 @@ import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lichess_mobile/src/model/auth/auth_session.dart'; import 'package:lichess_mobile/src/network/http.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; @@ -29,28 +30,31 @@ typedef AccountPrefState = ({ }); /// A provider that tells if the user wants to see ratings in the app. -final showRatingsPrefProvider = FutureProvider((ref) async { +@Riverpod(keepAlive: true) +Future showRatingsPref(Ref ref) async { return ref.watch( accountPreferencesProvider .selectAsync((state) => state?.showRatings.value ?? true), ); -}); +} -final clockSoundProvider = FutureProvider((ref) async { +@Riverpod(keepAlive: true) +Future clockSound(Ref ref) async { return ref.watch( accountPreferencesProvider .selectAsync((state) => state?.clockSound.value ?? true), ); -}); +} -final pieceNotationProvider = FutureProvider((ref) async { +@Riverpod(keepAlive: true) +Future pieceNotation(Ref ref) async { return ref.watch( accountPreferencesProvider.selectAsync( (state) => state?.pieceNotation ?? defaultAccountPreferences.pieceNotation, ), ); -}); +} final defaultAccountPreferences = ( zenMode: Zen.no, diff --git a/lib/src/model/clock/chess_clock.dart b/lib/src/model/clock/chess_clock.dart new file mode 100644 index 0000000000..b4560932e2 --- /dev/null +++ b/lib/src/model/clock/chess_clock.dart @@ -0,0 +1,184 @@ +import 'dart:async'; + +import 'package:clock/clock.dart'; +import 'package:dartchess/dartchess.dart'; +import 'package:flutter/foundation.dart'; + +const _emergencyDelay = Duration(seconds: 20); +const _tickDelay = Duration(milliseconds: 100); + +/// A chess clock. +class ChessClock { + ChessClock({ + required Duration whiteTime, + required Duration blackTime, + this.emergencyThreshold, + this.onFlag, + this.onEmergency, + }) : _whiteTime = ValueNotifier(whiteTime), + _blackTime = ValueNotifier(blackTime), + _activeSide = Side.white; + + /// The threshold at which the clock will call [onEmergency] if provided. + final Duration? emergencyThreshold; + + /// Callback when the clock reaches zero. + VoidCallback? onFlag; + + /// Called when one clock timers reaches the emergency threshold. + final void Function(Side activeSide)? onEmergency; + + Timer? _timer; + Timer? _startDelayTimer; + DateTime? _lastStarted; + final _stopwatch = clock.stopwatch(); + bool _shouldPlayEmergencyFeedback = true; + DateTime? _nextEmergency; + + final ValueNotifier _whiteTime; + final ValueNotifier _blackTime; + Side _activeSide; + + bool get isRunning { + return _lastStarted != null; + } + + /// Returns the current white time. + ValueListenable get whiteTime => _whiteTime; + + /// Returns the current black time. + ValueListenable get blackTime => _blackTime; + + /// Returns the current active time. + ValueListenable get activeTime => _activeTime; + + /// Returns the current active side. + Side get activeSide => _activeSide; + + /// Sets the time for either side. + void setTimes({Duration? whiteTime, Duration? blackTime}) { + if (whiteTime != null) { + _whiteTime.value = whiteTime; + } + if (blackTime != null) { + _blackTime.value = blackTime; + } + } + + /// Sets the time for the given side. + void setTime(Side side, Duration time) { + if (side == Side.white) { + _whiteTime.value = time; + } else { + _blackTime.value = time; + } + } + + /// Increments the time for either side. + void incTimes({Duration? whiteInc, Duration? blackInc}) { + if (whiteInc != null) { + _whiteTime.value += whiteInc; + } + if (blackInc != null) { + _blackTime.value += blackInc; + } + } + + /// Increments the time for the given side. + void incTime(Side side, Duration increment) { + if (side == Side.white) { + _whiteTime.value += increment; + } else { + _blackTime.value += increment; + } + } + + /// Starts the clock and switch to the given side. + /// + /// Trying to start an already running clock on the same side is a no-op. + /// + /// The [delay] parameter can be used to add a delay before the clock starts counting down. This is useful for lag compensation. + /// + /// Returns the think time of the active side before switching or `null` if the clock is not running. + Duration? startSide(Side side, {Duration? delay}) { + if (isRunning && _activeSide == side) { + return _thinkTime; + } + _activeSide = side; + final thinkTime = _thinkTime; + start(delay: delay); + return thinkTime; + } + + /// Starts the clock. + /// + /// The [delay] parameter can be used to add a delay before the clock starts counting down. This is useful for lag compensation. + void start({Duration? delay}) { + _lastStarted = clock.now().add(delay ?? Duration.zero); + _startDelayTimer?.cancel(); + _startDelayTimer = Timer(delay ?? Duration.zero, _scheduleTick); + } + + /// Pauses the clock. + /// + /// Returns the current think time for the active side. + Duration stop() { + _stopwatch.stop(); + _startDelayTimer?.cancel(); + _timer?.cancel(); + final thinkTime = _thinkTime ?? Duration.zero; + _lastStarted = null; + return thinkTime; + } + + void dispose() { + _timer?.cancel(); + _startDelayTimer?.cancel(); + _whiteTime.dispose(); + _blackTime.dispose(); + } + + /// Returns the current think time for the active side. + Duration? get _thinkTime { + if (_lastStarted == null) { + return null; + } + return clock.now().difference(_lastStarted!); + } + + ValueNotifier get _activeTime { + return activeSide == Side.white ? _whiteTime : _blackTime; + } + + void _scheduleTick() { + _stopwatch.reset(); + _stopwatch.start(); + _timer?.cancel(); + _timer = Timer(_tickDelay, _tick); + } + + void _tick() { + final newTime = _activeTime.value - _stopwatch.elapsed; + _activeTime.value = newTime < Duration.zero ? Duration.zero : newTime; + _checkEmergency(); + if (_activeTime.value == Duration.zero) { + onFlag?.call(); + } + _scheduleTick(); + } + + void _checkEmergency() { + final timeLeft = _activeTime.value; + if (emergencyThreshold != null && + timeLeft <= emergencyThreshold! && + _shouldPlayEmergencyFeedback && + (_nextEmergency == null || _nextEmergency!.isBefore(clock.now()))) { + _shouldPlayEmergencyFeedback = false; + _nextEmergency = clock.now().add(_emergencyDelay); + onEmergency?.call(_activeSide); + } else if (emergencyThreshold != null && + timeLeft > emergencyThreshold! * 1.5) { + _shouldPlayEmergencyFeedback = true; + } + } +} diff --git a/lib/src/model/clock/clock_controller.dart b/lib/src/model/clock/clock_controller.dart deleted file mode 100644 index 1a70a0ff92..0000000000 --- a/lib/src/model/clock/clock_controller.dart +++ /dev/null @@ -1,191 +0,0 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:lichess_mobile/src/model/common/service/sound_service.dart'; -import 'package:lichess_mobile/src/model/common/time_increment.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; - -part 'clock_controller.freezed.dart'; -part 'clock_controller.g.dart'; - -@riverpod -class ClockController extends _$ClockController { - @override - ClockState build() { - const time = Duration(minutes: 10); - const increment = Duration.zero; - return ClockState.fromOptions( - const ClockOptions( - timePlayerTop: time, - timePlayerBottom: time, - incrementPlayerTop: increment, - incrementPlayerBottom: increment, - ), - ); - } - - void onTap(ClockPlayerType playerType) { - final started = state.started; - if (playerType == ClockPlayerType.top) { - state = state.copyWith( - started: true, - activeSide: ClockPlayerType.bottom, - playerTopMoves: started ? state.playerTopMoves + 1 : 0, - ); - } else { - state = state.copyWith( - started: true, - activeSide: ClockPlayerType.top, - playerBottomMoves: started ? state.playerBottomMoves + 1 : 0, - ); - } - ref.read(soundServiceProvider).play(Sound.clock); - } - - void updateDuration(ClockPlayerType playerType, Duration duration) { - if (state.loser != null || state.paused) { - return; - } - - if (playerType == ClockPlayerType.top) { - state = state.copyWith( - playerTopTime: duration + state.options.incrementPlayerTop, - ); - } else { - state = state.copyWith( - playerBottomTime: duration + state.options.incrementPlayerBottom, - ); - } - } - - void updateOptions(TimeIncrement timeIncrement) => - state = ClockState.fromTimeIncrement(timeIncrement); - - void updateOptionsCustom( - TimeIncrement clock, - ClockPlayerType player, - ) => - state = ClockState.fromOptions( - ClockOptions( - timePlayerTop: player == ClockPlayerType.top - ? Duration(seconds: clock.time) - : state.options.timePlayerTop, - timePlayerBottom: player == ClockPlayerType.bottom - ? Duration(seconds: clock.time) - : state.options.timePlayerBottom, - incrementPlayerTop: player == ClockPlayerType.top - ? Duration(seconds: clock.increment) - : state.options.incrementPlayerTop, - incrementPlayerBottom: player == ClockPlayerType.bottom - ? Duration(seconds: clock.increment) - : state.options.incrementPlayerBottom, - ), - ); - - void setActiveSide(ClockPlayerType playerType) => - state = state.copyWith(activeSide: playerType); - - void setLoser(ClockPlayerType playerType) => - state = state.copyWith(loser: playerType); - - void reset() => state = ClockState.fromOptions(state.options); - - void start() => state = state.copyWith(started: true); - - void pause() => state = state.copyWith(paused: true); - - void resume() => state = state.copyWith(paused: false); -} - -enum ClockPlayerType { top, bottom } - -@freezed -class ClockOptions with _$ClockOptions { - const ClockOptions._(); - - const factory ClockOptions({ - required Duration timePlayerTop, - required Duration timePlayerBottom, - required Duration incrementPlayerTop, - required Duration incrementPlayerBottom, - }) = _ClockOptions; -} - -@freezed -class ClockState with _$ClockState { - const ClockState._(); - - const factory ClockState({ - required int id, - required ClockOptions options, - required Duration playerTopTime, - required Duration playerBottomTime, - required ClockPlayerType activeSide, - ClockPlayerType? loser, - @Default(false) bool started, - @Default(false) bool paused, - @Default(0) int playerTopMoves, - @Default(0) int playerBottomMoves, - }) = _ClockState; - - factory ClockState.fromTimeIncrement(TimeIncrement timeIncrement) { - final options = ClockOptions( - timePlayerTop: Duration(seconds: timeIncrement.time), - timePlayerBottom: Duration(seconds: timeIncrement.time), - incrementPlayerTop: Duration(seconds: timeIncrement.increment), - incrementPlayerBottom: Duration(seconds: timeIncrement.increment), - ); - - return ClockState( - id: DateTime.now().millisecondsSinceEpoch, - options: options, - activeSide: ClockPlayerType.top, - playerTopTime: options.timePlayerTop, - playerBottomTime: options.timePlayerBottom, - ); - } - - factory ClockState.fromSeparateTimeIncrements( - TimeIncrement playerTop, - TimeIncrement playerBottom, - ) { - final options = ClockOptions( - timePlayerTop: Duration(seconds: playerTop.time), - timePlayerBottom: Duration(seconds: playerBottom.time), - incrementPlayerTop: Duration(seconds: playerTop.increment), - incrementPlayerBottom: Duration(seconds: playerBottom.increment), - ); - return ClockState( - id: DateTime.now().millisecondsSinceEpoch, - activeSide: ClockPlayerType.top, - options: options, - playerTopTime: options.timePlayerTop, - playerBottomTime: options.timePlayerBottom, - ); - } - - factory ClockState.fromOptions(ClockOptions options) { - return ClockState( - id: DateTime.now().millisecondsSinceEpoch, - activeSide: ClockPlayerType.top, - options: options, - playerTopTime: options.timePlayerTop, - playerBottomTime: options.timePlayerBottom, - ); - } - - Duration getDuration(ClockPlayerType playerType) => - playerType == ClockPlayerType.top ? playerTopTime : playerBottomTime; - - int getMovesCount(ClockPlayerType playerType) => - playerType == ClockPlayerType.top ? playerTopMoves : playerBottomMoves; - - bool isPlayersTurn(ClockPlayerType playerType) => - started && activeSide == playerType && loser == null; - - bool isPlayersMoveAllowed(ClockPlayerType playerType) => - isPlayersTurn(playerType) && !paused; - - bool isActivePlayer(ClockPlayerType playerType) => - isPlayersTurn(playerType) && !paused; - - bool isLoser(ClockPlayerType playerType) => loser == playerType; -} diff --git a/lib/src/model/clock/clock_tool_controller.dart b/lib/src/model/clock/clock_tool_controller.dart new file mode 100644 index 0000000000..524b543a9d --- /dev/null +++ b/lib/src/model/clock/clock_tool_controller.dart @@ -0,0 +1,231 @@ +import 'package:dartchess/dartchess.dart'; +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:lichess_mobile/src/model/clock/chess_clock.dart'; +import 'package:lichess_mobile/src/model/common/service/sound_service.dart'; +import 'package:lichess_mobile/src/model/common/time_increment.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'clock_tool_controller.freezed.dart'; +part 'clock_tool_controller.g.dart'; + +@riverpod +class ClockToolController extends _$ClockToolController { + late final ChessClock _clock; + + @override + ClockState build() { + const time = Duration(minutes: 10); + const increment = Duration.zero; + const options = ClockOptions( + whiteTime: time, + blackTime: time, + whiteIncrement: increment, + blackIncrement: increment, + ); + _clock = ChessClock( + whiteTime: time, + blackTime: time, + onFlag: _onFlagged, + ); + + ref.onDispose(() { + _clock.dispose(); + }); + + return ClockState( + options: options, + whiteTime: _clock.whiteTime, + blackTime: _clock.blackTime, + activeSide: Side.white, + ); + } + + void _onFlagged() { + _clock.stop(); + state = state.copyWith(flagged: _clock.activeSide); + } + + void onTap(Side playerType) { + final started = state.started; + if (playerType == Side.white) { + state = state.copyWith( + started: true, + activeSide: Side.black, + whiteMoves: started ? state.whiteMoves + 1 : 0, + ); + } else { + state = state.copyWith( + started: true, + activeSide: Side.white, + blackMoves: started ? state.blackMoves + 1 : 0, + ); + } + ref.read(soundServiceProvider).play(Sound.clock); + _clock.startSide(playerType.opposite); + _clock.incTime( + playerType, + playerType == Side.white + ? state.options.whiteIncrement + : state.options.blackIncrement, + ); + } + + void updateDuration(Side playerType, Duration duration) { + if (state.flagged != null || state.paused) { + return; + } + + _clock.setTimes( + whiteTime: playerType == Side.white + ? duration + state.options.whiteIncrement + : null, + blackTime: playerType == Side.black + ? duration + state.options.blackIncrement + : null, + ); + } + + void updateOptions(TimeIncrement timeIncrement) { + final options = ClockOptions.fromTimeIncrement(timeIncrement); + _clock.setTimes( + whiteTime: options.whiteTime, + blackTime: options.blackTime, + ); + state = state.copyWith( + options: options, + whiteTime: _clock.whiteTime, + blackTime: _clock.blackTime, + ); + } + + void updateOptionsCustom( + TimeIncrement clock, + Side player, + ) { + final options = ClockOptions( + whiteTime: player == Side.white + ? Duration(seconds: clock.time) + : state.options.whiteTime, + blackTime: player == Side.black + ? Duration(seconds: clock.time) + : state.options.blackTime, + whiteIncrement: player == Side.white + ? Duration(seconds: clock.increment) + : state.options.whiteIncrement, + blackIncrement: player == Side.black + ? Duration(seconds: clock.increment) + : state.options.blackIncrement, + ); + _clock.setTimes( + whiteTime: options.whiteTime, + blackTime: options.blackTime, + ); + state = ClockState( + options: options, + whiteTime: _clock.whiteTime, + blackTime: _clock.blackTime, + activeSide: state.activeSide, + ); + } + + void setBottomPlayer(Side playerType) => + state = state.copyWith(bottomPlayer: playerType); + + void reset() { + _clock.setTimes( + whiteTime: state.options.whiteTime, + blackTime: state.options.whiteTime, + ); + state = state.copyWith( + whiteTime: _clock.whiteTime, + blackTime: _clock.blackTime, + activeSide: Side.white, + flagged: null, + started: false, + paused: false, + whiteMoves: 0, + blackMoves: 0, + ); + } + + void start() { + _clock.start(); + state = state.copyWith(started: true); + } + + void pause() { + _clock.stop(); + state = state.copyWith(paused: true); + } + + void resume() { + _clock.start(); + state = state.copyWith(paused: false); + } +} + +@freezed +class ClockOptions with _$ClockOptions { + const ClockOptions._(); + + const factory ClockOptions({ + required Duration whiteTime, + required Duration blackTime, + required Duration whiteIncrement, + required Duration blackIncrement, + }) = _ClockOptions; + + factory ClockOptions.fromTimeIncrement(TimeIncrement timeIncrement) => + ClockOptions( + whiteTime: Duration(seconds: timeIncrement.time), + blackTime: Duration(seconds: timeIncrement.time), + whiteIncrement: Duration(seconds: timeIncrement.increment), + blackIncrement: Duration(seconds: timeIncrement.increment), + ); + + factory ClockOptions.fromSeparateTimeIncrements( + TimeIncrement playerTop, + TimeIncrement playerBottom, + ) => + ClockOptions( + whiteTime: Duration(seconds: playerTop.time), + blackTime: Duration(seconds: playerBottom.time), + whiteIncrement: Duration(seconds: playerTop.increment), + blackIncrement: Duration(seconds: playerBottom.increment), + ); +} + +@freezed +class ClockState with _$ClockState { + const ClockState._(); + + const factory ClockState({ + required ClockOptions options, + required ValueListenable whiteTime, + required ValueListenable blackTime, + required Side activeSide, + @Default(Side.white) Side bottomPlayer, + Side? flagged, + @Default(false) bool started, + @Default(false) bool paused, + @Default(0) int whiteMoves, + @Default(0) int blackMoves, + }) = _ClockState; + + ValueListenable getDuration(Side playerType) => + playerType == Side.white ? whiteTime : blackTime; + + int getMovesCount(Side playerType) => + playerType == Side.white ? whiteMoves : blackMoves; + + bool isPlayersTurn(Side playerType) => + started && activeSide == playerType && flagged == null; + + bool isPlayersMoveAllowed(Side playerType) => + isPlayersTurn(playerType) && !paused; + + bool isActivePlayer(Side playerType) => isPlayersTurn(playerType) && !paused; + + bool isFlagged(Side playerType) => flagged == playerType; +} diff --git a/lib/src/model/correspondence/offline_correspondence_game.dart b/lib/src/model/correspondence/offline_correspondence_game.dart index 1c8fc0cf5a..d7bcb1599f 100644 --- a/lib/src/model/correspondence/offline_correspondence_game.dart +++ b/lib/src/model/correspondence/offline_correspondence_game.dart @@ -72,7 +72,6 @@ class OfflineCorrespondenceGame return null; } - bool get isPlayerTurn => lastPosition.turn == youAre; bool get playable => status.value < GameStatus.aborted.value; bool get playing => status.value > GameStatus.started.value; bool get finished => status.value >= GameStatus.mate.value; diff --git a/lib/src/model/game/chat_controller.dart b/lib/src/model/game/chat_controller.dart index 6ec13b78a6..8c0f294945 100644 --- a/lib/src/model/game/chat_controller.dart +++ b/lib/src/model/game/chat_controller.dart @@ -125,14 +125,8 @@ class ChatController extends _$ChatController { } } else if (event.topic == 'message') { final data = event.data as Map; - final message = data['t'] as String; - final username = data['u'] as String?; - _addMessage( - ( - message: message, - username: username, - ), - ); + final message = _messageFromPick(RequiredPick(data)); + _addMessage(message); } } } @@ -147,11 +141,58 @@ class ChatState with _$ChatState { }) = _ChatState; } -typedef Message = ({String? username, String message}); +typedef Message = ({ + String? username, + String message, + bool troll, + bool deleted, +}); Message _messageFromPick(RequiredPick pick) { return ( message: pick('t').asStringOrThrow(), username: pick('u').asStringOrNull(), + troll: pick('r').asBoolOrNull() ?? false, + deleted: pick('d').asBoolOrNull() ?? false, ); } + +bool isSpam(Message message) { + return spamRegex.hasMatch(message.message) || + followMeRegex.hasMatch(message.message); +} + +final RegExp spamRegex = RegExp( + [ + 'xcamweb.com', + '(^|[^i])chess-bot', + 'chess-cheat', + 'coolteenbitch', + 'letcafa.webcam', + 'tinyurl.com/', + 'wooga.info/', + 'bit.ly/', + 'wbt.link/', + 'eb.by/', + '001.rs/', + 'shr.name/', + 'u.to/', + '.3-a.net', + '.ssl443.org', + '.ns02.us', + '.myftp.info', + '.flinkup.com', + '.serveusers.com', + 'badoogirls.com', + 'hide.su', + 'wyon.de', + 'sexdatingcz.club', + 'qps.ru', + 'tiny.cc/', + 'trasderk.blogspot.com', + 't.ly/', + 'shorturl.at/', + ].map((url) => url.replaceAll('.', '\\.').replaceAll('/', '\\/')).join('|'), +); + +final followMeRegex = RegExp('follow me|join my team', caseSensitive: false); diff --git a/lib/src/model/game/game.dart b/lib/src/model/game/game.dart index 13d11db993..5e0a3750fe 100644 --- a/lib/src/model/game/game.dart +++ b/lib/src/model/game/game.dart @@ -319,15 +319,6 @@ class GameMeta with _$GameMeta { _$GameMetaFromJson(json); } -@freezed -class PlayableClockData with _$PlayableClockData { - const factory PlayableClockData({ - required bool running, - required Duration white, - required Duration black, - }) = _PlayableClockData; -} - @Freezed(fromJson: true, toJson: true) class CorrespondenceClockData with _$CorrespondenceClockData { const factory CorrespondenceClockData({ diff --git a/lib/src/model/game/game_controller.dart b/lib/src/model/game/game_controller.dart index d2a4ac3791..51a8cbb023 100644 --- a/lib/src/model/game/game_controller.dart +++ b/lib/src/model/game/game_controller.dart @@ -5,6 +5,7 @@ import 'package:collection/collection.dart'; import 'package:dartchess/dartchess.dart'; import 'package:deep_pick/deep_pick.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; @@ -12,6 +13,7 @@ import 'package:lichess_mobile/src/model/account/account_preferences.dart'; import 'package:lichess_mobile/src/model/account/account_repository.dart'; import 'package:lichess_mobile/src/model/analysis/analysis_controller.dart'; import 'package:lichess_mobile/src/model/analysis/server_analysis_service.dart'; +import 'package:lichess_mobile/src/model/clock/chess_clock.dart'; import 'package:lichess_mobile/src/model/common/chess.dart'; import 'package:lichess_mobile/src/model/common/id.dart'; import 'package:lichess_mobile/src/model/common/service/move_feedback.dart'; @@ -62,14 +64,12 @@ class GameController extends _$GameController { /// Last socket version received int? _socketEventVersion; - /// Last move time - DateTime? _lastMoveTime; - - late SocketClient _socketClient; - static Uri gameSocketUri(GameFullId gameFullId) => Uri(path: '/play/$gameFullId/v6'); + ChessClock? _clock; + late final SocketClient _socketClient; + @override Future build(GameFullId gameFullId) { final socketPool = ref.watch(socketPoolProvider); @@ -85,6 +85,7 @@ class GameController extends _$GameController { _opponentLeftCountdownTimer?.cancel(); _transientMoveTimer?.cancel(); _appLifecycleListener?.dispose(); + _clock?.dispose(); }); return _socketClient.stream.firstWhere((e) => e.topic == 'full').then( @@ -113,6 +114,13 @@ class GameController extends _$GameController { _socketEventVersion = fullEvent.socketEventVersion; + // Play "dong" sound when this is a new game and we're playing it (not spectating) + final isMyGame = game.youAre != null; + final noMovePlayed = game.steps.length == 1; + if (isMyGame && noMovePlayed && game.status == GameStatus.started) { + ref.read(soundServiceProvider).play(Sound.dong); + } + if (game.playable) { _appLifecycleListener = AppLifecycleListener( onResume: () { @@ -123,13 +131,29 @@ class GameController extends _$GameController { } }, ); + + if (game.clock != null) { + _clock = ChessClock( + whiteTime: game.clock!.white, + blackTime: game.clock!.black, + emergencyThreshold: game.meta.clock?.emergency, + onEmergency: onClockEmergency, + onFlag: onFlag, + ); + if (game.clock!.running) { + final pos = game.lastPosition; + if (pos.fullmoves > 1) { + _clock!.startSide(pos.turn); + } + } + } } return GameState( gameFullId: gameFullId, game: game, stepCursor: game.steps.length - 1, - stopClockWaitingForServerAck: false, + liveClock: _liveClock, ); }, ); @@ -161,7 +185,6 @@ class GameController extends _$GameController { steps: curState.game.steps.add(newStep), ), stepCursor: curState.stepCursor + 1, - stopClockWaitingForServerAck: !shouldConfirmMove, moveToConfirm: shouldConfirmMove ? move : null, promotionMove: null, premove: null, @@ -174,7 +197,6 @@ class GameController extends _$GameController { _sendMoveToSocket( move, isPremove: isPremove ?? false, - hasClock: curState.game.clock != null, // same logic as web client // we want to send client lag only at the beginning of the game when the clock is not running yet withLag: @@ -229,14 +251,12 @@ class GameController extends _$GameController { state = AsyncValue.data( curState.copyWith( - stopClockWaitingForServerAck: true, moveToConfirm: null, ), ); _sendMoveToSocket( moveToConfirm, isPremove: false, - hasClock: curState.game.clock != null, // same logic as web client // we want to send client lag only at the beginning of the game when the clock is not running yet withLag: curState.game.clock != null && curState.activeClockSide == null, @@ -334,6 +354,15 @@ class GameController extends _$GameController { } } + /// Play a sound when the clock is about to run out + Future onClockEmergency(Side activeSide) async { + if (activeSide != state.valueOrNull?.game.youAre) return; + final shouldPlay = await ref.read(clockSoundProvider.future); + if (shouldPlay) { + ref.read(soundServiceProvider).play(Sound.lowTime); + } + } + void onFlag() { _onFlagThrottler(() { if (state.hasValue) { @@ -410,18 +439,39 @@ class GameController extends _$GameController { Future.value(); } + /// Gets the live game clock if available. + LiveGameClock? get _liveClock => _clock != null + ? ( + white: _clock!.whiteTime, + black: _clock!.blackTime, + ) + : null; + + /// Update the internal clock on clock server event + void _updateClock({ + required Duration white, + required Duration black, + required Side? activeSide, + Duration? lag, + }) { + _clock?.setTimes(whiteTime: white, blackTime: black); + if (activeSide != null) { + _clock?.startSide(activeSide, delay: lag); + } else { + _clock?.stop(); + } + } + void _sendMoveToSocket( Move move, { required bool isPremove, - required bool hasClock, required bool withLag, }) { - final moveTime = hasClock + final thinkTime = _clock?.stop(); + final moveTime = _clock != null ? isPremove == true ? Duration.zero - : _lastMoveTime != null - ? DateTime.now().difference(_lastMoveTime!) - : null + : thinkTime : null; _socketClient.send( 'move', @@ -431,7 +481,7 @@ class GameController extends _$GameController { 's': (moveTime.inMilliseconds * 0.1).round().toRadixString(36), }, ackable: true, - withLag: hasClock && (moveTime == null || withLag), + withLag: _clock != null && (moveTime == null || withLag), ); _transientMoveTimer = Timer(const Duration(seconds: 10), _resyncGameData); @@ -542,20 +592,27 @@ class GameController extends _$GameController { return; } _socketEventVersion = fullEvent.socketEventVersion; - _lastMoveTime = null; state = AsyncValue.data( GameState( gameFullId: gameFullId, game: fullEvent.game, stepCursor: fullEvent.game.steps.length - 1, - stopClockWaitingForServerAck: false, + liveClock: _liveClock, // cancel the premove to avoid playing wrong premove when the full // game data is reloaded premove: null, ), ); + if (fullEvent.game.clock != null) { + _updateClock( + white: fullEvent.game.clock!.white, + black: fullEvent.game.clock!.black, + activeSide: state.requireValue.activeClockSide, + ); + } + // Move event, received after sending a move or receiving a move from the // opponent case 'move': @@ -602,11 +659,24 @@ class GameController extends _$GameController { } } - // TODO handle delay if (data.clock != null) { - _lastMoveTime = DateTime.now(); + final lag = newState.game.playable && newState.game.isMyTurn + // my own clock doesn't need to be compensated for + ? Duration.zero + // server will send the lag only if it's more than 10ms + // default lag of 10ms is also used by web client + : data.clock?.lag ?? const Duration(milliseconds: 10); + + _updateClock( + white: data.clock!.white, + black: data.clock!.black, + lag: lag, + activeSide: newState.activeClockSide, + ); if (newState.game.clock != null) { + // we don't rely on these values to display the clock, but let's keep + // the game object in sync newState = newState.copyWith.game.clock!( white: data.clock!.white, black: data.clock!.black, @@ -617,10 +687,6 @@ class GameController extends _$GameController { black: data.clock!.black, ); } - - newState = newState.copyWith( - stopClockWaitingForServerAck: false, - ); } if (newState.game.expiration != null) { @@ -686,6 +752,11 @@ class GameController extends _$GameController { white: endData.clock!.white, black: endData.clock!.black, ); + _updateClock( + white: endData.clock!.white, + black: endData.clock!.black, + activeSide: newState.activeClockSide, + ); } if (curState.game.lastPosition.fullmoves > 1) { @@ -723,7 +794,11 @@ class GameController extends _$GameController { final newClock = pick(data['total']) .letOrNull((it) => Duration(milliseconds: it.asIntOrThrow() * 10)); final curState = state.requireValue; + if (side != null && newClock != null) { + _clock?.setTime(side, newClock); + + // sync game clock object even if it's not used to display the clock final newState = side == Side.white ? curState.copyWith.game.clock!( white: newClock, @@ -978,6 +1053,11 @@ class GameController extends _$GameController { } } +typedef LiveGameClock = ({ + ValueListenable white, + ValueListenable black, +}); + @freezed class GameState with _$GameState { const GameState._(); @@ -986,9 +1066,9 @@ class GameState with _$GameState { required GameFullId gameFullId, required PlayableGame game, required int stepCursor, + required LiveGameClock? liveClock, int? lastDrawOfferAtPly, Duration? opponentLeftCountdown, - required bool stopClockWaitingForServerAck, /// Promotion waiting to be selected (only if auto queen is disabled) NormalMove? promotionMove, @@ -1057,7 +1137,7 @@ class GameState with _$GameState { game.drawable && (lastDrawOfferAtPly ?? -99) < game.lastPly - 20; bool get canShowClaimWinCountdown => - !game.isPlayerTurn && + !game.isMyTurn && game.resignable && (game.meta.rules == null || !game.meta.rules!.contains(GameRule.noClaimWin)); @@ -1090,9 +1170,6 @@ class GameState with _$GameState { if (game.clock == null && game.correspondenceClock == null) { return null; } - if (stopClockWaitingForServerAck) { - return null; - } if (game.status == GameStatus.started) { final pos = game.lastPosition; if (pos.fullmoves > 1) { diff --git a/lib/src/model/game/game_socket_events.dart b/lib/src/model/game/game_socket_events.dart index df44964a89..cfd8dd10e8 100644 --- a/lib/src/model/game/game_socket_events.dart +++ b/lib/src/model/game/game_socket_events.dart @@ -1,3 +1,4 @@ +import 'package:clock/clock.dart'; import 'package:dartchess/dartchess.dart'; import 'package:deep_pick/deep_pick.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; @@ -40,7 +41,12 @@ class MoveEvent with _$MoveEvent { bool? blackOfferingDraw, GameStatus? status, Side? winner, - ({Duration white, Duration black, Duration? lag})? clock, + ({ + Duration white, + Duration black, + Duration? lag, + DateTime at, + })? clock, }) = _MoveEvent; factory MoveEvent.fromJson(Map json) => @@ -78,6 +84,7 @@ MoveEvent _socketMoveEventFromPick(RequiredPick pick) { blackOfferingDraw: pick('bDraw').asBoolOrNull(), clock: pick('clock').letOrNull( (it) => ( + at: clock.now(), white: it('white').asDurationFromSecondsOrThrow(), black: it('black').asDurationFromSecondsOrThrow(), lag: it('lag') diff --git a/lib/src/model/game/game_status.dart b/lib/src/model/game/game_status.dart index 58047c6aac..b091550940 100644 --- a/lib/src/model/game/game_status.dart +++ b/lib/src/model/game/game_status.dart @@ -2,19 +2,34 @@ import 'package:deep_pick/deep_pick.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; enum GameStatus { + /// Unknown game status (not handled by the app). unknown(-1), + + /// The game is created but not started yet. created(10), started(20), + + /// From here on, the game is finished. aborted(25), mate(30), resign(31), stalemate(32), + + /// When a player leaves the game. timeout(33), draw(34), + + /// When a player runs out of time (clock flags). outoftime(35), cheat(36), + + /// The player did not make the first move in time. noStart(37), + + /// We don't know why the game ended. unknownFinish(38), + + /// Chess variant special endings. variantEnd(60); static final nameMap = IMap(GameStatus.values.asNameMap()); diff --git a/lib/src/model/game/playable_game.dart b/lib/src/model/game/playable_game.dart index 1e23968787..e0ae58fc0f 100644 --- a/lib/src/model/game/playable_game.dart +++ b/lib/src/model/game/playable_game.dart @@ -95,7 +95,8 @@ class PlayableGame bool get imported => source == GameSource.import; - bool get isPlayerTurn => lastPosition.turn == youAre; + /// Whether it is the current player's turn. + bool get isMyTurn => lastPosition.turn == youAre; /// Whether the game is properly finished (not aborted). bool get finished => status.value >= GameStatus.mate.value; @@ -125,7 +126,7 @@ class PlayableGame bool get canClaimWin => opponent?.isGone == true && - !isPlayerTurn && + !isMyTurn && resignable && (meta.rules == null || !meta.rules!.contains(GameRule.noClaimWin)); @@ -171,6 +172,23 @@ class PlayableGame } } +@freezed +class PlayableClockData with _$PlayableClockData { + const factory PlayableClockData({ + required bool running, + required Duration white, + required Duration black, + + /// The network lag of the clock. + /// + /// Will be sent along with move events. + required Duration? lag, + + /// The time when the clock event was received. + required DateTime at, + }) = _PlayableClockData; +} + PlayableGame _playableGameFromPick(RequiredPick pick) { final requiredGamePick = pick('game').required(); final meta = _playableGameMetaFromPick(pick); @@ -296,6 +314,10 @@ PlayableClockData _playableClockDataFromPick(RequiredPick pick) { running: pick('running').asBoolOrThrow(), white: pick('white').asDurationFromSecondsOrThrow(), black: pick('black').asDurationFromSecondsOrThrow(), + lag: pick('lag').letOrNull( + (it) => Duration(milliseconds: it.asIntOrThrow() * 10), + ), + at: DateTime.now(), ); } diff --git a/lib/src/model/settings/board_preferences.dart b/lib/src/model/settings/board_preferences.dart index e07555b201..cfc8d8edcc 100644 --- a/lib/src/model/settings/board_preferences.dart +++ b/lib/src/model/settings/board_preferences.dart @@ -82,11 +82,18 @@ class BoardPreferences extends _$BoardPreferences ); } + Future setDragTargetKind(DragTargetKind dragTargetKind) { + return save(state.copyWith(dragTargetKind: dragTargetKind)); + } + Future setMaterialDifferenceFormat( - MaterialDifferenceFormat materialDifferenceFormat, - ) { + MaterialDifferenceFormat materialDifferenceFormat) { + return save(state.copyWith(materialDifferenceFormat: materialDifferenceFormat)); + } + + Future setClockPosition(ClockPosition clockPosition) { return save( - state.copyWith(materialDifferenceFormat: materialDifferenceFormat), + state.copyWith(clockPosition: clockPosition), ); } @@ -115,6 +122,8 @@ class BoardPrefs with _$BoardPrefs implements Serializable { required bool coordinates, required bool pieceAnimation, required MaterialDifferenceFormat materialDifferenceFormat, + required ClockPosition clockPosition, + @JsonKey( defaultValue: PieceShiftMethod.either, unknownEnumValue: PieceShiftMethod.either, @@ -124,6 +133,8 @@ class BoardPrefs with _$BoardPrefs implements Serializable { /// Whether to enable shape drawings on the board for games and puzzles. @JsonKey(defaultValue: true) required bool enableShapeDrawings, @JsonKey(defaultValue: true) required bool magnifyDraggedPiece, + @JsonKey(defaultValue: DragTargetKind.circle) + required DragTargetKind dragTargetKind, @JsonKey( defaultValue: ShapeColor.green, unknownEnumValue: ShapeColor.green, @@ -142,9 +153,11 @@ class BoardPrefs with _$BoardPrefs implements Serializable { coordinates: true, pieceAnimation: true, materialDifferenceFormat: MaterialDifferenceFormat.materialDifference, + clockPosition: ClockPosition.right, pieceShiftMethod: PieceShiftMethod.either, enableShapeDrawings: true, magnifyDraggedPiece: true, + dragTargetKind: DragTargetKind.circle, shapeColor: ShapeColor.green, showBorder: false, ); @@ -165,6 +178,7 @@ class BoardPrefs with _$BoardPrefs implements Serializable { animationDuration: pieceAnimationDuration, dragFeedbackScale: magnifyDraggedPiece ? 2.0 : 1.0, dragFeedbackOffset: Offset(0.0, magnifyDraggedPiece ? -1.0 : 0.0), + dragTargetKind: dragTargetKind, pieceShiftMethod: pieceShiftMethod, drawShape: DrawShapeOptions( enable: enableShapeDrawings, @@ -325,3 +339,21 @@ enum MaterialDifferenceFormat { MaterialDifferenceFormat.hidden => hidden.label, }; } + +enum ClockPosition { + left, + right; + + // TODO: l10n + String get label => switch (this) { + ClockPosition.left => 'Left', + ClockPosition.right => 'Right', + }; +} + +String dragTargetKindLabel(DragTargetKind kind) => switch (kind) { + DragTargetKind.circle => 'Circle', + DragTargetKind.square => 'Square', + DragTargetKind.none => 'None', + } +; diff --git a/lib/src/model/study/study_controller.dart b/lib/src/model/study/study_controller.dart index 320faf2954..1337dff19c 100644 --- a/lib/src/model/study/study_controller.dart +++ b/lib/src/model/study/study_controller.dart @@ -33,11 +33,14 @@ class StudyController extends _$StudyController implements PgnTreeNotifier { Timer? _startEngineEvalTimer; + Timer? _opponentFirstMoveTimer; + @override Future build(StudyId id) async { final evaluationService = ref.watch(evaluationServiceProvider); ref.onDispose(() { _startEngineEvalTimer?.cancel(); + _opponentFirstMoveTimer?.cancel(); _engineEvalDebounce.dispose(); evaluationService.disposeEngine(); }); @@ -62,6 +65,7 @@ class StudyController extends _$StudyController implements PgnTreeNotifier { chapterId: chapterId, ), ); + _ensureItsOurTurnIfGamebook(); } Future _fetchChapter( @@ -95,6 +99,7 @@ class StudyController extends _$StudyController implements PgnTreeNotifier { pov: orientation, isLocalEvaluationAllowed: false, isLocalEvaluationEnabled: false, + gamebookActive: false, pgn: pgn, ); } @@ -119,6 +124,7 @@ class StudyController extends _$StudyController implements PgnTreeNotifier { isLocalEvaluationAllowed: study.chapter.features.computer && !study.chapter.gamebook, isLocalEvaluationEnabled: prefs.enableLocalEvaluation, + gamebookActive: study.chapter.gamebook, pgn: pgn, ); @@ -144,6 +150,19 @@ class StudyController extends _$StudyController implements PgnTreeNotifier { return studyState; } + // The PGNs of some gamebook studies start with the opponent's turn, so trigger their move after a delay + void _ensureItsOurTurnIfGamebook() { + _opponentFirstMoveTimer?.cancel(); + if (state.requireValue.isAtStartOfChapter && + state.requireValue.gamebookActive && + state.requireValue.gamebookComment == null && + state.requireValue.position!.turn != state.requireValue.pov) { + _opponentFirstMoveTimer = Timer(const Duration(milliseconds: 750), () { + userNext(); + }); + } + } + EvaluationContext _evaluationContext(Variant variant) => EvaluationContext( variant: variant, initialPosition: _root.position, @@ -168,6 +187,20 @@ class StudyController extends _$StudyController implements PgnTreeNotifier { shouldForceShowVariation: true, ); } + + if (state.requireValue.gamebookActive) { + final comment = state.requireValue.gamebookComment; + // If there's no explicit comment why the move was good/bad, trigger next/previous move automatically + if (comment == null) { + Timer(const Duration(milliseconds: 750), () { + if (state.requireValue.isOnMainline) { + userNext(); + } else { + userPrevious(); + } + }); + } + } } void onPromotionSelection(Role? role) { @@ -237,6 +270,7 @@ class StudyController extends _$StudyController implements PgnTreeNotifier { void reset() { if (state.hasValue) { _setPath(UciPath.empty); + _ensureItsOurTurnIfGamebook(); } } @@ -486,6 +520,14 @@ class StudyController extends _$StudyController implements PgnTreeNotifier { } } +enum GamebookState { + startLesson, + findTheMove, + correctMove, + incorrectMove, + lessonComplete +} + @freezed class StudyState with _$StudyState { const StudyState._(); @@ -519,6 +561,9 @@ class StudyState with _$StudyState { /// Whether local evaluation is allowed for this study. required bool isLocalEvaluationAllowed, + /// Whether we're currently in gamebook mode, where the user has to find the right moves. + required bool gamebookActive, + /// Whether the user has enabled local evaluation. required bool isLocalEvaluationEnabled, @@ -567,6 +612,37 @@ class StudyState with _$StudyState { bool get isAtStartOfChapter => currentPath.isEmpty; + String? get gamebookComment { + final comment = + (currentNode.isRoot ? pgnRootComments : currentNode.comments) + ?.map((comment) => comment.text) + .nonNulls + .join('\n'); + return comment?.isNotEmpty == true + ? comment + : gamebookState == GamebookState.incorrectMove + ? gamebookDeviationComment + : null; + } + + String? get gamebookHint => study.hints.getOrNull(currentPath.size); + + String? get gamebookDeviationComment => + study.deviationComments.getOrNull(currentPath.size); + + GamebookState get gamebookState { + if (isAtEndOfChapter) return GamebookState.lessonComplete; + + final bool myTurn = currentNode.position!.turn == pov; + if (isAtStartOfChapter && !myTurn) return GamebookState.startLesson; + + return myTurn + ? GamebookState.findTheMove + : isOnMainline + ? GamebookState.correctMove + : GamebookState.incorrectMove; + } + bool get isIntroductoryChapter => currentNode.isRoot && currentNode.children.isEmpty; @@ -576,7 +652,9 @@ class StudyState with _$StudyState { .flattened, ); - PlayerSide get playerSide => PlayerSide.both; + PlayerSide get playerSide => gamebookActive + ? (pov == Side.white ? PlayerSide.white : PlayerSide.black) + : PlayerSide.both; } @freezed diff --git a/lib/src/model/tv/tv_controller.dart b/lib/src/model/tv/tv_controller.dart index f53e002667..78b6eeacc4 100644 --- a/lib/src/model/tv/tv_controller.dart +++ b/lib/src/model/tv/tv_controller.dart @@ -212,6 +212,8 @@ class TvController extends _$TvController { newState = newState.copyWith.game.clock!( white: data.clock!.white, black: data.clock!.black, + lag: data.clock!.lag, + at: data.clock!.at, ); } if (!curState.isReplaying) { @@ -228,6 +230,25 @@ class TvController extends _$TvController { state = AsyncData(newState); + case 'endData': + final endData = + GameEndEvent.fromJson(event.data as Map); + TvState newState = state.requireValue.copyWith( + game: state.requireValue.game.copyWith( + status: endData.status, + winner: endData.winner, + ), + ); + if (endData.clock != null) { + newState = newState.copyWith.game.clock!( + white: endData.clock!.white, + black: endData.clock!.black, + at: DateTime.now(), + lag: null, + ); + } + state = AsyncData(newState); + case 'tvSelect': final json = event.data as Map; final eventChannel = pick(json, 'channel').asTvChannelOrNull(); diff --git a/lib/src/network/socket.dart b/lib/src/network/socket.dart index 1997cf23e2..fdb9d7eed0 100644 --- a/lib/src/network/socket.dart +++ b/lib/src/network/socket.dart @@ -563,9 +563,10 @@ class SocketPool { return client; } - /// Disposes the pool and all its clients. + /// Disposes the pool and all its clients and resources. void dispose() { _averageLag.dispose(); + _disposeTimers.forEach((_, t) => t?.cancel()); _pool.forEach((_, c) => c._dispose()); } } diff --git a/lib/src/view/clock/clock_settings.dart b/lib/src/view/clock/clock_settings.dart index a5371b4f91..decea94991 100644 --- a/lib/src/view/clock/clock_settings.dart +++ b/lib/src/view/clock/clock_settings.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:lichess_mobile/src/model/clock/clock_controller.dart'; +import 'package:lichess_mobile/src/model/clock/clock_tool_controller.dart'; import 'package:lichess_mobile/src/model/common/time_increment.dart'; import 'package:lichess_mobile/src/model/settings/general_preferences.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; @@ -16,7 +16,7 @@ class ClockSettings extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final state = ref.watch(clockControllerProvider); + final state = ref.watch(clockToolControllerProvider); final buttonsEnabled = !state.started || state.paused; final isSoundEnabled = ref.watch( @@ -36,7 +36,7 @@ class ClockSettings extends ConsumerWidget { iconSize: _iconSize, onPressed: buttonsEnabled ? () { - ref.read(clockControllerProvider.notifier).reset(); + ref.read(clockToolControllerProvider.notifier).reset(); } : null, icon: const Icon(Icons.refresh), @@ -57,18 +57,18 @@ class ClockSettings extends ConsumerWidget { ), builder: (BuildContext context) { final options = ref.watch( - clockControllerProvider + clockToolControllerProvider .select((value) => value.options), ); return TimeControlModal( excludeUltraBullet: true, value: TimeIncrement( - options.timePlayerTop.inSeconds, - options.incrementPlayerTop.inSeconds, + options.whiteTime.inSeconds, + options.whiteIncrement.inSeconds, ), onSelected: (choice) { ref - .read(clockControllerProvider.notifier) + .read(clockToolControllerProvider.notifier) .updateOptions(choice); }, ); @@ -107,8 +107,8 @@ class _PlayResumeButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final controller = ref.read(clockControllerProvider.notifier); - final state = ref.watch(clockControllerProvider); + final controller = ref.read(clockToolControllerProvider.notifier); + final state = ref.watch(clockToolControllerProvider); if (!state.started) { return IconButton( diff --git a/lib/src/view/clock/clock_screen.dart b/lib/src/view/clock/clock_tool_screen.dart similarity index 73% rename from lib/src/view/clock/clock_screen.dart rename to lib/src/view/clock/clock_tool_screen.dart index f01b4d4880..b9962a0802 100644 --- a/lib/src/view/clock/clock_screen.dart +++ b/lib/src/view/clock/clock_tool_screen.dart @@ -1,6 +1,7 @@ +import 'package:dartchess/dartchess.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:lichess_mobile/src/model/clock/clock_controller.dart'; +import 'package:lichess_mobile/src/model/clock/clock_tool_controller.dart'; import 'package:lichess_mobile/src/model/common/time_increment.dart'; import 'package:lichess_mobile/src/styles/styles.dart'; import 'package:lichess_mobile/src/utils/immersive_mode.dart'; @@ -8,12 +9,12 @@ import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/view/clock/clock_settings.dart'; import 'package:lichess_mobile/src/widgets/adaptive_bottom_sheet.dart'; import 'package:lichess_mobile/src/widgets/buttons.dart'; -import 'package:lichess_mobile/src/widgets/countdown_clock.dart'; +import 'package:lichess_mobile/src/widgets/clock.dart'; import 'custom_clock_settings.dart'; -class ClockScreen extends StatelessWidget { - const ClockScreen({super.key}); +class ClockToolScreen extends StatelessWidget { + const ClockToolScreen({super.key}); @override Widget build(BuildContext context) { @@ -23,12 +24,14 @@ class ClockScreen extends StatelessWidget { } } +enum TilePosition { bottom, top } + class _Body extends ConsumerWidget { const _Body(); @override Widget build(BuildContext context, WidgetRef ref) { - final state = ref.watch(clockControllerProvider); + final state = ref.watch(clockToolControllerProvider); return OrientationBuilder( builder: (context, orientation) { @@ -37,16 +40,18 @@ class _Body extends ConsumerWidget { children: [ Expanded( child: ClockTile( + position: TilePosition.top, orientation: orientation, - playerType: ClockPlayerType.top, + playerType: state.bottomPlayer.opposite, clockState: state, ), ), ClockSettings(orientation: orientation), Expanded( child: ClockTile( + position: TilePosition.bottom, orientation: orientation, - playerType: ClockPlayerType.bottom, + playerType: state.bottomPlayer, clockState: state, ), ), @@ -59,20 +64,22 @@ class _Body extends ConsumerWidget { class ClockTile extends ConsumerWidget { const ClockTile({ + required this.position, required this.playerType, required this.clockState, required this.orientation, super.key, }); - final ClockPlayerType playerType; + final TilePosition position; + final Side playerType; final ClockState clockState; final Orientation orientation; @override Widget build(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; - final backgroundColor = clockState.isLoser(playerType) + final backgroundColor = clockState.isFlagged(playerType) ? context.lichessColors.error : !clockState.paused && clockState.isPlayersTurn(playerType) ? colorScheme.primary @@ -92,10 +99,10 @@ class ClockTile extends ConsumerWidget { ); return RotatedBox( - quarterTurns: orientation == Orientation.portrait && - playerType == ClockPlayerType.top - ? 2 - : 0, + quarterTurns: + orientation == Orientation.portrait && position == TilePosition.top + ? 2 + : 0, child: Stack( alignment: Alignment.center, fit: StackFit.expand, @@ -107,15 +114,19 @@ class ClockTile extends ConsumerWidget { onTap: !clockState.started ? () { ref - .read(clockControllerProvider.notifier) - .setActiveSide(playerType); + .read(clockToolControllerProvider.notifier) + .setBottomPlayer( + position == TilePosition.bottom + ? Side.white + : Side.black, + ); } : null, onTapDown: clockState.started && clockState.isPlayersMoveAllowed(playerType) ? (_) { ref - .read(clockControllerProvider.notifier) + .read(clockToolControllerProvider.notifier) .onTap(playerType); } : null, @@ -129,25 +140,19 @@ class ClockTile extends ConsumerWidget { FittedBox( child: AnimatedCrossFade( duration: const Duration(milliseconds: 300), - firstChild: CountdownClock( - key: Key('${clockState.id}-$playerType'), - padLeft: true, - clockStyle: clockStyle, - duration: clockState.getDuration(playerType), - active: clockState.isActivePlayer(playerType), - onFlag: () { - ref - .read(clockControllerProvider.notifier) - .setLoser(playerType); - }, - onStop: (remaining) { - ref - .read(clockControllerProvider.notifier) - .updateDuration(playerType, remaining); + firstChild: ValueListenableBuilder( + valueListenable: clockState.getDuration(playerType), + builder: (context, value, _) { + return Clock( + padLeft: true, + clockStyle: clockStyle, + timeLeft: value, + active: clockState.isActivePlayer(playerType), + ); }, ), secondChild: const Icon(Icons.flag), - crossFadeState: clockState.isLoser(playerType) + crossFadeState: clockState.isFlagged(playerType) ? CrossFadeState.showSecond : CrossFadeState.showFirst, ), @@ -188,22 +193,22 @@ class ClockTile extends ConsumerWidget { builder: (BuildContext context) => CustomClockSettings( player: playerType, - clock: playerType == ClockPlayerType.top + clock: playerType == Side.white ? TimeIncrement.fromDurations( - clockState.options.timePlayerTop, - clockState.options.incrementPlayerTop, + clockState.options.whiteTime, + clockState.options.whiteIncrement, ) : TimeIncrement.fromDurations( - clockState.options.timePlayerBottom, - clockState.options.incrementPlayerBottom, + clockState.options.blackTime, + clockState.options.blackIncrement, ), onSubmit: ( - ClockPlayerType player, + Side player, TimeIncrement clock, ) { Navigator.of(context).pop(); ref - .read(clockControllerProvider.notifier) + .read(clockToolControllerProvider.notifier) .updateOptionsCustom(clock, player); }, ), diff --git a/lib/src/view/clock/custom_clock_settings.dart b/lib/src/view/clock/custom_clock_settings.dart index ee2bdc6d2d..3ac2172a1a 100644 --- a/lib/src/view/clock/custom_clock_settings.dart +++ b/lib/src/view/clock/custom_clock_settings.dart @@ -1,5 +1,5 @@ +import 'package:dartchess/dartchess.dart'; import 'package:flutter/material.dart'; -import 'package:lichess_mobile/src/model/clock/clock_controller.dart'; import 'package:lichess_mobile/src/model/common/time_increment.dart'; import 'package:lichess_mobile/src/model/lobby/game_setup_preferences.dart'; import 'package:lichess_mobile/src/styles/styles.dart'; @@ -16,9 +16,9 @@ class CustomClockSettings extends StatefulWidget { required this.clock, }); - final ClockPlayerType player; + final Side player; final TimeIncrement clock; - final void Function(ClockPlayerType player, TimeIncrement clock) onSubmit; + final void Function(Side player, TimeIncrement clock) onSubmit; @override State createState() => _CustomClockSettingsState(); diff --git a/lib/src/view/correspondence/offline_correspondence_game_screen.dart b/lib/src/view/correspondence/offline_correspondence_game_screen.dart index 8f7c6b6b08..d89d631db7 100644 --- a/lib/src/view/correspondence/offline_correspondence_game_screen.dart +++ b/lib/src/view/correspondence/offline_correspondence_game_screen.dart @@ -282,7 +282,7 @@ class _BodyState extends ConsumerState<_Body> { data: (games) { final nextTurn = games .whereNot((g) => g.$2.id == game.id) - .firstWhereOrNull((g) => g.$2.isPlayerTurn); + .firstWhereOrNull((g) => g.$2.isMyTurn); return nextTurn != null ? () { widget.onGameChanged(nextTurn); diff --git a/lib/src/view/game/archived_game_screen.dart b/lib/src/view/game/archived_game_screen.dart index a4666b2863..ab7c41cec3 100644 --- a/lib/src/view/game/archived_game_screen.dart +++ b/lib/src/view/game/archived_game_screen.dart @@ -21,7 +21,7 @@ import 'package:lichess_mobile/src/widgets/board_table.dart'; import 'package:lichess_mobile/src/widgets/bottom_bar.dart'; import 'package:lichess_mobile/src/widgets/bottom_bar_button.dart'; import 'package:lichess_mobile/src/widgets/buttons.dart'; -import 'package:lichess_mobile/src/widgets/countdown_clock.dart'; +import 'package:lichess_mobile/src/widgets/clock.dart'; import 'package:lichess_mobile/src/widgets/platform_scaffold.dart'; import 'archived_game_screen_providers.dart'; @@ -258,23 +258,13 @@ class _BoardBody extends ConsumerWidget { final black = GamePlayer( key: const ValueKey('black-player'), player: gameData.black, - clock: blackClock != null - ? CountdownClock( - duration: blackClock, - active: false, - ) - : null, + clock: blackClock != null ? Clock(timeLeft: blackClock) : null, materialDiff: game.materialDiffAt(cursor, Side.black), ); final white = GamePlayer( key: const ValueKey('white-player'), player: gameData.white, - clock: whiteClock != null - ? CountdownClock( - duration: whiteClock, - active: false, - ) - : null, + clock: whiteClock != null ? Clock(timeLeft: whiteClock) : null, materialDiff: game.materialDiffAt(cursor, Side.white), ); diff --git a/lib/src/view/game/correspondence_clock_widget.dart b/lib/src/view/game/correspondence_clock_widget.dart index 418162624f..73f4e2a076 100644 --- a/lib/src/view/game/correspondence_clock_widget.dart +++ b/lib/src/view/game/correspondence_clock_widget.dart @@ -6,7 +6,7 @@ import 'package:lichess_mobile/src/constants.dart'; import 'package:lichess_mobile/src/model/settings/brightness.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/utils/screen.dart'; -import 'package:lichess_mobile/src/widgets/countdown_clock.dart'; +import 'package:lichess_mobile/src/widgets/clock.dart'; class CorrespondenceClock extends ConsumerStatefulWidget { /// The duration left on the clock. diff --git a/lib/src/view/game/game_body.dart b/lib/src/view/game/game_body.dart index eea37bfced..0dcb9e9d29 100644 --- a/lib/src/view/game/game_body.dart +++ b/lib/src/view/game/game_body.dart @@ -6,7 +6,6 @@ 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/model/account/account_preferences.dart'; import 'package:lichess_mobile/src/model/account/account_repository.dart'; import 'package:lichess_mobile/src/model/common/id.dart'; import 'package:lichess_mobile/src/model/common/speed.dart'; @@ -27,7 +26,7 @@ import 'package:lichess_mobile/src/widgets/board_table.dart'; import 'package:lichess_mobile/src/widgets/bottom_bar.dart'; import 'package:lichess_mobile/src/widgets/bottom_bar_button.dart'; import 'package:lichess_mobile/src/widgets/buttons.dart'; -import 'package:lichess_mobile/src/widgets/countdown_clock.dart'; +import 'package:lichess_mobile/src/widgets/clock.dart'; import 'package:lichess_mobile/src/widgets/platform_alert_dialog.dart'; import 'package:lichess_mobile/src/widgets/user_full_name.dart'; import 'package:lichess_mobile/src/widgets/yes_no_dialog.dart'; @@ -104,11 +103,6 @@ class GameBody extends ConsumerWidget { ); final boardPreferences = ref.watch(boardPreferencesProvider); - final emergencySoundEnabled = ref.watch(clockSoundProvider).maybeWhen( - data: (clockSound) => clockSound, - orElse: () => true, - ); - final blindfoldMode = ref.watch( gamePreferencesProvider.select( (prefs) => prefs.blindfoldMode, @@ -137,6 +131,7 @@ class GameBody extends ConsumerWidget { : null, mePlaying: youAre == Side.black, zenMode: gameState.isZenModeActive, + clockPosition: boardPreferences.clockPosition, confirmMoveCallbacks: youAre == Side.black && gameState.moveToConfirm != null ? ( @@ -148,24 +143,35 @@ class GameBody extends ConsumerWidget { }, ) : null, - clock: gameState.game.meta.clock != null - ? CountdownClock( - key: blackClockKey, - duration: archivedBlackClock ?? gameState.game.clock!.black, - active: gameState.activeClockSide == Side.black, - emergencyThreshold: youAre == Side.black - ? gameState.game.meta.clock?.emergency - : null, - emergencySoundEnabled: emergencySoundEnabled, - onFlag: () => ref.read(ctrlProvider.notifier).onFlag(), + clock: archivedBlackClock != null + ? Clock( + timeLeft: archivedBlackClock, + active: false, ) - : gameState.game.correspondenceClock != null - ? CorrespondenceClock( - duration: gameState.game.correspondenceClock!.black, - active: gameState.activeClockSide == Side.black, - onFlag: () => ref.read(ctrlProvider.notifier).onFlag(), + : gameState.liveClock != null + ? RepaintBoundary( + child: ValueListenableBuilder( + key: blackClockKey, + valueListenable: gameState.liveClock!.black, + builder: (context, value, _) { + return Clock( + timeLeft: value, + active: gameState.activeClockSide == Side.black, + emergencyThreshold: youAre == Side.black + ? gameState.game.meta.clock?.emergency + : null, + ); + }, + ), ) - : null, + : gameState.game.correspondenceClock != null + ? CorrespondenceClock( + duration: gameState.game.correspondenceClock!.black, + active: gameState.activeClockSide == Side.black, + onFlag: () => + ref.read(ctrlProvider.notifier).onFlag(), + ) + : null, ); final white = GamePlayer( player: gameState.game.white, @@ -178,6 +184,7 @@ class GameBody extends ConsumerWidget { : null, mePlaying: youAre == Side.white, zenMode: gameState.isZenModeActive, + clockPosition: boardPreferences.clockPosition, confirmMoveCallbacks: youAre == Side.white && gameState.moveToConfirm != null ? ( @@ -189,24 +196,35 @@ class GameBody extends ConsumerWidget { }, ) : null, - clock: gameState.game.meta.clock != null - ? CountdownClock( - key: whiteClockKey, - duration: archivedWhiteClock ?? gameState.game.clock!.white, - active: gameState.activeClockSide == Side.white, - emergencyThreshold: youAre == Side.white - ? gameState.game.meta.clock?.emergency - : null, - emergencySoundEnabled: emergencySoundEnabled, - onFlag: () => ref.read(ctrlProvider.notifier).onFlag(), + clock: archivedWhiteClock != null + ? Clock( + timeLeft: archivedWhiteClock, + active: false, ) - : gameState.game.correspondenceClock != null - ? CorrespondenceClock( - duration: gameState.game.correspondenceClock!.white, - active: gameState.activeClockSide == Side.white, - onFlag: () => ref.read(ctrlProvider.notifier).onFlag(), + : gameState.liveClock != null + ? RepaintBoundary( + child: ValueListenableBuilder( + key: whiteClockKey, + valueListenable: gameState.liveClock!.white, + builder: (context, value, _) { + return Clock( + timeLeft: value, + active: gameState.activeClockSide == Side.white, + emergencyThreshold: youAre == Side.white + ? gameState.game.meta.clock?.emergency + : null, + ); + }, + ), ) - : null, + : gameState.game.correspondenceClock != null + ? CorrespondenceClock( + duration: gameState.game.correspondenceClock!.white, + active: gameState.activeClockSide == Side.white, + onFlag: () => + ref.read(ctrlProvider.notifier).onFlag(), + ) + : null, ); final isBoardTurned = ref.watch(isBoardTurnedProvider); diff --git a/lib/src/view/game/game_common_widgets.dart b/lib/src/view/game/game_common_widgets.dart index 8d3718cf57..94199f99cf 100644 --- a/lib/src/view/game/game_common_widgets.dart +++ b/lib/src/view/game/game_common_widgets.dart @@ -12,6 +12,7 @@ import 'package:lichess_mobile/src/model/lobby/game_seek.dart'; import 'package:lichess_mobile/src/network/http.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/utils/share.dart'; +import 'package:lichess_mobile/src/view/settings/toggle_sound_button.dart'; import 'package:lichess_mobile/src/widgets/adaptive_action_sheet.dart'; import 'package:lichess_mobile/src/widgets/adaptive_bottom_sheet.dart'; import 'package:lichess_mobile/src/widgets/buttons.dart'; @@ -64,6 +65,7 @@ class GameAppBar extends ConsumerWidget { ? _ChallengeGameTitle(challenge: challenge!) : const SizedBox.shrink(), actions: [ + const ToggleSoundButton(), if (id != null) AppBarIconButton( onPressed: () => showAdaptiveBottomSheet( @@ -71,6 +73,9 @@ class GameAppBar extends ConsumerWidget { isDismissible: true, isScrollControlled: true, showDragHandle: true, + constraints: BoxConstraints( + minHeight: MediaQuery.sizeOf(context).height * 0.5, + ), builder: (_) => GameSettings(id: id!), ), semanticsLabel: context.l10n.settingsSettings, diff --git a/lib/src/view/game/game_player.dart b/lib/src/view/game/game_player.dart index 394ebcd672..9691cfae76 100644 --- a/lib/src/view/game/game_player.dart +++ b/lib/src/view/game/game_player.dart @@ -5,7 +5,9 @@ import 'package:dartchess/dartchess.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lichess_mobile/src/constants.dart'; +import 'package:lichess_mobile/src/model/common/service/sound_service.dart'; import 'package:lichess_mobile/src/model/game/material_diff.dart'; import 'package:lichess_mobile/src/model/game/player.dart'; import 'package:lichess_mobile/src/model/settings/board_preferences.dart'; @@ -33,6 +35,7 @@ class GamePlayer extends StatelessWidget { this.shouldLinkToUserProfile = true, this.mePlaying = false, this.zenMode = false, + this.clockPosition = ClockPosition.right, super.key, }); @@ -47,6 +50,7 @@ class GamePlayer extends StatelessWidget { final bool shouldLinkToUserProfile; final bool mePlaying; final bool zenMode; + final ClockPosition clockPosition; /// Time left for the player to move at the start of the game. final Duration? timeToMove; @@ -63,7 +67,9 @@ class GamePlayer extends StatelessWidget { children: [ if (!zenMode) Row( - mainAxisAlignment: MainAxisAlignment.start, + mainAxisAlignment: clockPosition == ClockPosition.right + ? MainAxisAlignment.start + : MainAxisAlignment.end, children: [ if (player.user != null) ...[ Icon( @@ -147,9 +153,31 @@ class GamePlayer extends StatelessWidget { else if (materialDiff != null) MaterialDifferenceDisplay( materialDiff: materialDiff!, - materialDifferenceFormat: materialDifferenceFormat!, - ) - else + materialDifferenceFormat: materialDifferenceFormat!,), + Row( + mainAxisAlignment: clockPosition == ClockPosition.right + ? MainAxisAlignment.start + : MainAxisAlignment.end, + children: [ + for (final role in Role.values) + for (int i = 0; i < materialDiff!.pieces[role]!; i++) + Icon( + _iconByRole[role], + size: 13, + color: Colors.grey, + ), + const SizedBox(width: 3), + Text( + style: const TextStyle( + fontSize: 13, + color: Colors.grey, + ), + materialDiff != null && materialDiff!.score > 0 + ? '+${materialDiff!.score}' + : '', + ), + ], + ), // to avoid shifts use an empty text widget const Text('', style: TextStyle(fontSize: 13)), ], @@ -159,6 +187,8 @@ class GamePlayer extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.center, children: [ + if (clock != null && clockPosition == ClockPosition.left) + Flexible(flex: 3, child: clock!), if (mePlaying && confirmMoveCallbacks != null) Expanded( flex: 7, @@ -194,7 +224,8 @@ class GamePlayer extends StatelessWidget { : playerWidget, ), ), - if (clock != null) Flexible(flex: 3, child: clock!), + if (clock != null && clockPosition == ClockPosition.right) + Flexible(flex: 3, child: clock!), ], ); } @@ -244,7 +275,7 @@ class ConfirmMove extends StatelessWidget { } } -class MoveExpiration extends StatefulWidget { +class MoveExpiration extends ConsumerStatefulWidget { const MoveExpiration({ required this.timeToMove, required this.mePlaying, @@ -255,13 +286,14 @@ class MoveExpiration extends StatefulWidget { final bool mePlaying; @override - State createState() => _MoveExpirationState(); + ConsumerState createState() => _MoveExpirationState(); } -class _MoveExpirationState extends State { +class _MoveExpirationState extends ConsumerState { static const _period = Duration(milliseconds: 1000); Timer? _timer; Duration timeLeft = Duration.zero; + bool playedEmergencySound = false; Timer startTimer() { return Timer.periodic(_period, (timer) { @@ -299,6 +331,14 @@ class _MoveExpirationState extends State { Widget build(BuildContext context) { final secs = timeLeft.inSeconds.remainder(60); final emerg = timeLeft <= const Duration(seconds: 8); + + if (emerg && widget.mePlaying && !playedEmergencySound) { + ref.read(soundServiceProvider).play(Sound.lowTime); + setState(() { + playedEmergencySound = true; + }); + } + return secs <= 20 ? Text( context.l10n.nbSecondsToPlayTheFirstMove(secs), diff --git a/lib/src/view/game/game_settings.dart b/lib/src/view/game/game_settings.dart index 25e9c2b1f7..42d6bf0290 100644 --- a/lib/src/view/game/game_settings.dart +++ b/lib/src/view/game/game_settings.dart @@ -1,5 +1,4 @@ import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lichess_mobile/l10n/l10n.dart'; import 'package:lichess_mobile/src/model/account/account_preferences.dart'; @@ -7,9 +6,11 @@ import 'package:lichess_mobile/src/model/common/id.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/settings/board_preferences.dart'; -import 'package:lichess_mobile/src/model/settings/general_preferences.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; +import 'package:lichess_mobile/src/utils/navigation.dart'; +import 'package:lichess_mobile/src/view/settings/board_settings_screen.dart'; import 'package:lichess_mobile/src/widgets/adaptive_bottom_sheet.dart'; +import 'package:lichess_mobile/src/widgets/list.dart'; import 'package:lichess_mobile/src/widgets/settings.dart'; import '../../widgets/adaptive_choice_picker.dart'; @@ -22,31 +23,12 @@ class GameSettings extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final isSoundEnabled = ref.watch( - generalPreferencesProvider.select( - (prefs) => prefs.isSoundEnabled, - ), - ); - final boardPrefs = ref.watch(boardPreferencesProvider); final gamePrefs = ref.watch(gamePreferencesProvider); final userPrefsAsync = ref.watch(userGamePrefsProvider(id)); + final boardPrefs = ref.watch(boardPreferencesProvider); return BottomSheetScrollableContainer( children: [ - SwitchSettingTile( - title: Text(context.l10n.sound), - value: isSoundEnabled, - onChanged: (value) { - ref.read(generalPreferencesProvider.notifier).toggleSoundEnabled(); - }, - ), - SwitchSettingTile( - title: Text(context.l10n.mobileSettingsHapticFeedback), - value: boardPrefs.hapticFeedback, - onChanged: (value) { - ref.read(boardPreferencesProvider.notifier).toggleHapticFeedback(); - }, - ), ...userPrefsAsync.maybeWhen( data: (data) { return [ @@ -111,6 +93,18 @@ class GameSettings extends ConsumerWidget { ref.read(boardPreferencesProvider.notifier).togglePieceAnimation(); }, ), + PlatformListTile( + // TODO translate + title: const Text('Board settings'), + trailing: const Icon(CupertinoIcons.chevron_right), + onTap: () { + pushPlatformRoute( + context, + fullscreenDialog: true, + screen: const BoardSettingsScreen(), + ); + }, + ), SwitchSettingTile( title: Text( context.l10n.toggleTheChat, diff --git a/lib/src/view/game/message_screen.dart b/lib/src/view/game/message_screen.dart index d684f855c0..00187cc040 100644 --- a/lib/src/view/game/message_screen.dart +++ b/lib/src/view/game/message_screen.dart @@ -83,29 +83,34 @@ class _Body extends ConsumerWidget { child: GestureDetector( onTap: () => FocusScope.of(context).unfocus(), child: chatStateAsync.when( - data: (chatState) => ListView.builder( - // remove the automatic bottom padding of the ListView, which on iOS - // corresponds to the safe area insets - // and which is here taken care of by the _ChatBottomBar - padding: MediaQuery.of(context).padding.copyWith(bottom: 0), - reverse: true, - itemCount: chatState.messages.length, - itemBuilder: (context, index) { - final message = - chatState.messages[chatState.messages.length - index - 1]; - return (message.username == 'lichess') - ? _MessageAction(message: message.message) - : (message.username == me?.name) - ? _MessageBubble( - you: true, - message: message.message, - ) - : _MessageBubble( - you: false, - message: message.message, - ); - }, - ), + data: (chatState) { + final selectedMessages = chatState.messages + .where((m) => !m.troll && !m.deleted && !isSpam(m)) + .toList(); + final messagesCount = selectedMessages.length; + return ListView.builder( + // remove the automatic bottom padding of the ListView, which on iOS + // corresponds to the safe area insets + // and which is here taken care of by the _ChatBottomBar + padding: MediaQuery.of(context).padding.copyWith(bottom: 0), + reverse: true, + itemCount: messagesCount, + itemBuilder: (context, index) { + final message = selectedMessages[messagesCount - index - 1]; + return (message.username == 'lichess') + ? _MessageAction(message: message.message) + : (message.username == me?.name) + ? _MessageBubble( + you: true, + message: message.message, + ) + : _MessageBubble( + you: false, + message: message.message, + ); + }, + ); + }, loading: () => const Center( child: CircularProgressIndicator(), ), 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 5478945df8..7470cac66d 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 @@ -25,7 +25,7 @@ import 'package:lichess_mobile/src/widgets/board_table.dart'; import 'package:lichess_mobile/src/widgets/bottom_bar.dart'; import 'package:lichess_mobile/src/widgets/bottom_bar_button.dart'; import 'package:lichess_mobile/src/widgets/buttons.dart'; -import 'package:lichess_mobile/src/widgets/countdown_clock.dart'; +import 'package:lichess_mobile/src/widgets/clock.dart'; import 'package:lichess_mobile/src/widgets/platform_scaffold.dart'; class OverTheBoardScreen extends StatelessWidget { diff --git a/lib/src/view/puzzle/puzzle_screen.dart b/lib/src/view/puzzle/puzzle_screen.dart index 6f51d20735..ef8681c596 100644 --- a/lib/src/view/puzzle/puzzle_screen.dart +++ b/lib/src/view/puzzle/puzzle_screen.dart @@ -31,6 +31,7 @@ import 'package:lichess_mobile/src/view/account/rating_pref_aware.dart'; import 'package:lichess_mobile/src/view/analysis/analysis_screen.dart'; import 'package:lichess_mobile/src/view/game/archived_game_screen.dart'; import 'package:lichess_mobile/src/view/puzzle/puzzle_settings_screen.dart'; +import 'package:lichess_mobile/src/view/settings/toggle_sound_button.dart'; import 'package:lichess_mobile/src/widgets/adaptive_action_sheet.dart'; import 'package:lichess_mobile/src/widgets/adaptive_bottom_sheet.dart'; import 'package:lichess_mobile/src/widgets/adaptive_choice_picker.dart'; @@ -91,6 +92,7 @@ class _PuzzleScreenState extends ConsumerState with RouteAware { child: PlatformScaffold( appBar: PlatformAppBar( actions: const [ + ToggleSoundButton(), _PuzzleSettingsButton(), ], title: _Title(angle: widget.angle), @@ -604,6 +606,9 @@ class _PuzzleSettingsButton extends StatelessWidget { isDismissible: true, isScrollControlled: true, showDragHandle: true, + constraints: BoxConstraints( + minHeight: MediaQuery.sizeOf(context).height * 0.5, + ), builder: (_) => const PuzzleSettingsScreen(), ), semanticsLabel: context.l10n.settingsSettings, diff --git a/lib/src/view/puzzle/puzzle_settings_screen.dart b/lib/src/view/puzzle/puzzle_settings_screen.dart index 7f59b61d24..33e14c9518 100644 --- a/lib/src/view/puzzle/puzzle_settings_screen.dart +++ b/lib/src/view/puzzle/puzzle_settings_screen.dart @@ -1,11 +1,11 @@ import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lichess_mobile/src/model/puzzle/puzzle_preferences.dart'; -import 'package:lichess_mobile/src/model/settings/board_preferences.dart'; -import 'package:lichess_mobile/src/model/settings/general_preferences.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; +import 'package:lichess_mobile/src/utils/navigation.dart'; +import 'package:lichess_mobile/src/view/settings/board_settings_screen.dart'; import 'package:lichess_mobile/src/widgets/adaptive_bottom_sheet.dart'; +import 'package:lichess_mobile/src/widgets/list.dart'; import 'package:lichess_mobile/src/widgets/settings.dart'; class PuzzleSettingsScreen extends ConsumerWidget { @@ -13,23 +13,11 @@ class PuzzleSettingsScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final isSoundEnabled = ref.watch( - generalPreferencesProvider.select((pref) => pref.isSoundEnabled), - ); final autoNext = ref.watch( puzzlePreferencesProvider.select((value) => value.autoNext), ); - final boardPrefs = ref.watch(boardPreferencesProvider); - return BottomSheetScrollableContainer( children: [ - SwitchSettingTile( - title: Text(context.l10n.sound), - value: isSoundEnabled, - onChanged: (value) { - ref.read(generalPreferencesProvider.notifier).toggleSoundEnabled(); - }, - ), SwitchSettingTile( title: Text(context.l10n.puzzleJumpToNextPuzzleImmediately), value: autoNext, @@ -37,28 +25,15 @@ class PuzzleSettingsScreen extends ConsumerWidget { ref.read(puzzlePreferencesProvider.notifier).setAutoNext(value); }, ), - SwitchSettingTile( - // TODO: Add l10n - title: const Text('Shape drawing'), - subtitle: const Text( - 'Draw shapes using two fingers.', - maxLines: 5, - textAlign: TextAlign.justify, - ), - value: boardPrefs.enableShapeDrawings, - onChanged: (value) { - ref - .read(boardPreferencesProvider.notifier) - .toggleEnableShapeDrawings(); - }, - ), - SwitchSettingTile( - title: Text( - context.l10n.preferencesPieceAnimation, - ), - value: boardPrefs.pieceAnimation, - onChanged: (value) { - ref.read(boardPreferencesProvider.notifier).togglePieceAnimation(); + PlatformListTile( + title: const Text('Board settings'), + trailing: const Icon(CupertinoIcons.chevron_right), + onTap: () { + pushPlatformRoute( + context, + fullscreenDialog: true, + screen: const BoardSettingsScreen(), + ); }, ), ], diff --git a/lib/src/view/settings/board_settings_screen.dart b/lib/src/view/settings/board_settings_screen.dart index e2bc8b5ccd..d126acc24b 100644 --- a/lib/src/view/settings/board_settings_screen.dart +++ b/lib/src/view/settings/board_settings_screen.dart @@ -4,11 +4,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lichess_mobile/l10n/l10n.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/l10n_context.dart'; import 'package:lichess_mobile/src/utils/navigation.dart'; import 'package:lichess_mobile/src/utils/screen.dart'; import 'package:lichess_mobile/src/utils/system.dart'; -import 'package:lichess_mobile/src/view/settings/piece_shift_method_settings_screen.dart'; import 'package:lichess_mobile/src/widgets/adaptive_choice_picker.dart'; import 'package:lichess_mobile/src/widgets/list.dart'; import 'package:lichess_mobile/src/widgets/platform.dart'; @@ -53,6 +53,7 @@ class _Body extends ConsumerWidget { child: ListView( children: [ ListSection( + header: SettingsSectionTitle(context.l10n.preferencesGameBehavior), hasLeading: false, showDivider: false, children: [ @@ -88,29 +89,96 @@ class _Body extends ConsumerWidget { }, ), SwitchSettingTile( - // TODO: Add l10n - title: const Text('Shape drawing'), + title: Text(context.l10n.mobilePrefMagnifyDraggedPiece), + value: boardPrefs.magnifyDraggedPiece, + onChanged: (value) { + ref + .read(boardPreferencesProvider.notifier) + .toggleMagnifyDraggedPiece(); + }, + ), + SettingsListTile( + // TODO translate + settingsLabel: const Text('Drag target'), + explanation: + // TODO translate + 'How the target square is highlighted when dragging a piece.', + settingsValue: dragTargetKindLabel(boardPrefs.dragTargetKind), + onTap: () { + if (Theme.of(context).platform == TargetPlatform.android) { + showChoicePicker( + context, + choices: DragTargetKind.values, + selectedItem: boardPrefs.dragTargetKind, + labelBuilder: (t) => Text(dragTargetKindLabel(t)), + onSelectedItemChanged: (DragTargetKind? value) { + ref + .read(boardPreferencesProvider.notifier) + .setDragTargetKind( + value ?? DragTargetKind.circle, + ); + }, + ); + } else { + pushPlatformRoute( + context, + title: 'Dragged piece target', + builder: (context) => + const DragTargetKindSettingsScreen(), + ); + } + }, + ), + SwitchSettingTile( + // TODO translate + title: const Text('Touch feedback'), + value: boardPrefs.hapticFeedback, subtitle: const Text( - 'Draw shapes using two fingers on game and puzzle boards.', + // TODO translate + 'Vibrate when moving pieces or capturing them.', maxLines: 5, textAlign: TextAlign.justify, ), - value: boardPrefs.enableShapeDrawings, onChanged: (value) { ref .read(boardPreferencesProvider.notifier) - .toggleEnableShapeDrawings(); + .toggleHapticFeedback(); }, ), SwitchSettingTile( - title: Text(context.l10n.mobileSettingsHapticFeedback), - value: boardPrefs.hapticFeedback, + title: Text( + context.l10n.preferencesPieceAnimation, + ), + value: boardPrefs.pieceAnimation, onChanged: (value) { ref .read(boardPreferencesProvider.notifier) - .toggleHapticFeedback(); + .togglePieceAnimation(); + }, + ), + SwitchSettingTile( + // TODO: Add l10n + title: const Text('Shape drawing'), + subtitle: const Text( + // TODO: translate + 'Draw shapes using two fingers: maintain one finger on an empty square and drag another finger to draw a shape.', + maxLines: 5, + textAlign: TextAlign.justify, + ), + value: boardPrefs.enableShapeDrawings, + onChanged: (value) { + ref + .read(boardPreferencesProvider.notifier) + .toggleEnableShapeDrawings(); }, ), + ], + ), + ListSection( + header: SettingsSectionTitle(context.l10n.preferencesDisplay), + hasLeading: false, + showDivider: false, + children: [ if (Theme.of(context).platform == TargetPlatform.android && !isTabletOrLarger(context)) androidVersionAsync.maybeWhen( @@ -132,6 +200,30 @@ class _Body extends ConsumerWidget { : const SizedBox.shrink(), orElse: () => const SizedBox.shrink(), ), + SettingsListTile( + //TODO Add l10n + settingsLabel: const Text('Clock position'), + settingsValue: boardPrefs.clockPosition.label, + onTap: () { + if (Theme.of(context).platform == TargetPlatform.android) { + showChoicePicker( + context, + choices: ClockPosition.values, + selectedItem: boardPrefs.clockPosition, + labelBuilder: (t) => Text(t.label), + onSelectedItemChanged: (ClockPosition? value) => ref + .read(boardPreferencesProvider.notifier) + .setClockPosition(value ?? ClockPosition.right), + ); + } else { + pushPlatformRoute( + context, + title: 'Clock position', + builder: (context) => const BoardClockPositionScreen(), + ); + } + }, + ), SwitchSettingTile( title: Text( context.l10n.preferencesPieceDestinations, @@ -213,3 +305,122 @@ class _Body extends ConsumerWidget { ); } } + +class PieceShiftMethodSettingsScreen extends ConsumerWidget { + const PieceShiftMethodSettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pieceShiftMethod = ref.watch( + boardPreferencesProvider.select( + (state) => state.pieceShiftMethod, + ), + ); + + void onChanged(PieceShiftMethod? value) { + ref + .read(boardPreferencesProvider.notifier) + .setPieceShiftMethod(value ?? PieceShiftMethod.either); + } + + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar(), + child: SafeArea( + child: ListView( + children: [ + ChoicePicker( + notchedTile: true, + choices: PieceShiftMethod.values, + selectedItem: pieceShiftMethod, + titleBuilder: (t) => Text(pieceShiftMethodl10n(context, t)), + onSelectedItemChanged: onChanged, + ), + ], + ), + ), + ); + } +} + +class BoardClockPositionScreen extends ConsumerWidget { + const BoardClockPositionScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final clockPosition = ref.watch( + boardPreferencesProvider.select((state) => state.clockPosition), + ); + void onChanged(ClockPosition? value) => ref + .read(boardPreferencesProvider.notifier) + .setClockPosition(value ?? ClockPosition.right); + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar(), + child: SafeArea( + child: ListView( + children: [ + ChoicePicker( + choices: ClockPosition.values, + selectedItem: clockPosition, + titleBuilder: (t) => Text(t.label), + onSelectedItemChanged: onChanged, + ), + ], + ), + ), + ); + } +} + +class DragTargetKindSettingsScreen extends ConsumerWidget { + const DragTargetKindSettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final dragTargetKind = ref.watch( + boardPreferencesProvider.select( + (state) => state.dragTargetKind, + ), + ); + + void onChanged(DragTargetKind? value) { + ref + .read(boardPreferencesProvider.notifier) + .setDragTargetKind(value ?? DragTargetKind.circle); + } + + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar(), + child: SafeArea( + child: ListView( + children: [ + Padding( + padding: + Styles.horizontalBodyPadding.add(Styles.sectionTopPadding), + child: const Text( + 'How the target square is highlighted when dragging a piece.', + ), + ), + ChoicePicker( + notchedTile: true, + choices: DragTargetKind.values, + selectedItem: dragTargetKind, + titleBuilder: (t) => Text(dragTargetKindLabel(t)), + onSelectedItemChanged: onChanged, + ), + ], + ), + ), + ); + } +} + +String pieceShiftMethodl10n( + BuildContext context, + PieceShiftMethod pieceShiftMethod, +) => + switch (pieceShiftMethod) { + // TODO add this to mobile translations + PieceShiftMethod.either => 'Either tap or drag', + PieceShiftMethod.drag => context.l10n.preferencesDragPiece, + PieceShiftMethod.tapTwoSquares => 'Tap two squares', + }; diff --git a/lib/src/view/settings/piece_shift_method_settings_screen.dart b/lib/src/view/settings/piece_shift_method_settings_screen.dart deleted file mode 100644 index 4edc23ba8d..0000000000 --- a/lib/src/view/settings/piece_shift_method_settings_screen.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'package:chessground/chessground.dart'; -import 'package:flutter/cupertino.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/utils/l10n_context.dart'; -import 'package:lichess_mobile/src/widgets/platform.dart'; -import 'package:lichess_mobile/src/widgets/settings.dart'; - -class PieceShiftMethodSettingsScreen extends StatelessWidget { - const PieceShiftMethodSettingsScreen({super.key}); - - @override - Widget build(BuildContext context) { - return PlatformWidget( - androidBuilder: _androidBuilder, - iosBuilder: _iosBuilder, - ); - } - - Widget _androidBuilder(BuildContext context) { - return Scaffold( - appBar: AppBar(title: Text(context.l10n.preferencesHowDoYouMovePieces)), - body: _Body(), - ); - } - - Widget _iosBuilder(BuildContext context) { - return CupertinoPageScaffold( - navigationBar: const CupertinoNavigationBar(), - child: _Body(), - ); - } -} - -String pieceShiftMethodl10n( - BuildContext context, - PieceShiftMethod pieceShiftMethod, -) => - switch (pieceShiftMethod) { - // This is called 'Either' in the Web UI, but in the app we might display this string - // without having the other values as context, so we need to be more explicit. - // TODO add this to mobile translations - PieceShiftMethod.either => 'Either click or drag', - PieceShiftMethod.drag => context.l10n.preferencesDragPiece, - // TODO This string uses 'click', we might want to use 'tap' instead in a mobile-specific translation - PieceShiftMethod.tapTwoSquares => context.l10n.preferencesClickTwoSquares, - }; - -class _Body extends ConsumerWidget { - @override - Widget build(BuildContext context, WidgetRef ref) { - final pieceShiftMethod = ref.watch( - boardPreferencesProvider.select( - (state) => state.pieceShiftMethod, - ), - ); - - void onChanged(PieceShiftMethod? value) { - ref - .read(boardPreferencesProvider.notifier) - .setPieceShiftMethod(value ?? PieceShiftMethod.either); - } - - return SafeArea( - child: ListView( - children: [ - ChoicePicker( - notchedTile: true, - choices: PieceShiftMethod.values, - selectedItem: pieceShiftMethod, - titleBuilder: (t) => Text(pieceShiftMethodl10n(context, t)), - onSelectedItemChanged: onChanged, - ), - ], - ), - ); - } -} diff --git a/lib/src/view/settings/theme_screen.dart b/lib/src/view/settings/theme_screen.dart index e0db758b15..8f34cc8d0d 100644 --- a/lib/src/view/settings/theme_screen.dart +++ b/lib/src/view/settings/theme_screen.dart @@ -80,7 +80,6 @@ class _Body extends ConsumerWidget { ), }.lock, settings: boardPrefs.toBoardSettings().copyWith( - enableCoordinates: true, borderRadius: const BorderRadius.all(Radius.circular(4.0)), boxShadow: boardShadows, @@ -151,6 +150,18 @@ class _Body extends ConsumerWidget { ); }, ), + SwitchSettingTile( + leading: const Icon(Icons.location_on), + title: Text( + context.l10n.preferencesBoardCoordinates, + ), + value: boardPrefs.coordinates, + onChanged: (value) { + ref + .read(boardPreferencesProvider.notifier) + .toggleCoordinates(); + }, + ), SwitchSettingTile( // TODO translate leading: const Icon(Icons.border_outer), diff --git a/lib/src/view/study/study_bottom_bar.dart b/lib/src/view/study/study_bottom_bar.dart index 815e54a97d..ade01ed711 100644 --- a/lib/src/view/study/study_bottom_bar.dart +++ b/lib/src/view/study/study_bottom_bar.dart @@ -1,9 +1,12 @@ import 'package:flutter/cupertino.dart'; 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/study/study_controller.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; +import 'package:lichess_mobile/src/utils/navigation.dart'; +import 'package:lichess_mobile/src/view/analysis/analysis_screen.dart'; import 'package:lichess_mobile/src/widgets/bottom_bar.dart'; import 'package:lichess_mobile/src/widgets/bottom_bar_button.dart'; import 'package:lichess_mobile/src/widgets/buttons.dart'; @@ -15,6 +18,25 @@ class StudyBottomBar extends ConsumerWidget { final StudyId id; + @override + Widget build(BuildContext context, WidgetRef ref) { + final gamebook = ref.watch( + studyControllerProvider(id).select( + (s) => s.requireValue.gamebookActive, + ), + ); + + return gamebook ? _GamebookBottomBar(id: id) : _AnalysisBottomBar(id: id); + } +} + +class _AnalysisBottomBar extends ConsumerWidget { + const _AnalysisBottomBar({ + required this.id, + }); + + final StudyId id; + @override Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch(studyControllerProvider(id)).valueOrNull; @@ -68,3 +90,97 @@ class StudyBottomBar extends ConsumerWidget { ); } } + +class _GamebookBottomBar extends ConsumerWidget { + const _GamebookBottomBar({ + required this.id, + }); + + final StudyId id; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(studyControllerProvider(id)).requireValue; + + return BottomBar( + children: [ + ...switch (state.gamebookState) { + GamebookState.findTheMove => [ + if (!state.currentNode.isRoot) + BottomBarButton( + onTap: ref.read(studyControllerProvider(id).notifier).reset, + icon: Icons.skip_previous, + label: 'Back', + showLabel: true, + ), + BottomBarButton( + icon: Icons.help, + label: context.l10n.viewTheSolution, + showLabel: true, + onTap: ref + .read(studyControllerProvider(id).notifier) + .showGamebookSolution, + ), + ], + GamebookState.startLesson || GamebookState.correctMove => [ + BottomBarButton( + onTap: ref.read(studyControllerProvider(id).notifier).userNext, + icon: Icons.play_arrow, + label: context.l10n.studyNext, + showLabel: true, + blink: state.gamebookComment != null && + !state.isIntroductoryChapter, + ), + ], + GamebookState.incorrectMove => [ + BottomBarButton( + onTap: + ref.read(studyControllerProvider(id).notifier).userPrevious, + label: context.l10n.retry, + showLabel: true, + icon: Icons.refresh, + blink: state.gamebookComment != null, + ), + ], + GamebookState.lessonComplete => [ + if (!state.isIntroductoryChapter) + BottomBarButton( + onTap: ref.read(studyControllerProvider(id).notifier).reset, + icon: Icons.refresh, + label: context.l10n.studyPlayAgain, + showLabel: true, + ), + BottomBarButton( + onTap: state.hasNextChapter + ? ref.read(studyControllerProvider(id).notifier).nextChapter + : null, + icon: Icons.play_arrow, + label: context.l10n.studyNextChapter, + showLabel: true, + blink: !state.isIntroductoryChapter && state.hasNextChapter, + ), + if (!state.isIntroductoryChapter) + BottomBarButton( + onTap: () => pushPlatformRoute( + context, + rootNavigator: true, + builder: (context) => AnalysisScreen( + pgnOrId: state.pgn, + options: AnalysisOptions( + isLocalEvaluationAllowed: true, + variant: state.variant, + orientation: state.pov, + id: standaloneAnalysisId, + ), + ), + ), + icon: Icons.biotech, + label: context.l10n.analysis, + showLabel: true, + ), + ], + }, + ], + ); + } +} diff --git a/lib/src/view/study/study_gamebook.dart b/lib/src/view/study/study_gamebook.dart new file mode 100644 index 0000000000..67b6d17583 --- /dev/null +++ b/lib/src/view/study/study_gamebook.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_linkify/flutter_linkify.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lichess_mobile/src/model/common/id.dart'; +import 'package:lichess_mobile/src/model/study/study_controller.dart'; +import 'package:lichess_mobile/src/utils/l10n_context.dart'; +import 'package:lichess_mobile/src/widgets/buttons.dart'; +import 'package:url_launcher/url_launcher.dart'; + +class StudyGamebook extends StatelessWidget { + const StudyGamebook( + this.id, + ); + + final StudyId id; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(5), + child: Column( + children: [ + Expanded( + child: Card( + child: Padding( + padding: const EdgeInsets.all(10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _Comment(id: id), + _Hint(id: id), + ], + ), + ), + ), + ), + ], + ), + ); + } +} + +class _Comment extends ConsumerWidget { + const _Comment({ + required this.id, + }); + + final StudyId id; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(studyControllerProvider(id)).requireValue; + + final comment = state.gamebookComment ?? + switch (state.gamebookState) { + GamebookState.findTheMove => context.l10n.studyWhatWouldYouPlay, + GamebookState.correctMove => context.l10n.studyGoodMove, + GamebookState.incorrectMove => context.l10n.puzzleNotTheMove, + GamebookState.lessonComplete => + context.l10n.studyYouCompletedThisLesson, + _ => '' + }; + + return Expanded( + child: Scrollbar( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.only(right: 5), + child: Linkify( + text: comment, + style: const TextStyle( + fontSize: 16, + ), + onOpen: (link) async { + launchUrl(Uri.parse(link.url)); + }, + ), + ), + ), + ), + ); + } +} + +class _Hint extends ConsumerStatefulWidget { + const _Hint({ + required this.id, + }); + + final StudyId id; + + @override + ConsumerState<_Hint> createState() => _HintState(); +} + +class _HintState extends ConsumerState<_Hint> { + bool showHint = false; + + @override + Widget build(BuildContext context) { + final hint = + ref.watch(studyControllerProvider(widget.id)).requireValue.gamebookHint; + return hint == null + ? const SizedBox.shrink() + : SizedBox( + height: 40, + child: showHint + ? Center(child: Text(hint)) + : TextButton( + onPressed: () { + setState(() { + showHint = true; + }); + }, + child: Text(context.l10n.getAHint), + ), + ); + } +} + +class GamebookButton extends StatelessWidget { + const GamebookButton({ + required this.icon, + required this.label, + required this.onTap, + this.highlighted = false, + super.key, + }); + + final IconData icon; + final String label; + final VoidCallback? onTap; + + final bool highlighted; + + bool get enabled => onTap != null; + + @override + Widget build(BuildContext context) { + final primary = Theme.of(context).colorScheme.primary; + + return Semantics( + container: true, + enabled: enabled, + button: true, + label: label, + excludeSemantics: true, + child: AdaptiveInkWell( + borderRadius: BorderRadius.zero, + onTap: onTap, + child: Opacity( + opacity: enabled ? 1.0 : 0.4, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(icon, color: highlighted ? primary : null, size: 24), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Text( + label, + style: TextStyle( + fontSize: 16.0, + color: highlighted ? primary : null, + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/view/study/study_screen.dart b/lib/src/view/study/study_screen.dart index 3380854e23..7a1b246d6a 100644 --- a/lib/src/view/study/study_screen.dart +++ b/lib/src/view/study/study_screen.dart @@ -19,6 +19,7 @@ import 'package:lichess_mobile/src/utils/screen.dart'; import 'package:lichess_mobile/src/view/engine/engine_gauge.dart'; import 'package:lichess_mobile/src/view/engine/engine_lines.dart'; import 'package:lichess_mobile/src/view/study/study_bottom_bar.dart'; +import 'package:lichess_mobile/src/view/study/study_gamebook.dart'; import 'package:lichess_mobile/src/view/study/study_settings.dart'; import 'package:lichess_mobile/src/view/study/study_tree_view.dart'; import 'package:lichess_mobile/src/widgets/adaptive_bottom_sheet.dart'; @@ -176,6 +177,11 @@ class _Body extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final gamebookActive = ref.watch( + studyControllerProvider(id) + .select((state) => state.requireValue.gamebookActive), + ); + return SafeArea( child: Column( children: [ @@ -229,6 +235,9 @@ class _Body extends ConsumerWidget { ) : null; + final bottomChild = + gamebookActive ? StudyGamebook(id) : StudyTreeView(id); + return landscape ? Row( mainAxisSize: MainAxisSize.max, @@ -267,7 +276,7 @@ class _Body extends ConsumerWidget { kTabletBoardTableSidePadding, ), semanticContainer: false, - child: StudyTreeView(id), + child: bottomChild, ), ), ], @@ -305,7 +314,7 @@ class _Body extends ConsumerWidget { horizontal: kTabletBoardTableSidePadding, ) : EdgeInsets.zero, - child: StudyTreeView(id), + child: bottomChild, ), ), ], @@ -384,6 +393,7 @@ class _StudyBoardState extends ConsumerState<_StudyBoard> { (prefs) => prefs.showVariationArrows, ), ) && + !studyState.gamebookActive && currentNode.children.length > 1; final pgnShapes = ISet( diff --git a/lib/src/view/tools/tools_tab_screen.dart b/lib/src/view/tools/tools_tab_screen.dart index 77765f9282..8270f84bcc 100644 --- a/lib/src/view/tools/tools_tab_screen.dart +++ b/lib/src/view/tools/tools_tab_screen.dart @@ -12,7 +12,7 @@ import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/utils/navigation.dart'; import 'package:lichess_mobile/src/view/analysis/analysis_screen.dart'; import 'package:lichess_mobile/src/view/board_editor/board_editor_screen.dart'; -import 'package:lichess_mobile/src/view/clock/clock_screen.dart'; +import 'package:lichess_mobile/src/view/clock/clock_tool_screen.dart'; import 'package:lichess_mobile/src/view/coordinate_training/coordinate_training_screen.dart'; import 'package:lichess_mobile/src/view/opening_explorer/opening_explorer_screen.dart'; import 'package:lichess_mobile/src/view/study/study_list_screen.dart'; @@ -209,7 +209,7 @@ class _Body extends ConsumerWidget { title: context.l10n.clock, onTap: () => pushPlatformRoute( context, - builder: (context) => const ClockScreen(), + builder: (context) => const ClockToolScreen(), rootNavigator: true, ), ), diff --git a/lib/src/view/watch/tv_screen.dart b/lib/src/view/watch/tv_screen.dart index 5d55705d7e..98df340be2 100644 --- a/lib/src/view/watch/tv_screen.dart +++ b/lib/src/view/watch/tv_screen.dart @@ -13,7 +13,7 @@ import 'package:lichess_mobile/src/widgets/board_table.dart'; import 'package:lichess_mobile/src/widgets/bottom_bar.dart'; import 'package:lichess_mobile/src/widgets/bottom_bar_button.dart'; import 'package:lichess_mobile/src/widgets/buttons.dart'; -import 'package:lichess_mobile/src/widgets/countdown_clock.dart'; +import 'package:lichess_mobile/src/widgets/clock.dart'; import 'package:lichess_mobile/src/widgets/platform_scaffold.dart'; class TvScreen extends ConsumerStatefulWidget { @@ -92,10 +92,19 @@ class _Body extends ConsumerWidget { final blackPlayerWidget = GamePlayer( player: game.black.setOnGame(true), clock: gameState.game.clock != null - ? CountdownClock( + ? CountdownClockBuilder( key: blackClockKey, - duration: gameState.game.clock!.black, + timeLeft: gameState.game.clock!.black, + delay: gameState.game.clock!.lag ?? + const Duration(milliseconds: 10), + clockUpdatedAt: gameState.game.clock!.at, active: gameState.activeClockSide == Side.black, + builder: (context, timeLeft) { + return Clock( + timeLeft: timeLeft, + active: gameState.activeClockSide == Side.black, + ); + }, ) : null, materialDiff: game.lastMaterialDiffAt(Side.black), @@ -103,10 +112,19 @@ class _Body extends ConsumerWidget { final whitePlayerWidget = GamePlayer( player: game.white.setOnGame(true), clock: gameState.game.clock != null - ? CountdownClock( + ? CountdownClockBuilder( key: whiteClockKey, - duration: gameState.game.clock!.white, + timeLeft: gameState.game.clock!.white, + clockUpdatedAt: gameState.game.clock!.at, + delay: gameState.game.clock!.lag ?? + const Duration(milliseconds: 10), active: gameState.activeClockSide == Side.white, + builder: (context, timeLeft) { + return Clock( + timeLeft: timeLeft, + active: gameState.activeClockSide == Side.white, + ); + }, ) : null, materialDiff: game.lastMaterialDiffAt(Side.white), diff --git a/lib/src/widgets/board_table.dart b/lib/src/widgets/board_table.dart index c9cba27a51..90681e2455 100644 --- a/lib/src/widgets/board_table.dart +++ b/lib/src/widgets/board_table.dart @@ -267,7 +267,7 @@ class _BoardTableState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - Flexible(child: widget.topTable), + widget.topTable, if (!widget.zenMode && slicedMoves != null) Expanded( child: Padding( @@ -282,14 +282,8 @@ class _BoardTableState extends ConsumerState { ), ) else - // same height as [MoveList] - const Expanded( - child: Padding( - padding: EdgeInsets.all(16.0), - child: SizedBox(height: 40), - ), - ), - Flexible(child: widget.bottomTable), + const Spacer(), + widget.bottomTable, ], ), ), diff --git a/lib/src/widgets/countdown_clock.dart b/lib/src/widgets/clock.dart similarity index 64% rename from lib/src/widgets/countdown_clock.dart rename to lib/src/widgets/clock.dart index cfae7ca1ce..7de23af3e0 100644 --- a/lib/src/widgets/countdown_clock.dart +++ b/lib/src/widgets/clock.dart @@ -1,170 +1,23 @@ import 'dart:async'; +import 'package:clock/clock.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/common/service/sound_service.dart'; import 'package:lichess_mobile/src/utils/screen.dart'; -/// A simple countdown clock. -/// -/// The clock starts only when [active] is `true`. -class CountdownClock extends ConsumerStatefulWidget { - const CountdownClock({ - required this.duration, - required this.active, - this.emergencyThreshold, - this.emergencySoundEnabled = true, - this.onFlag, - this.onStop, - this.clockStyle, - this.padLeft = false, - super.key, - }); - - /// The duration left on the clock. - final Duration duration; - - /// If [timeLeft] is less than [emergencyThreshold], the clock will change - /// its background color to [ClockStyle.emergencyBackgroundColor] activeBackgroundColor - /// If [emergencySoundEnabled] is `true`, the clock will also play a sound. - final Duration? emergencyThreshold; - - /// Whether to play an emergency sound when the clock reaches the emergency - final bool emergencySoundEnabled; - - /// If [active] is `true`, the clock starts counting down. - final bool active; - - /// Callback when the clock reaches zero. - final VoidCallback? onFlag; - - /// Callback with the remaining duration when the clock stops - final ValueSetter? onStop; - - /// Custom color style - final ClockStyle? clockStyle; - - /// Whether to pad with a leading zero (default is `false`). - final bool padLeft; - - @override - ConsumerState createState() => _CountdownClockState(); -} - -const _period = Duration(milliseconds: 100); -const _emergencyDelay = Duration(seconds: 20); - -class _CountdownClockState extends ConsumerState { - Timer? _timer; - Duration timeLeft = Duration.zero; - bool _shouldPlayEmergencyFeedback = true; - DateTime? _nextEmergency; - - final _stopwatch = Stopwatch(); - - void startClock() { - _timer?.cancel(); - _stopwatch.reset(); - _stopwatch.start(); - _timer = Timer.periodic(_period, (timer) { - setState(() { - timeLeft = timeLeft - _stopwatch.elapsed; - _stopwatch.reset(); - _playEmergencyFeedback(); - if (timeLeft <= Duration.zero) { - widget.onFlag?.call(); - timeLeft = Duration.zero; - stopClock(); - } - }); - }); - } - - void stopClock() { - setState(() { - timeLeft = timeLeft - _stopwatch.elapsed; - if (timeLeft < Duration.zero) { - timeLeft = Duration.zero; - } - }); - _timer?.cancel(); - _stopwatch.stop(); - scheduleMicrotask(() { - widget.onStop?.call(timeLeft); - }); - } - - void _playEmergencyFeedback() { - if (widget.emergencyThreshold != null && - timeLeft <= widget.emergencyThreshold! && - _shouldPlayEmergencyFeedback && - (_nextEmergency == null || _nextEmergency!.isBefore(DateTime.now()))) { - _shouldPlayEmergencyFeedback = false; - _nextEmergency = DateTime.now().add(_emergencyDelay); - if (widget.emergencySoundEnabled) { - ref.read(soundServiceProvider).play(Sound.lowTime); - } - HapticFeedback.heavyImpact(); - } else if (widget.emergencyThreshold != null && - timeLeft > widget.emergencyThreshold! * 1.5) { - _shouldPlayEmergencyFeedback = true; - } - } - - @override - void initState() { - super.initState(); - timeLeft = widget.duration; - if (widget.active) { - startClock(); - } - } - - @override - void didUpdateWidget(CountdownClock oldClock) { - super.didUpdateWidget(oldClock); - if (widget.duration != oldClock.duration) { - timeLeft = widget.duration; - } - - if (widget.active != oldClock.active) { - widget.active ? startClock() : stopClock(); - } - } - - @override - void dispose() { - super.dispose(); - _timer?.cancel(); - } - - @override - Widget build(BuildContext context) { - return RepaintBoundary( - child: Clock( - padLeft: widget.padLeft, - timeLeft: timeLeft, - active: widget.active, - emergencyThreshold: widget.emergencyThreshold, - clockStyle: widget.clockStyle, - ), - ); - } -} - const _kClockFontSize = 26.0; const _kClockTenthFontSize = 20.0; const _kClockHundredsFontSize = 18.0; +const _showTenthsThreshold = Duration(seconds: 10); + /// A stateless widget that displays the time left on the clock. /// -/// For a clock widget that automatically counts down, see [CountdownClock]. +/// For a clock widget that automatically counts down, see [CountdownClockBuilder]. class Clock extends StatelessWidget { const Clock({ required this.timeLeft, - required this.active, + this.active = false, this.clockStyle, this.emergencyThreshold, this.padLeft = false, @@ -192,7 +45,7 @@ class Clock extends StatelessWidget { final hours = timeLeft.inHours; final mins = timeLeft.inMinutes.remainder(60); final secs = timeLeft.inSeconds.remainder(60).toString().padLeft(2, '0'); - final showTenths = timeLeft < const Duration(seconds: 10); + final showTenths = timeLeft < _showTenthsThreshold; final isEmergency = emergencyThreshold != null && timeLeft <= emergencyThreshold!; final remainingHeight = estimateRemainingHeightLeftBoard(context); @@ -310,3 +163,154 @@ class ClockStyle { emergencyBackgroundColor: Color(0xFFF2CCCC), ); } + +typedef ClockWidgetBuilder = Widget Function(BuildContext, Duration); + +/// A widget that automatically starts a countdown from [timeLeft] when [active] is `true`. +/// +/// The clock will update the UI every [tickInterval], which defaults to 100ms, +/// and the [builder] will be called with the new [timeLeft] value. +/// +/// The clock can be synchronized with the time at which the clock event was received from the server +/// by setting the [clockUpdatedAt] parameter. +/// This widget will only update its internal clock when the [clockUpdatedAt] parameter changes. +/// +/// The [delay] parameter can be used to delay the start of the clock. +/// +/// The clock will stop counting down when [active] is set to `false`. +/// +/// The clock will stop counting down when the time left reaches zero. +class CountdownClockBuilder extends StatefulWidget { + const CountdownClockBuilder({ + required this.timeLeft, + required this.active, + required this.builder, + this.delay, + this.tickInterval = const Duration(milliseconds: 100), + this.clockUpdatedAt, + super.key, + }); + + /// The duration left on the clock. + final Duration timeLeft; + + /// The delay before the clock starts counting down. + /// + /// This can be used to implement lag compensation. + final Duration? delay; + + /// The interval at which the clock updates the UI. + final Duration tickInterval; + + /// The time at which the clock was updated. + /// + /// Use this parameter to synchronize the clock with the time at which the clock + /// event was received from the server and to compensate for UI lag. + final DateTime? clockUpdatedAt; + + /// If `true`, the clock starts counting down. + final bool active; + + /// A [ClockWidgetBuilder] that builds the clock on each tick with the new [timeLeft] value. + final ClockWidgetBuilder builder; + + @override + State createState() => _CountdownClockState(); +} + +class _CountdownClockState extends State { + Timer? _timer; + Timer? _delayTimer; + Duration timeLeft = Duration.zero; + + final _stopwatch = clock.stopwatch(); + + void startClock() { + final now = clock.now(); + final delay = widget.delay ?? Duration.zero; + final clockUpdatedAt = widget.clockUpdatedAt ?? now; + // UI lag diff: the elapsed time between the time the clock should have started + // and the time the clock is actually started + final uiLag = now.difference(clockUpdatedAt); + final realDelay = delay - uiLag; + + // real delay is negative, we need to adjust the timeLeft. + if (realDelay < Duration.zero) { + final newTimeLeft = timeLeft + realDelay; + timeLeft = newTimeLeft > Duration.zero ? newTimeLeft : Duration.zero; + } + + if (realDelay > Duration.zero) { + _delayTimer?.cancel(); + _delayTimer = Timer(realDelay, _scheduleTick); + } else { + _scheduleTick(); + } + } + + void _scheduleTick() { + _timer?.cancel(); + _timer = Timer(widget.tickInterval, _tick); + _stopwatch.reset(); + _stopwatch.start(); + } + + void _tick() { + final newTimeLeft = timeLeft - _stopwatch.elapsed; + setState(() { + timeLeft = newTimeLeft; + if (timeLeft <= Duration.zero) { + timeLeft = Duration.zero; + } + }); + if (timeLeft > Duration.zero) { + _scheduleTick(); + } + } + + void stopClock() { + _delayTimer?.cancel(); + _timer?.cancel(); + _stopwatch.stop(); + } + + @override + void initState() { + super.initState(); + timeLeft = widget.timeLeft; + if (widget.active) { + startClock(); + } + } + + @override + void didUpdateWidget(CountdownClockBuilder oldClock) { + super.didUpdateWidget(oldClock); + + if (widget.clockUpdatedAt != oldClock.clockUpdatedAt) { + timeLeft = widget.timeLeft; + } + + if (widget.active != oldClock.active) { + if (widget.active) { + startClock(); + } else { + stopClock(); + } + } + } + + @override + void dispose() { + super.dispose(); + _delayTimer?.cancel(); + _timer?.cancel(); + } + + @override + Widget build(BuildContext context) { + return RepaintBoundary( + child: widget.builder(context, timeLeft), + ); + } +} diff --git a/lib/src/widgets/rating.dart b/lib/src/widgets/rating.dart index 69df2388c9..fae2ea6472 100644 --- a/lib/src/widgets/rating.dart +++ b/lib/src/widgets/rating.dart @@ -19,10 +19,8 @@ class RatingWidget extends StatelessWidget { @override Widget build(BuildContext context) { - final ratingStr = - rating is double ? rating.toStringAsFixed(2) : rating.toString(); return Text( - '$ratingStr${provisional == true || deviation > kProvisionalDeviation ? '?' : ''}', + '${rating.round()}${provisional == true || deviation > kProvisionalDeviation ? '?' : ''}', style: style, ); } diff --git a/test/model/challenge/challenge_service_test.dart b/test/model/challenge/challenge_service_test.dart index 10872457a1..25c637853a 100644 --- a/test/model/challenge/challenge_service_test.dart +++ b/test/model/challenge/challenge_service_test.dart @@ -32,7 +32,7 @@ void main() { test('exposes a challenges stream', () async { final fakeChannel = FakeWebSocketChannel(); final socketClient = - makeTestSocketClient(FakeWebSocketChannelFactory(() => fakeChannel)); + makeTestSocketClient(FakeWebSocketChannelFactory((_) => fakeChannel)); await socketClient.connect(); await socketClient.firstConnection; @@ -118,7 +118,7 @@ void main() { fakeAsync((async) { final fakeChannel = FakeWebSocketChannel(); final socketClient = - makeTestSocketClient(FakeWebSocketChannelFactory(() => fakeChannel)); + makeTestSocketClient(FakeWebSocketChannelFactory((_) => fakeChannel)); socketClient.connect(); notificationService.start(); challengeService.start(); @@ -221,7 +221,7 @@ void main() { fakeAsync((async) { final fakeChannel = FakeWebSocketChannel(); final socketClient = - makeTestSocketClient(FakeWebSocketChannelFactory(() => fakeChannel)); + makeTestSocketClient(FakeWebSocketChannelFactory((_) => fakeChannel)); socketClient.connect(); notificationService.start(); challengeService.start(); diff --git a/test/model/clock/chess_clock_test.dart b/test/model/clock/chess_clock_test.dart new file mode 100644 index 0000000000..f0c2a45870 --- /dev/null +++ b/test/model/clock/chess_clock_test.dart @@ -0,0 +1,231 @@ +import 'package:dartchess/dartchess.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lichess_mobile/src/model/clock/chess_clock.dart'; + +void main() { + test('make clock', () { + final clock = ChessClock( + whiteTime: const Duration(seconds: 5), + blackTime: const Duration(seconds: 5), + ); + expect(clock.isRunning, false); + expect(clock.whiteTime.value, const Duration(seconds: 5)); + expect(clock.blackTime.value, const Duration(seconds: 5)); + }); + + test('start clock', () { + fakeAsync((async) { + final clock = ChessClock( + whiteTime: const Duration(seconds: 5), + blackTime: const Duration(seconds: 5), + ); + clock.start(); + expect(clock.isRunning, true); + }); + }); + + test('clock ticking', () { + fakeAsync((async) { + final clock = ChessClock( + whiteTime: const Duration(seconds: 5), + blackTime: const Duration(seconds: 5), + ); + clock.start(); + expect(clock.whiteTime.value, const Duration(seconds: 5)); + expect(clock.blackTime.value, const Duration(seconds: 5)); + async.elapse(const Duration(seconds: 1)); + expect(clock.whiteTime.value, const Duration(seconds: 4)); + expect(clock.blackTime.value, const Duration(seconds: 5)); + async.elapse(const Duration(seconds: 1)); + expect(clock.whiteTime.value, const Duration(seconds: 3)); + expect(clock.blackTime.value, const Duration(seconds: 5)); + }); + }); + + test('stop clock', () { + fakeAsync((async) { + final clock = ChessClock( + whiteTime: const Duration(seconds: 5), + blackTime: const Duration(seconds: 5), + ); + clock.start(); + expect(clock.isRunning, true); + async.elapse(const Duration(seconds: 1)); + expect(clock.whiteTime.value, const Duration(seconds: 4)); + expect(clock.blackTime.value, const Duration(seconds: 5)); + final thinkTime = clock.stop(); + expect(clock.isRunning, false); + expect(thinkTime, const Duration(seconds: 1)); + async.elapse(const Duration(seconds: 1)); + expect(clock.whiteTime.value, const Duration(seconds: 4)); + expect(clock.blackTime.value, const Duration(seconds: 5)); + }); + }); + + test('start side', () { + fakeAsync((async) { + final clock = ChessClock( + whiteTime: const Duration(seconds: 5), + blackTime: const Duration(seconds: 5), + ); + final thinkTime = clock.startSide(Side.black); + expect(thinkTime, null); + async.elapse(const Duration(seconds: 1)); + expect(clock.whiteTime.value, const Duration(seconds: 5)); + expect(clock.blackTime.value, const Duration(seconds: 4)); + }); + }); + + test('start side (running clock)', () { + fakeAsync((async) { + final clock = ChessClock( + whiteTime: const Duration(seconds: 5), + blackTime: const Duration(seconds: 5), + ); + clock.start(); + expect(clock.isRunning, true); + expect(clock.whiteTime.value, const Duration(seconds: 5)); + expect(clock.blackTime.value, const Duration(seconds: 5)); + async.elapse(const Duration(seconds: 1)); + expect(clock.whiteTime.value, const Duration(seconds: 4)); + expect(clock.blackTime.value, const Duration(seconds: 5)); + final thinkTime = clock.startSide(Side.black); + expect(thinkTime, const Duration(seconds: 1)); + async.elapse(const Duration(seconds: 1)); + expect(clock.whiteTime.value, const Duration(seconds: 4)); + expect(clock.blackTime.value, const Duration(seconds: 4)); + }); + }); + + test('start side (running clock, same side)', () { + fakeAsync((async) { + final clock = ChessClock( + whiteTime: const Duration(seconds: 5), + blackTime: const Duration(seconds: 5), + ); + clock.start(); + expect(clock.isRunning, true); + expect(clock.whiteTime.value, const Duration(seconds: 5)); + expect(clock.blackTime.value, const Duration(seconds: 5)); + async.elapse(const Duration(seconds: 1)); + expect(clock.whiteTime.value, const Duration(seconds: 4)); + expect(clock.blackTime.value, const Duration(seconds: 5)); + final thinkTime = clock.startSide(Side.white); + expect(thinkTime, const Duration(seconds: 1)); + async.elapse(const Duration(seconds: 1)); + expect(clock.whiteTime.value, const Duration(seconds: 3)); + expect(clock.blackTime.value, const Duration(seconds: 5)); + }); + }); + + test('start with delay', () { + fakeAsync((async) { + final clock = ChessClock( + whiteTime: const Duration(seconds: 5), + blackTime: const Duration(seconds: 5), + ); + clock.start(delay: const Duration(milliseconds: 20)); + expect(clock.isRunning, true); + expect(clock.whiteTime.value, const Duration(seconds: 5)); + async.elapse(const Duration(milliseconds: 10)); + expect(clock.whiteTime.value, const Duration(seconds: 5)); + // the start delay is reached, but clock not updated yet since tick delay is 100ms + async.elapse(const Duration(milliseconds: 100)); + expect(clock.whiteTime.value, const Duration(seconds: 5)); + async.elapse(const Duration(milliseconds: 10)); + expect(clock.whiteTime.value, const Duration(milliseconds: 4900)); + final thinkTime = clock.stop(); + expect(thinkTime, const Duration(milliseconds: 100)); + }); + }); + + test('increment times', () { + fakeAsync((async) { + final clock = ChessClock( + whiteTime: const Duration(seconds: 5), + blackTime: const Duration(seconds: 5), + ); + clock.start(); + expect(clock.whiteTime.value, const Duration(seconds: 5)); + expect(clock.blackTime.value, const Duration(seconds: 5)); + clock.incTimes(whiteInc: const Duration(seconds: 1)); + expect(clock.whiteTime.value, const Duration(seconds: 6)); + expect(clock.blackTime.value, const Duration(seconds: 5)); + clock.incTimes(blackInc: const Duration(seconds: 1)); + expect(clock.whiteTime.value, const Duration(seconds: 6)); + expect(clock.blackTime.value, const Duration(seconds: 6)); + }); + }); + + test('increment specific side', () { + fakeAsync((async) { + final clock = ChessClock( + whiteTime: const Duration(seconds: 5), + blackTime: const Duration(seconds: 5), + ); + clock.start(); + expect(clock.whiteTime.value, const Duration(seconds: 5)); + expect(clock.blackTime.value, const Duration(seconds: 5)); + clock.incTime(Side.white, const Duration(seconds: 1)); + expect(clock.whiteTime.value, const Duration(seconds: 6)); + expect(clock.blackTime.value, const Duration(seconds: 5)); + clock.incTime(Side.black, const Duration(seconds: 1)); + expect(clock.whiteTime.value, const Duration(seconds: 6)); + expect(clock.blackTime.value, const Duration(seconds: 6)); + }); + }); + + test('flag', () { + fakeAsync((async) { + int flagCount = 0; + final clock = ChessClock( + whiteTime: const Duration(seconds: 5), + blackTime: const Duration(seconds: 5), + onFlag: () { + flagCount++; + }, + ); + clock.start(); + expect(clock.whiteTime.value, const Duration(seconds: 5)); + expect(clock.blackTime.value, const Duration(seconds: 5)); + async.elapse(const Duration(seconds: 5)); + expect(flagCount, 1); + expect(clock.whiteTime.value, Duration.zero); + expect(clock.blackTime.value, const Duration(seconds: 5)); + + // continue ticking and calling onFlag + async.elapse(const Duration(milliseconds: 200)); + expect(flagCount, 3); + clock.stop(); + + // no more onFlag calls + async.elapse(const Duration(seconds: 5)); + expect(flagCount, 3); + }); + }); + + test('onEmergency', () { + fakeAsync((async) { + int onEmergencyCount = 0; + final clock = ChessClock( + whiteTime: const Duration(seconds: 5), + blackTime: const Duration(seconds: 5), + emergencyThreshold: const Duration(seconds: 2), + onEmergency: (_) { + onEmergencyCount++; + }, + ); + clock.start(); + expect(clock.whiteTime.value, const Duration(seconds: 5)); + expect(clock.blackTime.value, const Duration(seconds: 5)); + async.elapse(const Duration(seconds: 2)); + expect(clock.whiteTime.value, const Duration(seconds: 3)); + expect(clock.blackTime.value, const Duration(seconds: 5)); + async.elapse(const Duration(seconds: 1)); + expect(onEmergencyCount, 1); + async.elapse(const Duration(milliseconds: 100)); + expect(onEmergencyCount, 1); + }); + }); +} diff --git a/test/model/game/game_socket_events_test.dart b/test/model/game/game_socket_events_test.dart index 12d4ee32a4..7bb4597c5c 100644 --- a/test/model/game/game_socket_events_test.dart +++ b/test/model/game/game_socket_events_test.dart @@ -15,12 +15,17 @@ void main() { final game = fullEvent.game; expect(game.id, const GameId('nV3DaALy')); expect( - game.clock, - const PlayableClockData( - running: true, - white: Duration(seconds: 149, milliseconds: 50), - black: Duration(seconds: 775, milliseconds: 940), - ), + game.clock?.running, + true, + ); + expect( + game.clock?.white, + const Duration(seconds: 149, milliseconds: 50), + ); + + expect( + game.clock?.black, + const Duration(seconds: 775, milliseconds: 940), ); expect( game.meta, diff --git a/test/model/game/game_socket_example_data.dart b/test/model/game/game_socket_example_data.dart new file mode 100644 index 0000000000..f4f3cc4c95 --- /dev/null +++ b/test/model/game/game_socket_example_data.dart @@ -0,0 +1,103 @@ +import 'package:dartchess/dartchess.dart'; +import 'package:lichess_mobile/src/model/common/id.dart'; + +typedef FullEventTestClock = ({ + bool running, + Duration initial, + Duration increment, + Duration? emerg, + Duration white, + Duration black, +}); + +String makeFullEvent( + GameId id, + String pgn, { + required String whiteUserName, + required String blackUserName, + int socketVersion = 0, + Side? youAre, + FullEventTestClock clock = const ( + running: false, + initial: Duration(minutes: 3), + increment: Duration(seconds: 2), + emerg: Duration(seconds: 30), + white: Duration(minutes: 3), + black: Duration(minutes: 3), + ), +}) { + final youAreStr = youAre != null ? '"youAre": "${youAre.name}",' : ''; + return ''' +{ + "t": "full", + "d": { + "game": { + "id": "$id", + "variant": { + "key": "standard", + "name": "Standard", + "short": "Std" + }, + "speed": "blitz", + "perf": "blitz", + "rated": false, + "source": "lobby", + "status": { + "id": 20, + "name": "started" + }, + "createdAt": 1685698678928, + "pgn": "$pgn" + }, + "white": { + "user": { + "name": "$whiteUserName", + "patron": true, + "id": "${whiteUserName.toLowerCase()}" + }, + "rating": 1806, + "provisional": true, + "onGame": true + }, + "black": { + "user": { + "name": "$blackUserName", + "patron": true, + "id": "${blackUserName.toLowerCase()}" + }, + "onGame": true + }, + $youAreStr + "socket": $socketVersion, + "clock": { + "running": ${clock.running}, + "initial": ${clock.initial.inSeconds}, + "increment": ${clock.increment.inSeconds}, + "white": ${(clock.white.inMilliseconds / 1000).toStringAsFixed(2)}, + "black": ${(clock.black.inMilliseconds / 1000).toStringAsFixed(2)}, + "emerg": 30, + "moretime": 15 + }, + "expiration": { + "idleMillis": 245, + "millisToMove": 30000 + }, + "chat": { + "lines": [ + { + "u": "Zidrox", + "t": "Good luck", + "f": "people.man-singer" + }, + { + "u": "lichess", + "t": "Takeback accepted", + "f": "activity.lichess" + } + ] + } + }, + "v": $socketVersion +} +'''; +} diff --git a/test/network/fake_websocket_channel.dart b/test/network/fake_websocket_channel.dart index 1d639eb2f2..88e3391d04 100644 --- a/test/network/fake_websocket_channel.dart +++ b/test/network/fake_websocket_channel.dart @@ -7,7 +7,7 @@ import 'package:stream_channel/stream_channel.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; class FakeWebSocketChannelFactory implements WebSocketChannelFactory { - final FutureOr Function() createFunction; + final FutureOr Function(String url) createFunction; const FakeWebSocketChannelFactory(this.createFunction); @@ -17,7 +17,7 @@ class FakeWebSocketChannelFactory implements WebSocketChannelFactory { Map? headers, Duration timeout = const Duration(seconds: 1), }) async { - return createFunction(); + return createFunction(url); } } @@ -30,11 +30,19 @@ class FakeWebSocketChannelFactory implements WebSocketChannelFactory { /// behavior can be changed by setting [shouldSendPong] to false. /// /// It also allows to increase the lag of the connection by setting the -/// [connectionLag] property. +/// [connectionLag] property. By default [connectionLag] is set to [Duration.zero] +/// to simplify testing. +/// When lag is 0, the pong response will be sent in the next microtask. /// /// The [sentMessages] and [sentMessagesExceptPing] streams can be used to /// verify that the client sends the expected messages. class FakeWebSocketChannel implements WebSocketChannel { + FakeWebSocketChannel({this.connectionLag = Duration.zero}); + + int _pongCount = 0; + + final _connectionCompleter = Completer(); + static bool isPing(dynamic data) { if (data is! String) { return false; @@ -55,13 +63,19 @@ class FakeWebSocketChannel implements WebSocketChannel { /// The controller for outgoing (to server) messages. final _outcomingController = StreamController.broadcast(); + /// The lag of the connection (duration before pong response) in milliseconds. + Duration connectionLag; + /// Whether the server should send a pong response to a ping request. /// /// Can be used to simulate a faulty connection. bool shouldSendPong = true; - /// The lag of the connection (duration before pong response) in milliseconds. - Duration connectionLag = const Duration(milliseconds: 10); + /// Number of pong response received + int get pongCount => _pongCount; + + /// A Future that resolves when the first pong message is received + Future get connectionEstablished => _connectionCompleter.future; /// The stream of all outgoing messages. Stream get sentMessages => _outcomingController.stream; @@ -157,12 +171,22 @@ class FakeWebSocketSink implements WebSocketSink { // Simulates pong response if connection is not closed if (_channel.shouldSendPong && FakeWebSocketChannel.isPing(data)) { - Future.delayed(_channel.connectionLag, () { + void sendPong() { if (_channel._incomingController.isClosed) { return; } + _channel._pongCount++; + if (_channel._pongCount == 1) { + _channel._connectionCompleter.complete(); + } _channel._incomingController.add('0'); - }); + } + + if (_channel.connectionLag > Duration.zero) { + Future.delayed(_channel.connectionLag, sendPong); + } else { + scheduleMicrotask(sendPong); + } } } diff --git a/test/network/socket_test.dart b/test/network/socket_test.dart index 86e5d34d80..c5cbe7d5d2 100644 --- a/test/network/socket_test.dart +++ b/test/network/socket_test.dart @@ -45,7 +45,7 @@ void main() { final fakeChannel = FakeWebSocketChannel(); final socketClient = - makeTestSocketClient(FakeWebSocketChannelFactory(() => fakeChannel)); + makeTestSocketClient(FakeWebSocketChannelFactory((_) => fakeChannel)); socketClient.connect(); int sentPingCount = 0; @@ -69,7 +69,7 @@ void main() { test('reconnects when connection attempt fails', () async { int numConnectionAttempts = 0; - final fakeChannelFactory = FakeWebSocketChannelFactory(() { + final fakeChannelFactory = FakeWebSocketChannelFactory((_) { numConnectionAttempts++; if (numConnectionAttempts == 1) { throw const SocketException('Connection failed'); @@ -95,7 +95,7 @@ void main() { // channels per connection attempt final Map channels = {}; - final fakeChannelFactory = FakeWebSocketChannelFactory(() { + final fakeChannelFactory = FakeWebSocketChannelFactory((_) { numConnectionAttempts++; final channel = FakeWebSocketChannel(); int sentPingCount = 0; @@ -133,10 +133,11 @@ void main() { }); test('computes average lag', () async { - final fakeChannel = FakeWebSocketChannel(); + final fakeChannel = + FakeWebSocketChannel(connectionLag: const Duration(milliseconds: 10)); final socketClient = - makeTestSocketClient(FakeWebSocketChannelFactory(() => fakeChannel)); + makeTestSocketClient(FakeWebSocketChannelFactory((_) => fakeChannel)); socketClient.connect(); // before the connection is ready the average lag is zero @@ -188,7 +189,7 @@ void main() { final fakeChannel = FakeWebSocketChannel(); final socketClient = - makeTestSocketClient(FakeWebSocketChannelFactory(() => fakeChannel)); + makeTestSocketClient(FakeWebSocketChannelFactory((_) => fakeChannel)); socketClient.connect(); await socketClient.firstConnection; diff --git a/test/test_container.dart b/test/test_container.dart index ecb340ee0a..7e1ab64506 100644 --- a/test/test_container.dart +++ b/test/test_container.dart @@ -68,7 +68,7 @@ Future makeContainer({ return db; }), webSocketChannelFactoryProvider.overrideWith((ref) { - return FakeWebSocketChannelFactory(() => FakeWebSocketChannel()); + return FakeWebSocketChannelFactory((_) => FakeWebSocketChannel()); }), socketPoolProvider.overrideWith((ref) { final pool = SocketPool(ref); diff --git a/test/test_helpers.dart b/test/test_helpers.dart index 448d85578c..dfedcc6b3c 100644 --- a/test/test_helpers.dart +++ b/test/test_helpers.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:chessground/chessground.dart'; import 'package:dartchess/dartchess.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; @@ -114,17 +115,18 @@ Offset squareOffset( /// Plays a move on the board. Future playMove( WidgetTester tester, - Rect boardRect, String from, String to, { + Rect? boardRect, Side orientation = Side.white, }) async { + final rect = boardRect ?? tester.getRect(find.byType(Chessboard)); await tester.tapAt( - squareOffset(Square.fromName(from), boardRect, orientation: orientation), + squareOffset(Square.fromName(from), rect, orientation: orientation), ); await tester.pump(); await tester.tapAt( - squareOffset(Square.fromName(to), boardRect, orientation: orientation), + squareOffset(Square.fromName(to), rect, orientation: orientation), ); await tester.pump(); } diff --git a/test/test_provider_scope.dart b/test/test_provider_scope.dart index 8410a60edc..81419fd5a0 100644 --- a/test/test_provider_scope.dart +++ b/test/test_provider_scope.dart @@ -183,7 +183,7 @@ Future makeTestProviderScope( }), // ignore: scoped_providers_should_specify_dependencies webSocketChannelFactoryProvider.overrideWith((ref) { - return FakeWebSocketChannelFactory(() => FakeWebSocketChannel()); + return FakeWebSocketChannelFactory((_) => FakeWebSocketChannel()); }), // ignore: scoped_providers_should_specify_dependencies socketPoolProvider.overrideWith((ref) { diff --git a/test/view/game/game_screen_test.dart b/test/view/game/game_screen_test.dart new file mode 100644 index 0000000000..18d2c45d88 --- /dev/null +++ b/test/view/game/game_screen_test.dart @@ -0,0 +1,497 @@ +import 'package:chessground/chessground.dart'; +import 'package:dartchess/dartchess.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/testing.dart'; +import 'package:lichess_mobile/src/model/common/id.dart'; +import 'package:lichess_mobile/src/model/common/service/sound_service.dart'; +import 'package:lichess_mobile/src/model/lobby/game_seek.dart'; +import 'package:lichess_mobile/src/network/http.dart'; +import 'package:lichess_mobile/src/network/socket.dart'; +import 'package:lichess_mobile/src/view/game/game_screen.dart'; +import 'package:lichess_mobile/src/widgets/bottom_bar_button.dart'; +import 'package:lichess_mobile/src/widgets/clock.dart'; +import 'package:mocktail/mocktail.dart'; + +import '../../model/game/game_socket_example_data.dart'; +import '../../network/fake_websocket_channel.dart'; +import '../../test_helpers.dart'; +import '../../test_provider_scope.dart'; + +final client = MockClient((request) { + if (request.url.path == '/api/board/seek') { + return mockResponse('ok', 200); + } + return mockResponse('', 404); +}); + +class MockSoundService extends Mock implements SoundService {} + +void main() { + group('Loading', () { + testWidgets('a game directly with initialGameId', + (WidgetTester tester) async { + final fakeSocket = FakeWebSocketChannel(); + + final app = await makeTestProviderScopeApp( + tester, + home: const GameScreen( + initialGameId: GameFullId('qVChCOTcHSeW'), + ), + overrides: [ + lichessClientProvider + .overrideWith((ref) => LichessClient(client, ref)), + webSocketChannelFactoryProvider.overrideWith((ref) { + return FakeWebSocketChannelFactory((_) => fakeSocket); + }), + ], + ); + await tester.pumpWidget(app); + + // while loading, displays an empty board + expect(find.byType(Chessboard), findsOneWidget); + expect(find.byType(PieceWidget), findsNothing); + + // now the game controller is loading and screen doesn't have changed yet + await tester.pump(const Duration(milliseconds: 10)); + expect(find.byType(Chessboard), findsOneWidget); + expect(find.byType(PieceWidget), findsNothing); + + await fakeSocket.connectionEstablished; + + fakeSocket.addIncomingMessages([ + makeFullEvent( + const GameId('qVChCOTc'), + '', + whiteUserName: 'Peter', + blackUserName: 'Steven', + ), + ]); + // wait for socket message + await tester.pump(const Duration(milliseconds: 10)); + + expect(find.byType(PieceWidget), findsNWidgets(32)); + expect(find.text('Peter'), findsOneWidget); + expect(find.text('Steven'), findsOneWidget); + }); + + testWidgets('a game from the pool with a seek', + (WidgetTester tester) async { + final fakeLobbySocket = FakeWebSocketChannel(); + final fakeGameSocket = FakeWebSocketChannel(); + + final app = await makeTestProviderScopeApp( + tester, + home: const GameScreen( + seek: GameSeek( + clock: (Duration(minutes: 3), Duration(seconds: 2)), + rated: true, + ), + ), + overrides: [ + lichessClientProvider + .overrideWith((ref) => LichessClient(client, ref)), + webSocketChannelFactoryProvider.overrideWith((ref) { + return FakeWebSocketChannelFactory( + (String url) => + url.contains('lobby') ? fakeLobbySocket : fakeGameSocket, + ); + }), + ], + ); + await tester.pumpWidget(app); + + expect(find.byType(Chessboard), findsOneWidget); + expect(find.byType(PieceWidget), findsNothing); + expect(find.text('Waiting for opponent to join...'), findsOneWidget); + expect(find.text('3+2'), findsOneWidget); + expect(find.widgetWithText(BottomBarButton, 'Cancel'), findsOneWidget); + + // waiting for the game + await tester.pump(const Duration(seconds: 2)); + + // when a seek is accepted, server sends a 'redirect' message with game id + fakeLobbySocket.addIncomingMessages([ + '{"t": "redirect", "d": {"id": "qVChCOTcHSeW" }, "v": 1}', + ]); + await tester.pump(const Duration(milliseconds: 1)); + + // now the game controller is loading + expect(find.byType(Chessboard), findsOneWidget); + expect(find.byType(PieceWidget), findsNothing); + expect(find.text('Waiting for opponent to join...'), findsNothing); + expect(find.text('3+2'), findsNothing); + expect(find.widgetWithText(BottomBarButton, 'Cancel'), findsNothing); + + await fakeGameSocket.connectionEstablished; + // now that game socket is open, lobby socket should be closed + expect(fakeLobbySocket.closeCode, isNotNull); + + fakeGameSocket.addIncomingMessages([ + makeFullEvent( + const GameId('qVChCOTc'), + '', + whiteUserName: 'Peter', + blackUserName: 'Steven', + ), + ]); + // wait for socket message + await tester.pump(const Duration(milliseconds: 10)); + + expect(find.byType(PieceWidget), findsNWidgets(32)); + expect(find.text('Peter'), findsOneWidget); + expect(find.text('Steven'), findsOneWidget); + expect(find.text('Waiting for opponent to join...'), findsNothing); + expect(find.text('3+2'), findsNothing); + }); + }); + + group('Clock', () { + testWidgets('loads on game start', (WidgetTester tester) async { + final fakeSocket = FakeWebSocketChannel(); + await createTestGame(fakeSocket, tester); + expect(findClockWithTime('3:00'), findsNWidgets(2)); + expect( + tester + .widgetList(find.byType(Clock)) + .where((widget) => widget.active == false) + .length, + 2, + reason: 'clocks are not active yet', + ); + }); + + testWidgets('ticks after the first full move', (WidgetTester tester) async { + final fakeSocket = FakeWebSocketChannel(); + await createTestGame(fakeSocket, tester); + expect(findClockWithTime('3:00'), findsNWidgets(2)); + await playMove(tester, 'e2', 'e4'); + // at that point clock is not yet started + expect( + tester + .widgetList(find.byType(Clock)) + .where((widget) => widget.active == false) + .length, + 2, + reason: 'clocks are not active yet', + ); + fakeSocket.addIncomingMessages([ + '{"t": "move", "v": 1, "d": {"ply": 1, "uci": "e2e4", "san": "e4", "clock": {"white": 180, "black": 180}}}', + '{"t": "move", "v": 2, "d": {"ply": 2, "uci": "e7e5", "san": "e5", "clock": {"white": 180, "black": 180}}}', + ]); + await tester.pump(const Duration(milliseconds: 10)); + expect( + tester.widgetList(find.byType(Clock)).last.active, + true, + reason: 'my clock is now active', + ); + await tester.pump(const Duration(seconds: 1)); + expect(findClockWithTime('2:59'), findsOneWidget); + await tester.pump(const Duration(seconds: 1)); + expect(findClockWithTime('2:58'), findsOneWidget); + }); + + testWidgets('ticks immediately when resuming game', + (WidgetTester tester) async { + final fakeSocket = FakeWebSocketChannel(); + await createTestGame( + fakeSocket, + tester, + pgn: 'e4 e5 Nf3', + clock: const ( + running: true, + initial: Duration(minutes: 3), + increment: Duration(seconds: 2), + white: Duration(minutes: 2, seconds: 58), + black: Duration(minutes: 2, seconds: 54), + emerg: Duration(seconds: 30), + ), + ); + expect( + tester.widgetList(find.byType(Clock)).first.active, + true, + reason: 'black clock is already active', + ); + expect(findClockWithTime('2:58'), findsOneWidget); + expect(findClockWithTime('2:54'), findsOneWidget); + await tester.pump(const Duration(seconds: 1)); + expect(findClockWithTime('2:53'), findsOneWidget); + await tester.pump(const Duration(seconds: 1)); + expect(findClockWithTime('2:52'), findsOneWidget); + }); + + testWidgets('switch timer side after a move', (WidgetTester tester) async { + final fakeSocket = FakeWebSocketChannel(); + await createTestGame( + fakeSocket, + tester, + pgn: 'e4 e5', + clock: const ( + running: true, + initial: Duration(minutes: 3), + increment: Duration(seconds: 2), + white: Duration(minutes: 2, seconds: 58), + black: Duration(minutes: 3), + emerg: Duration(seconds: 30), + ), + ); + expect(tester.widgetList(find.byType(Clock)).last.active, true); + // simulates think time of 3s + await tester.pump(const Duration(seconds: 3)); + await playMove(tester, 'g1', 'f3'); + expect(findClockWithTime('2:55'), findsOneWidget); + expect( + tester.widgetList(find.byType(Clock)).last.active, + false, + reason: 'white clock is stopped while waiting for server ack', + ); + expect( + tester.widgetList(find.byType(Clock)).first.active, + true, + reason: 'black clock is now active but not yet ticking', + ); + expect(findClockWithTime('3:00'), findsOneWidget); + // simulates a long lag just to show the clock is not running yet + await tester.pump(const Duration(milliseconds: 200)); + expect(findClockWithTime('3:00'), findsOneWidget); + // server ack having the white clock updated with the increment + fakeSocket.addIncomingMessages([ + '{"t": "move", "v": 1, "d": {"ply": 3, "uci": "g1f3", "san": "Nf3", "clock": {"white": 177, "black": 180}}}', + ]); + await tester.pump(const Duration(milliseconds: 10)); + // we see now the white clock has got its increment + expect(findClockWithTime('2:57'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + // black clock is ticking + expect(findClockWithTime('2:59'), findsOneWidget); + await tester.pump(const Duration(seconds: 1)); + expect(findClockWithTime('2:57'), findsOneWidget); + expect(findClockWithTime('2:58'), findsOneWidget); + await tester.pump(const Duration(seconds: 1)); + expect(findClockWithTime('2:57'), findsNWidgets(2)); + await tester.pump(const Duration(seconds: 1)); + expect(findClockWithTime('2:57'), findsOneWidget); + expect(findClockWithTime('2:56'), findsOneWidget); + }); + + testWidgets('compensates opponent lag', (WidgetTester tester) async { + final fakeSocket = FakeWebSocketChannel(); + int socketVersion = 0; + await createTestGame( + fakeSocket, + tester, + pgn: 'e4 e5 Nf3 Nc6', + clock: const ( + running: true, + initial: Duration(minutes: 1), + increment: Duration.zero, + white: Duration(seconds: 58), + black: Duration(seconds: 54), + emerg: Duration(seconds: 10), + ), + socketVersion: socketVersion, + ); + await tester.pump(const Duration(seconds: 3)); + await playMoveWithServerAck( + fakeSocket, + tester, + 'f1', + 'c4', + ply: 5, + san: 'Bc4', + clockAck: ( + white: const Duration(seconds: 55), + black: const Duration(seconds: 54), + lag: const Duration(milliseconds: 250), + ), + socketVersion: ++socketVersion, + ); + // black clock is active + expect(tester.widgetList(find.byType(Clock)).first.active, true); + expect(findClockWithTime('0:54'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 250)); + // lag is 250ms, so clock will only start after that delay + expect(findClockWithTime('0:54'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + expect(findClockWithTime('0:53'), findsOneWidget); + await tester.pump(const Duration(seconds: 1)); + expect(findClockWithTime('0:52'), findsOneWidget); + }); + + testWidgets('onEmergency', (WidgetTester tester) async { + final mockSoundService = MockSoundService(); + when(() => mockSoundService.play(Sound.lowTime)).thenAnswer((_) async {}); + final fakeSocket = FakeWebSocketChannel(); + await createTestGame( + fakeSocket, + tester, + pgn: 'e4 e5', + clock: const ( + running: true, + initial: Duration(minutes: 3), + increment: Duration(seconds: 2), + white: Duration(seconds: 40), + black: Duration(minutes: 3), + emerg: Duration(seconds: 30), + ), + overrides: [ + soundServiceProvider.overrideWith((_) => mockSoundService), + ], + ); + expect( + tester.widget(findClockWithTime('0:40')).emergencyThreshold, + const Duration(seconds: 30), + ); + await tester.pump(const Duration(seconds: 10)); + expect(findClockWithTime('0:30'), findsOneWidget); + verify(() => mockSoundService.play(Sound.lowTime)).called(1); + }); + + testWidgets('flags', (WidgetTester tester) async { + final fakeSocket = FakeWebSocketChannel(); + await createTestGame( + fakeSocket, + tester, + pgn: 'e4 e5 Nf3', + clock: const ( + running: true, + initial: Duration(minutes: 3), + increment: Duration(seconds: 2), + white: Duration(minutes: 2, seconds: 58), + black: Duration(minutes: 2, seconds: 54), + emerg: Duration(seconds: 30), + ), + ); + expect( + tester.widgetList(find.byType(Clock)).first.active, + true, + reason: 'black clock is active', + ); + + expect(findClockWithTime('2:58'), findsOneWidget); + expect(findClockWithTime('2:54'), findsOneWidget); + await tester.pump(const Duration(seconds: 1)); + expect(findClockWithTime('2:53'), findsOneWidget); + await tester.pump(const Duration(minutes: 2, seconds: 53)); + expect(findClockWithTime('2:58'), findsOneWidget); + expect(findClockWithTime('0:00.0'), findsOneWidget); + + expect( + tester.widgetList(find.byType(Clock)).first.active, + true, + reason: + 'black clock is still active after flag (as long as we have not received server ack)', + ); + + // flag messages are throttled with 500ms delay + // we'll simulate an anormally long server response of 1s to check 2 + // flag messages are sent + expectLater( + fakeSocket.sentMessagesExceptPing, + emitsInOrder([ + '{"t":"flag","d":"black"}', + '{"t":"flag","d":"black"}', + ]), + ); + await tester.pump(const Duration(seconds: 1)); + fakeSocket.addIncomingMessages([ + '{"t":"endData","d":{"status":"outoftime","winner":"white","clock":{"wc":17800,"bc":0}}}', + ]); + await tester.pump(const Duration(milliseconds: 10)); + + expect( + tester + .widgetList(find.byType(Clock)) + .where((widget) => widget.active == false) + .length, + 2, + reason: 'both clocks are now inactive', + ); + expect(findClockWithTime('2:58'), findsOneWidget); + expect(findClockWithTime('0:00.00'), findsOneWidget); + + // wait for the dong + await tester.pump(const Duration(seconds: 500)); + }); + }); +} + +Finder findClockWithTime(String text, {bool skipOffstage = true}) { + return find.ancestor( + of: find.text(text, findRichText: true, skipOffstage: skipOffstage), + matching: find.byType(Clock, skipOffstage: skipOffstage), + ); +} + +/// Simulates playing a move and getting the ack from the server after [elapsedTime]. +Future playMoveWithServerAck( + FakeWebSocketChannel socket, + WidgetTester tester, + String from, + String to, { + required String san, + required ({Duration white, Duration black, Duration? lag}) clockAck, + required int socketVersion, + required int ply, + Duration elapsedTime = const Duration(milliseconds: 10), + Side orientation = Side.white, +}) async { + await playMove(tester, from, to, orientation: orientation); + final uci = '$from$to'; + final lagStr = clockAck.lag != null + ? ', "lag": ${(clockAck.lag!.inMilliseconds / 10).round()}' + : ''; + await tester.pump(elapsedTime - const Duration(milliseconds: 1)); + socket.addIncomingMessages([ + '{"t": "move", "v": $socketVersion, "d": {"ply": $ply, "uci": "$uci", "san": "$san", "clock": {"white": ${(clockAck.white.inMilliseconds / 1000).toStringAsFixed(2)}, "black": ${(clockAck.black.inMilliseconds / 1000).toStringAsFixed(2)}$lagStr}}}', + ]); + await tester.pump(const Duration(milliseconds: 1)); +} + +/// Convenient function to start a new test game +Future createTestGame( + FakeWebSocketChannel socket, + WidgetTester tester, { + Side? youAre = Side.white, + String? pgn, + int socketVersion = 0, + FullEventTestClock clock = const ( + running: false, + initial: Duration(minutes: 3), + increment: Duration(seconds: 2), + white: Duration(minutes: 3), + black: Duration(minutes: 3), + emerg: Duration(seconds: 30), + ), + List? overrides, +}) async { + final app = await makeTestProviderScopeApp( + tester, + home: const GameScreen( + initialGameId: GameFullId('qVChCOTcHSeW'), + ), + overrides: [ + lichessClientProvider.overrideWith((ref) => LichessClient(client, ref)), + webSocketChannelFactoryProvider.overrideWith((ref) { + return FakeWebSocketChannelFactory((_) => socket); + }), + ...?overrides, + ], + ); + await tester.pumpWidget(app); + await tester.pump(const Duration(milliseconds: 10)); + await socket.connectionEstablished; + + socket.addIncomingMessages([ + makeFullEvent( + const GameId('qVChCOTc'), + pgn ?? '', + whiteUserName: 'Peter', + blackUserName: 'Steven', + youAre: youAre, + socketVersion: socketVersion, + clock: clock, + ), + ]); + await tester.pump(const Duration(milliseconds: 10)); +} diff --git a/test/view/over_the_board/over_the_board_screen_test.dart b/test/view/over_the_board/over_the_board_screen_test.dart index febe1c1614..720ccfba09 100644 --- a/test/view/over_the_board/over_the_board_screen_test.dart +++ b/test/view/over_the_board/over_the_board_screen_test.dart @@ -9,7 +9,7 @@ import 'package:lichess_mobile/src/model/common/time_increment.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/view/over_the_board/over_the_board_screen.dart'; -import 'package:lichess_mobile/src/widgets/countdown_clock.dart'; +import 'package:lichess_mobile/src/widgets/clock.dart'; import '../../test_helpers.dart'; import '../../test_provider_scope.dart'; @@ -28,11 +28,11 @@ void main() { boardRect.bottomLeft, ); - await playMove(tester, boardRect, 'e2', 'e4'); - await playMove(tester, boardRect, 'f7', 'f6'); - await playMove(tester, boardRect, 'd2', 'd4'); - await playMove(tester, boardRect, 'g7', 'g5'); - await playMove(tester, boardRect, 'd1', 'h5'); + await playMove(tester, 'e2', 'e4'); + await playMove(tester, 'f7', 'f6'); + await playMove(tester, 'd2', 'd4'); + await playMove(tester, 'g7', 'g5'); + await playMove(tester, 'd1', 'h5'); await tester.pumpAndSettle(const Duration(milliseconds: 600)); expect(find.text('Checkmate • White is victorious'), findsOneWidget); @@ -58,13 +58,13 @@ void main() { testWidgets('Game ends when out of time', (tester) async { const time = Duration(seconds: 1); - final boardRect = await initOverTheBoardGame( + await initOverTheBoardGame( tester, TimeIncrement(time.inSeconds, 0), ); - await playMove(tester, boardRect, 'e2', 'e4'); - await playMove(tester, boardRect, 'e7', 'e5'); + await playMove(tester, 'e2', 'e4'); + await playMove(tester, 'e7', 'e5'); // The clock measures system time internally, so we need to actually sleep in order // for the clock to reach 0, instead of using tester.pump() @@ -81,13 +81,13 @@ void main() { testWidgets('Pausing the clock', (tester) async { const time = Duration(seconds: 10); - final boardRect = await initOverTheBoardGame( + await initOverTheBoardGame( tester, TimeIncrement(time.inSeconds, 0), ); - await playMove(tester, boardRect, 'e2', 'e4'); - await playMove(tester, boardRect, 'e7', 'e5'); + await playMove(tester, 'e2', 'e4'); + await playMove(tester, 'e7', 'e5'); await tester.tap(find.byTooltip('Pause')); await tester.pump(); @@ -108,7 +108,7 @@ void main() { expect(activeClock(tester), null); // ... but playing a move resumes the clock - await playMove(tester, boardRect, 'd7', 'd5'); + await playMove(tester, 'd7', 'd5'); expect(activeClock(tester), Side.white); }); @@ -116,13 +116,13 @@ void main() { testWidgets('Go back and Forward', (tester) async { const time = Duration(seconds: 10); - final boardRect = await initOverTheBoardGame( + await initOverTheBoardGame( tester, TimeIncrement(time.inSeconds, 0), ); - await playMove(tester, boardRect, 'e2', 'e4'); - await playMove(tester, boardRect, 'e7', 'e5'); + await playMove(tester, 'e2', 'e4'); + await playMove(tester, 'e7', 'e5'); await tester.tap(find.byTooltip('Previous')); await tester.pumpAndSettle(); @@ -148,7 +148,7 @@ void main() { expect(activeClock(tester), Side.white); - await playMove(tester, boardRect, 'e2', 'e4'); + await playMove(tester, 'e2', 'e4'); expect(find.byKey(const ValueKey('e4-whitepawn')), findsOneWidget); expect(activeClock(tester), Side.black); @@ -166,7 +166,7 @@ void main() { testWidgets('Clock logic', (tester) async { const time = Duration(minutes: 5); - final boardRect = await initOverTheBoardGame( + await initOverTheBoardGame( tester, TimeIncrement(time.inSeconds, 3), ); @@ -176,7 +176,7 @@ void main() { expect(findWhiteClock(tester).timeLeft, time); expect(findBlackClock(tester).timeLeft, time); - await playMove(tester, boardRect, 'e2', 'e4'); + await playMove(tester, 'e2', 'e4'); const moveTime = Duration(milliseconds: 500); await tester.pumpAndSettle(moveTime); @@ -186,7 +186,7 @@ void main() { expect(findWhiteClock(tester).timeLeft, time); expect(findBlackClock(tester).timeLeft, lessThan(time)); - await playMove(tester, boardRect, 'e7', 'e5'); + await playMove(tester, 'e7', 'e5'); await tester.pumpAndSettle(); expect(activeClock(tester), Side.white); diff --git a/test/view/puzzle/puzzle_screen_test.dart b/test/view/puzzle/puzzle_screen_test.dart index 41497a07ab..8845f25da3 100644 --- a/test/view/puzzle/puzzle_screen_test.dart +++ b/test/view/puzzle/puzzle_screen_test.dart @@ -209,9 +209,7 @@ void main() { expect(find.byKey(const Key('g4-blackrook')), findsOneWidget); expect(find.byKey(const Key('h8-whitequeen')), findsOneWidget); - final boardRect = tester.getRect(find.byType(Chessboard)); - - await playMove(tester, boardRect, 'g4', 'h4', orientation: orientation); + await playMove(tester, 'g4', 'h4', orientation: orientation); expect(find.byKey(const Key('h4-blackrook')), findsOneWidget); expect(find.text('Best move!'), findsOneWidget); @@ -222,7 +220,7 @@ void main() { expect(find.byKey(const Key('h4-whitequeen')), findsOneWidget); - await playMove(tester, boardRect, 'b4', 'h4', orientation: orientation); + await playMove(tester, 'b4', 'h4', orientation: orientation); expect(find.byKey(const Key('h4-blackrook')), findsOneWidget); expect(find.text('Success!'), findsOneWidget); @@ -313,9 +311,7 @@ void main() { expect(find.byKey(const Key('g4-blackrook')), findsOneWidget); - final boardRect = tester.getRect(find.byType(Chessboard)); - - await playMove(tester, boardRect, 'g4', 'f4', orientation: orientation); + await playMove(tester, 'g4', 'f4', orientation: orientation); expect( find.text("That's not the move!"), @@ -329,7 +325,7 @@ void main() { // can still play the puzzle expect(find.byKey(const Key('g4-blackrook')), findsOneWidget); - await playMove(tester, boardRect, 'g4', 'h4', orientation: orientation); + await playMove(tester, 'g4', 'h4', orientation: orientation); expect(find.byKey(const Key('h4-blackrook')), findsOneWidget); expect(find.text('Best move!'), findsOneWidget); @@ -338,7 +334,7 @@ void main() { await tester.pump(const Duration(milliseconds: 500)); await tester.pumpAndSettle(); - await playMove(tester, boardRect, 'b4', 'h4', orientation: orientation); + await playMove(tester, 'b4', 'h4', orientation: orientation); expect(find.byKey(const Key('h4-blackrook')), findsOneWidget); expect( diff --git a/test/view/puzzle/storm_screen_test.dart b/test/view/puzzle/storm_screen_test.dart index ac581af0d4..7f6e38996a 100644 --- a/test/view/puzzle/storm_screen_test.dart +++ b/test/view/puzzle/storm_screen_test.dart @@ -96,11 +96,8 @@ void main() { expect(find.byKey(const Key('g8-blackking')), findsOneWidget); - final boardRect = tester.getRect(find.byType(Chessboard)); - await playMove( tester, - boardRect, 'h5', 'h7', orientation: Side.white, @@ -113,7 +110,6 @@ void main() { await playMove( tester, - boardRect, 'e3', 'g1', orientation: Side.white, @@ -143,11 +139,8 @@ void main() { // wait for first move to be played await tester.pump(const Duration(seconds: 1)); - final boardRect = tester.getRect(find.byType(Chessboard)); - await playMove( tester, - boardRect, 'h5', 'h7', orientation: Side.white, @@ -156,7 +149,6 @@ void main() { await tester.pump(const Duration(milliseconds: 500)); await playMove( tester, - boardRect, 'e3', 'g1', orientation: Side.white, @@ -186,9 +178,8 @@ void main() { await tester.pumpWidget(app); await tester.pump(const Duration(seconds: 1)); - final boardRect = tester.getRect(find.byType(Chessboard)); - await playMove(tester, boardRect, 'h5', 'h6'); + await playMove(tester, 'h5', 'h6'); await tester.pump(const Duration(milliseconds: 500)); expect(find.byKey(const Key('h6-blackking')), findsOneWidget); diff --git a/test/view/study/study_screen_test.dart b/test/view/study/study_screen_test.dart index dd80b3a92c..5073bfd240 100644 --- a/test/view/study/study_screen_test.dart +++ b/test/view/study/study_screen_test.dart @@ -1,4 +1,3 @@ -import 'package:chessground/chessground.dart'; import 'package:dartchess/dartchess.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/widgets.dart'; @@ -225,13 +224,12 @@ void main() { // Wait for study to load await tester.pumpAndSettle(); - final boardRect = tester.getRect(find.byType(Chessboard)); - await playMove(tester, boardRect, 'e2', 'e4', orientation: Side.black); + await playMove(tester, 'e2', 'e4', orientation: Side.black); expect(find.byKey(const Key('e2-whitepawn')), findsNothing); expect(find.byKey(const Key('e4-whitepawn')), findsOneWidget); - await playMove(tester, boardRect, 'e7', 'e5', orientation: Side.black); + await playMove(tester, 'e7', 'e5', orientation: Side.black); expect(find.byKey(const Key('e5-blackpawn')), findsOneWidget); expect(find.byKey(const Key('e7-blackpawn')), findsNothing); @@ -239,5 +237,232 @@ void main() { expect(find.text('1. e4'), findsOneWidget); expect(find.text('e5'), findsOneWidget); }); + + testWidgets('Interactive study', (WidgetTester tester) async { + final mockRepository = MockStudyRepository(); + when(() => mockRepository.getStudy(id: testId)).thenAnswer( + (_) async => ( + makeStudy( + chapter: makeChapter( + id: const StudyChapterId('1'), + orientation: Side.white, + gamebook: true, + ), + ), + ''' +[Event "Improve Your Chess Calculation: Candidates| Ex 1: Hard"] +[Site "https://lichess.org/study/xgZOEizT/OfF4eLmN"] +[Result "*"] +[Variant "Standard"] +[ECO "?"] +[Opening "?"] +[Annotator "https://lichess.org/@/RushConnectedPawns"] +[FEN "r1b2rk1/3pbppp/p3p3/1p6/2qBPP2/P1N2R2/1PPQ2PP/R6K w - - 0 1"] +[SetUp "1"] +[UTCDate "2024.10.23"] +[UTCTime "02:04:11"] +[ChapterMode "gamebook"] + +{ We begin our lecture with an 'easy but not easy' example. White to play and win. } +1. Nd5!! { Brilliant! You noticed that the queen on c4 was kinda smothered. } (1. Ne2? { Not much to say after ...Qc7. }) 1... exd5 2. Rc3 Qa4 3. Rg3! { A fork, threatening Rg7 & b3. } { [%csl Gg7][%cal Gg3g7,Gd4g7,Gb2b3] } (3. Rxc8?? { Uh-oh! After Rc8, b3, there is the counter-sac Rxc2, which is winning for black!! } 3... Raxc8 4. b3 Rxc2!! 5. Qxc2 Qxd4 \$19) 3... g6 4. b3 \$18 { ...and the queen is trapped. GGs. If this was too hard for you, don't worry, there will be easier examples. } * + ''' + ), + ); + + final app = await makeTestProviderScopeApp( + tester, + home: const StudyScreen(id: testId), + overrides: [ + studyRepositoryProvider.overrideWith( + (ref) => mockRepository, + ), + ], + ); + await tester.pumpWidget(app); + // Wait for study to load + await tester.pumpAndSettle(); + + const introText = + "We begin our lecture with an 'easy but not easy' example. White to play and win."; + + expect(find.text(introText), findsOneWidget); + + expect( + find.text( + 'Brilliant! You noticed that the queen on c4 was kinda smothered.', + ), + findsNothing, + ); + + // Play a wrong move + await playMove(tester, 'c3', 'a2'); + expect(find.text("That's not the move!"), findsOneWidget); + expect(find.text(introText), findsNothing); + + // Wrong move will be taken back automatically after a short delay + await tester.pump(const Duration(seconds: 1)); + expect(find.text("That's not move!"), findsNothing); + expect(find.text(introText), findsOneWidget); + + // Play another wrong move, but this one has an explicit comment + await playMove(tester, 'c3', 'e2'); + + // If there's an explicit comment, the move is not taken back automatically + // Verify this by waiting the same duration as above + await tester.pump(const Duration(seconds: 1)); + + expect(find.text('Not much to say after ...Qc7.'), findsOneWidget); + expect(find.text(introText), findsNothing); + + await tester.tap(find.byTooltip('Retry')); + await tester.pump(); // Wait for move to be taken back + + expect(find.text(introText), findsOneWidget); + + // Play the correct move + await playMove(tester, 'c3', 'd5'); + + expect( + find.text( + 'Brilliant! You noticed that the queen on c4 was kinda smothered.', + ), + findsOneWidget, + ); + + // The move has an explicit feedback comment, so opponent move should not be played automatically + await tester.pump(const Duration(seconds: 1)); + + expect( + find.text( + 'Brilliant! You noticed that the queen on c4 was kinda smothered.', + ), + findsOneWidget, + ); + + await tester.tap(find.byTooltip('Next')); + await tester.pump(); // Wait for opponent move to be played + + expect( + find.text('What would you play in this position?'), + findsOneWidget, + ); + + await playMove(tester, 'f3', 'c3'); + expect(find.text('Good move'), findsOneWidget); + + // No explicit feedback, so opponent move should be played automatically after delay + await tester.pump(const Duration(seconds: 1)); + + expect( + find.text('What would you play in this position?'), + findsOneWidget, + ); + + await playMove(tester, 'c3', 'g3'); + expect(find.text('A fork, threatening Rg7 & b3.'), findsOneWidget); + + await tester.tap(find.byTooltip('Next')); + await tester.pump(); // Wait for opponent move to be played + + expect( + find.text('What would you play in this position?'), + findsOneWidget, + ); + + await playMove(tester, 'b2', 'b3'); + + expect( + find.text( + "...and the queen is trapped. GGs. If this was too hard for you, don't worry, there will be easier examples.", + ), + findsOneWidget, + ); + + expect(find.byTooltip('Play again'), findsOneWidget); + expect(find.byTooltip('Next chapter'), findsOneWidget); + expect(find.byTooltip('Analysis board'), findsOneWidget); + }); + + testWidgets('Interactive study hints and deviation comments', + (WidgetTester tester) async { + final mockRepository = MockStudyRepository(); + when(() => mockRepository.getStudy(id: testId)).thenAnswer( + (_) async => ( + makeStudy( + chapter: makeChapter( + id: const StudyChapterId('1'), + orientation: Side.white, + gamebook: true, + ), + hints: [ + 'Hint 1', + null, + null, + null, + ].lock, + deviationComments: [ + null, + 'Shown if any move other than d4 is played', + null, + null, + ].lock, + ), + '1. e4 (1. d4 {Shown if d4 is played}) e5 2. Nf3' + ), + ); + + final app = await makeTestProviderScopeApp( + tester, + home: const StudyScreen(id: testId), + overrides: [ + studyRepositoryProvider.overrideWith( + (ref) => mockRepository, + ), + ], + ); + await tester.pumpWidget(app); + // Wait for study to load + await tester.pumpAndSettle(); + + expect(find.text('Get a hint'), findsOneWidget); + expect(find.text('Hint 1'), findsNothing); + + await tester.tap(find.text('Get a hint')); + await tester.pump(); // Wait for hint to be shown + expect(find.text('Hint 1'), findsOneWidget); + expect(find.text('Get a hint'), findsNothing); + + await playMove(tester, 'e2', 'e3'); + expect( + find.text('Shown if any move other than d4 is played'), + findsOneWidget, + ); + await tester.tap(find.byTooltip('Retry')); + await tester.pump(); // Wait for move to be taken back + + await playMove(tester, 'd2', 'd4'); + expect(find.text('Shown if d4 is played'), findsOneWidget); + await tester.tap(find.byTooltip('Retry')); + await tester.pump(); // Wait for move to be taken back + + expect(find.text('View the solution'), findsOneWidget); + await tester.tap(find.byTooltip('View the solution')); + // Wait for correct move and opponent's response to be played + await tester.pump(const Duration(seconds: 1)); + + expect(find.text('Get a hint'), findsNothing); + + // Play a wrong move again - generic feedback should be shown + await playMove(tester, 'a2', 'a3'); + expect(find.text("That's not the move!"), findsOneWidget); + // Wait for wrong move to be taken back + await tester.pump(const Duration(seconds: 1)); + + expect( + find.text('What would you play in this position?'), + findsOneWidget, + ); + expect(find.text("That's not the move!"), findsNothing); + }); }); } diff --git a/test/view/user/perf_stats_screen_test.dart b/test/view/user/perf_stats_screen_test.dart index a2a208f8e4..33013947fd 100644 --- a/test/view/user/perf_stats_screen_test.dart +++ b/test/view/user/perf_stats_screen_test.dart @@ -90,7 +90,7 @@ void main() { ]; // rating - expect(find.text('1500.42'), findsOneWidget); + expect(find.text('1500'), findsOneWidget); for (final val in requiredStatsValues) { expect( diff --git a/test/widgets/clock_test.dart b/test/widgets/clock_test.dart new file mode 100644 index 0000000000..73e20078f6 --- /dev/null +++ b/test/widgets/clock_test.dart @@ -0,0 +1,249 @@ +import 'package:clock/clock.dart' as clock; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lichess_mobile/src/widgets/clock.dart'; + +void main() { + group('Clock', () { + testWidgets('shows milliseconds when time < 1s and active is false', + (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Clock( + timeLeft: Duration(seconds: 1), + active: true, + ), + ), + ); + + expect(find.text('0:01.0', findRichText: true), findsOneWidget); + + await tester.pumpWidget( + const MaterialApp( + home: Clock( + timeLeft: Duration(milliseconds: 988), + active: false, + ), + ), + duration: const Duration(milliseconds: 1000), + ); + + expect(find.text('0:00.98', findRichText: true), findsOneWidget); + }); + }); + + group('CountdownClockBuilder', () { + Widget clockBuilder(BuildContext context, Duration timeLeft) { + final mins = timeLeft.inMinutes.remainder(60); + final secs = timeLeft.inSeconds.remainder(60).toString().padLeft(2, '0'); + final tenths = timeLeft.inMilliseconds.remainder(1000) ~/ 100; + return Text('$mins:$secs.$tenths'); + } + + testWidgets('does not tick when not active', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: CountdownClockBuilder( + timeLeft: const Duration(seconds: 10), + active: false, + builder: clockBuilder, + ), + ), + ); + + expect(find.text('0:10.0'), findsOneWidget); + + await tester.pump(const Duration(seconds: 2)); + expect(find.text('0:10.0'), findsOneWidget); + }); + + testWidgets('ticks when active', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: CountdownClockBuilder( + timeLeft: const Duration(seconds: 10), + active: true, + builder: clockBuilder, + ), + ), + ); + + expect(find.text('0:10.0'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('0:09.9'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('0:09.8'), findsOneWidget); + await tester.pump(const Duration(seconds: 10)); + expect(find.text('0:00.0'), findsOneWidget); + }); + + testWidgets('update time by changing widget configuration', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: CountdownClockBuilder( + timeLeft: const Duration(seconds: 10), + clockUpdatedAt: clock.clock.now(), + active: true, + builder: clockBuilder, + ), + ), + ); + + expect(find.text('0:10.0'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('0:09.9'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('0:09.8'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('0:09.7'), findsOneWidget); + + await tester.pumpWidget( + MaterialApp( + home: CountdownClockBuilder( + timeLeft: const Duration(seconds: 11), + clockUpdatedAt: clock.clock.now(), + active: true, + builder: clockBuilder, + ), + ), + ); + expect(find.text('0:11.0'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('0:10.9'), findsOneWidget); + await tester.pump(const Duration(seconds: 11)); + expect(find.text('0:00.0'), findsOneWidget); + }); + + testWidgets('do not update if clockUpdatedAt is same', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: CountdownClockBuilder( + timeLeft: const Duration(seconds: 10), + active: true, + builder: clockBuilder, + ), + ), + ); + + expect(find.text('0:10.0'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('0:09.9'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('0:09.8'), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('0:09.7'), findsOneWidget); + + await tester.pumpWidget( + MaterialApp( + home: CountdownClockBuilder( + timeLeft: const Duration(seconds: 11), + active: true, + builder: clockBuilder, + ), + ), + ); + + expect(find.text('0:09.7'), findsOneWidget); + await tester.pump(const Duration(seconds: 10)); + expect(find.text('0:00.0'), findsOneWidget); + }); + + testWidgets('stops when active become false', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: CountdownClockBuilder( + timeLeft: const Duration(seconds: 10), + active: true, + builder: clockBuilder, + ), + ), + ); + + expect(find.text('0:10.0', findRichText: true), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('0:09.9', findRichText: true), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('0:09.8', findRichText: true), findsOneWidget); + + // clock is rebuilt with same time but inactive: + // the time is kept and the clock stops counting the elapsed time + await tester.pumpWidget( + MaterialApp( + home: CountdownClockBuilder( + timeLeft: const Duration(seconds: 10), + active: false, + builder: clockBuilder, + ), + ), + duration: const Duration(milliseconds: 100), + ); + expect(find.text('0:09.7', findRichText: true), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('0:09.7', findRichText: true), findsOneWidget); + }); + + testWidgets('starts with a delay if set', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: CountdownClockBuilder( + timeLeft: const Duration(seconds: 10), + active: true, + delay: const Duration(milliseconds: 250), + builder: clockBuilder, + ), + ), + ); + expect(find.text('0:10.0', findRichText: true), findsOneWidget); + await tester.pump(const Duration(milliseconds: 250)); + expect(find.text('0:10.0', findRichText: true), findsOneWidget); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('0:09.9', findRichText: true), findsOneWidget); + }); + + testWidgets('compensates for UI lag', (WidgetTester tester) async { + final now = clock.clock.now(); + await tester.pump(const Duration(milliseconds: 100)); + + await tester.pumpWidget( + MaterialApp( + home: CountdownClockBuilder( + timeLeft: const Duration(seconds: 10), + active: true, + delay: const Duration(milliseconds: 200), + clockUpdatedAt: now, + builder: clockBuilder, + ), + ), + ); + expect(find.text('0:10.0', findRichText: true), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('0:10.0', findRichText: true), findsOneWidget); + + // delay was 200ms but UI lagged 100ms so with the compensation the clock has started already + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('0:09.9', findRichText: true), findsOneWidget); + }); + + testWidgets('UI lag negative start delay', (WidgetTester tester) async { + final now = clock.clock.now(); + await tester.pump(const Duration(milliseconds: 200)); + + await tester.pumpWidget( + MaterialApp( + home: CountdownClockBuilder( + timeLeft: const Duration(seconds: 10), + active: true, + delay: const Duration(milliseconds: 100), + clockUpdatedAt: now, + builder: clockBuilder, + ), + ), + ); + // delay was 100ms but UI lagged 200ms so the clock time is already 100ms ahead + expect(find.text('0:09.9', findRichText: true), findsOneWidget); + }); + }); +} From 66d561a6fb81a8622d218db63e5a19bf04decdb2 Mon Sep 17 00:00:00 2001 From: Jimima Date: Wed, 20 Nov 2024 17:19:02 +0000 Subject: [PATCH 18/25] Merge fix --- lib/src/model/settings/board_preferences.dart | 16 ++++------ lib/src/view/game/game_player.dart | 29 ++----------------- 2 files changed, 8 insertions(+), 37 deletions(-) diff --git a/lib/src/model/settings/board_preferences.dart b/lib/src/model/settings/board_preferences.dart index 4d4d66eadd..4a3f50a646 100644 --- a/lib/src/model/settings/board_preferences.dart +++ b/lib/src/model/settings/board_preferences.dart @@ -81,19 +81,16 @@ class BoardPreferences extends _$BoardPreferences ), ); } - + Future setDragTargetKind(DragTargetKind dragTargetKind) { return save(state.copyWith(dragTargetKind: dragTargetKind)); } - - Future setMaterialDifferenceFormat( - MaterialDifferenceFormat materialDifferenceFormat) { - return save(state.copyWith(materialDifferenceFormat: materialDifferenceFormat)); - } - Future setClockPosition(ClockPosition clockPosition) { + Future setMaterialDifferenceFormat( + MaterialDifferenceFormat materialDifferenceFormat, + ) { return save( - state.copyWith(clockPosition: clockPosition), + state.copyWith(materialDifferenceFormat: materialDifferenceFormat), ); } @@ -129,9 +126,6 @@ class BoardPrefs with _$BoardPrefs implements Serializable { required bool pieceAnimation, required MaterialDifferenceFormat materialDifferenceFormat, required ClockPosition clockPosition, - required bool showMaterialDifference, - required ClockPosition clockPosition, - @JsonKey( defaultValue: PieceShiftMethod.either, unknownEnumValue: PieceShiftMethod.either, diff --git a/lib/src/view/game/game_player.dart b/lib/src/view/game/game_player.dart index 9691cfae76..00bd5bc588 100644 --- a/lib/src/view/game/game_player.dart +++ b/lib/src/view/game/game_player.dart @@ -153,33 +153,10 @@ class GamePlayer extends StatelessWidget { else if (materialDiff != null) MaterialDifferenceDisplay( materialDiff: materialDiff!, - materialDifferenceFormat: materialDifferenceFormat!,), - Row( - mainAxisAlignment: clockPosition == ClockPosition.right - ? MainAxisAlignment.start - : MainAxisAlignment.end, - children: [ - for (final role in Role.values) - for (int i = 0; i < materialDiff!.pieces[role]!; i++) - Icon( - _iconByRole[role], - size: 13, - color: Colors.grey, - ), - const SizedBox(width: 3), - Text( - style: const TextStyle( - fontSize: 13, - color: Colors.grey, - ), - materialDiff != null && materialDiff!.score > 0 - ? '+${materialDiff!.score}' - : '', - ), - ], + materialDifferenceFormat: materialDifferenceFormat!, ), - // to avoid shifts use an empty text widget - const Text('', style: TextStyle(fontSize: 13)), + // to avoid shifts use an empty text widget + const Text('', style: TextStyle(fontSize: 13)), ], ); From c3f98e2cac79affcc8fc05cd3ee56377b22642fd Mon Sep 17 00:00:00 2001 From: Jimima Date: Wed, 20 Nov 2024 17:38:05 +0000 Subject: [PATCH 19/25] Refactor --- lib/src/model/game/material_diff.dart | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/lib/src/model/game/material_diff.dart b/lib/src/model/game/material_diff.dart index 30cd29cf33..52623a11c0 100644 --- a/lib/src/model/game/material_diff.dart +++ b/lib/src/model/game/material_diff.dart @@ -47,12 +47,9 @@ class MaterialDiff with _$MaterialDiff { IMap startingCount, IMap subtractCount, ) { - IMap capturedPieces = IMap(); - startingCount.forEach((role, count) { - capturedPieces = - capturedPieces.add(role, count - (subtractCount.get(role) ?? 0)); - }); - return capturedPieces; + return startingCount.map( + (role, count) => MapEntry(role, count - (subtractCount.get(role) ?? 0)), + ); } final IMap blackCapturedPieces = From b6d4599e29313deff72da5517f70153a21fe721e Mon Sep 17 00:00:00 2001 From: Jimima Date: Wed, 20 Nov 2024 18:12:59 +0000 Subject: [PATCH 20/25] Modified null handling --- lib/src/view/game/game_player.dart | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/lib/src/view/game/game_player.dart b/lib/src/view/game/game_player.dart index 00bd5bc588..b46198dea2 100644 --- a/lib/src/view/game/game_player.dart +++ b/lib/src/view/game/game_player.dart @@ -153,7 +153,7 @@ class GamePlayer extends StatelessWidget { else if (materialDiff != null) MaterialDifferenceDisplay( materialDiff: materialDiff!, - materialDifferenceFormat: materialDifferenceFormat!, + materialDifferenceFormat: materialDifferenceFormat, ), // to avoid shifts use an empty text widget const Text('', style: TextStyle(fontSize: 13)), @@ -336,7 +336,7 @@ class MaterialDifferenceDisplay extends StatelessWidget { }); final MaterialDiffSide materialDiff; - final MaterialDifferenceFormat materialDifferenceFormat; + final MaterialDifferenceFormat? materialDifferenceFormat; @override Widget build(BuildContext context) { @@ -345,9 +345,8 @@ class MaterialDifferenceDisplay extends StatelessWidget { ? materialDiff.capturedPieces : materialDiff.pieces); - return !materialDifferenceFormat.visible - ? const SizedBox.shrink() - : Row( + return materialDifferenceFormat?.visible ?? true + ? Row( children: [ for (final role in Role.values) for (int i = 0; i < piecesToRender[role]!; i++) @@ -365,7 +364,8 @@ class MaterialDifferenceDisplay extends StatelessWidget { materialDiff.score > 0 ? '+${materialDiff.score}' : '', ), ], - ); + ) + : const SizedBox.shrink(); } } From 18297cebc2d7e0ccceee295536037ec9a2377134 Mon Sep 17 00:00:00 2001 From: Jimima Date: Wed, 20 Nov 2024 19:57:31 +0000 Subject: [PATCH 21/25] Removed duplicates from board settings page --- .../view/settings/board_settings_screen.dart | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/lib/src/view/settings/board_settings_screen.dart b/lib/src/view/settings/board_settings_screen.dart index d126acc24b..f57d06ae24 100644 --- a/lib/src/view/settings/board_settings_screen.dart +++ b/lib/src/view/settings/board_settings_screen.dart @@ -257,26 +257,6 @@ class _Body extends ConsumerWidget { .toggleCoordinates(); }, ), - SwitchSettingTile( - title: Text(context.l10n.mobilePrefMagnifyDraggedPiece), - value: boardPrefs.magnifyDraggedPiece, - onChanged: (value) { - ref - .read(boardPreferencesProvider.notifier) - .toggleMagnifyDraggedPiece(); - }, - ), - SwitchSettingTile( - title: Text( - context.l10n.preferencesPieceAnimation, - ), - value: boardPrefs.pieceAnimation, - onChanged: (value) { - ref - .read(boardPreferencesProvider.notifier) - .togglePieceAnimation(); - }, - ), SettingsListTile( settingsLabel: const Text('Material'), //TODO: l10n settingsValue: boardPrefs.materialDifferenceFormat From 9a84086183886f7ba3291f879fb7661501a66b9b Mon Sep 17 00:00:00 2001 From: Jimima Date: Wed, 20 Nov 2024 20:22:27 +0000 Subject: [PATCH 22/25] Removed from game settings (also duplicates) --- lib/src/view/game/game_settings.dart | 42 ---------------------------- 1 file changed, 42 deletions(-) diff --git a/lib/src/view/game/game_settings.dart b/lib/src/view/game/game_settings.dart index 42d6bf0290..4283166cc4 100644 --- a/lib/src/view/game/game_settings.dart +++ b/lib/src/view/game/game_settings.dart @@ -69,30 +69,6 @@ class GameSettings extends ConsumerWidget { }, orElse: () => [], ), - SwitchSettingTile( - // TODO: Add l10n - title: const Text('Shape drawing'), - subtitle: const Text( - 'Draw shapes using two fingers.', - maxLines: 5, - textAlign: TextAlign.justify, - ), - value: boardPrefs.enableShapeDrawings, - onChanged: (value) { - ref - .read(boardPreferencesProvider.notifier) - .toggleEnableShapeDrawings(); - }, - ), - SwitchSettingTile( - title: Text( - context.l10n.preferencesPieceAnimation, - ), - value: boardPrefs.pieceAnimation, - onChanged: (value) { - ref.read(boardPreferencesProvider.notifier).togglePieceAnimation(); - }, - ), PlatformListTile( // TODO translate title: const Text('Board settings'), @@ -122,24 +98,6 @@ class GameSettings extends ConsumerWidget { ref.read(gamePreferencesProvider.notifier).toggleBlindfoldMode(); }, ), - SettingsListTile( - settingsLabel: const Text('Material'), //TODO: l10n - settingsValue: boardPrefs.materialDifferenceFormat - .l10n(AppLocalizations.of(context)), - onTap: () { - showChoicePicker( - context, - choices: MaterialDifferenceFormat.values, - selectedItem: boardPrefs.materialDifferenceFormat, - labelBuilder: (t) => Text(t.l10n(AppLocalizations.of(context))), - onSelectedItemChanged: (MaterialDifferenceFormat? value) => ref - .read(boardPreferencesProvider.notifier) - .setMaterialDifferenceFormat( - value ?? MaterialDifferenceFormat.materialDifference, - ), - ); - }, - ), ], ); } From 38150373572f76bcf535e68d5e7797d32adde783 Mon Sep 17 00:00:00 2001 From: Jimima Date: Wed, 20 Nov 2024 20:33:44 +0000 Subject: [PATCH 23/25] Appeased linter --- lib/src/view/game/game_settings.dart | 5 ----- 1 file changed, 5 deletions(-) diff --git a/lib/src/view/game/game_settings.dart b/lib/src/view/game/game_settings.dart index 4283166cc4..d8eed2bd0c 100644 --- a/lib/src/view/game/game_settings.dart +++ b/lib/src/view/game/game_settings.dart @@ -1,19 +1,15 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:lichess_mobile/l10n/l10n.dart'; import 'package:lichess_mobile/src/model/account/account_preferences.dart'; import 'package:lichess_mobile/src/model/common/id.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/settings/board_preferences.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/utils/navigation.dart'; import 'package:lichess_mobile/src/view/settings/board_settings_screen.dart'; import 'package:lichess_mobile/src/widgets/adaptive_bottom_sheet.dart'; import 'package:lichess_mobile/src/widgets/list.dart'; import 'package:lichess_mobile/src/widgets/settings.dart'; - -import '../../widgets/adaptive_choice_picker.dart'; import 'game_screen_providers.dart'; class GameSettings extends ConsumerWidget { @@ -25,7 +21,6 @@ class GameSettings extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final gamePrefs = ref.watch(gamePreferencesProvider); final userPrefsAsync = ref.watch(userGamePrefsProvider(id)); - final boardPrefs = ref.watch(boardPreferencesProvider); return BottomSheetScrollableContainer( children: [ From 5a6db91868ec8669d5c261ca95e28cfd11b41395 Mon Sep 17 00:00:00 2001 From: Jimima Date: Thu, 21 Nov 2024 10:35:36 +0000 Subject: [PATCH 24/25] Added alternate picker on ios --- .../view/settings/board_settings_screen.dart | 64 +++++++++++++++---- 1 file changed, 50 insertions(+), 14 deletions(-) diff --git a/lib/src/view/settings/board_settings_screen.dart b/lib/src/view/settings/board_settings_screen.dart index f57d06ae24..da3c8911aa 100644 --- a/lib/src/view/settings/board_settings_screen.dart +++ b/lib/src/view/settings/board_settings_screen.dart @@ -262,20 +262,26 @@ class _Body extends ConsumerWidget { settingsValue: boardPrefs.materialDifferenceFormat .l10n(AppLocalizations.of(context)), onTap: () { - showChoicePicker( - context, - choices: MaterialDifferenceFormat.values, - selectedItem: boardPrefs.materialDifferenceFormat, - labelBuilder: (t) => - Text(t.l10n(AppLocalizations.of(context))), - onSelectedItemChanged: (MaterialDifferenceFormat? value) => - ref - .read(boardPreferencesProvider.notifier) - .setMaterialDifferenceFormat( - value ?? - MaterialDifferenceFormat.materialDifference, - ), - ); + if (Theme.of(context).platform == TargetPlatform.android) { + showChoicePicker( + context, + choices: MaterialDifferenceFormat.values, + selectedItem: boardPrefs.materialDifferenceFormat, + labelBuilder: (t) => Text(t.label), + onSelectedItemChanged: + (MaterialDifferenceFormat? value) => ref + .read(boardPreferencesProvider.notifier) + .setMaterialDifferenceFormat(value ?? + MaterialDifferenceFormat.materialDifference), + ); + } else { + pushPlatformRoute( + context, + title: 'Clock position', + builder: (context) => + const MaterialDifferenceFormatScreen(), + ); + } }, ), ], @@ -351,6 +357,36 @@ class BoardClockPositionScreen extends ConsumerWidget { } } +class MaterialDifferenceFormatScreen extends ConsumerWidget { + const MaterialDifferenceFormatScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final materialDifferenceFormat = ref.watch( + boardPreferencesProvider + .select((state) => state.materialDifferenceFormat), + ); + void onChanged(MaterialDifferenceFormat? value) => + ref.read(boardPreferencesProvider.notifier).setMaterialDifferenceFormat( + value ?? MaterialDifferenceFormat.materialDifference); + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar(), + child: SafeArea( + child: ListView( + children: [ + ChoicePicker( + choices: MaterialDifferenceFormat.values, + selectedItem: materialDifferenceFormat, + titleBuilder: (t) => Text(t.label), + onSelectedItemChanged: onChanged, + ), + ], + ), + ), + ); + } +} + class DragTargetKindSettingsScreen extends ConsumerWidget { const DragTargetKindSettingsScreen({super.key}); From 442a4fd6a2d1d6a06e69ce73dd415fd7a166b8b4 Mon Sep 17 00:00:00 2001 From: Jimima Date: Thu, 21 Nov 2024 10:43:54 +0000 Subject: [PATCH 25/25] Linter fix --- lib/src/view/settings/board_settings_screen.dart | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/src/view/settings/board_settings_screen.dart b/lib/src/view/settings/board_settings_screen.dart index da3c8911aa..79a5d2f1fa 100644 --- a/lib/src/view/settings/board_settings_screen.dart +++ b/lib/src/view/settings/board_settings_screen.dart @@ -271,8 +271,10 @@ class _Body extends ConsumerWidget { onSelectedItemChanged: (MaterialDifferenceFormat? value) => ref .read(boardPreferencesProvider.notifier) - .setMaterialDifferenceFormat(value ?? - MaterialDifferenceFormat.materialDifference), + .setMaterialDifferenceFormat( + value ?? + MaterialDifferenceFormat.materialDifference, + ), ); } else { pushPlatformRoute( @@ -368,7 +370,8 @@ class MaterialDifferenceFormatScreen extends ConsumerWidget { ); void onChanged(MaterialDifferenceFormat? value) => ref.read(boardPreferencesProvider.notifier).setMaterialDifferenceFormat( - value ?? MaterialDifferenceFormat.materialDifference); + value ?? MaterialDifferenceFormat.materialDifference, + ); return CupertinoPageScaffold( navigationBar: const CupertinoNavigationBar(), child: SafeArea(