diff --git a/lib/src/model/analysis/analysis_controller.dart b/lib/src/model/analysis/analysis_controller.dart index 830225ab88..91b36e52de 100644 --- a/lib/src/model/analysis/analysis_controller.dart +++ b/lib/src/model/analysis/analysis_controller.dart @@ -17,6 +17,7 @@ import 'package:lichess_mobile/src/model/common/service/sound_service.dart'; import 'package:lichess_mobile/src/model/common/uci.dart'; import 'package:lichess_mobile/src/model/engine/evaluation_service.dart'; import 'package:lichess_mobile/src/model/engine/work.dart'; +import 'package:lichess_mobile/src/model/game/game_repository_providers.dart'; import 'package:lichess_mobile/src/model/game/player.dart'; import 'package:lichess_mobile/src/utils/rate_limit.dart'; import 'package:lichess_mobile/src/view/engine/engine_gauge.dart'; @@ -26,69 +27,64 @@ import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'analysis_controller.freezed.dart'; part 'analysis_controller.g.dart'; -const standaloneAnalysisId = StringId('standalone_analysis'); -const standaloneOpeningExplorerId = StringId('standalone_opening_explorer'); - final _dateFormat = DateFormat('yyyy.MM.dd'); -/// Whether the analysis is a standalone analysis (not a lichess game analysis). -bool _isStandaloneAnalysis(StringId id) => - id == standaloneAnalysisId || id == standaloneOpeningExplorerId; +typedef StandaloneAnalysis = ({ + String pgn, + Variant variant, + bool isComputerAnalysisAllowed, +}); @freezed class AnalysisOptions with _$AnalysisOptions { const AnalysisOptions._(); + + @Assert('standalone != null || gameId != null') const factory AnalysisOptions({ - /// The ID of the analysis. Can be a game ID or a standalone ID. - required StringId id, - required bool isLocalEvaluationAllowed, required Side orientation, - required Variant variant, + StandaloneAnalysis? standalone, + GameId? gameId, int? initialMoveCursor, - LightOpening? opening, - Division? division, - - /// Optional server analysis to display player stats. - ({PlayerAnalysis white, PlayerAnalysis black})? serverAnalysis, }) = _AnalysisOptions; - /// Whether the analysis is for a lichess game. - bool get isLichessGameAnalysis => gameAnyId != null; - - /// The game ID of the analysis, if it's a lichess game. - GameAnyId? get gameAnyId => - _isStandaloneAnalysis(id) ? null : GameAnyId(id.value); + bool get isLichessGameAnalysis => gameId != null; } @riverpod class AnalysisController extends _$AnalysisController implements PgnTreeNotifier { late Root _root; + late Variant _variant; final _engineEvalDebounce = Debouncer(const Duration(milliseconds: 150)); Timer? _startEngineEvalTimer; @override - AnalysisState build(String pgn, AnalysisOptions options) { + Future build(AnalysisOptions options) async { final evaluationService = ref.watch(evaluationServiceProvider); final serverAnalysisService = ref.watch(serverAnalysisServiceProvider); - final isEngineAllowed = options.isLocalEvaluationAllowed && - engineSupportedVariants.contains(options.variant); - - ref.onDispose(() { - _startEngineEvalTimer?.cancel(); - _engineEvalDebounce.dispose(); - if (isEngineAllowed) { - evaluationService.disposeEngine(); - } - serverAnalysisService.lastAnalysisEvent - .removeListener(_listenToServerAnalysisEvents); - }); - - serverAnalysisService.lastAnalysisEvent - .addListener(_listenToServerAnalysisEvents); + late final String pgn; + late final LightOpening? opening; + late final ({PlayerAnalysis white, PlayerAnalysis black})? serverAnalysis; + late final Division? division; + + if (options.gameId != null) { + final game = + await ref.watch(archivedGameProvider(id: options.gameId!).future); + _variant = game.meta.variant; + pgn = game.makePgn(); + opening = game.data.opening; + serverAnalysis = game.serverAnalysis; + division = game.meta.division; + } else { + _variant = options.standalone!.variant; + pgn = options.standalone!.pgn; + opening = null; + serverAnalysis = null; + division = null; + } UciPath path = UciPath.empty; Move? lastMove; @@ -113,7 +109,9 @@ class AnalysisController extends _$AnalysisController final pgnHeaders = IMap(game.headers); final rootComments = IList(game.comments.map((c) => PgnComment.fromPgn(c))); - Future? openingFuture; + final isComputerAnalysisAllowed = options.isLichessGameAnalysis + ? pgnHeaders['Result'] != '*' + : options.standalone!.isComputerAnalysisAllowed; _root = Root.fromPgnGame( game, @@ -127,17 +125,9 @@ class AnalysisController extends _$AnalysisController path = path + branch.id; lastMove = branch.sanMove.move; } - if (isMainline && options.opening == null && branch.position.ply <= 5) { - openingFuture = _fetchOpening(root, path); - } }, ); - // wait for the opening to be fetched to recompute the branch opening - openingFuture?.then((_) { - _setPath(state.currentPath); - }); - final currentPath = options.initialMoveCursor == null ? _root.mainlinePath : path; final currentNode = _root.nodeAt(currentPath); @@ -146,9 +136,24 @@ class AnalysisController extends _$AnalysisController // analysis preferences change final prefs = ref.read(analysisPreferencesProvider); + final isEngineAllowed = engineSupportedVariants.contains(_variant); + + ref.onDispose(() { + _startEngineEvalTimer?.cancel(); + _engineEvalDebounce.dispose(); + if (isEngineAllowed) { + evaluationService.disposeEngine(); + } + serverAnalysisService.lastAnalysisEvent + .removeListener(_listenToServerAnalysisEvents); + }); + + serverAnalysisService.lastAnalysisEvent + .addListener(_listenToServerAnalysisEvents); + final analysisState = AnalysisState( - variant: options.variant, - id: options.id, + variant: _variant, + gameId: options.gameId, currentPath: currentPath, isOnMainline: _root.isOnMainline(currentPath), root: _root.view, @@ -157,13 +162,13 @@ class AnalysisController extends _$AnalysisController pgnRootComments: rootComments, lastMove: lastMove, pov: options.orientation, - contextOpening: options.opening, - isLocalEvaluationAllowed: options.isLocalEvaluationAllowed, + contextOpening: opening, + isComputerAnalysisAllowed: isComputerAnalysisAllowed, + isComputerAnalysisEnabled: prefs.enableComputerAnalysis, isLocalEvaluationEnabled: prefs.enableLocalEvaluation, - displayMode: DisplayMode.moves, - playersAnalysis: options.serverAnalysis, - acplChartData: - options.serverAnalysis != null ? _makeAcplChartData() : null, + playersAnalysis: serverAnalysis, + acplChartData: serverAnalysis != null ? _makeAcplChartData() : null, + division: division, ); if (analysisState.isEngineAvailable) { @@ -186,23 +191,25 @@ class AnalysisController extends _$AnalysisController } EvaluationContext get _evaluationContext => EvaluationContext( - variant: options.variant, + variant: _variant, initialPosition: _root.position, ); - void onUserMove(NormalMove move) { - if (!state.position.isLegal(move)) return; + void onUserMove(NormalMove move, {bool shouldReplace = false}) { + if (!state.requireValue.position.isLegal(move)) return; - if (isPromotionPawnMove(state.position, move)) { - state = state.copyWith(promotionMove: move); + if (isPromotionPawnMove(state.requireValue.position, move)) { + state = AsyncValue.data( + state.requireValue.copyWith(promotionMove: move), + ); return; } - // For the opening explorer, last played move should always be the mainline - final shouldReplace = options.id == standaloneOpeningExplorerId; - - final (newPath, isNewNode) = - _root.addMoveAt(state.currentPath, move, replace: shouldReplace); + final (newPath, isNewNode) = _root.addMoveAt( + state.requireValue.currentPath, + move, + replace: shouldReplace, + ); if (newPath != null) { _setPath( newPath, @@ -214,10 +221,10 @@ class AnalysisController extends _$AnalysisController void onPromotionSelection(Role? role) { if (role == null) { - state = state.copyWith(promotionMove: null); + state = AsyncData(state.requireValue.copyWith(promotionMove: null)); return; } - final promotionMove = state.promotionMove; + final promotionMove = state.requireValue.promotionMove; if (promotionMove != null) { final promotion = promotionMove.withPromotion(role); onUserMove(promotion); @@ -225,9 +232,11 @@ class AnalysisController extends _$AnalysisController } void userNext() { - if (!state.currentNode.hasChild) return; + final curState = state.requireValue; + if (!curState.currentNode.hasChild) return; _setPath( - state.currentPath + _root.nodeAt(state.currentPath).children.first.id, + curState.currentPath + + _root.nodeAt(curState.currentPath).children.first.id, replaying: true, ); } @@ -256,11 +265,12 @@ class AnalysisController extends _$AnalysisController } void toggleBoard() { - state = state.copyWith(pov: state.pov.opposite); + final curState = state.requireValue; + state = AsyncData(curState.copyWith(pov: curState.pov.opposite)); } void userPrevious() { - _setPath(state.currentPath.penultimate, replaying: true); + _setPath(state.requireValue.currentPath.penultimate, replaying: true); } @override @@ -276,12 +286,12 @@ class AnalysisController extends _$AnalysisController _root.isOnMainline(path) ? node.children.skip(1) : node.children; for (final child in childrenToShow) { - child.isHidden = false; + child.isCollapsed = false; for (final grandChild in child.children) { - grandChild.isHidden = false; + grandChild.isCollapsed = false; } } - state = state.copyWith(root: _root.view); + state = AsyncData(state.requireValue.copyWith(root: _root.view)); } @override @@ -289,18 +299,21 @@ class AnalysisController extends _$AnalysisController final node = _root.nodeAt(path); for (final child in node.children) { - child.isHidden = true; + child.isCollapsed = true; } - state = state.copyWith(root: _root.view); + state = AsyncData(state.requireValue.copyWith(root: _root.view)); } @override void promoteVariation(UciPath path, bool toMainline) { _root.promoteAt(path, toMainline: toMainline); - state = state.copyWith( - isOnMainline: _root.isOnMainline(state.currentPath), - root: _root.view, + final curState = state.requireValue; + state = AsyncData( + curState.copyWith( + isOnMainline: _root.isOnMainline(curState.currentPath), + root: _root.view, + ), ); } @@ -310,16 +323,42 @@ class AnalysisController extends _$AnalysisController _setPath(path.penultimate, shouldRecomputeRootView: true); } + /// Toggles the computer analysis on/off. + /// + /// Acts both on local evaluation and server analysis. + Future toggleComputerAnalysis() async { + await ref + .read(analysisPreferencesProvider.notifier) + .toggleEnableComputerAnalysis(); + + final curState = state.requireValue; + final engineWasAvailable = curState.isEngineAvailable; + + state = AsyncData( + curState.copyWith( + isComputerAnalysisEnabled: !curState.isComputerAnalysisEnabled, + ), + ); + + final computerAllowed = state.requireValue.isComputerAnalysisEnabled; + if (!computerAllowed && engineWasAvailable) { + toggleLocalEvaluation(); + } + } + + /// Toggles the local evaluation on/off. Future toggleLocalEvaluation() async { - ref + await ref .read(analysisPreferencesProvider.notifier) .toggleEnableLocalEvaluation(); - state = state.copyWith( - isLocalEvaluationEnabled: !state.isLocalEvaluationEnabled, + state = AsyncData( + state.requireValue.copyWith( + isLocalEvaluationEnabled: !state.requireValue.isLocalEvaluationEnabled, + ), ); - if (state.isEngineAvailable) { + if (state.requireValue.isEngineAvailable) { final prefs = ref.read(analysisPreferencesProvider); await ref.read(evaluationServiceProvider).initEngine( _evaluationContext, @@ -349,9 +388,12 @@ class AnalysisController extends _$AnalysisController _root.updateAll((node) => node.eval = null); - state = state.copyWith( - currentNode: - AnalysisCurrentNode.fromNode(_root.nodeAt(state.currentPath)), + final curState = state.requireValue; + state = AsyncData( + curState.copyWith( + currentNode: + AnalysisCurrentNode.fromNode(_root.nodeAt(curState.currentPath)), + ), ); _startEngineEval(); @@ -373,21 +415,14 @@ class AnalysisController extends _$AnalysisController } void updatePgnHeader(String key, String value) { - final headers = state.pgnHeaders.add(key, value); - state = state.copyWith(pgnHeaders: headers); - } - - void setDisplayMode(DisplayMode mode) { - state = state.copyWith(displayMode: mode); + final headers = state.requireValue.pgnHeaders.add(key, value); + state = AsyncData(state.requireValue.copyWith(pgnHeaders: headers)); } Future requestServerAnalysis() { - if (state.canRequestServerAnalysis) { + if (state.requireValue.canRequestServerAnalysis) { final service = ref.read(serverAnalysisServiceProvider); - return service.requestAnalysis( - options.id as GameAnyId, - options.orientation, - ); + return service.requestAnalysis(options.gameId!, options.orientation); } return Future.error('Cannot request server analysis'); } @@ -405,12 +440,13 @@ class AnalysisController extends _$AnalysisController /// Makes a full PGN string (including headers and comments) of the current game state. String makeExportPgn() { - return _root.makePgn(state.pgnHeaders, state.pgnRootComments); + final curState = state.requireValue; + return _root.makePgn(curState.pgnHeaders, curState.pgnRootComments); } /// Makes a PGN string up to the current node only. String makeCurrentNodePgn() { - final nodes = _root.branchesOn(state.currentPath); + final nodes = _root.branchesOn(state.requireValue.currentPath); return nodes.map((node) => node.sanMove.san).join(' '); } @@ -420,15 +456,16 @@ class AnalysisController extends _$AnalysisController bool shouldRecomputeRootView = false, bool replaying = false, }) { - final pathChange = state.currentPath != path; + final curState = state.requireValue; + final pathChange = curState.currentPath != path; final (currentNode, opening) = _nodeOpeningAt(_root, path); // always show variation if the user plays a move if (shouldForceShowVariation && currentNode is Branch && - currentNode.isHidden) { + currentNode.isCollapsed) { _root.updateAt(path, (node) { - if (node is Branch) node.isHidden = false; + if (node is Branch) node.isCollapsed = false; }); } @@ -437,9 +474,9 @@ class AnalysisController extends _$AnalysisController // or a variation is hidden/shown final rootView = shouldForceShowVariation || shouldRecomputeRootView ? _root.view - : state.root; + : curState.root; - final isForward = path.size > state.currentPath.size; + final isForward = path.size > curState.currentPath.size; if (currentNode is Branch) { if (!replaying) { if (isForward) { @@ -462,65 +499,72 @@ class AnalysisController extends _$AnalysisController } if (currentNode.opening == null && currentNode.position.ply <= 30) { - _fetchOpening(_root, path); + _fetchOpening(_root, path).then((opening) { + if (opening != null) { + _root.updateAt(path, (node) => node.opening = opening); + + final curState = state.requireValue; + if (curState.currentPath == path) { + state = AsyncData( + curState.copyWith( + currentNode: AnalysisCurrentNode.fromNode(_root.nodeAt(path)), + ), + ); + } + } + }); } - state = state.copyWith( - currentPath: path, - isOnMainline: _root.isOnMainline(path), - currentNode: AnalysisCurrentNode.fromNode(currentNode), - currentBranchOpening: opening, - lastMove: currentNode.sanMove.move, - promotionMove: null, - root: rootView, + state = AsyncData( + curState.copyWith( + currentPath: path, + isOnMainline: _root.isOnMainline(path), + currentNode: AnalysisCurrentNode.fromNode(currentNode), + currentBranchOpening: opening, + lastMove: currentNode.sanMove.move, + promotionMove: null, + root: rootView, + ), ); } else { - state = state.copyWith( - currentPath: path, - isOnMainline: _root.isOnMainline(path), - currentNode: AnalysisCurrentNode.fromNode(currentNode), - currentBranchOpening: opening, - lastMove: null, - promotionMove: null, - root: rootView, + state = AsyncData( + curState.copyWith( + currentPath: path, + isOnMainline: _root.isOnMainline(path), + currentNode: AnalysisCurrentNode.fromNode(currentNode), + currentBranchOpening: opening, + lastMove: null, + promotionMove: null, + root: rootView, + ), ); } - if (pathChange && state.isEngineAvailable) { + if (pathChange && curState.isEngineAvailable) { _debouncedStartEngineEval(); } } - Future _fetchOpening(Node fromNode, UciPath path) async { - if (!kOpeningAllowedVariants.contains(options.variant)) return; + Future _fetchOpening(Node fromNode, UciPath path) async { + if (!kOpeningAllowedVariants.contains(_variant)) return null; final moves = fromNode.branchesOn(path).map((node) => node.sanMove.move); - if (moves.isEmpty) return; - if (moves.length > 40) return; - - final opening = - await ref.read(openingServiceProvider).fetchFromMoves(moves); + if (moves.isEmpty) return null; + if (moves.length > 40) return null; - if (opening != null) { - fromNode.updateAt(path, (node) => node.opening = opening); - - if (state.currentPath == path) { - state = state.copyWith( - currentNode: AnalysisCurrentNode.fromNode(fromNode.nodeAt(path)), - ); - } - } + return ref.read(openingServiceProvider).fetchFromMoves(moves); } void _startEngineEval() { - if (!state.isEngineAvailable) return; + final curState = state.requireValue; + if (!curState.isEngineAvailable) return; ref .read(evaluationServiceProvider) .start( - state.currentPath, - _root.branchesOn(state.currentPath).map(Step.fromNode), + curState.currentPath, + _root.branchesOn(curState.currentPath).map(Step.fromNode), initialPositionEval: _root.eval, - shouldEmit: (work) => work.path == state.currentPath, + shouldEmit: (work) => work.path == curState.currentPath, ) ?.forEach( (t) => _root.updateAt(t.$1.path, (node) => node.eval = t.$2), @@ -536,23 +580,31 @@ class AnalysisController extends _$AnalysisController void _stopEngineEval() { ref.read(evaluationServiceProvider).stop(); // update the current node with last cached eval - state = state.copyWith( - currentNode: - AnalysisCurrentNode.fromNode(_root.nodeAt(state.currentPath)), + final curState = state.requireValue; + state = AsyncData( + curState.copyWith( + currentNode: + AnalysisCurrentNode.fromNode(_root.nodeAt(curState.currentPath)), + ), ); } void _listenToServerAnalysisEvents() { final event = ref.read(serverAnalysisServiceProvider).lastAnalysisEvent.value; - if (event != null && event.$1 == state.id) { + if (event != null && event.$1 == state.requireValue.gameId) { _mergeOngoingAnalysis(_root, event.$2.tree); - state = state.copyWith( - acplChartData: _makeAcplChartData(), - playersAnalysis: event.$2.analysis != null - ? (white: event.$2.analysis!.white, black: event.$2.analysis!.black) - : null, - root: _root.view, + state = AsyncData( + state.requireValue.copyWith( + acplChartData: _makeAcplChartData(), + playersAnalysis: event.$2.analysis != null + ? ( + white: event.$2.analysis!.white, + black: event.$2.analysis!.black + ) + : null, + root: _root.view, + ), ); } } @@ -601,7 +653,7 @@ class AnalysisController extends _$AnalysisController Branch( position: n1.position.playUnchecked(move), sanMove: SanMove(san, move), - isHidden: children.length > 1, + isCollapsed: children.length > 1, ), ); } @@ -646,18 +698,13 @@ class AnalysisController extends _$AnalysisController } } -enum DisplayMode { - moves, - summary, -} - @freezed class AnalysisState with _$AnalysisState { const AnalysisState._(); const factory AnalysisState({ - /// Analysis ID - required StringId id, + /// The ID of the game if it's a lichess game. + required GameId? gameId, /// The variant of the analysis. required Variant variant, @@ -681,16 +728,20 @@ class AnalysisState with _$AnalysisState { /// The side to display the board from. required Side pov, - /// Whether local evaluation is allowed for this analysis. - required bool isLocalEvaluationAllowed, + /// Whether computer evaluation is allowed for this analysis. + /// + /// Acts on both local and server analysis. + required bool isComputerAnalysisAllowed, - /// Whether the user has enabled local evaluation. - required bool isLocalEvaluationEnabled, + /// Whether the user has enabled computer analysis. + /// + /// This is a user preference and acts both on local and server analysis. + required bool isComputerAnalysisEnabled, - /// The display mode of the analysis. + /// Whether the user has enabled local evaluation. /// - /// It can be either moves, summary or opening explorer. - required DisplayMode displayMode, + /// This is a user preference and acts only on local analysis. + required bool isLocalEvaluationEnabled, /// The last move played. Move? lastMove, @@ -707,6 +758,9 @@ class AnalysisState with _$AnalysisState { /// Optional server analysis to display player stats. ({PlayerAnalysis white, PlayerAnalysis black})? playersAnalysis, + /// Optional game division data, given by server analysis. + Division? division, + /// Optional ACPL chart data of the game, coming from lichess server analysis. IList? acplChartData, @@ -719,12 +773,8 @@ class AnalysisState with _$AnalysisState { IList? pgnRootComments, }) = _AnalysisState; - /// The game ID of the analysis, if it's a lichess game. - GameAnyId? get gameAnyId => - _isStandaloneAnalysis(id) ? null : GameAnyId(id.value); - /// Whether the analysis is for a lichess game. - bool get isLichessGameAnalysis => gameAnyId != null; + bool get isLichessGameAnalysis => gameId != null; IMap> get validMoves => makeLegalMoves( currentNode.position, @@ -735,25 +785,27 @@ class AnalysisState with _$AnalysisState { /// /// It must be a lichess game, which is finished and not already analyzed. bool get canRequestServerAnalysis => - gameAnyId != null && - (id.length == 8 || id.length == 12) && - !hasServerAnalysis && - pgnHeaders['Result'] != '*'; - - bool get canShowGameSummary => hasServerAnalysis || canRequestServerAnalysis; + gameId != null && !hasServerAnalysis && pgnHeaders['Result'] != '*'; + /// Whether the server analysis is available. bool get hasServerAnalysis => playersAnalysis != null; + bool get canShowGameSummary => hasServerAnalysis || canRequestServerAnalysis; + /// Whether an evaluation can be available bool get hasAvailableEval => isEngineAvailable || - (isLocalEvaluationAllowed && + (isComputerAnalysisAllowedAndEnabled && acplChartData != null && acplChartData!.isNotEmpty); + bool get isComputerAnalysisAllowedAndEnabled => + isComputerAnalysisAllowed && isComputerAnalysisEnabled; + /// Whether the engine is allowed for this analysis and variant. bool get isEngineAllowed => - isLocalEvaluationAllowed && engineSupportedVariants.contains(variant); + isComputerAnalysisAllowedAndEnabled && + engineSupportedVariants.contains(variant); /// Whether the engine is available for evaluation bool get isEngineAvailable => isEngineAllowed && isLocalEvaluationEnabled; @@ -768,14 +820,6 @@ class AnalysisState with _$AnalysisState { position: position, savedEval: currentNode.eval ?? currentNode.serverEval, ); - - AnalysisOptions get openingExplorerOptions => AnalysisOptions( - id: standaloneOpeningExplorerId, - isLocalEvaluationAllowed: false, - orientation: pov, - variant: variant, - initialMoveCursor: currentPath.size, - ); } @freezed diff --git a/lib/src/model/analysis/analysis_preferences.dart b/lib/src/model/analysis/analysis_preferences.dart index 5633e3db30..30615646d3 100644 --- a/lib/src/model/analysis/analysis_preferences.dart +++ b/lib/src/model/analysis/analysis_preferences.dart @@ -26,6 +26,14 @@ class AnalysisPreferences extends _$AnalysisPreferences return fetch(); } + Future toggleEnableComputerAnalysis() { + return save( + state.copyWith( + enableComputerAnalysis: !state.enableComputerAnalysis, + ), + ); + } + Future toggleEnableLocalEvaluation() { return save( state.copyWith( @@ -90,6 +98,7 @@ class AnalysisPrefs with _$AnalysisPrefs implements Serializable { const AnalysisPrefs._(); const factory AnalysisPrefs({ + @JsonKey(defaultValue: true) required bool enableComputerAnalysis, required bool enableLocalEvaluation, required bool showEvaluationGauge, required bool showBestMoveArrow, @@ -101,6 +110,7 @@ class AnalysisPrefs with _$AnalysisPrefs implements Serializable { }) = _AnalysisPrefs; static const defaults = AnalysisPrefs( + enableComputerAnalysis: true, enableLocalEvaluation: true, showEvaluationGauge: true, showBestMoveArrow: true, diff --git a/lib/src/model/analysis/server_analysis_service.dart b/lib/src/model/analysis/server_analysis_service.dart index 94665dfdac..ed73593547 100644 --- a/lib/src/model/analysis/server_analysis_service.dart +++ b/lib/src/model/analysis/server_analysis_service.dart @@ -40,11 +40,9 @@ class ServerAnalysisService { /// /// This will return a future that completes when the server analysis is /// launched (but not when it is finished). - Future requestAnalysis(GameAnyId id, [Side? side]) async { + Future requestAnalysis(GameId id, [Side? side]) async { final socketPool = ref.read(socketPoolProvider); - final uri = id.isFullId - ? Uri(path: '/play/$id/v6') - : Uri(path: '/watch/$id/${side?.name ?? Side.white}/v6'); + final uri = Uri(path: '/watch/$id/${side?.name ?? Side.white}/v6'); final socketClient = socketPool.open(uri); _socketSubscription?.$2.cancel(); diff --git a/lib/src/model/common/node.dart b/lib/src/model/common/node.dart index ea9bd6c638..fefd59fdcc 100644 --- a/lib/src/model/common/node.dart +++ b/lib/src/model/common/node.dart @@ -130,7 +130,7 @@ abstract class Node { return null; } - /// Updates all nodes. + /// Recursively applies [update] to all nodes of the tree. void updateAll(void Function(Node node) update) { update(this); for (final child in children) { @@ -360,7 +360,8 @@ class Branch extends Node { super.eval, super.opening, required this.sanMove, - this.isHidden = false, + this.isComputerVariation = false, + this.isCollapsed = false, this.lichessAnalysisComments, // below are fields from dartchess [PgnNodeData] this.startingComments, @@ -368,8 +369,11 @@ class Branch extends Node { this.nags, }); + /// Whether this branch is from a variation generated by lichess computer analysis. + final bool isComputerVariation; + /// Whether the branch should be hidden in the tree view. - bool isHidden; + bool isCollapsed; /// The id of the branch, using a concise notation of associated move. UciCharPair get id => UciCharPair.fromMove(sanMove.move); @@ -398,7 +402,8 @@ class Branch extends Node { eval: eval, opening: opening, children: IList(children.map((child) => child.view)), - isHidden: isHidden, + isComputerVariation: isComputerVariation, + isCollapsed: isCollapsed, lichessAnalysisComments: lichessAnalysisComments?.lock, startingComments: startingComments?.lock, comments: comments?.lock, @@ -487,7 +492,8 @@ class Root extends Node { final branch = Branch( sanMove: SanMove(childFrom.data.san, move), position: newPos, - isHidden: frame.nesting > 2 || hideVariations && childIdx > 0, + isCollapsed: frame.nesting > 2 || hideVariations && childIdx > 0, + isComputerVariation: isLichessAnalysis && childIdx > 0, lichessAnalysisComments: isLichessAnalysis ? comments?.toList() : null, startingComments: isLichessAnalysis @@ -587,7 +593,8 @@ class ViewBranch extends ViewNode with _$ViewBranch { required Position position, Opening? opening, required IList children, - @Default(false) bool isHidden, + @Default(false) bool isCollapsed, + required bool isComputerVariation, ClientEval? eval, IList? lichessAnalysisComments, IList? startingComments, diff --git a/lib/src/model/game/game_controller.dart b/lib/src/model/game/game_controller.dart index 51a8cbb023..568782422f 100644 --- a/lib/src/model/game/game_controller.dart +++ b/lib/src/model/game/game_controller.dart @@ -12,7 +12,6 @@ import 'package:freezed_annotation/freezed_annotation.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/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'; @@ -424,21 +423,6 @@ class GameController extends _$GameController { _socketClient.send('rematch-no', null); } - Future requestServerAnalysis() { - return state.mapOrNull( - data: (d) { - if (!d.value.game.finished) { - return Future.error( - 'Cannot request server analysis on a non finished game', - ); - } - final service = ref.read(serverAnalysisServiceProvider); - return service.requestAnalysis(gameFullId); - }, - ) ?? - Future.value(); - } - /// Gets the live game clock if available. LiveGameClock? get _liveClock => _clock != null ? ( @@ -1183,13 +1167,8 @@ class GameState with _$GameState { String get analysisPgn => game.makePgn(); AnalysisOptions get analysisOptions => AnalysisOptions( - isLocalEvaluationAllowed: true, - variant: game.meta.variant, - initialMoveCursor: stepCursor, orientation: game.youAre ?? Side.white, - id: gameFullId, - opening: game.meta.opening, - serverAnalysis: game.serverAnalysis, - division: game.meta.division, + initialMoveCursor: stepCursor, + gameId: gameFullId.gameId, ); } diff --git a/lib/src/model/opening_explorer/opening_explorer_repository.dart b/lib/src/model/opening_explorer/opening_explorer_repository.dart index f8f6d5210b..a6b0921fc5 100644 --- a/lib/src/model/opening_explorer/opening_explorer_repository.dart +++ b/lib/src/model/opening_explorer/opening_explorer_repository.dart @@ -8,6 +8,7 @@ import 'package:lichess_mobile/src/model/common/speed.dart'; import 'package:lichess_mobile/src/model/opening_explorer/opening_explorer.dart'; import 'package:lichess_mobile/src/model/opening_explorer/opening_explorer_preferences.dart'; import 'package:lichess_mobile/src/network/http.dart'; +import 'package:lichess_mobile/src/utils/riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'opening_explorer_repository.g.dart'; @@ -20,6 +21,7 @@ class OpeningExplorer extends _$OpeningExplorer { Future<({OpeningExplorerEntry entry, bool isIndexing})?> build({ required String fen, }) async { + await ref.debounce(const Duration(milliseconds: 300)); ref.onDispose(() { _openingExplorerSubscription?.cancel(); }); diff --git a/lib/src/model/study/study_controller.dart b/lib/src/model/study/study_controller.dart index 1337dff19c..5648833799 100644 --- a/lib/src/model/study/study_controller.dart +++ b/lib/src/model/study/study_controller.dart @@ -97,7 +97,7 @@ class StudyController extends _$StudyController implements PgnTreeNotifier { currentNode: StudyCurrentNode.illegalPosition(), pgnRootComments: rootComments, pov: orientation, - isLocalEvaluationAllowed: false, + isComputerAnalysisAllowed: false, isLocalEvaluationEnabled: false, gamebookActive: false, pgn: pgn, @@ -121,7 +121,7 @@ class StudyController extends _$StudyController implements PgnTreeNotifier { pgnRootComments: rootComments, lastMove: lastMove, pov: orientation, - isLocalEvaluationAllowed: + isComputerAnalysisAllowed: study.chapter.features.computer && !study.chapter.gamebook, isLocalEvaluationEnabled: prefs.enableLocalEvaluation, gamebookActive: study.chapter.gamebook, @@ -289,9 +289,9 @@ class StudyController extends _$StudyController implements PgnTreeNotifier { _root.isOnMainline(path) ? node.children.skip(1) : node.children; for (final child in childrenToShow) { - child.isHidden = false; + child.isCollapsed = false; for (final grandChild in child.children) { - grandChild.isHidden = false; + grandChild.isCollapsed = false; } } state = AsyncValue.data(state.requireValue.copyWith(root: _root.view)); @@ -304,7 +304,7 @@ class StudyController extends _$StudyController implements PgnTreeNotifier { final node = _root.nodeAt(path); for (final child in node.children) { - child.isHidden = true; + child.isCollapsed = true; } state = AsyncValue.data(state.requireValue.copyWith(root: _root.view)); @@ -417,9 +417,9 @@ class StudyController extends _$StudyController implements PgnTreeNotifier { // always show variation if the user plays a move if (shouldForceShowVariation && currentNode is Branch && - currentNode.isHidden) { + currentNode.isCollapsed) { _root.updateAt(path, (node) { - if (node is Branch) node.isHidden = false; + if (node is Branch) node.isCollapsed = false; }); } @@ -559,7 +559,7 @@ class StudyState with _$StudyState { required Side pov, /// Whether local evaluation is allowed for this study. - required bool isLocalEvaluationAllowed, + required bool isComputerAnalysisAllowed, /// Whether we're currently in gamebook mode, where the user has to find the right moves. required bool gamebookActive, @@ -583,7 +583,7 @@ class StudyState with _$StudyState { /// Whether the engine is available for evaluation bool get isEngineAvailable => - isLocalEvaluationAllowed && + isComputerAnalysisAllowed && engineSupportedVariants.contains(variant) && isLocalEvaluationEnabled; diff --git a/lib/src/view/analysis/analysis_board.dart b/lib/src/view/analysis/analysis_board.dart index bfb74f672e..8f37f0fe54 100644 --- a/lib/src/view/analysis/analysis_board.dart +++ b/lib/src/view/analysis/analysis_board.dart @@ -15,19 +15,19 @@ import 'package:lichess_mobile/src/widgets/pgn.dart'; class AnalysisBoard extends ConsumerStatefulWidget { const AnalysisBoard( - this.pgn, this.options, this.boardSize, { this.borderRadius, this.enableDrawingShapes = true, + this.shouldReplaceChildOnUserMove = false, }); - final String pgn; final AnalysisOptions options; final double boardSize; final BorderRadiusGeometry? borderRadius; final bool enableDrawingShapes; + final bool shouldReplaceChildOnUserMove; @override ConsumerState createState() => AnalysisBoardState(); @@ -38,26 +38,28 @@ class AnalysisBoardState extends ConsumerState { @override Widget build(BuildContext context) { - final ctrlProvider = analysisControllerProvider(widget.pgn, widget.options); - final analysisState = ref.watch(ctrlProvider); + final ctrlProvider = analysisControllerProvider(widget.options); + final analysisState = ref.watch(ctrlProvider).requireValue; final boardPrefs = ref.watch(boardPreferencesProvider); - final showBestMoveArrow = ref.watch( - analysisPreferencesProvider.select( - (value) => value.showBestMoveArrow, - ), - ); - final showAnnotationsOnBoard = ref.watch( - analysisPreferencesProvider.select((value) => value.showAnnotations), - ); - - final evalBestMoves = ref.watch( - engineEvaluationProvider.select((s) => s.eval?.bestMoves), - ); + final analysisPrefs = ref.watch(analysisPreferencesProvider); + final enableComputerAnalysis = analysisPrefs.enableComputerAnalysis; + final showBestMoveArrow = + enableComputerAnalysis && analysisPrefs.showBestMoveArrow; + final showAnnotationsOnBoard = + enableComputerAnalysis && analysisPrefs.showAnnotations; + final evalBestMoves = enableComputerAnalysis + ? ref.watch( + engineEvaluationProvider.select((s) => s.eval?.bestMoves), + ) + : null; final currentNode = analysisState.currentNode; - final annotation = makeAnnotation(currentNode.nags); + final annotation = + showAnnotationsOnBoard ? makeAnnotation(currentNode.nags) : null; - final bestMoves = evalBestMoves ?? currentNode.eval?.bestMoves; + final bestMoves = enableComputerAnalysis + ? evalBestMoves ?? currentNode.eval?.bestMoves + : null; final sanMove = currentNode.sanMove; @@ -87,7 +89,10 @@ class AnalysisBoardState extends ConsumerState { validMoves: analysisState.validMoves, promotionMove: analysisState.promotionMove, onMove: (move, {isDrop, captured}) => - ref.read(ctrlProvider.notifier).onUserMove(move), + ref.read(ctrlProvider.notifier).onUserMove( + move, + shouldReplace: widget.shouldReplaceChildOnUserMove, + ), onPromotionSelection: (role) => ref.read(ctrlProvider.notifier).onPromotionSelection(role), ), diff --git a/lib/src/view/analysis/analysis_layout.dart b/lib/src/view/analysis/analysis_layout.dart new file mode 100644 index 0000000000..80f76e7595 --- /dev/null +++ b/lib/src/view/analysis/analysis_layout.dart @@ -0,0 +1,278 @@ +import 'package:flutter/material.dart'; +import 'package:lichess_mobile/l10n/l10n.dart'; +import 'package:lichess_mobile/src/constants.dart'; +import 'package:lichess_mobile/src/styles/lichess_icons.dart'; +import 'package:lichess_mobile/src/utils/l10n_context.dart'; +import 'package:lichess_mobile/src/utils/screen.dart'; +import 'package:lichess_mobile/src/widgets/adaptive_action_sheet.dart'; +import 'package:lichess_mobile/src/widgets/buttons.dart'; +import 'package:lichess_mobile/src/widgets/platform.dart'; + +typedef BoardBuilder = Widget Function( + BuildContext context, + double boardSize, + BorderRadiusGeometry? borderRadius, +); + +typedef EngineGaugeBuilder = Widget Function( + BuildContext context, + Orientation orientation, +); + +enum AnalysisTab { + opening(Icons.explore), + moves(LichessIcons.flow_cascade), + summary(Icons.area_chart); + + const AnalysisTab(this.icon); + + final IconData icon; + + String l10n(AppLocalizations l10n) { + switch (this) { + case AnalysisTab.opening: + return l10n.openingExplorer; + case AnalysisTab.moves: + return l10n.movesPlayed; + case AnalysisTab.summary: + return l10n.computerAnalysis; + } + } +} + +/// Indicator for the analysis tab, typically shown in the app bar. +class AppBarAnalysisTabIndicator extends StatefulWidget { + const AppBarAnalysisTabIndicator({ + required this.tabs, + required this.controller, + super.key, + }); + + final TabController controller; + + /// Typically a list of two or more [AnalysisTab] widgets. + /// + /// The length of this list must match the [controller]'s [TabController.length] + /// and the length of the [AnalysisLayout.children] list. + final List tabs; + + @override + State createState() => + _AppBarAnalysisTabIndicatorState(); +} + +class _AppBarAnalysisTabIndicatorState + extends State { + @override + void didChangeDependencies() { + super.didChangeDependencies(); + widget.controller.addListener(_listener); + } + + @override + void dispose() { + widget.controller.removeListener(_listener); + super.dispose(); + } + + void _listener() { + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return AppBarIconButton( + icon: Icon(widget.tabs[widget.controller.index].icon), + semanticsLabel: widget.tabs[widget.controller.index].l10n(context.l10n), + onPressed: () { + showAdaptiveActionSheet( + context: context, + actions: widget.tabs.map((tab) { + return BottomSheetAction( + leading: Icon(tab.icon), + makeLabel: (context) => Text(tab.l10n(context.l10n)), + onPressed: (_) { + widget.controller.animateTo(widget.tabs.indexOf(tab)); + }, + ); + }).toList(), + ); + }, + ); + } +} + +/// Layout for the analysis and similar screens (study, broadcast, etc.). +/// +/// The layout is responsive and adapts to the screen size and orientation. +/// +/// It includes a [TabBarView] with the [children] widgets. If a [TabController] +/// is not provided, then there must be a [DefaultTabController] ancestor. +/// +/// The length of the [children] list must match the [tabController]'s +/// [TabController.length] and the length of the [AppBarAnalysisTabIndicator.tabs] +class AnalysisLayout extends StatelessWidget { + const AnalysisLayout({ + this.tabController, + required this.boardBuilder, + required this.children, + this.engineGaugeBuilder, + this.engineLines, + this.bottomBar, + super.key, + }); + + /// The tab controller for the tab view. + final TabController? tabController; + + /// The builder for the board widget. + final BoardBuilder boardBuilder; + + /// The children of the tab view. + /// + /// The length of this list must match the [tabController]'s [TabController.length] + /// and the length of the [AppBarAnalysisTabIndicator.tabs] list. + final List children; + + final EngineGaugeBuilder? engineGaugeBuilder; + final Widget? engineLines; + final Widget? bottomBar; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Expanded( + child: SafeArea( + bottom: false, + child: LayoutBuilder( + builder: (context, constraints) { + final aspectRatio = constraints.biggest.aspectRatio; + final defaultBoardSize = constraints.biggest.shortestSide; + final isTablet = isTabletOrLarger(context); + final remainingHeight = + constraints.maxHeight - defaultBoardSize; + final isSmallScreen = + remainingHeight < kSmallRemainingHeightLeftBoardThreshold; + final boardSize = isTablet || isSmallScreen + ? defaultBoardSize - kTabletBoardTableSidePadding * 2 + : defaultBoardSize; + + const tabletBoardRadius = + BorderRadius.all(Radius.circular(4.0)); + + // If the aspect ratio is greater than 1, we are in landscape mode. + if (aspectRatio > 1) { + return Row( + mainAxisSize: MainAxisSize.max, + children: [ + Padding( + padding: const EdgeInsets.only( + left: kTabletBoardTableSidePadding, + top: kTabletBoardTableSidePadding, + bottom: kTabletBoardTableSidePadding, + ), + child: Row( + children: [ + boardBuilder( + context, + boardSize, + isTablet ? tabletBoardRadius : null, + ), + if (engineGaugeBuilder != null) ...[ + const SizedBox(width: 4.0), + engineGaugeBuilder!( + context, + Orientation.landscape, + ), + ], + ], + ), + ), + Flexible( + fit: FlexFit.loose, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + if (engineLines != null) + Padding( + padding: const EdgeInsets.only( + top: kTabletBoardTableSidePadding, + left: kTabletBoardTableSidePadding, + right: kTabletBoardTableSidePadding, + ), + child: engineLines, + ), + Expanded( + child: PlatformCard( + clipBehavior: Clip.hardEdge, + borderRadius: const BorderRadius.all( + Radius.circular(4.0), + ), + margin: const EdgeInsets.all( + kTabletBoardTableSidePadding, + ), + semanticContainer: false, + child: TabBarView( + controller: tabController, + children: children, + ), + ), + ), + ], + ), + ), + ], + ); + } + // If the aspect ratio is less than 1, we are in portrait mode. + else { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (engineGaugeBuilder != null) + engineGaugeBuilder!( + context, + Orientation.portrait, + ), + if (engineLines != null) engineLines!, + if (isTablet) + Padding( + padding: const EdgeInsets.all( + kTabletBoardTableSidePadding, + ), + child: boardBuilder( + context, + boardSize, + tabletBoardRadius, + ), + ) + else + boardBuilder(context, boardSize, null), + Expanded( + child: Padding( + padding: isTablet + ? const EdgeInsets.symmetric( + horizontal: kTabletBoardTableSidePadding, + ) + : EdgeInsets.zero, + child: TabBarView( + controller: tabController, + children: children, + ), + ), + ), + ], + ); + } + }, + ), + ), + ), + if (bottomBar != null) bottomBar!, + ], + ); + } +} diff --git a/lib/src/view/analysis/analysis_screen.dart b/lib/src/view/analysis/analysis_screen.dart index a87c4155fa..45d3ae1f2f 100644 --- a/lib/src/view/analysis/analysis_screen.dart +++ b/lib/src/view/analysis/analysis_screen.dart @@ -1,215 +1,145 @@ -import 'dart:math' as math; - -import 'package:collection/collection.dart'; -import 'package:dartchess/dartchess.dart'; -import 'package:fast_immutable_collections/fast_immutable_collections.dart'; -import 'package:fl_chart/fl_chart.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/analysis/analysis_controller.dart'; import 'package:lichess_mobile/src/model/analysis/analysis_preferences.dart'; -import 'package:lichess_mobile/src/model/analysis/server_analysis_service.dart'; -import 'package:lichess_mobile/src/model/auth/auth_session.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/engine/engine.dart'; -import 'package:lichess_mobile/src/model/engine/evaluation_service.dart'; -import 'package:lichess_mobile/src/model/game/game_repository_providers.dart'; import 'package:lichess_mobile/src/model/game/game_share_service.dart'; -import 'package:lichess_mobile/src/network/connectivity.dart'; import 'package:lichess_mobile/src/network/http.dart'; -import 'package:lichess_mobile/src/styles/lichess_icons.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/string.dart'; +import 'package:lichess_mobile/src/view/analysis/analysis_layout.dart'; import 'package:lichess_mobile/src/view/analysis/analysis_share_screen.dart'; +import 'package:lichess_mobile/src/view/analysis/opening_explorer_view.dart'; +import 'package:lichess_mobile/src/view/analysis/server_analysis.dart'; import 'package:lichess_mobile/src/view/board_editor/board_editor_screen.dart'; +import 'package:lichess_mobile/src/view/engine/engine_depth.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/opening_explorer/opening_explorer_screen.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/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/feedback.dart'; -import 'package:lichess_mobile/src/widgets/list.dart'; -import 'package:lichess_mobile/src/widgets/platform.dart'; import 'package:lichess_mobile/src/widgets/platform_scaffold.dart'; -import 'package:popover/popover.dart'; +import 'package:logging/logging.dart'; import '../../utils/share.dart'; import 'analysis_board.dart'; import 'analysis_settings.dart'; import 'tree_view.dart'; -class AnalysisScreen extends StatelessWidget { +final _logger = Logger('AnalysisScreen'); + +class AnalysisScreen extends ConsumerStatefulWidget { const AnalysisScreen({ required this.options, - required this.pgnOrId, this.enableDrawingShapes = true, }); - /// The analysis options. final AnalysisOptions options; - /// The PGN or game ID to load. - final String pgnOrId; - final bool enableDrawingShapes; @override - Widget build(BuildContext context) { - return pgnOrId.length == 8 && GameId(pgnOrId).isValid - ? _LoadGame( - GameId(pgnOrId), - options, - enableDrawingShapes: enableDrawingShapes, - ) - : _LoadedAnalysisScreen( - options: options, - pgn: pgnOrId, - enableDrawingShapes: enableDrawingShapes, - ); - } + ConsumerState createState() => _AnalysisScreenState(); } -class _LoadGame extends ConsumerWidget { - const _LoadGame( - this.gameId, - this.options, { - required this.enableDrawingShapes, - }); - - final AnalysisOptions options; - final GameId gameId; - - final bool enableDrawingShapes; +class _AnalysisScreenState extends ConsumerState + with SingleTickerProviderStateMixin { + late final List tabs; + late final TabController _tabController; @override - Widget build(BuildContext context, WidgetRef ref) { - final gameAsync = ref.watch(archivedGameProvider(id: gameId)); - - return gameAsync.when( - data: (game) { - final serverAnalysis = - game.white.analysis != null && game.black.analysis != null - ? (white: game.white.analysis!, black: game.black.analysis!) - : null; - return _LoadedAnalysisScreen( - options: options.copyWith( - id: game.id, - opening: game.meta.opening, - division: game.meta.division, - serverAnalysis: serverAnalysis, - ), - pgn: game.makePgn(), - enableDrawingShapes: enableDrawingShapes, - ); - }, - loading: () => const Center(child: CircularProgressIndicator.adaptive()), - error: (error, _) { - return Center( - child: Text('Cannot load game analysis: $error'), - ); - }, + void initState() { + super.initState(); + + tabs = [ + AnalysisTab.opening, + AnalysisTab.moves, + if (widget.options.gameId != null) AnalysisTab.summary, + ]; + + _tabController = TabController( + vsync: this, + initialIndex: 1, + length: tabs.length, ); } -} - -class _LoadedAnalysisScreen extends ConsumerWidget { - const _LoadedAnalysisScreen({ - required this.options, - required this.pgn, - required this.enableDrawingShapes, - }); - - final AnalysisOptions options; - final String pgn; - - final bool enableDrawingShapes; @override - Widget build(BuildContext context, WidgetRef ref) { - return ConsumerPlatformWidget( - androidBuilder: _androidBuilder, - iosBuilder: _iosBuilder, - ref: ref, - ); + void dispose() { + _tabController.dispose(); + super.dispose(); } - Widget _androidBuilder(BuildContext context, WidgetRef ref) { - final ctrlProvider = analysisControllerProvider(pgn, options); - - return PlatformScaffold( - resizeToAvoidBottomInset: false, - appBar: PlatformAppBar( - title: _Title(options: options), - actions: [ - _EngineDepth(ctrlProvider), - AppBarIconButton( - onPressed: () => showAdaptiveBottomSheet( - context: context, - isScrollControlled: true, - showDragHandle: true, - isDismissible: true, - builder: (_) => AnalysisSettings(pgn, options), - ), - semanticsLabel: context.l10n.settingsSettings, - icon: const Icon(Icons.settings), - ), - ], - ), - body: _Body( - pgn: pgn, - options: options, - enableDrawingShapes: enableDrawingShapes, + @override + Widget build(BuildContext context) { + final ctrlProvider = analysisControllerProvider(widget.options); + final asyncState = ref.watch(ctrlProvider); + final prefs = ref.watch(analysisPreferencesProvider); + + final appBarActions = [ + if (prefs.enableComputerAnalysis) + EngineDepth(defaultEval: asyncState.valueOrNull?.currentNode.eval), + AppBarAnalysisTabIndicator( + tabs: tabs, + controller: _tabController, ), - ); - } - - Widget _iosBuilder(BuildContext context, WidgetRef ref) { - final ctrlProvider = analysisControllerProvider(pgn, options); - - return CupertinoPageScaffold( - resizeToAvoidBottomInset: false, - navigationBar: CupertinoNavigationBar( - padding: Styles.cupertinoAppBarTrailingWidgetPadding, - middle: _Title(options: options), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _EngineDepth(ctrlProvider), - AppBarIconButton( - onPressed: () => showAdaptiveBottomSheet( - context: context, - isScrollControlled: true, - showDragHandle: true, - isDismissible: true, - builder: (_) => AnalysisSettings(pgn, options), - ), - semanticsLabel: context.l10n.settingsSettings, - icon: const Icon(Icons.settings), - ), - ], + AppBarIconButton( + onPressed: () => showAdaptiveBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + isDismissible: true, + constraints: BoxConstraints( + minHeight: MediaQuery.sizeOf(context).height * 0.5, + ), + builder: (_) => AnalysisSettings(widget.options), ), + semanticsLabel: context.l10n.settingsSettings, + icon: const Icon(Icons.settings), ), - child: _Body( - pgn: pgn, - options: options, - enableDrawingShapes: enableDrawingShapes, - ), - ); + ]; + + switch (asyncState) { + case AsyncData(:final value): + return PlatformScaffold( + resizeToAvoidBottomInset: false, + appBar: PlatformAppBar( + title: _Title(variant: value.variant), + actions: appBarActions, + ), + body: _Body( + options: widget.options, + controller: _tabController, + enableDrawingShapes: widget.enableDrawingShapes, + ), + ); + case AsyncError(:final error, :final stackTrace): + _logger.severe('Cannot load analysis: $error', stackTrace); + return FullScreenRetryRequest( + onRetry: () { + ref.invalidate(ctrlProvider); + }, + ); + case _: + return PlatformScaffold( + resizeToAvoidBottomInset: false, + appBar: PlatformAppBar( + title: const _Title(variant: Variant.standard), + actions: appBarActions, + ), + body: const Center(child: CircularProgressIndicator()), + ); + } } } class _Title extends StatelessWidget { - const _Title({required this.options}); - final AnalysisOptions options; + const _Title({required this.variant}); + final Variant variant; static const excludedIcons = [Variant.standard, Variant.fromPosition]; @@ -218,8 +148,8 @@ class _Title extends StatelessWidget { return Row( mainAxisSize: MainAxisSize.min, children: [ - if (!excludedIcons.contains(options.variant)) ...[ - Icon(options.variant.icon), + if (!excludedIcons.contains(variant)) ...[ + Icon(variant.icon), const SizedBox(width: 5.0), ], Text(context.l10n.analysis), @@ -230,257 +160,81 @@ class _Title extends StatelessWidget { class _Body extends ConsumerWidget { const _Body({ - required this.pgn, required this.options, + required this.controller, required this.enableDrawingShapes, }); - final String pgn; + final TabController controller; final AnalysisOptions options; final bool enableDrawingShapes; @override Widget build(BuildContext context, WidgetRef ref) { - final ctrlProvider = analysisControllerProvider(pgn, options); - final showEvaluationGauge = ref.watch( - analysisPreferencesProvider.select((value) => value.showEvaluationGauge), - ); - - final isEngineAvailable = ref.watch( - ctrlProvider.select( - (value) => value.isEngineAvailable, + final analysisPrefs = ref.watch(analysisPreferencesProvider); + final showEvaluationGauge = analysisPrefs.showEvaluationGauge; + final numEvalLines = analysisPrefs.numEvalLines; + + final ctrlProvider = analysisControllerProvider(options); + final analysisState = ref.watch(ctrlProvider).requireValue; + + final isEngineAvailable = analysisState.isEngineAvailable; + final hasEval = analysisState.hasAvailableEval; + final currentNode = analysisState.currentNode; + + return AnalysisLayout( + tabController: controller, + boardBuilder: (context, boardSize, borderRadius) => AnalysisBoard( + options, + boardSize, + borderRadius: borderRadius, + enableDrawingShapes: enableDrawingShapes, ), - ); - - final hasEval = - ref.watch(ctrlProvider.select((value) => value.hasAvailableEval)); - - final displayMode = - ref.watch(ctrlProvider.select((value) => value.displayMode)); - - final currentNode = ref.watch( - ctrlProvider.select((value) => value.currentNode), - ); - - return Column( - children: [ - Expanded( - child: SafeArea( - bottom: false, - child: LayoutBuilder( - builder: (context, constraints) { - final aspectRatio = constraints.biggest.aspectRatio; - final defaultBoardSize = constraints.biggest.shortestSide; - final isTablet = isTabletOrLarger(context); - final remainingHeight = - constraints.maxHeight - defaultBoardSize; - final isSmallScreen = - remainingHeight < kSmallRemainingHeightLeftBoardThreshold; - final boardSize = isTablet || isSmallScreen - ? defaultBoardSize - kTabletBoardTableSidePadding * 2 - : defaultBoardSize; - - const tabletBoardRadius = - BorderRadius.all(Radius.circular(4.0)); - - final display = switch (displayMode) { - DisplayMode.summary => ServerAnalysisSummary(pgn, options), - DisplayMode.moves => AnalysisTreeView( - pgn, - options, - aspectRatio > 1 - ? Orientation.landscape - : Orientation.portrait, - ), - }; - - // If the aspect ratio is greater than 1, we are in landscape mode. - if (aspectRatio > 1) { - return Row( - mainAxisSize: MainAxisSize.max, - children: [ - Padding( - padding: const EdgeInsets.only( - left: kTabletBoardTableSidePadding, - top: kTabletBoardTableSidePadding, - bottom: kTabletBoardTableSidePadding, - ), - child: Row( - children: [ - AnalysisBoard( - pgn, - options, - boardSize, - borderRadius: isTablet ? tabletBoardRadius : null, - enableDrawingShapes: enableDrawingShapes, - ), - if (hasEval && showEvaluationGauge) ...[ - const SizedBox(width: 4.0), - _EngineGaugeVertical(ctrlProvider), - ], - ], - ), + engineGaugeBuilder: hasEval && showEvaluationGauge + ? (context, orientation) { + return orientation == Orientation.portrait + ? EngineGauge( + displayMode: EngineGaugeDisplayMode.horizontal, + params: analysisState.engineGaugeParams, + ) + : Container( + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4.0), ), - Flexible( - fit: FlexFit.loose, - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - if (isEngineAvailable) - Padding( - padding: const EdgeInsets.all( - kTabletBoardTableSidePadding, - ), - child: EngineLines( - onTapMove: ref - .read(ctrlProvider.notifier) - .onUserMove, - clientEval: currentNode.eval, - isGameOver: currentNode.position.isGameOver, - ), - ), - Expanded( - child: PlatformCard( - clipBehavior: Clip.hardEdge, - borderRadius: const BorderRadius.all( - Radius.circular(4.0), - ), - margin: const EdgeInsets.all( - kTabletBoardTableSidePadding, - ), - semanticContainer: false, - child: display, - ), - ), - ], - ), + child: EngineGauge( + displayMode: EngineGaugeDisplayMode.vertical, + params: analysisState.engineGaugeParams, ), - ], - ); - } - // If the aspect ratio is less than 1, we are in portrait mode. - else { - return Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.max, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - _ColumnTopTable(ctrlProvider), - if (isTablet) - Padding( - padding: const EdgeInsets.all( - kTabletBoardTableSidePadding, - ), - child: AnalysisBoard( - pgn, - options, - boardSize, - borderRadius: isTablet ? tabletBoardRadius : null, - enableDrawingShapes: enableDrawingShapes, - ), - ) - else - AnalysisBoard( - pgn, - options, - boardSize, - borderRadius: isTablet ? tabletBoardRadius : null, - enableDrawingShapes: enableDrawingShapes, - ), - Expanded( - child: Padding( - padding: isTablet - ? const EdgeInsets.symmetric( - horizontal: kTabletBoardTableSidePadding, - ) - : EdgeInsets.zero, - child: display, - ), - ), - ], - ); - } - }, - ), - ), - ), - _BottomBar(pgn: pgn, options: options), + ); + } + : null, + engineLines: isEngineAvailable && numEvalLines > 0 + ? EngineLines( + onTapMove: ref.read(ctrlProvider.notifier).onUserMove, + clientEval: currentNode.eval, + isGameOver: currentNode.position.isGameOver, + ) + : null, + bottomBar: _BottomBar(options: options), + children: [ + OpeningExplorerView(options: options), + AnalysisTreeView(options), + if (options.gameId != null) ServerAnalysisSummary(options), ], ); } } -class _EngineGaugeVertical extends ConsumerWidget { - const _EngineGaugeVertical(this.ctrlProvider); - - final AnalysisControllerProvider ctrlProvider; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final analysisState = ref.watch(ctrlProvider); - - return Container( - clipBehavior: Clip.hardEdge, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(4.0), - ), - child: EngineGauge( - displayMode: EngineGaugeDisplayMode.vertical, - params: analysisState.engineGaugeParams, - ), - ); - } -} - -class _ColumnTopTable extends ConsumerWidget { - const _ColumnTopTable(this.ctrlProvider); - - final AnalysisControllerProvider ctrlProvider; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final analysisState = ref.watch(ctrlProvider); - final showEvaluationGauge = ref.watch( - analysisPreferencesProvider.select((p) => p.showEvaluationGauge), - ); - - return analysisState.hasAvailableEval - ? Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showEvaluationGauge) - EngineGauge( - displayMode: EngineGaugeDisplayMode.horizontal, - params: analysisState.engineGaugeParams, - ), - if (analysisState.isEngineAvailable) - EngineLines( - clientEval: analysisState.currentNode.eval, - isGameOver: analysisState.currentNode.position.isGameOver, - onTapMove: ref.read(ctrlProvider.notifier).onUserMove, - ), - ], - ) - : kEmptyWidget; - } -} - class _BottomBar extends ConsumerWidget { - const _BottomBar({ - required this.pgn, - required this.options, - }); + const _BottomBar({required this.options}); - final String pgn; final AnalysisOptions options; @override Widget build(BuildContext context, WidgetRef ref) { - final ctrlProvider = analysisControllerProvider(pgn, options); - final analysisState = ref.watch(ctrlProvider); - final isOnline = - ref.watch(connectivityChangesProvider).valueOrNull?.isOnline ?? false; + final ctrlProvider = analysisControllerProvider(options); + final analysisState = ref.watch(ctrlProvider).requireValue; return BottomBar( children: [ @@ -491,37 +245,10 @@ class _BottomBar extends ConsumerWidget { }, icon: Icons.menu, ), - if (analysisState.canShowGameSummary) - BottomBarButton( - // TODO: l10n - label: analysisState.displayMode == DisplayMode.summary - ? 'Moves' - : 'Summary', - onTap: () { - final newMode = analysisState.displayMode == DisplayMode.summary - ? DisplayMode.moves - : DisplayMode.summary; - ref.read(ctrlProvider.notifier).setDisplayMode(newMode); - }, - icon: analysisState.displayMode == DisplayMode.summary - ? LichessIcons.flow_cascade - : Icons.area_chart, - ), BottomBarButton( - label: context.l10n.openingExplorer, - onTap: isOnline - ? () { - pushPlatformRoute( - context, - title: context.l10n.openingExplorer, - builder: (_) => OpeningExplorerScreen( - pgn: ref.read(ctrlProvider.notifier).makeCurrentNodePgn(), - options: analysisState.openingExplorerOptions, - ), - ); - } - : null, - icon: Icons.explore, + label: context.l10n.flipBoard, + onTap: () => ref.read(ctrlProvider.notifier).toggleBoard(), + icon: CupertinoIcons.arrow_2_squarepath, ), RepeatButton( onLongPress: @@ -549,10 +276,9 @@ class _BottomBar extends ConsumerWidget { } void _moveForward(WidgetRef ref) => - ref.read(analysisControllerProvider(pgn, options).notifier).userNext(); - void _moveBackward(WidgetRef ref) => ref - .read(analysisControllerProvider(pgn, options).notifier) - .userPrevious(); + ref.read(analysisControllerProvider(options).notifier).userNext(); + void _moveBackward(WidgetRef ref) => + ref.read(analysisControllerProvider(options).notifier).userPrevious(); Future _showAnalysisMenu(BuildContext context, WidgetRef ref) { return showAdaptiveActionSheet( @@ -562,7 +288,7 @@ class _BottomBar extends ConsumerWidget { makeLabel: (context) => Text(context.l10n.flipBoard), onPressed: (context) { ref - .read(analysisControllerProvider(pgn, options).notifier) + .read(analysisControllerProvider(options).notifier) .toggleBoard(); }, ), @@ -570,7 +296,7 @@ class _BottomBar extends ConsumerWidget { makeLabel: (context) => Text(context.l10n.boardEditor), onPressed: (context) { final analysisState = - ref.read(analysisControllerProvider(pgn, options)); + ref.read(analysisControllerProvider(options)).requireValue; final boardFen = analysisState.position.fen; pushPlatformRoute( context, @@ -587,7 +313,7 @@ class _BottomBar extends ConsumerWidget { pushPlatformRoute( context, title: context.l10n.studyShareAndExport, - builder: (_) => AnalysisShareScreen(pgn: pgn, options: options), + builder: (_) => AnalysisShareScreen(options: options), ); }, ), @@ -595,26 +321,26 @@ class _BottomBar extends ConsumerWidget { makeLabel: (context) => Text(context.l10n.mobileSharePositionAsFEN), onPressed: (_) { final analysisState = - ref.read(analysisControllerProvider(pgn, options)); + ref.read(analysisControllerProvider(options)).requireValue; launchShareDialog( context, text: analysisState.position.fen, ); }, ), - if (options.gameAnyId != null) + if (options.gameId != null) BottomSheetAction( makeLabel: (context) => Text(context.l10n.screenshotCurrentPosition), onPressed: (_) async { - final gameId = options.gameAnyId!.gameId; + final gameId = options.gameId!; final analysisState = - ref.read(analysisControllerProvider(pgn, options)); + ref.read(analysisControllerProvider(options)).requireValue; try { final image = await ref.read(gameShareServiceProvider).screenshotPosition( gameId, - options.orientation, + analysisState.pov, analysisState.position.fen, analysisState.lastMove, ); @@ -642,601 +368,3 @@ class _BottomBar extends ConsumerWidget { ); } } - -class _EngineDepth extends ConsumerWidget { - const _EngineDepth(this.ctrlProvider); - - final AnalysisControllerProvider ctrlProvider; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final isEngineAvailable = ref.watch( - ctrlProvider.select( - (value) => value.isEngineAvailable, - ), - ); - final currentNode = ref.watch( - ctrlProvider.select((value) => value.currentNode), - ); - final depth = ref.watch( - engineEvaluationProvider.select((value) => value.eval?.depth), - ) ?? - currentNode.eval?.depth; - - return isEngineAvailable && depth != null - ? AppBarTextButton( - onPressed: () { - showPopover( - context: context, - bodyBuilder: (context) { - return _StockfishInfo(currentNode); - }, - direction: PopoverDirection.top, - width: 240, - backgroundColor: - Theme.of(context).platform == TargetPlatform.android - ? Theme.of(context).dialogBackgroundColor - : CupertinoDynamicColor.resolve( - CupertinoColors.tertiarySystemBackground, - context, - ), - transitionDuration: Duration.zero, - popoverTransitionBuilder: (_, child) => child, - ); - }, - child: RepaintBoundary( - child: Container( - width: 20.0, - height: 20.0, - padding: const EdgeInsets.all(2.0), - decoration: BoxDecoration( - color: Theme.of(context).platform == TargetPlatform.android - ? Theme.of(context).colorScheme.secondary - : CupertinoTheme.of(context).primaryColor, - borderRadius: BorderRadius.circular(4.0), - ), - child: FittedBox( - fit: BoxFit.contain, - child: Text( - '${math.min(99, depth)}', - style: TextStyle( - color: Theme.of(context).platform == - TargetPlatform.android - ? Theme.of(context).colorScheme.onSecondary - : CupertinoTheme.of(context).primaryContrastingColor, - fontFeatures: const [ - FontFeature.tabularFigures(), - ], - ), - ), - ), - ), - ), - ) - : const SizedBox.shrink(); - } -} - -class _StockfishInfo extends ConsumerWidget { - const _StockfishInfo(this.currentNode); - - final AnalysisCurrentNode currentNode; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final (engineName: engineName, eval: eval, state: engineState) = - ref.watch(engineEvaluationProvider); - - final currentEval = eval ?? currentNode.eval; - - final knps = engineState == EngineState.computing - ? ', ${eval?.knps.round()}kn/s' - : ''; - final depth = currentEval?.depth ?? 0; - final maxDepth = math.max(depth, kMaxEngineDepth); - - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - PlatformListTile( - leading: Image.asset( - 'assets/images/stockfish/icon.png', - width: 44, - height: 44, - ), - title: Text(engineName), - subtitle: Text( - context.l10n.depthX( - '$depth/$maxDepth$knps', - ), - ), - ), - ], - ); - } -} - -class ServerAnalysisSummary extends ConsumerWidget { - const ServerAnalysisSummary(this.pgn, this.options); - - final String pgn; - final AnalysisOptions options; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final ctrlProvider = analysisControllerProvider(pgn, options); - final playersAnalysis = - ref.watch(ctrlProvider.select((value) => value.playersAnalysis)); - final pgnHeaders = - ref.watch(ctrlProvider.select((value) => value.pgnHeaders)); - final currentGameAnalysis = ref.watch(currentAnalysisProvider); - - return playersAnalysis != null - ? ListView( - children: [ - if (currentGameAnalysis == options.gameAnyId?.gameId) - const Padding( - padding: EdgeInsets.only(top: 16.0), - child: WaitingForServerAnalysis(), - ), - AcplChart(pgn, options), - Center( - child: SizedBox( - width: math.min(MediaQuery.sizeOf(context).width, 500), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Table( - defaultVerticalAlignment: - TableCellVerticalAlignment.middle, - columnWidths: const { - 0: FlexColumnWidth(1), - 1: FlexColumnWidth(1), - 2: FlexColumnWidth(1), - }, - children: [ - TableRow( - decoration: const BoxDecoration( - border: Border( - bottom: BorderSide(color: Colors.grey), - ), - ), - children: [ - _SummaryPlayerName(Side.white, pgnHeaders), - Center( - child: Text( - pgnHeaders.get('Result') ?? '', - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ), - ), - _SummaryPlayerName(Side.black, pgnHeaders), - ], - ), - if (playersAnalysis.white.accuracy != null && - playersAnalysis.black.accuracy != null) - TableRow( - children: [ - _SummaryNumber( - '${playersAnalysis.white.accuracy}%', - ), - Center( - heightFactor: 1.8, - child: Text( - context.l10n.accuracy, - softWrap: true, - ), - ), - _SummaryNumber( - '${playersAnalysis.black.accuracy}%', - ), - ], - ), - for (final item in [ - ( - playersAnalysis.white.inaccuracies.toString(), - context.l10n - .nbInaccuracies(2) - .replaceAll('2', '') - .trim() - .capitalize(), - playersAnalysis.black.inaccuracies.toString() - ), - ( - playersAnalysis.white.mistakes.toString(), - context.l10n - .nbMistakes(2) - .replaceAll('2', '') - .trim() - .capitalize(), - playersAnalysis.black.mistakes.toString() - ), - ( - playersAnalysis.white.blunders.toString(), - context.l10n - .nbBlunders(2) - .replaceAll('2', '') - .trim() - .capitalize(), - playersAnalysis.black.blunders.toString() - ), - ]) - TableRow( - children: [ - _SummaryNumber(item.$1), - Center( - heightFactor: 1.2, - child: Text( - item.$2, - softWrap: true, - ), - ), - _SummaryNumber(item.$3), - ], - ), - if (playersAnalysis.white.acpl != null && - playersAnalysis.black.acpl != null) - TableRow( - children: [ - _SummaryNumber( - playersAnalysis.white.acpl.toString(), - ), - Center( - heightFactor: 1.5, - child: Text( - context.l10n.averageCentipawnLoss, - softWrap: true, - textAlign: TextAlign.center, - ), - ), - _SummaryNumber( - playersAnalysis.black.acpl.toString(), - ), - ], - ), - ], - ), - ), - ), - ), - ], - ) - : Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Spacer(), - if (currentGameAnalysis == options.gameAnyId?.gameId) - const Center( - child: Padding( - padding: EdgeInsets.symmetric(vertical: 16.0), - child: WaitingForServerAnalysis(), - ), - ) - else - Center( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16.0), - child: Builder( - builder: (context) { - Future? pendingRequest; - return StatefulBuilder( - builder: (context, setState) { - return FutureBuilder( - future: pendingRequest, - builder: (context, snapshot) { - return SecondaryButton( - semanticsLabel: - context.l10n.requestAComputerAnalysis, - onPressed: ref.watch(authSessionProvider) == - null - ? () { - showPlatformSnackbar( - context, - context - .l10n.youNeedAnAccountToDoThat, - ); - } - : snapshot.connectionState == - ConnectionState.waiting - ? null - : () { - setState(() { - pendingRequest = ref - .read(ctrlProvider.notifier) - .requestServerAnalysis() - .catchError((Object e) { - if (context.mounted) { - showPlatformSnackbar( - context, - e.toString(), - type: SnackBarType.error, - ); - } - }); - }); - }, - child: Text( - context.l10n.requestAComputerAnalysis, - ), - ); - }, - ); - }, - ); - }, - ), - ), - ), - const Spacer(), - ], - ); - } -} - -class WaitingForServerAnalysis extends StatelessWidget { - const WaitingForServerAnalysis({super.key}); - - @override - Widget build(BuildContext context) { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.max, - children: [ - Image.asset( - 'assets/images/stockfish/icon.png', - width: 30, - height: 30, - ), - const SizedBox(width: 8.0), - Text(context.l10n.waitingForAnalysis), - const SizedBox(width: 8.0), - const CircularProgressIndicator.adaptive(), - ], - ); - } -} - -class _SummaryNumber extends StatelessWidget { - const _SummaryNumber(this.data); - final String data; - - @override - Widget build(BuildContext context) { - return Center( - child: Text( - data, - softWrap: true, - ), - ); - } -} - -class _SummaryPlayerName extends StatelessWidget { - const _SummaryPlayerName(this.side, this.pgnHeaders); - final Side side; - final IMap pgnHeaders; - - @override - Widget build(BuildContext context) { - final playerTitle = side == Side.white - ? pgnHeaders.get('WhiteTitle') - : pgnHeaders.get('BlackTitle'); - final playerName = side == Side.white - ? pgnHeaders.get('White') ?? context.l10n.white - : pgnHeaders.get('Black') ?? context.l10n.black; - - final brightness = Theme.of(context).brightness; - - return TableCell( - verticalAlignment: TableCellVerticalAlignment.top, - child: Center( - child: Padding( - padding: const EdgeInsets.only(bottom: 5), - child: Column( - children: [ - Icon( - side == Side.white - ? brightness == Brightness.light - ? CupertinoIcons.circle - : CupertinoIcons.circle_filled - : brightness == Brightness.light - ? CupertinoIcons.circle_filled - : CupertinoIcons.circle, - size: 14, - ), - Text( - '${playerTitle != null ? '$playerTitle ' : ''}$playerName', - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - textAlign: TextAlign.center, - softWrap: true, - ), - ], - ), - ), - ), - ); - } -} - -class AcplChart extends ConsumerWidget { - const AcplChart(this.pgn, this.options); - - final String pgn; - final AnalysisOptions options; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final mainLineColor = Theme.of(context).colorScheme.secondary; - // yes it looks like below/above are inverted in fl_chart - final brightness = Theme.of(context).brightness; - final white = Theme.of(context).colorScheme.surfaceContainerHighest; - final black = Theme.of(context).colorScheme.outline; - // yes it looks like below/above are inverted in fl_chart - final belowLineColor = brightness == Brightness.light ? white : black; - final aboveLineColor = brightness == Brightness.light ? black : white; - - VerticalLine phaseVerticalBar(double x, String label) => VerticalLine( - x: x, - color: const Color(0xFF707070), - strokeWidth: 0.5, - label: VerticalLineLabel( - style: TextStyle( - fontSize: 10, - color: Theme.of(context) - .textTheme - .labelMedium - ?.color - ?.withValues(alpha: 0.3), - ), - labelResolver: (line) => label, - padding: const EdgeInsets.only(right: 1), - alignment: Alignment.topRight, - direction: LabelDirection.vertical, - show: true, - ), - ); - - final data = ref.watch( - analysisControllerProvider(pgn, options) - .select((value) => value.acplChartData), - ); - - final rootPly = ref.watch( - analysisControllerProvider(pgn, options) - .select((value) => value.root.position.ply), - ); - - final currentNode = ref.watch( - analysisControllerProvider(pgn, options) - .select((value) => value.currentNode), - ); - - final isOnMainline = ref.watch( - analysisControllerProvider(pgn, options) - .select((value) => value.isOnMainline), - ); - - if (data == null) { - return const SizedBox.shrink(); - } - - final spots = data - .mapIndexed( - (i, e) => FlSpot(i.toDouble(), e.winningChances(Side.white)), - ) - .toList(growable: false); - - final divisionLines = []; - - if (options.division?.middlegame != null) { - if (options.division!.middlegame! > 0) { - divisionLines.add(phaseVerticalBar(0.0, context.l10n.opening)); - divisionLines.add( - phaseVerticalBar( - options.division!.middlegame! - 1, - context.l10n.middlegame, - ), - ); - } else { - divisionLines.add(phaseVerticalBar(0.0, context.l10n.middlegame)); - } - } - - if (options.division?.endgame != null) { - if (options.division!.endgame! > 0) { - divisionLines.add( - phaseVerticalBar( - options.division!.endgame! - 1, - context.l10n.endgame, - ), - ); - } else { - divisionLines.add( - phaseVerticalBar( - 0.0, - context.l10n.endgame, - ), - ); - } - } - return Center( - child: AspectRatio( - aspectRatio: 2.5, - child: Padding( - padding: const EdgeInsets.all(16.0), - child: LineChart( - LineChartData( - lineTouchData: LineTouchData( - enabled: false, - touchCallback: - (FlTouchEvent event, LineTouchResponse? touchResponse) { - if (event is FlTapDownEvent || - event is FlPanUpdateEvent || - event is FlLongPressMoveUpdate) { - final touchX = event.localPosition!.dx; - final chartWidth = context.size!.width - - 32; // Insets on both sides of the chart of 16 - final minX = spots.first.x; - final maxX = spots.last.x; - final touchXDataValue = - minX + (touchX / chartWidth) * (maxX - minX); - final closestSpot = spots.reduce( - (a, b) => (a.x - touchXDataValue).abs() < - (b.x - touchXDataValue).abs() - ? a - : b, - ); - final closestNodeIndex = closestSpot.x.round(); - ref - .read(analysisControllerProvider(pgn, options).notifier) - .jumpToNthNodeOnMainline(closestNodeIndex); - } - }, - ), - minY: -1.0, - maxY: 1.0, - lineBarsData: [ - LineChartBarData( - spots: spots, - isCurved: false, - barWidth: 1, - color: mainLineColor.withValues(alpha: 0.7), - aboveBarData: BarAreaData( - show: true, - color: aboveLineColor, - applyCutOffY: true, - ), - belowBarData: BarAreaData( - show: true, - color: belowLineColor, - applyCutOffY: true, - ), - dotData: const FlDotData( - show: false, - ), - ), - ], - extraLinesData: ExtraLinesData( - verticalLines: [ - if (isOnMainline) - VerticalLine( - x: (currentNode.position.ply - 1 - rootPly).toDouble(), - color: mainLineColor, - strokeWidth: 1.0, - ), - ...divisionLines, - ], - ), - gridData: const FlGridData(show: false), - borderData: FlBorderData(show: false), - titlesData: const FlTitlesData(show: false), - ), - ), - ), - ), - ); - } -} diff --git a/lib/src/view/analysis/analysis_settings.dart b/lib/src/view/analysis/analysis_settings.dart index f80fb44473..7571566adc 100644 --- a/lib/src/view/analysis/analysis_settings.dart +++ b/lib/src/view/analysis/analysis_settings.dart @@ -1,141 +1,175 @@ 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/analysis/analysis_preferences.dart'; import 'package:lichess_mobile/src/model/engine/evaluation_service.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/view/opening_explorer/opening_explorer_settings.dart'; import 'package:lichess_mobile/src/widgets/adaptive_bottom_sheet.dart'; +import 'package:lichess_mobile/src/widgets/feedback.dart'; import 'package:lichess_mobile/src/widgets/list.dart'; import 'package:lichess_mobile/src/widgets/non_linear_slider.dart'; import 'package:lichess_mobile/src/widgets/settings.dart'; class AnalysisSettings extends ConsumerWidget { - const AnalysisSettings(this.pgn, this.options); + const AnalysisSettings(this.options); - final String pgn; final AnalysisOptions options; @override Widget build(BuildContext context, WidgetRef ref) { - final ctrlProvider = analysisControllerProvider(pgn, options); - final isLocalEvaluationAllowed = - ref.watch(ctrlProvider.select((s) => s.isLocalEvaluationAllowed)); - final isEngineAvailable = ref.watch( - ctrlProvider.select((s) => s.isEngineAvailable), - ); + final ctrlProvider = analysisControllerProvider(options); final prefs = ref.watch(analysisPreferencesProvider); + final asyncState = ref.watch(ctrlProvider); final isSoundEnabled = ref.watch( generalPreferencesProvider.select((pref) => pref.isSoundEnabled), ); - return BottomSheetScrollableContainer( - children: [ - SwitchSettingTile( - title: Text(context.l10n.toggleLocalEvaluation), - value: prefs.enableLocalEvaluation, - onChanged: isLocalEvaluationAllowed - ? (_) { - ref.read(ctrlProvider.notifier).toggleLocalEvaluation(); - } - : null, - ), - PlatformListTile( - title: Text.rich( - TextSpan( - text: '${context.l10n.multipleLines}: ', - style: const TextStyle( - fontWeight: FontWeight.normal, + switch (asyncState) { + case AsyncData(:final value): + return BottomSheetScrollableContainer( + children: [ + PlatformListTile( + title: Text(context.l10n.openingExplorer), + onTap: () => showAdaptiveBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + isDismissible: true, + builder: (_) => OpeningExplorerSettings(options), ), - children: [ - TextSpan( - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 18, - ), - text: prefs.numEvalLines.toString(), - ), - ], + trailing: const Icon(CupertinoIcons.chevron_right), ), - ), - subtitle: NonLinearSlider( - value: prefs.numEvalLines, - values: const [0, 1, 2, 3], - onChangeEnd: isEngineAvailable - ? (value) => ref - .read(ctrlProvider.notifier) - .setNumEvalLines(value.toInt()) - : null, - ), - ), - if (maxEngineCores > 1) - PlatformListTile( - title: Text.rich( - TextSpan( - text: '${context.l10n.cpus}: ', - style: const TextStyle( - fontWeight: FontWeight.normal, - ), + if (value.isComputerAnalysisAllowed) + SwitchSettingTile( + title: Text(context.l10n.computerAnalysis), + value: prefs.enableComputerAnalysis, + onChanged: (_) { + ref.read(ctrlProvider.notifier).toggleComputerAnalysis(); + }, + ), + AnimatedCrossFade( + duration: const Duration(milliseconds: 300), + crossFadeState: value.isComputerAnalysisAllowedAndEnabled + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + firstChild: const SizedBox.shrink(), + secondChild: Column( + mainAxisSize: MainAxisSize.min, children: [ - TextSpan( - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 18, + SwitchSettingTile( + title: Text(context.l10n.toggleLocalEvaluation), + value: prefs.enableLocalEvaluation, + onChanged: (_) { + ref.read(ctrlProvider.notifier).toggleLocalEvaluation(); + }, + ), + PlatformListTile( + title: Text.rich( + TextSpan( + text: '${context.l10n.multipleLines}: ', + style: const TextStyle( + fontWeight: FontWeight.normal, + ), + children: [ + TextSpan( + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + ), + text: prefs.numEvalLines.toString(), + ), + ], + ), + ), + subtitle: NonLinearSlider( + value: prefs.numEvalLines, + values: const [0, 1, 2, 3], + onChangeEnd: value.isEngineAvailable + ? (value) => ref + .read(ctrlProvider.notifier) + .setNumEvalLines(value.toInt()) + : null, ), - text: prefs.numEngineCores.toString(), + ), + if (maxEngineCores > 1) + PlatformListTile( + title: Text.rich( + TextSpan( + text: '${context.l10n.cpus}: ', + style: const TextStyle( + fontWeight: FontWeight.normal, + ), + children: [ + TextSpan( + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + ), + text: prefs.numEngineCores.toString(), + ), + ], + ), + ), + subtitle: NonLinearSlider( + value: prefs.numEngineCores, + values: + List.generate(maxEngineCores, (index) => index + 1), + onChangeEnd: value.isEngineAvailable + ? (value) => ref + .read(ctrlProvider.notifier) + .setEngineCores(value.toInt()) + : null, + ), + ), + SwitchSettingTile( + title: Text(context.l10n.bestMoveArrow), + value: prefs.showBestMoveArrow, + onChanged: (value) => ref + .read(analysisPreferencesProvider.notifier) + .toggleShowBestMoveArrow(), + ), + SwitchSettingTile( + title: Text(context.l10n.evaluationGauge), + value: prefs.showEvaluationGauge, + onChanged: (value) => ref + .read(analysisPreferencesProvider.notifier) + .toggleShowEvaluationGauge(), + ), + SwitchSettingTile( + title: Text(context.l10n.toggleGlyphAnnotations), + value: prefs.showAnnotations, + onChanged: (_) => ref + .read(analysisPreferencesProvider.notifier) + .toggleAnnotations(), + ), + SwitchSettingTile( + title: Text(context.l10n.mobileShowComments), + value: prefs.showPgnComments, + onChanged: (_) => ref + .read(analysisPreferencesProvider.notifier) + .togglePgnComments(), ), ], ), ), - subtitle: NonLinearSlider( - value: prefs.numEngineCores, - values: List.generate(maxEngineCores, (index) => index + 1), - onChangeEnd: isEngineAvailable - ? (value) => ref - .read(ctrlProvider.notifier) - .setEngineCores(value.toInt()) - : null, + SwitchSettingTile( + title: Text(context.l10n.sound), + value: isSoundEnabled, + onChanged: (value) { + ref + .read(generalPreferencesProvider.notifier) + .toggleSoundEnabled(); + }, ), - ), - SwitchSettingTile( - title: Text(context.l10n.bestMoveArrow), - value: prefs.showBestMoveArrow, - onChanged: isEngineAvailable - ? (value) => ref - .read(analysisPreferencesProvider.notifier) - .toggleShowBestMoveArrow() - : null, - ), - SwitchSettingTile( - title: Text(context.l10n.evaluationGauge), - value: prefs.showEvaluationGauge, - onChanged: (value) => ref - .read(analysisPreferencesProvider.notifier) - .toggleShowEvaluationGauge(), - ), - SwitchSettingTile( - title: Text(context.l10n.toggleGlyphAnnotations), - value: prefs.showAnnotations, - onChanged: (_) => ref - .read(analysisPreferencesProvider.notifier) - .toggleAnnotations(), - ), - SwitchSettingTile( - title: Text(context.l10n.mobileShowComments), - value: prefs.showPgnComments, - onChanged: (_) => ref - .read(analysisPreferencesProvider.notifier) - .togglePgnComments(), - ), - SwitchSettingTile( - title: Text(context.l10n.sound), - value: isSoundEnabled, - onChanged: (value) { - ref.read(generalPreferencesProvider.notifier).toggleSoundEnabled(); - }, - ), - ], - ); + ], + ); + case AsyncError(:final error): + debugPrint('Error loading analysis: $error'); + return const SizedBox.shrink(); + case _: + return const CenterLoadingIndicator(); + } } } diff --git a/lib/src/view/analysis/analysis_share_screen.dart b/lib/src/view/analysis/analysis_share_screen.dart index 97e626ad3f..63552ffe2e 100644 --- a/lib/src/view/analysis/analysis_share_screen.dart +++ b/lib/src/view/analysis/analysis_share_screen.dart @@ -17,9 +17,8 @@ import 'package:lichess_mobile/src/widgets/platform_scaffold.dart'; final _dateFormatter = DateFormat('yyyy.MM.dd'); class AnalysisShareScreen extends StatelessWidget { - const AnalysisShareScreen({required this.pgn, required this.options}); + const AnalysisShareScreen({required this.options}); - final String pgn; final AnalysisOptions options; @override @@ -28,7 +27,7 @@ class AnalysisShareScreen extends StatelessWidget { appBar: PlatformAppBar( title: Text(context.l10n.studyShareAndExport), ), - body: _EditPgnTagsForm(pgn, options), + body: _EditPgnTagsForm(options), ); } } @@ -41,9 +40,8 @@ const Set _ratingHeaders = { }; class _EditPgnTagsForm extends ConsumerStatefulWidget { - const _EditPgnTagsForm(this.pgn, this.options); + const _EditPgnTagsForm(this.options); - final String pgn; final AnalysisOptions options; @override @@ -57,8 +55,8 @@ class _EditPgnTagsFormState extends ConsumerState<_EditPgnTagsForm> { @override void initState() { super.initState(); - final ctrlProvider = analysisControllerProvider(widget.pgn, widget.options); - final pgnHeaders = ref.read(ctrlProvider).pgnHeaders; + final ctrlProvider = analysisControllerProvider(widget.options); + final pgnHeaders = ref.read(ctrlProvider).requireValue.pgnHeaders; for (final entry in pgnHeaders.entries) { _controllers[entry.key] = TextEditingController(text: entry.value); @@ -87,8 +85,9 @@ class _EditPgnTagsFormState extends ConsumerState<_EditPgnTagsForm> { @override Widget build(BuildContext context) { - final ctrlProvider = analysisControllerProvider(widget.pgn, widget.options); - final pgnHeaders = ref.watch(ctrlProvider.select((c) => c.pgnHeaders)); + final ctrlProvider = analysisControllerProvider(widget.options); + final pgnHeaders = + ref.watch(ctrlProvider.select((c) => c.requireValue.pgnHeaders)); final showRatingAsync = ref.watch(showRatingsPrefProvider); void focusAndSelectNextField(int index, IMap pgnHeaders) { @@ -181,7 +180,6 @@ class _EditPgnTagsFormState extends ConsumerState<_EditPgnTagsForm> { text: ref .read( analysisControllerProvider( - widget.pgn, widget.options, ).notifier, ) @@ -207,7 +205,7 @@ class _EditPgnTagsFormState extends ConsumerState<_EditPgnTagsForm> { required BuildContext context, required void Function() onEntryChanged, }) { - final ctrlProvider = analysisControllerProvider(widget.pgn, widget.options); + final ctrlProvider = analysisControllerProvider(widget.options); if (Theme.of(context).platform == TargetPlatform.iOS) { return showCupertinoModalPopup( context: context, @@ -272,7 +270,7 @@ class _EditPgnTagsFormState extends ConsumerState<_EditPgnTagsForm> { onSelectedItemChanged: (choice) { ref .read( - analysisControllerProvider(widget.pgn, widget.options).notifier, + analysisControllerProvider(widget.options).notifier, ) .updatePgnHeader( entry.key, diff --git a/lib/src/view/analysis/opening_explorer_view.dart b/lib/src/view/analysis/opening_explorer_view.dart new file mode 100644 index 0000000000..df320b632a --- /dev/null +++ b/lib/src/view/analysis/opening_explorer_view.dart @@ -0,0 +1,243 @@ +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/opening_explorer/opening_explorer.dart'; +import 'package:lichess_mobile/src/model/opening_explorer/opening_explorer_preferences.dart'; +import 'package:lichess_mobile/src/model/opening_explorer/opening_explorer_repository.dart'; +import 'package:lichess_mobile/src/network/connectivity.dart'; +import 'package:lichess_mobile/src/utils/l10n_context.dart'; +import 'package:lichess_mobile/src/view/opening_explorer/opening_explorer_widgets.dart'; +import 'package:lichess_mobile/src/widgets/feedback.dart'; +import 'package:lichess_mobile/src/widgets/shimmer.dart'; + +const _kTableRowVerticalPadding = 12.0; +const _kTableRowHorizontalPadding = 8.0; +const _kTableRowPadding = EdgeInsets.symmetric( + horizontal: _kTableRowHorizontalPadding, + vertical: _kTableRowVerticalPadding, +); + +class OpeningExplorerView extends ConsumerStatefulWidget { + const OpeningExplorerView({required this.options}); + + final AnalysisOptions options; + + @override + ConsumerState createState() => _OpeningExplorerState(); +} + +class _OpeningExplorerState extends ConsumerState { + final Map cache = {}; + + /// Last explorer content that was successfully loaded. This is used to + /// display a loading indicator while the new content is being fetched. + List? lastExplorerWidgets; + + @override + Widget build(BuildContext context) { + final connectivity = ref.watch(connectivityChangesProvider); + return connectivity.whenIsLoading( + loading: () => const CenterLoadingIndicator(), + offline: () => const Center( + child: Padding( + padding: EdgeInsets.all(16.0), + // TODO l10n + child: Text('Opening explorer is not available offline.'), + ), + ), + online: () { + final analysisState = + ref.watch(analysisControllerProvider(widget.options)).requireValue; + + if (analysisState.position.ply >= 50) { + return _OpeningExplorerView( + isLoading: false, + children: [ + OpeningExplorerMoveTable.maxDepth( + options: widget.options, + ), + ], + ); + } + + final prefs = ref.watch(openingExplorerPreferencesProvider); + + if (prefs.db == OpeningDatabase.player && + prefs.playerDb.username == null) { + return const _OpeningExplorerView( + isLoading: false, + children: [ + Padding( + padding: _kTableRowPadding, + child: Center( + // TODO: l10n + child: Text('Select a Lichess player in the settings.'), + ), + ), + ], + ); + } + + final cacheKey = OpeningExplorerCacheKey( + fen: analysisState.position.fen, + prefs: prefs, + ); + final cacheOpeningExplorer = cache[cacheKey]; + final openingExplorerAsync = cacheOpeningExplorer != null + ? AsyncValue.data( + (entry: cacheOpeningExplorer, isIndexing: false), + ) + : ref.watch( + openingExplorerProvider(fen: analysisState.position.fen), + ); + + if (cacheOpeningExplorer == null) { + ref.listen(openingExplorerProvider(fen: analysisState.position.fen), + (_, curAsync) { + curAsync.whenData((cur) { + if (cur != null && !cur.isIndexing) { + cache[cacheKey] = cur.entry; + } + }); + }); + } + + final isLoading = openingExplorerAsync.isLoading || + openingExplorerAsync.value == null; + + return _OpeningExplorerView( + isLoading: isLoading, + children: openingExplorerAsync.when( + data: (openingExplorer) { + if (openingExplorer == null) { + return lastExplorerWidgets ?? + [ + Shimmer( + child: ShimmerLoading( + isLoading: true, + child: OpeningExplorerMoveTable.loading( + options: widget.options, + ), + ), + ), + ]; + } + + final topGames = openingExplorer.entry.topGames; + final recentGames = openingExplorer.entry.recentGames; + + final ply = analysisState.position.ply; + + final children = [ + OpeningExplorerMoveTable( + moves: openingExplorer.entry.moves, + whiteWins: openingExplorer.entry.white, + draws: openingExplorer.entry.draws, + blackWins: openingExplorer.entry.black, + options: widget.options, + ), + if (topGames != null && topGames.isNotEmpty) ...[ + OpeningExplorerHeaderTile( + key: const Key('topGamesHeader'), + child: Text(context.l10n.topGames), + ), + ...List.generate( + topGames.length, + (int index) { + return OpeningExplorerGameTile( + key: Key('top-game-${topGames.get(index).id}'), + game: topGames.get(index), + color: index.isEven + ? Theme.of(context).colorScheme.surfaceContainerLow + : Theme.of(context) + .colorScheme + .surfaceContainerHigh, + ply: ply, + ); + }, + growable: false, + ), + ], + if (recentGames != null && recentGames.isNotEmpty) ...[ + OpeningExplorerHeaderTile( + key: const Key('recentGamesHeader'), + child: Text(context.l10n.recentGames), + ), + ...List.generate( + recentGames.length, + (int index) { + return OpeningExplorerGameTile( + key: Key('recent-game-${recentGames.get(index).id}'), + game: recentGames.get(index), + color: index.isEven + ? Theme.of(context).colorScheme.surfaceContainerLow + : Theme.of(context) + .colorScheme + .surfaceContainerHigh, + ply: ply, + ); + }, + growable: false, + ), + ], + ]; + + lastExplorerWidgets = children; + + return children; + }, + loading: () => + lastExplorerWidgets ?? + [ + Shimmer( + child: ShimmerLoading( + isLoading: true, + child: OpeningExplorerMoveTable.loading( + options: widget.options, + ), + ), + ), + ], + error: (e, s) { + debugPrint( + 'SEVERE: [OpeningExplorerView] could not load opening explorer data; $e\n$s', + ); + return [ + Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text(e.toString()), + ), + ), + ]; + }, + ), + ); + }, + ); + } +} + +class _OpeningExplorerView extends StatelessWidget { + const _OpeningExplorerView({ + required this.children, + required this.isLoading, + }); + + final List children; + final bool isLoading; + + @override + Widget build(BuildContext context) { + final loadingOverlay = Positioned.fill( + child: IgnorePointer(ignoring: !isLoading), + ); + + return Stack( + children: [ + ListView(children: children), + loadingOverlay, + ], + ); + } +} diff --git a/lib/src/view/analysis/server_analysis.dart b/lib/src/view/analysis/server_analysis.dart new file mode 100644 index 0000000000..ae54ce5de7 --- /dev/null +++ b/lib/src/view/analysis/server_analysis.dart @@ -0,0 +1,515 @@ +import 'dart:math' as math; + +import 'package:collection/collection.dart'; +import 'package:dartchess/dartchess.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:fl_chart/fl_chart.dart'; +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/analysis/analysis_preferences.dart'; +import 'package:lichess_mobile/src/model/analysis/server_analysis_service.dart'; +import 'package:lichess_mobile/src/model/auth/auth_session.dart'; +import 'package:lichess_mobile/src/utils/l10n_context.dart'; +import 'package:lichess_mobile/src/utils/string.dart'; +import 'package:lichess_mobile/src/widgets/buttons.dart'; +import 'package:lichess_mobile/src/widgets/feedback.dart'; + +class ServerAnalysisSummary extends ConsumerWidget { + const ServerAnalysisSummary(this.options); + + final AnalysisOptions options; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final analysisPrefs = ref.watch(analysisPreferencesProvider); + final ctrlProvider = analysisControllerProvider(options); + final playersAnalysis = ref.watch( + ctrlProvider.select((value) => value.requireValue.playersAnalysis), + ); + final canShowGameSummary = ref.watch( + ctrlProvider.select((value) => value.requireValue.canShowGameSummary), + ); + final pgnHeaders = ref + .watch(ctrlProvider.select((value) => value.requireValue.pgnHeaders)); + final currentGameAnalysis = ref.watch(currentAnalysisProvider); + + if (analysisPrefs.enableComputerAnalysis == false) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: Column( + mainAxisSize: MainAxisSize.max, + children: [ + const Spacer(), + Text(context.l10n.computerAnalysisDisabled), + if (canShowGameSummary) + SecondaryButton( + onPressed: () { + ref.read(ctrlProvider.notifier).toggleComputerAnalysis(); + }, + semanticsLabel: context.l10n.enable, + child: Text(context.l10n.enable), + ), + const Spacer(), + ], + ), + ), + ); + } + + return playersAnalysis != null + ? ListView( + children: [ + if (currentGameAnalysis == options.gameId) + const Padding( + padding: EdgeInsets.only(top: 16.0), + child: WaitingForServerAnalysis(), + ), + AcplChart(options), + Center( + child: SizedBox( + width: math.min(MediaQuery.sizeOf(context).width, 500), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Table( + defaultVerticalAlignment: + TableCellVerticalAlignment.middle, + columnWidths: const { + 0: FlexColumnWidth(1), + 1: FlexColumnWidth(1), + 2: FlexColumnWidth(1), + }, + children: [ + TableRow( + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: Colors.grey), + ), + ), + children: [ + _SummaryPlayerName(Side.white, pgnHeaders), + Center( + child: Text( + pgnHeaders.get('Result') ?? '', + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + ), + _SummaryPlayerName(Side.black, pgnHeaders), + ], + ), + if (playersAnalysis.white.accuracy != null && + playersAnalysis.black.accuracy != null) + TableRow( + children: [ + _SummaryNumber( + '${playersAnalysis.white.accuracy}%', + ), + Center( + heightFactor: 1.8, + child: Text( + context.l10n.accuracy, + softWrap: true, + ), + ), + _SummaryNumber( + '${playersAnalysis.black.accuracy}%', + ), + ], + ), + for (final item in [ + ( + playersAnalysis.white.inaccuracies.toString(), + context.l10n + .nbInaccuracies(2) + .replaceAll('2', '') + .trim() + .capitalize(), + playersAnalysis.black.inaccuracies.toString() + ), + ( + playersAnalysis.white.mistakes.toString(), + context.l10n + .nbMistakes(2) + .replaceAll('2', '') + .trim() + .capitalize(), + playersAnalysis.black.mistakes.toString() + ), + ( + playersAnalysis.white.blunders.toString(), + context.l10n + .nbBlunders(2) + .replaceAll('2', '') + .trim() + .capitalize(), + playersAnalysis.black.blunders.toString() + ), + ]) + TableRow( + children: [ + _SummaryNumber(item.$1), + Center( + heightFactor: 1.2, + child: Text( + item.$2, + softWrap: true, + ), + ), + _SummaryNumber(item.$3), + ], + ), + if (playersAnalysis.white.acpl != null && + playersAnalysis.black.acpl != null) + TableRow( + children: [ + _SummaryNumber( + playersAnalysis.white.acpl.toString(), + ), + Center( + heightFactor: 1.5, + child: Text( + context.l10n.averageCentipawnLoss, + softWrap: true, + textAlign: TextAlign.center, + ), + ), + _SummaryNumber( + playersAnalysis.black.acpl.toString(), + ), + ], + ), + ], + ), + ), + ), + ), + ], + ) + : Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Spacer(), + if (currentGameAnalysis == options.gameId) + const Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 16.0), + child: WaitingForServerAnalysis(), + ), + ) + else + Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: Builder( + builder: (context) { + Future? pendingRequest; + return StatefulBuilder( + builder: (context, setState) { + return FutureBuilder( + future: pendingRequest, + builder: (context, snapshot) { + return SecondaryButton( + semanticsLabel: + context.l10n.requestAComputerAnalysis, + onPressed: ref.watch(authSessionProvider) == + null + ? () { + showPlatformSnackbar( + context, + context + .l10n.youNeedAnAccountToDoThat, + ); + } + : snapshot.connectionState == + ConnectionState.waiting + ? null + : () { + setState(() { + pendingRequest = ref + .read(ctrlProvider.notifier) + .requestServerAnalysis() + .catchError((Object e) { + if (context.mounted) { + showPlatformSnackbar( + context, + e.toString(), + type: SnackBarType.error, + ); + } + }); + }); + }, + child: Text( + context.l10n.requestAComputerAnalysis, + ), + ); + }, + ); + }, + ); + }, + ), + ), + ), + const Spacer(), + ], + ); + } +} + +class WaitingForServerAnalysis extends StatelessWidget { + const WaitingForServerAnalysis({super.key}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + Image.asset( + 'assets/images/stockfish/icon.png', + width: 30, + height: 30, + ), + const SizedBox(width: 8.0), + Text(context.l10n.waitingForAnalysis), + const SizedBox(width: 8.0), + const CircularProgressIndicator.adaptive(), + ], + ); + } +} + +class _SummaryNumber extends StatelessWidget { + const _SummaryNumber(this.data); + final String data; + + @override + Widget build(BuildContext context) { + return Center( + child: Text( + data, + softWrap: true, + ), + ); + } +} + +class _SummaryPlayerName extends StatelessWidget { + const _SummaryPlayerName(this.side, this.pgnHeaders); + final Side side; + final IMap pgnHeaders; + + @override + Widget build(BuildContext context) { + final playerTitle = side == Side.white + ? pgnHeaders.get('WhiteTitle') + : pgnHeaders.get('BlackTitle'); + final playerName = side == Side.white + ? pgnHeaders.get('White') ?? context.l10n.white + : pgnHeaders.get('Black') ?? context.l10n.black; + + final brightness = Theme.of(context).brightness; + + return TableCell( + verticalAlignment: TableCellVerticalAlignment.top, + child: Center( + child: Padding( + padding: const EdgeInsets.only(bottom: 5), + child: Column( + children: [ + Icon( + side == Side.white + ? brightness == Brightness.light + ? CupertinoIcons.circle + : CupertinoIcons.circle_filled + : brightness == Brightness.light + ? CupertinoIcons.circle_filled + : CupertinoIcons.circle, + size: 14, + ), + Text( + '${playerTitle != null ? '$playerTitle ' : ''}$playerName', + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + softWrap: true, + ), + ], + ), + ), + ), + ); + } +} + +class AcplChart extends ConsumerWidget { + const AcplChart(this.options); + + final AnalysisOptions options; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mainLineColor = Theme.of(context).colorScheme.secondary; + // yes it looks like below/above are inverted in fl_chart + final brightness = Theme.of(context).brightness; + final white = Theme.of(context).colorScheme.surfaceContainerHighest; + final black = Theme.of(context).colorScheme.outline; + // yes it looks like below/above are inverted in fl_chart + final belowLineColor = brightness == Brightness.light ? white : black; + final aboveLineColor = brightness == Brightness.light ? black : white; + + VerticalLine phaseVerticalBar(double x, String label) => VerticalLine( + x: x, + color: const Color(0xFF707070), + strokeWidth: 0.5, + label: VerticalLineLabel( + style: TextStyle( + fontSize: 10, + color: Theme.of(context) + .textTheme + .labelMedium + ?.color + ?.withValues(alpha: 0.3), + ), + labelResolver: (line) => label, + padding: const EdgeInsets.only(right: 1), + alignment: Alignment.topRight, + direction: LabelDirection.vertical, + show: true, + ), + ); + + final state = ref.watch(analysisControllerProvider(options)).requireValue; + final data = state.acplChartData; + final rootPly = state.root.position.ply; + final currentNode = state.currentNode; + final isOnMainline = state.isOnMainline; + + if (data == null) { + return const SizedBox.shrink(); + } + + final spots = data + .mapIndexed( + (i, e) => FlSpot(i.toDouble(), e.winningChances(Side.white)), + ) + .toList(growable: false); + + final divisionLines = []; + + if (state.division?.middlegame != null) { + if (state.division!.middlegame! > 0) { + divisionLines.add(phaseVerticalBar(0.0, context.l10n.opening)); + divisionLines.add( + phaseVerticalBar( + state.division!.middlegame! - 1, + context.l10n.middlegame, + ), + ); + } else { + divisionLines.add(phaseVerticalBar(0.0, context.l10n.middlegame)); + } + } + + if (state.division?.endgame != null) { + if (state.division!.endgame! > 0) { + divisionLines.add( + phaseVerticalBar( + state.division!.endgame! - 1, + context.l10n.endgame, + ), + ); + } else { + divisionLines.add( + phaseVerticalBar( + 0.0, + context.l10n.endgame, + ), + ); + } + } + return Center( + child: AspectRatio( + aspectRatio: 2.5, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: LineChart( + LineChartData( + lineTouchData: LineTouchData( + enabled: false, + touchCallback: + (FlTouchEvent event, LineTouchResponse? touchResponse) { + if (event is FlTapDownEvent || + event is FlPanUpdateEvent || + event is FlLongPressMoveUpdate) { + final touchX = event.localPosition!.dx; + final chartWidth = context.size!.width - + 32; // Insets on both sides of the chart of 16 + final minX = spots.first.x; + final maxX = spots.last.x; + final touchXDataValue = + minX + (touchX / chartWidth) * (maxX - minX); + final closestSpot = spots.reduce( + (a, b) => (a.x - touchXDataValue).abs() < + (b.x - touchXDataValue).abs() + ? a + : b, + ); + final closestNodeIndex = closestSpot.x.round(); + ref + .read(analysisControllerProvider(options).notifier) + .jumpToNthNodeOnMainline(closestNodeIndex); + } + }, + ), + minY: -1.0, + maxY: 1.0, + lineBarsData: [ + LineChartBarData( + spots: spots, + isCurved: false, + barWidth: 1, + color: mainLineColor.withValues(alpha: 0.7), + aboveBarData: BarAreaData( + show: true, + color: aboveLineColor, + applyCutOffY: true, + ), + belowBarData: BarAreaData( + show: true, + color: belowLineColor, + applyCutOffY: true, + ), + dotData: const FlDotData( + show: false, + ), + ), + ], + extraLinesData: ExtraLinesData( + verticalLines: [ + if (isOnMainline) + VerticalLine( + x: (currentNode.position.ply - 1 - rootPly).toDouble(), + color: mainLineColor, + strokeWidth: 1.0, + ), + ...divisionLines, + ], + ), + gridData: const FlGridData(show: false), + borderData: FlBorderData(show: false), + titlesData: const FlTitlesData(show: false), + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/view/analysis/tree_view.dart b/lib/src/view/analysis/tree_view.dart index 1c132b65bf..54c55d7bce 100644 --- a/lib/src/view/analysis/tree_view.dart +++ b/lib/src/view/analysis/tree_view.dart @@ -9,34 +9,30 @@ import 'package:lichess_mobile/src/widgets/pgn.dart'; const kOpeningHeaderHeight = 32.0; class AnalysisTreeView extends ConsumerWidget { - const AnalysisTreeView( - this.pgn, - this.options, - this.displayMode, - ); + const AnalysisTreeView(this.options); - final String pgn; final AnalysisOptions options; - final Orientation displayMode; @override Widget build(BuildContext context, WidgetRef ref) { - final ctrlProvider = analysisControllerProvider(pgn, options); + final ctrlProvider = analysisControllerProvider(options); - final root = ref.watch(ctrlProvider.select((value) => value.root)); - final currentPath = - ref.watch(ctrlProvider.select((value) => value.currentPath)); - final pgnRootComments = - ref.watch(ctrlProvider.select((value) => value.pgnRootComments)); + final variant = ref.watch( + ctrlProvider.select((value) => value.requireValue.variant), + ); + final root = + ref.watch(ctrlProvider.select((value) => value.requireValue.root)); + final currentPath = ref + .watch(ctrlProvider.select((value) => value.requireValue.currentPath)); + final pgnRootComments = ref.watch( + ctrlProvider.select((value) => value.requireValue.pgnRootComments), + ); return CustomScrollView( slivers: [ - if (kOpeningAllowedVariants.contains(options.variant)) + if (kOpeningAllowedVariants.contains(variant)) SliverPersistentHeader( - delegate: _OpeningHeaderDelegate( - ctrlProvider, - displayMode: displayMode, - ), + delegate: _OpeningHeaderDelegate(ctrlProvider), ), SliverFillRemaining( hasScrollBody: false, @@ -53,13 +49,9 @@ class AnalysisTreeView extends ConsumerWidget { } class _OpeningHeaderDelegate extends SliverPersistentHeaderDelegate { - const _OpeningHeaderDelegate( - this.ctrlProvider, { - required this.displayMode, - }); + const _OpeningHeaderDelegate(this.ctrlProvider); final AnalysisControllerProvider ctrlProvider; - final Orientation displayMode; @override Widget build( @@ -67,7 +59,7 @@ class _OpeningHeaderDelegate extends SliverPersistentHeaderDelegate { double shrinkOffset, bool overlapsContent, ) { - return _Opening(ctrlProvider, displayMode); + return _Opening(ctrlProvider); } @override @@ -82,22 +74,21 @@ class _OpeningHeaderDelegate extends SliverPersistentHeaderDelegate { } class _Opening extends ConsumerWidget { - const _Opening(this.ctrlProvider, this.displayMode); + const _Opening(this.ctrlProvider); final AnalysisControllerProvider ctrlProvider; - final Orientation displayMode; @override Widget build(BuildContext context, WidgetRef ref) { final isRootNode = ref.watch( - ctrlProvider.select((s) => s.currentNode.isRoot), + ctrlProvider.select((s) => s.requireValue.currentNode.isRoot), ); - final nodeOpening = - ref.watch(ctrlProvider.select((s) => s.currentNode.opening)); - final branchOpening = - ref.watch(ctrlProvider.select((s) => s.currentBranchOpening)); + final nodeOpening = ref + .watch(ctrlProvider.select((s) => s.requireValue.currentNode.opening)); + final branchOpening = ref + .watch(ctrlProvider.select((s) => s.requireValue.currentBranchOpening)); final contextOpening = - ref.watch(ctrlProvider.select((s) => s.contextOpening)); + ref.watch(ctrlProvider.select((s) => s.requireValue.contextOpening)); final opening = isRootNode ? LightOpening( eco: '', diff --git a/lib/src/view/board_editor/board_editor_screen.dart b/lib/src/view/board_editor/board_editor_screen.dart index 128e81cd06..e5a66bd187 100644 --- a/lib/src/view/board_editor/board_editor_screen.dart +++ b/lib/src/view/board_editor/board_editor_screen.dart @@ -335,12 +335,13 @@ class _BottomBar extends ConsumerWidget { context, rootNavigator: true, builder: (context) => AnalysisScreen( - pgnOrId: editorState.pgn!, options: AnalysisOptions( - isLocalEvaluationAllowed: true, - variant: Variant.fromPosition, orientation: editorState.orientation, - id: standaloneAnalysisId, + standalone: ( + pgn: editorState.pgn!, + isComputerAnalysisAllowed: true, + variant: Variant.fromPosition, + ), ), ), ); diff --git a/lib/src/view/correspondence/offline_correspondence_game_screen.dart b/lib/src/view/correspondence/offline_correspondence_game_screen.dart index 0ce0cdae57..f8a6b18697 100644 --- a/lib/src/view/correspondence/offline_correspondence_game_screen.dart +++ b/lib/src/view/correspondence/offline_correspondence_game_screen.dart @@ -259,14 +259,14 @@ class _BodyState extends ConsumerState<_Body> { pushPlatformRoute( context, builder: (_) => AnalysisScreen( - pgnOrId: game.makePgn(), options: AnalysisOptions( - isLocalEvaluationAllowed: false, - variant: game.variant, - initialMoveCursor: stepCursor, orientation: game.youAre, - id: game.id, - division: game.meta.division, + standalone: ( + pgn: game.makePgn(), + isComputerAnalysisAllowed: false, + variant: game.variant, + ), + initialMoveCursor: stepCursor, ), ), ); diff --git a/lib/src/view/engine/engine_depth.dart b/lib/src/view/engine/engine_depth.dart new file mode 100644 index 0000000000..d3352d676d --- /dev/null +++ b/lib/src/view/engine/engine_depth.dart @@ -0,0 +1,117 @@ +import 'dart:math' as math; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lichess_mobile/src/model/common/eval.dart'; +import 'package:lichess_mobile/src/model/engine/engine.dart'; +import 'package:lichess_mobile/src/model/engine/evaluation_service.dart'; +import 'package:lichess_mobile/src/utils/l10n_context.dart'; +import 'package:lichess_mobile/src/widgets/buttons.dart'; +import 'package:lichess_mobile/src/widgets/list.dart'; +import 'package:popover/popover.dart'; + +class EngineDepth extends ConsumerWidget { + const EngineDepth({this.defaultEval}); + + final ClientEval? defaultEval; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final depth = ref.watch( + engineEvaluationProvider.select((value) => value.eval?.depth), + ) ?? + defaultEval?.depth; + + return depth != null + ? AppBarTextButton( + onPressed: () { + showPopover( + context: context, + bodyBuilder: (context) { + return _StockfishInfo(defaultEval); + }, + direction: PopoverDirection.top, + width: 240, + backgroundColor: + Theme.of(context).platform == TargetPlatform.android + ? Theme.of(context).dialogBackgroundColor + : CupertinoDynamicColor.resolve( + CupertinoColors.tertiarySystemBackground, + context, + ), + transitionDuration: Duration.zero, + popoverTransitionBuilder: (_, child) => child, + ); + }, + child: RepaintBoundary( + child: Container( + width: 20.0, + height: 20.0, + padding: const EdgeInsets.all(2.0), + decoration: BoxDecoration( + color: Theme.of(context).platform == TargetPlatform.android + ? Theme.of(context).colorScheme.secondary + : CupertinoTheme.of(context).primaryColor, + borderRadius: BorderRadius.circular(4.0), + ), + child: FittedBox( + fit: BoxFit.contain, + child: Text( + '${math.min(99, depth)}', + style: TextStyle( + color: Theme.of(context).platform == + TargetPlatform.android + ? Theme.of(context).colorScheme.onSecondary + : CupertinoTheme.of(context).primaryContrastingColor, + fontFeatures: const [ + FontFeature.tabularFigures(), + ], + ), + ), + ), + ), + ), + ) + : const SizedBox.shrink(); + } +} + +class _StockfishInfo extends ConsumerWidget { + const _StockfishInfo(this.defaultEval); + + final ClientEval? defaultEval; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final (engineName: engineName, eval: eval, state: engineState) = + ref.watch(engineEvaluationProvider); + + final currentEval = eval ?? defaultEval; + + final knps = engineState == EngineState.computing + ? ', ${eval?.knps.round()}kn/s' + : ''; + final depth = currentEval?.depth ?? 0; + final maxDepth = math.max(depth, kMaxEngineDepth); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + PlatformListTile( + leading: Image.asset( + 'assets/images/stockfish/icon.png', + width: 44, + height: 44, + ), + title: Text(engineName), + subtitle: Text( + context.l10n.depthX( + '$depth/$maxDepth$knps', + ), + ), + ), + ], + ); + } +} diff --git a/lib/src/view/engine/engine_gauge.dart b/lib/src/view/engine/engine_gauge.dart index 4d35a79bb5..0da958228c 100644 --- a/lib/src/view/engine/engine_gauge.dart +++ b/lib/src/view/engine/engine_gauge.dart @@ -8,7 +8,7 @@ import 'package:lichess_mobile/src/model/settings/brightness.dart'; import 'package:lichess_mobile/src/styles/styles.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; -const double kEvalGaugeSize = 26.0; +const double kEvalGaugeSize = 24.0; const double kEvalGaugeFontSize = 11.0; const Color _kEvalGaugeBackgroundColor = Color(0xFF444444); const Color _kEvalGaugeValueColorDarkBg = Color(0xEEEEEEEE); diff --git a/lib/src/view/engine/engine_lines.dart b/lib/src/view/engine/engine_lines.dart index f20f21cf6b..96ffd7fcc1 100644 --- a/lib/src/view/engine/engine_lines.dart +++ b/lib/src/view/engine/engine_lines.dart @@ -55,6 +55,7 @@ class EngineLines extends ConsumerWidget { } return Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start, children: content, diff --git a/lib/src/view/game/archived_game_screen.dart b/lib/src/view/game/archived_game_screen.dart index ab7c41cec3..88b720a6f1 100644 --- a/lib/src/view/game/archived_game_screen.dart +++ b/lib/src/view/game/archived_game_screen.dart @@ -354,46 +354,31 @@ class _BottomBar extends ConsumerWidget { onTap: showGameMenu, icon: Icons.menu, ), - gameCursor.when( - data: (data) { - return BottomBarButton( - label: context.l10n.mobileShowResult, - icon: Icons.info_outline, - onTap: () { - showAdaptiveDialog( - context: context, - builder: (context) => ArchivedGameResultDialog(game: data.$1), - barrierDismissible: true, - ); - }, - ); - }, - loading: () => const SizedBox.shrink(), - error: (_, __) => const SizedBox.shrink(), - ), + if (gameCursor.hasValue) + BottomBarButton( + label: context.l10n.mobileShowResult, + icon: Icons.info_outline, + onTap: () { + showAdaptiveDialog( + context: context, + builder: (context) => + ArchivedGameResultDialog(game: gameCursor.requireValue.$1), + barrierDismissible: true, + ); + }, + ), BottomBarButton( label: context.l10n.gameAnalysis, - onTap: ref.read(gameCursorProvider(gameData.id)).hasValue + onTap: gameCursor.hasValue ? () { - final (game, cursor) = ref - .read( - gameCursorProvider(gameData.id), - ) - .requireValue; - + final cursor = gameCursor.requireValue.$2; pushPlatformRoute( context, builder: (context) => AnalysisScreen( - pgnOrId: game.makePgn(), options: AnalysisOptions( - isLocalEvaluationAllowed: true, - variant: gameData.variant, - initialMoveCursor: cursor, orientation: orientation, - id: gameData.id, - opening: gameData.opening, - serverAnalysis: game.serverAnalysis, - division: game.meta.division, + gameId: gameData.id, + initialMoveCursor: cursor, ), ), ); diff --git a/lib/src/view/game/game_body.dart b/lib/src/view/game/game_body.dart index 77a5a209d8..e6b6a6b341 100644 --- a/lib/src/view/game/game_body.dart +++ b/lib/src/view/game/game_body.dart @@ -569,7 +569,6 @@ class _GameBottomBar extends ConsumerWidget { pushPlatformRoute( context, builder: (_) => AnalysisScreen( - pgnOrId: gameState.analysisPgn, options: gameState.analysisOptions, ), ); @@ -702,9 +701,8 @@ class _GameBottomBar extends ConsumerWidget { pushPlatformRoute( context, builder: (_) => AnalysisScreen( - pgnOrId: gameState.analysisPgn, options: gameState.analysisOptions.copyWith( - isLocalEvaluationAllowed: false, + gameId: gameState.game.id, ), ), ); diff --git a/lib/src/view/game/game_list_tile.dart b/lib/src/view/game/game_list_tile.dart index 5009b84947..3901f67c2b 100644 --- a/lib/src/view/game/game_list_tile.dart +++ b/lib/src/view/game/game_list_tile.dart @@ -226,12 +226,9 @@ class _ContextMenu extends ConsumerWidget { pushPlatformRoute( context, builder: (context) => AnalysisScreen( - pgnOrId: game.id.value, options: AnalysisOptions( - isLocalEvaluationAllowed: true, - variant: game.variant, orientation: orientation, - id: game.id, + gameId: game.id, ), ), ); diff --git a/lib/src/view/game/game_result_dialog.dart b/lib/src/view/game/game_result_dialog.dart index 2611a15696..a4e6522d53 100644 --- a/lib/src/view/game/game_result_dialog.dart +++ b/lib/src/view/game/game_result_dialog.dart @@ -191,7 +191,6 @@ class _GameEndDialogState extends ConsumerState { pushPlatformRoute( context, builder: (_) => AnalysisScreen( - pgnOrId: gameState.analysisPgn, options: gameState.analysisOptions, ), ); @@ -261,12 +260,13 @@ class OverTheBoardGameResultDialog extends StatelessWidget { pushPlatformRoute( context, builder: (_) => AnalysisScreen( - pgnOrId: game.makePgn(), options: AnalysisOptions( - isLocalEvaluationAllowed: true, - variant: game.meta.variant, orientation: Side.white, - id: standaloneAnalysisId, + standalone: ( + pgn: game.makePgn(), + isComputerAnalysisAllowed: true, + variant: game.meta.variant, + ), ), ), ); diff --git a/lib/src/view/opening_explorer/opening_explorer_screen.dart b/lib/src/view/opening_explorer/opening_explorer_screen.dart index 0e2d1dbc29..063a134d3b 100644 --- a/lib/src/view/opening_explorer/opening_explorer_screen.dart +++ b/lib/src/view/opening_explorer/opening_explorer_screen.dart @@ -1,10 +1,7 @@ import 'package:collection/collection.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:flutter_riverpod/flutter_riverpod.dart'; -import 'package:intl/intl.dart'; import 'package:lichess_mobile/src/constants.dart'; import 'package:lichess_mobile/src/model/analysis/analysis_controller.dart'; import 'package:lichess_mobile/src/model/common/chess.dart'; @@ -13,14 +10,14 @@ import 'package:lichess_mobile/src/model/opening_explorer/opening_explorer_prefe import 'package:lichess_mobile/src/model/opening_explorer/opening_explorer_repository.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/view/analysis/analysis_board.dart'; -import 'package:lichess_mobile/src/view/game/archived_game_screen.dart'; +import 'package:lichess_mobile/src/view/opening_explorer/opening_explorer_widgets.dart'; import 'package:lichess_mobile/src/widgets/adaptive_bottom_sheet.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/feedback.dart'; import 'package:lichess_mobile/src/widgets/move_list.dart'; import 'package:lichess_mobile/src/widgets/platform.dart'; import 'package:lichess_mobile/src/widgets/shimmer.dart'; @@ -36,20 +33,9 @@ const _kTableRowPadding = EdgeInsets.symmetric( ); const _kTabletBoardRadius = BorderRadius.all(Radius.circular(4.0)); -Color _whiteBoxColor(BuildContext context) => - Theme.of(context).brightness == Brightness.dark - ? Colors.white.withValues(alpha: 0.8) - : Colors.white; - -Color _blackBoxColor(BuildContext context) => - Theme.of(context).brightness == Brightness.light - ? Colors.black.withValues(alpha: 0.7) - : Colors.black; - class OpeningExplorerScreen extends ConsumerStatefulWidget { - const OpeningExplorerScreen({required this.pgn, required this.options}); + const OpeningExplorerScreen({required this.options}); - final String pgn; final AnalysisOptions options; @override @@ -65,241 +51,253 @@ class _OpeningExplorerState extends ConsumerState { @override Widget build(BuildContext context) { - final analysisState = - ref.watch(analysisControllerProvider(widget.pgn, widget.options)); - - final opening = analysisState.currentNode.isRoot - ? LightOpening( - eco: '', - name: context.l10n.startPosition, - ) - : analysisState.currentNode.opening ?? - analysisState.currentBranchOpening ?? - analysisState.contextOpening; - - final Widget openingHeader = Container( - padding: _kTableRowPadding, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.secondaryContainer, - ), - child: opening != null - ? GestureDetector( - onTap: opening.name == context.l10n.startPosition - ? null - : () => launchUrl( - Uri.parse( - 'https://lichess.org/opening/${opening.name}', - ), - ), - child: Row( - children: [ - Icon( - Icons.open_in_browser_outlined, - color: Theme.of(context).colorScheme.onSecondaryContainer, - ), - const SizedBox(width: 6.0), - Expanded( - child: Text( - '${opening.eco.isEmpty ? "" : "${opening.eco} "}${opening.name}', - style: TextStyle( + switch (ref.watch(analysisControllerProvider(widget.options))) { + case AsyncData(value: final analysisState): + final opening = analysisState.currentNode.isRoot + ? LightOpening( + eco: '', + name: context.l10n.startPosition, + ) + : analysisState.currentNode.opening ?? + analysisState.currentBranchOpening ?? + analysisState.contextOpening; + + final Widget openingHeader = Container( + padding: _kTableRowPadding, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondaryContainer, + ), + child: opening != null + ? GestureDetector( + onTap: opening.name == context.l10n.startPosition + ? null + : () => launchUrl( + Uri.parse( + 'https://lichess.org/opening/${opening.name}', + ), + ), + child: Row( + children: [ + Icon( + Icons.open_in_browser_outlined, color: Theme.of(context).colorScheme.onSecondaryContainer, - fontWeight: FontWeight.bold, ), - ), + const SizedBox(width: 6.0), + Expanded( + child: Text( + opening.name, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSecondaryContainer, + fontWeight: FontWeight.bold, + ), + ), + ), + ], ), - ], - ), - ) - : const SizedBox.shrink(), - ); + ) + : const SizedBox.shrink(), + ); - if (analysisState.position.ply >= 50) { - return _OpeningExplorerView( - pgn: widget.pgn, - options: widget.options, - isLoading: false, - isIndexing: false, - children: [ - openingHeader, - _OpeningExplorerMoveTable.maxDepth( - pgn: widget.pgn, + if (analysisState.position.ply >= 50) { + return _OpeningExplorerView( options: widget.options, - ), - ], - ); - } - - final prefs = ref.watch(openingExplorerPreferencesProvider); - - if (prefs.db == OpeningDatabase.player && prefs.playerDb.username == null) { - return _OpeningExplorerView( - pgn: widget.pgn, - options: widget.options, - isLoading: false, - isIndexing: false, - children: [ - openingHeader, - const Padding( - padding: _kTableRowPadding, - child: Center( - // TODO: l10n - child: Text('Select a Lichess player in the settings.'), - ), - ), - ], - ); - } + isLoading: false, + isIndexing: false, + children: [ + openingHeader, + OpeningExplorerMoveTable.maxDepth( + options: widget.options, + ), + ], + ); + } - final cacheKey = OpeningExplorerCacheKey( - fen: analysisState.position.fen, - prefs: prefs, - ); - final cacheOpeningExplorer = cache[cacheKey]; - final openingExplorerAsync = cacheOpeningExplorer != null - ? AsyncValue.data( - (entry: cacheOpeningExplorer, isIndexing: false), - ) - : ref.watch(openingExplorerProvider(fen: analysisState.position.fen)); + final prefs = ref.watch(openingExplorerPreferencesProvider); - if (cacheOpeningExplorer == null) { - ref.listen(openingExplorerProvider(fen: analysisState.position.fen), - (_, curAsync) { - curAsync.whenData((cur) { - if (cur != null && !cur.isIndexing) { - cache[cacheKey] = cur.entry; - } - }); - }); - } + if (prefs.db == OpeningDatabase.player && + prefs.playerDb.username == null) { + return _OpeningExplorerView( + options: widget.options, + isLoading: false, + isIndexing: false, + children: [ + openingHeader, + const Padding( + padding: _kTableRowPadding, + child: Center( + // TODO: l10n + child: Text('Select a Lichess player in the settings.'), + ), + ), + ], + ); + } + + final cacheKey = OpeningExplorerCacheKey( + fen: analysisState.position.fen, + prefs: prefs, + ); + final cacheOpeningExplorer = cache[cacheKey]; + final openingExplorerAsync = cacheOpeningExplorer != null + ? AsyncValue.data( + (entry: cacheOpeningExplorer, isIndexing: false), + ) + : ref.watch( + openingExplorerProvider(fen: analysisState.position.fen), + ); + + if (cacheOpeningExplorer == null) { + ref.listen(openingExplorerProvider(fen: analysisState.position.fen), + (_, curAsync) { + curAsync.whenData((cur) { + if (cur != null && !cur.isIndexing) { + cache[cacheKey] = cur.entry; + } + }); + }); + } + + final isLoading = openingExplorerAsync.isLoading || + openingExplorerAsync.value == null; + + return _OpeningExplorerView( + options: widget.options, + isLoading: isLoading, + isIndexing: openingExplorerAsync.value?.isIndexing ?? false, + children: openingExplorerAsync.when( + data: (openingExplorer) { + if (openingExplorer == null) { + return lastExplorerWidgets ?? + [ + Shimmer( + child: ShimmerLoading( + isLoading: true, + child: OpeningExplorerMoveTable.loading( + options: widget.options, + ), + ), + ), + ]; + } + + final topGames = openingExplorer.entry.topGames; + final recentGames = openingExplorer.entry.recentGames; + + final ply = analysisState.position.ply; + + final children = [ + openingHeader, + OpeningExplorerMoveTable( + moves: openingExplorer.entry.moves, + whiteWins: openingExplorer.entry.white, + draws: openingExplorer.entry.draws, + blackWins: openingExplorer.entry.black, + options: widget.options, + ), + if (topGames != null && topGames.isNotEmpty) ...[ + OpeningExplorerHeaderTile( + key: const Key('topGamesHeader'), + child: Text(context.l10n.topGames), + ), + ...List.generate( + topGames.length, + (int index) { + return OpeningExplorerGameTile( + key: Key('top-game-${topGames.get(index).id}'), + game: topGames.get(index), + color: index.isEven + ? Theme.of(context).colorScheme.surfaceContainerLow + : Theme.of(context) + .colorScheme + .surfaceContainerHigh, + ply: ply, + ); + }, + growable: false, + ), + ], + if (recentGames != null && recentGames.isNotEmpty) ...[ + OpeningExplorerHeaderTile( + key: const Key('recentGamesHeader'), + child: Text(context.l10n.recentGames), + ), + ...List.generate( + recentGames.length, + (int index) { + return OpeningExplorerGameTile( + key: Key('recent-game-${recentGames.get(index).id}'), + game: recentGames.get(index), + color: index.isEven + ? Theme.of(context).colorScheme.surfaceContainerLow + : Theme.of(context) + .colorScheme + .surfaceContainerHigh, + ply: ply, + ); + }, + growable: false, + ), + ], + ]; - final isLoading = - openingExplorerAsync.isLoading || openingExplorerAsync.value == null; + lastExplorerWidgets = children; - return _OpeningExplorerView( - pgn: widget.pgn, - options: widget.options, - isLoading: isLoading, - isIndexing: openingExplorerAsync.value?.isIndexing ?? false, - children: openingExplorerAsync.when( - data: (openingExplorer) { - if (openingExplorer == null) { - return lastExplorerWidgets ?? + return children; + }, + loading: () => + lastExplorerWidgets ?? [ Shimmer( child: ShimmerLoading( isLoading: true, - child: _OpeningExplorerMoveTable.loading( - pgn: widget.pgn, + child: OpeningExplorerMoveTable.loading( options: widget.options, ), ), ), - ]; - } - - final topGames = openingExplorer.entry.topGames; - final recentGames = openingExplorer.entry.recentGames; - - final ply = analysisState.position.ply; - - final children = [ - openingHeader, - _OpeningExplorerMoveTable( - moves: openingExplorer.entry.moves, - whiteWins: openingExplorer.entry.white, - draws: openingExplorer.entry.draws, - blackWins: openingExplorer.entry.black, - pgn: widget.pgn, - options: widget.options, - ), - if (topGames != null && topGames.isNotEmpty) ...[ - _OpeningExplorerHeader( - key: const Key('topGamesHeader'), - child: Text(context.l10n.topGames), - ), - ...List.generate( - topGames.length, - (int index) { - return OpeningExplorerGameTile( - key: Key('top-game-${topGames.get(index).id}'), - game: topGames.get(index), - color: index.isEven - ? Theme.of(context).colorScheme.surfaceContainerLow - : Theme.of(context).colorScheme.surfaceContainerHigh, - ply: ply, - ); - }, - growable: false, - ), - ], - if (recentGames != null && recentGames.isNotEmpty) ...[ - _OpeningExplorerHeader( - key: const Key('recentGamesHeader'), - child: Text(context.l10n.recentGames), - ), - ...List.generate( - recentGames.length, - (int index) { - return OpeningExplorerGameTile( - key: Key('recent-game-${recentGames.get(index).id}'), - game: recentGames.get(index), - color: index.isEven - ? Theme.of(context).colorScheme.surfaceContainerLow - : Theme.of(context).colorScheme.surfaceContainerHigh, - ply: ply, - ); - }, - growable: false, - ), - ], - ]; - - lastExplorerWidgets = children; - - return children; - }, - loading: () => - lastExplorerWidgets ?? - [ - Shimmer( - child: ShimmerLoading( - isLoading: true, - child: _OpeningExplorerMoveTable.loading( - pgn: widget.pgn, - options: widget.options, + ], + error: (e, s) { + debugPrint( + 'SEVERE: [OpeningExplorerScreen] could not load opening explorer data; $e\n$s', + ); + return [ + Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text(e.toString()), ), ), - ), - ], - error: (e, s) { - debugPrint( - 'SEVERE: [OpeningExplorerScreen] could not load opening explorer data; $e\n$s', - ); - return [ - Center( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Text(e.toString()), - ), - ), - ]; - }, - ), - ); + ]; + }, + ), + ); + case AsyncError(:final error): + debugPrint( + 'SEVERE: [OpeningExplorerScreen] could not load analysis data; $error', + ); + return Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text(error.toString()), + ), + ); + case _: + return const CenterLoadingIndicator(); + } } } class _OpeningExplorerView extends StatelessWidget { const _OpeningExplorerView({ - required this.pgn, required this.options, required this.children, required this.isLoading, required this.isIndexing, }); - final String pgn; final AnalysisOptions options; final List children; final bool isLoading; @@ -320,7 +318,7 @@ class _OpeningExplorerView extends StatelessWidget { horizontal: kTabletBoardTableSidePadding, ) : EdgeInsets.zero, - child: _MoveList(pgn: pgn, options: options), + child: _MoveList(options: options), ), Expanded( child: LayoutBuilder( @@ -338,15 +336,7 @@ class _OpeningExplorerView extends StatelessWidget { final isLandscape = aspectRatio > 1; final loadingOverlay = Positioned.fill( - child: IgnorePointer( - ignoring: !isLoading, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 300), - curve: Curves.fastOutSlowIn, - opacity: isLoading ? 0.10 : 0.0, - child: const ColoredBox(color: Colors.black), - ), - ), + child: IgnorePointer(ignoring: !isLoading), ); if (isLandscape) { @@ -360,10 +350,10 @@ class _OpeningExplorerView extends StatelessWidget { bottom: kTabletBoardTableSidePadding, ), child: AnalysisBoard( - pgn, options, boardSize, borderRadius: isTablet ? _kTabletBoardRadius : null, + shouldReplaceChildOnUserMove: true, ), ), Flexible( @@ -411,9 +401,9 @@ class _OpeningExplorerView extends StatelessWidget { // disable scrolling when dragging the board onVerticalDragStart: (_) {}, child: AnalysisBoard( - pgn, options, boardSize, + shouldReplaceChildOnUserMove: true, ), ), ...children, @@ -426,7 +416,7 @@ class _OpeningExplorerView extends StatelessWidget { }, ), ), - _BottomBar(pgn: pgn, options: options), + _BottomBar(options: options), ], ), ); @@ -436,7 +426,7 @@ class _OpeningExplorerView extends StatelessWidget { body: body, appBar: AppBar( title: Text(context.l10n.openingExplorer), - bottom: _MoveList(pgn: pgn, options: options), + bottom: _MoveList(options: options), actions: [ if (isIndexing) const _IndexingIndicator(), ], @@ -501,489 +491,9 @@ class _IndexingIndicatorState extends State<_IndexingIndicator> } } -/// Table of moves for the opening explorer. -class _OpeningExplorerMoveTable extends ConsumerWidget { - const _OpeningExplorerMoveTable({ - required this.moves, - required this.whiteWins, - required this.draws, - required this.blackWins, - required this.pgn, - required this.options, - }) : _isLoading = false, - _maxDepthReached = false; - - const _OpeningExplorerMoveTable.loading({ - required this.pgn, - required this.options, - }) : _isLoading = true, - moves = const IListConst([]), - whiteWins = 0, - draws = 0, - blackWins = 0, - _maxDepthReached = false; - - const _OpeningExplorerMoveTable.maxDepth({ - required this.pgn, - required this.options, - }) : _isLoading = false, - moves = const IListConst([]), - whiteWins = 0, - draws = 0, - blackWins = 0, - _maxDepthReached = true; - - final IList moves; - final int whiteWins; - final int draws; - final int blackWins; - final String pgn; - final AnalysisOptions options; - - final bool _isLoading; - final bool _maxDepthReached; - - String formatNum(int num) => NumberFormat.decimalPatternDigits().format(num); - - static const columnWidths = { - 0: FractionColumnWidth(0.15), - 1: FractionColumnWidth(0.35), - 2: FractionColumnWidth(0.50), - }; - - @override - Widget build(BuildContext context, WidgetRef ref) { - if (_isLoading) { - return loadingTable; - } - - final games = whiteWins + draws + blackWins; - final ctrlProvider = analysisControllerProvider(pgn, options); - - const topPadding = EdgeInsets.only(top: _kTableRowVerticalPadding / 2); - const headerTextStyle = TextStyle(fontSize: 12); - - return Table( - columnWidths: columnWidths, - children: [ - TableRow( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.secondaryContainer, - ), - children: [ - Padding( - padding: _kTableRowPadding.subtract(topPadding), - child: Text(context.l10n.move, style: headerTextStyle), - ), - Padding( - padding: _kTableRowPadding.subtract(topPadding), - child: Text(context.l10n.games, style: headerTextStyle), - ), - Padding( - padding: _kTableRowPadding.subtract(topPadding), - child: Text(context.l10n.whiteDrawBlack, style: headerTextStyle), - ), - ], - ), - ...List.generate( - moves.length, - (int index) { - final move = moves.get(index); - final percentGames = ((move.games / games) * 100).round(); - return TableRow( - decoration: BoxDecoration( - color: index.isEven - ? Theme.of(context).colorScheme.surfaceContainerLow - : Theme.of(context).colorScheme.surfaceContainerHigh, - ), - children: [ - TableRowInkWell( - onTap: () => ref - .read(ctrlProvider.notifier) - .onUserMove(NormalMove.fromUci(move.uci)), - child: Padding( - padding: _kTableRowPadding, - child: Text(move.san), - ), - ), - TableRowInkWell( - onTap: () => ref - .read(ctrlProvider.notifier) - .onUserMove(NormalMove.fromUci(move.uci)), - child: Padding( - padding: _kTableRowPadding, - child: Text('${formatNum(move.games)} ($percentGames%)'), - ), - ), - TableRowInkWell( - onTap: () => ref - .read(ctrlProvider.notifier) - .onUserMove(NormalMove.fromUci(move.uci)), - child: Padding( - padding: _kTableRowPadding, - child: _WinPercentageChart( - whiteWins: move.white, - draws: move.draws, - blackWins: move.black, - ), - ), - ), - ], - ); - }, - ), - if (_maxDepthReached) - TableRow( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerLow, - ), - children: [ - Padding( - padding: _kTableRowPadding, - child: Text( - String.fromCharCode(Icons.not_interested_outlined.codePoint), - style: TextStyle( - fontFamily: Icons.not_interested_outlined.fontFamily, - ), - ), - ), - Padding( - padding: _kTableRowPadding, - child: Text(context.l10n.maxDepthReached), - ), - const Padding( - padding: _kTableRowPadding, - child: SizedBox.shrink(), - ), - ], - ) - else if (moves.isNotEmpty) - TableRow( - decoration: BoxDecoration( - color: moves.length.isEven - ? Theme.of(context).colorScheme.surfaceContainerLow - : Theme.of(context).colorScheme.surfaceContainerHigh, - ), - children: [ - Container( - padding: _kTableRowPadding, - alignment: Alignment.centerLeft, - child: const Icon(Icons.functions), - ), - Padding( - padding: _kTableRowPadding, - child: Text('${formatNum(games)} (100%)'), - ), - Padding( - padding: _kTableRowPadding, - child: _WinPercentageChart( - whiteWins: whiteWins, - draws: draws, - blackWins: blackWins, - ), - ), - ], - ) - else - TableRow( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerLow, - ), - children: [ - Padding( - padding: _kTableRowPadding, - child: Text( - String.fromCharCode(Icons.not_interested_outlined.codePoint), - style: TextStyle( - fontFamily: Icons.not_interested_outlined.fontFamily, - ), - ), - ), - Padding( - padding: _kTableRowPadding, - child: Text(context.l10n.noGameFound), - ), - const Padding( - padding: _kTableRowPadding, - child: SizedBox.shrink(), - ), - ], - ), - ], - ); - } - - static final loadingTable = Table( - columnWidths: columnWidths, - children: List.generate( - 10, - (int index) => TableRow( - children: [ - Padding( - padding: _kTableRowPadding, - child: Container( - height: 20, - width: double.infinity, - decoration: BoxDecoration( - color: Colors.black, - borderRadius: BorderRadius.circular(5), - ), - ), - ), - Padding( - padding: _kTableRowPadding, - child: Container( - height: 20, - width: double.infinity, - decoration: BoxDecoration( - color: Colors.black, - borderRadius: BorderRadius.circular(5), - ), - ), - ), - Padding( - padding: _kTableRowPadding, - child: Container( - height: 20, - width: double.infinity, - decoration: BoxDecoration( - color: Colors.black, - borderRadius: BorderRadius.circular(5), - ), - ), - ), - ], - ), - ), - ); -} - -/// A game tile for the opening explorer. -class OpeningExplorerGameTile extends ConsumerStatefulWidget { - const OpeningExplorerGameTile({ - required this.game, - required this.color, - required this.ply, - super.key, - }); - - final OpeningExplorerGame game; - final Color color; - final int ply; - - @override - ConsumerState createState() => - _OpeningExplorerGameTileState(); -} - -class _OpeningExplorerGameTileState - extends ConsumerState { - @override - Widget build(BuildContext context) { - const widthResultBox = 50.0; - const paddingResultBox = EdgeInsets.all(5); - - return Container( - padding: _kTableRowPadding, - color: widget.color, - child: AdaptiveInkWell( - onTap: () { - pushPlatformRoute( - context, - builder: (_) => ArchivedGameScreen( - gameId: widget.game.id, - orientation: Side.white, - initialCursor: widget.ply, - ), - ); - }, - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(widget.game.white.rating.toString()), - Text(widget.game.black.rating.toString()), - ], - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.game.white.name, - overflow: TextOverflow.ellipsis, - ), - Text( - widget.game.black.name, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - Row( - children: [ - if (widget.game.winner == 'white') - Container( - width: widthResultBox, - padding: paddingResultBox, - decoration: BoxDecoration( - color: _whiteBoxColor(context), - borderRadius: BorderRadius.circular(5), - ), - child: const Text( - '1-0', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.black, - ), - ), - ) - else if (widget.game.winner == 'black') - Container( - width: widthResultBox, - padding: paddingResultBox, - decoration: BoxDecoration( - color: _blackBoxColor(context), - borderRadius: BorderRadius.circular(5), - ), - child: const Text( - '0-1', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white, - ), - ), - ) - else - Container( - width: widthResultBox, - padding: paddingResultBox, - decoration: BoxDecoration( - color: Colors.grey, - borderRadius: BorderRadius.circular(5), - ), - child: const Text( - '½-½', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white, - ), - ), - ), - if (widget.game.month != null) ...[ - const SizedBox(width: 10.0), - Text( - widget.game.month!, - style: const TextStyle( - fontFeatures: [FontFeature.tabularFigures()], - ), - ), - ], - if (widget.game.speed != null) ...[ - const SizedBox(width: 10.0), - Icon(widget.game.speed!.icon, size: 20), - ], - ], - ), - ], - ), - ), - ); - } -} - -class _OpeningExplorerHeader extends StatelessWidget { - const _OpeningExplorerHeader({required this.child, super.key}); - - final Widget child; - - @override - Widget build(BuildContext context) { - return Container( - width: double.infinity, - padding: _kTableRowPadding, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.secondaryContainer, - ), - child: child, - ); - } -} - -class _WinPercentageChart extends StatelessWidget { - const _WinPercentageChart({ - required this.whiteWins, - required this.draws, - required this.blackWins, - }); - - final int whiteWins; - final int draws; - final int blackWins; - - int percentGames(int games) => - ((games / (whiteWins + draws + blackWins)) * 100).round(); - String label(int percent) => percent < 20 ? '' : '$percent%'; - - @override - Widget build(BuildContext context) { - final percentWhite = percentGames(whiteWins); - final percentDraws = percentGames(draws); - final percentBlack = percentGames(blackWins); - - return ClipRRect( - borderRadius: BorderRadius.circular(5), - child: Row( - children: [ - Expanded( - flex: percentWhite, - child: ColoredBox( - color: _whiteBoxColor(context), - child: Text( - label(percentWhite), - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.black), - ), - ), - ), - Expanded( - flex: percentDraws, - child: ColoredBox( - color: Colors.grey, - child: Text( - label(percentDraws), - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white), - ), - ), - ), - Expanded( - flex: percentBlack, - child: ColoredBox( - color: _blackBoxColor(context), - child: Text( - label(percentBlack), - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.white), - ), - ), - ), - ], - ), - ); - } -} - class _MoveList extends ConsumerWidget implements PreferredSizeWidget { - const _MoveList({ - required this.pgn, - required this.options, - }); + const _MoveList({required this.options}); - final String pgn; final AnalysisOptions options; @override @@ -991,8 +501,8 @@ class _MoveList extends ConsumerWidget implements PreferredSizeWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final ctrlProvider = analysisControllerProvider(pgn, options); - final state = ref.watch(ctrlProvider); + final ctrlProvider = analysisControllerProvider(options); + final state = ref.watch(ctrlProvider).requireValue; final slicedMoves = state.root.mainline .map((e) => e.sanMove.san) .toList() @@ -1024,23 +534,19 @@ class _MoveList extends ConsumerWidget implements PreferredSizeWidget { } class _BottomBar extends ConsumerWidget { - const _BottomBar({ - required this.pgn, - required this.options, - }); + const _BottomBar({required this.options}); - final String pgn; final AnalysisOptions options; @override Widget build(BuildContext context, WidgetRef ref) { final db = ref .watch(openingExplorerPreferencesProvider.select((value) => value.db)); - final ctrlProvider = analysisControllerProvider(pgn, options); + final ctrlProvider = analysisControllerProvider(options); final canGoBack = - ref.watch(ctrlProvider.select((value) => value.canGoBack)); + ref.watch(ctrlProvider.select((value) => value.requireValue.canGoBack)); final canGoNext = - ref.watch(ctrlProvider.select((value) => value.canGoNext)); + ref.watch(ctrlProvider.select((value) => value.requireValue.canGoNext)); final dbLabel = switch (db) { OpeningDatabase.master => 'Masters', @@ -1058,7 +564,7 @@ class _BottomBar extends ConsumerWidget { isScrollControlled: true, showDragHandle: true, isDismissible: true, - builder: (_) => OpeningExplorerSettings(pgn, options), + builder: (_) => OpeningExplorerSettings(options), ), icon: Icons.tune, ), @@ -1094,8 +600,7 @@ class _BottomBar extends ConsumerWidget { } void _moveForward(WidgetRef ref) => - ref.read(analysisControllerProvider(pgn, options).notifier).userNext(); - void _moveBackward(WidgetRef ref) => ref - .read(analysisControllerProvider(pgn, options).notifier) - .userPrevious(); + ref.read(analysisControllerProvider(options).notifier).userNext(); + void _moveBackward(WidgetRef ref) => + ref.read(analysisControllerProvider(options).notifier).userPrevious(); } diff --git a/lib/src/view/opening_explorer/opening_explorer_settings.dart b/lib/src/view/opening_explorer/opening_explorer_settings.dart index 51c3c38d8f..a880d8fb33 100644 --- a/lib/src/view/opening_explorer/opening_explorer_settings.dart +++ b/lib/src/view/opening_explorer/opening_explorer_settings.dart @@ -14,9 +14,8 @@ import 'package:lichess_mobile/src/widgets/adaptive_bottom_sheet.dart'; import 'package:lichess_mobile/src/widgets/list.dart'; class OpeningExplorerSettings extends ConsumerWidget { - const OpeningExplorerSettings(this.pgn, this.options); + const OpeningExplorerSettings(this.options); - final String pgn; final AnalysisOptions options; @override diff --git a/lib/src/view/opening_explorer/opening_explorer_widgets.dart b/lib/src/view/opening_explorer/opening_explorer_widgets.dart new file mode 100644 index 0000000000..43bf9f9deb --- /dev/null +++ b/lib/src/view/opening_explorer/opening_explorer_widgets.dart @@ -0,0 +1,499 @@ +import 'package:dartchess/dartchess.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:lichess_mobile/src/model/analysis/analysis_controller.dart'; +import 'package:lichess_mobile/src/model/opening_explorer/opening_explorer.dart'; +import 'package:lichess_mobile/src/utils/l10n_context.dart'; +import 'package:lichess_mobile/src/utils/navigation.dart'; +import 'package:lichess_mobile/src/view/game/archived_game_screen.dart'; +import 'package:lichess_mobile/src/widgets/buttons.dart'; + +const _kTableRowVerticalPadding = 10.0; +const _kTableRowHorizontalPadding = 8.0; +const _kTableRowPadding = EdgeInsets.symmetric( + horizontal: _kTableRowHorizontalPadding, + vertical: _kTableRowVerticalPadding, +); + +Color _whiteBoxColor(BuildContext context) => + Theme.of(context).brightness == Brightness.dark + ? Colors.white.withValues(alpha: 0.8) + : Colors.white; + +Color _blackBoxColor(BuildContext context) => + Theme.of(context).brightness == Brightness.light + ? Colors.black.withValues(alpha: 0.7) + : Colors.black; + +/// Table of moves for the opening explorer. +class OpeningExplorerMoveTable extends ConsumerWidget { + const OpeningExplorerMoveTable({ + required this.moves, + required this.whiteWins, + required this.draws, + required this.blackWins, + required this.options, + }) : _isLoading = false, + _maxDepthReached = false; + + const OpeningExplorerMoveTable.loading({ + required this.options, + }) : _isLoading = true, + moves = const IListConst([]), + whiteWins = 0, + draws = 0, + blackWins = 0, + _maxDepthReached = false; + + const OpeningExplorerMoveTable.maxDepth({ + required this.options, + }) : _isLoading = false, + moves = const IListConst([]), + whiteWins = 0, + draws = 0, + blackWins = 0, + _maxDepthReached = true; + + final IList moves; + final int whiteWins; + final int draws; + final int blackWins; + final AnalysisOptions options; + + final bool _isLoading; + final bool _maxDepthReached; + + String formatNum(int num) => NumberFormat.decimalPatternDigits().format(num); + + static const columnWidths = { + 0: FractionColumnWidth(0.15), + 1: FractionColumnWidth(0.35), + 2: FractionColumnWidth(0.50), + }; + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (_isLoading) { + return loadingTable; + } + + final games = whiteWins + draws + blackWins; + final ctrlProvider = analysisControllerProvider(options); + + const headerTextStyle = TextStyle(fontSize: 12); + + return Table( + columnWidths: columnWidths, + children: [ + TableRow( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondaryContainer, + ), + children: [ + Padding( + padding: _kTableRowPadding, + child: Text(context.l10n.move, style: headerTextStyle), + ), + Padding( + padding: _kTableRowPadding, + child: Text(context.l10n.games, style: headerTextStyle), + ), + Padding( + padding: _kTableRowPadding, + child: Text(context.l10n.whiteDrawBlack, style: headerTextStyle), + ), + ], + ), + ...List.generate( + moves.length, + (int index) { + final move = moves.get(index); + final percentGames = ((move.games / games) * 100).round(); + return TableRow( + decoration: BoxDecoration( + color: index.isEven + ? Theme.of(context).colorScheme.surfaceContainerLow + : Theme.of(context).colorScheme.surfaceContainerHigh, + ), + children: [ + TableRowInkWell( + onTap: () => ref + .read(ctrlProvider.notifier) + .onUserMove(NormalMove.fromUci(move.uci)), + child: Padding( + padding: _kTableRowPadding, + child: Text(move.san), + ), + ), + TableRowInkWell( + onTap: () => ref + .read(ctrlProvider.notifier) + .onUserMove(NormalMove.fromUci(move.uci)), + child: Padding( + padding: _kTableRowPadding, + child: Text('${formatNum(move.games)} ($percentGames%)'), + ), + ), + TableRowInkWell( + onTap: () => ref + .read(ctrlProvider.notifier) + .onUserMove(NormalMove.fromUci(move.uci)), + child: Padding( + padding: _kTableRowPadding, + child: _WinPercentageChart( + whiteWins: move.white, + draws: move.draws, + blackWins: move.black, + ), + ), + ), + ], + ); + }, + ), + if (_maxDepthReached) + TableRow( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + ), + children: [ + Padding( + padding: _kTableRowPadding, + child: Text( + String.fromCharCode(Icons.not_interested_outlined.codePoint), + style: TextStyle( + fontFamily: Icons.not_interested_outlined.fontFamily, + ), + ), + ), + Padding( + padding: _kTableRowPadding, + child: Text(context.l10n.maxDepthReached), + ), + const Padding( + padding: _kTableRowPadding, + child: SizedBox.shrink(), + ), + ], + ) + else if (moves.isNotEmpty) + TableRow( + decoration: BoxDecoration( + color: moves.length.isEven + ? Theme.of(context).colorScheme.surfaceContainerLow + : Theme.of(context).colorScheme.surfaceContainerHigh, + ), + children: [ + Container( + padding: _kTableRowPadding, + alignment: Alignment.centerLeft, + child: const Icon(Icons.functions), + ), + Padding( + padding: _kTableRowPadding, + child: Text('${formatNum(games)} (100%)'), + ), + Padding( + padding: _kTableRowPadding, + child: _WinPercentageChart( + whiteWins: whiteWins, + draws: draws, + blackWins: blackWins, + ), + ), + ], + ) + else + TableRow( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLow, + ), + children: [ + Padding( + padding: _kTableRowPadding, + child: Text( + String.fromCharCode(Icons.not_interested_outlined.codePoint), + style: TextStyle( + fontFamily: Icons.not_interested_outlined.fontFamily, + ), + ), + ), + Padding( + padding: _kTableRowPadding, + child: Text(context.l10n.noGameFound), + ), + const Padding( + padding: _kTableRowPadding, + child: SizedBox.shrink(), + ), + ], + ), + ], + ); + } + + static final loadingTable = Table( + columnWidths: columnWidths, + children: List.generate( + 10, + (int index) => TableRow( + children: [ + Padding( + padding: _kTableRowPadding, + child: Container( + height: 20, + width: double.infinity, + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(5), + ), + ), + ), + Padding( + padding: _kTableRowPadding, + child: Container( + height: 20, + width: double.infinity, + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(5), + ), + ), + ), + Padding( + padding: _kTableRowPadding, + child: Container( + height: 20, + width: double.infinity, + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(5), + ), + ), + ), + ], + ), + ), + ); +} + +class OpeningExplorerHeaderTile extends StatelessWidget { + const OpeningExplorerHeaderTile({required this.child, super.key}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: _kTableRowPadding, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondaryContainer, + ), + child: child, + ); + } +} + +/// A game tile for the opening explorer. +class OpeningExplorerGameTile extends ConsumerStatefulWidget { + const OpeningExplorerGameTile({ + required this.game, + required this.color, + required this.ply, + super.key, + }); + + final OpeningExplorerGame game; + final Color color; + final int ply; + + @override + ConsumerState createState() => + _OpeningExplorerGameTileState(); +} + +class _OpeningExplorerGameTileState + extends ConsumerState { + @override + Widget build(BuildContext context) { + const widthResultBox = 50.0; + const paddingResultBox = EdgeInsets.all(5); + + return Container( + padding: _kTableRowPadding, + color: widget.color, + child: AdaptiveInkWell( + onTap: () { + pushPlatformRoute( + context, + builder: (_) => ArchivedGameScreen( + gameId: widget.game.id, + orientation: Side.white, + initialCursor: widget.ply, + ), + ); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.game.white.rating.toString()), + Text(widget.game.black.rating.toString()), + ], + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.game.white.name, + overflow: TextOverflow.ellipsis, + ), + Text( + widget.game.black.name, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Row( + children: [ + if (widget.game.winner == 'white') + Container( + width: widthResultBox, + padding: paddingResultBox, + decoration: BoxDecoration( + color: _whiteBoxColor(context), + borderRadius: BorderRadius.circular(5), + ), + child: const Text( + '1-0', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.black, + ), + ), + ) + else if (widget.game.winner == 'black') + Container( + width: widthResultBox, + padding: paddingResultBox, + decoration: BoxDecoration( + color: _blackBoxColor(context), + borderRadius: BorderRadius.circular(5), + ), + child: const Text( + '0-1', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + ), + ), + ) + else + Container( + width: widthResultBox, + padding: paddingResultBox, + decoration: BoxDecoration( + color: Colors.grey, + borderRadius: BorderRadius.circular(5), + ), + child: const Text( + '½-½', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + ), + ), + ), + if (widget.game.month != null) ...[ + const SizedBox(width: 10.0), + Text( + widget.game.month!, + style: const TextStyle( + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ], + if (widget.game.speed != null) ...[ + const SizedBox(width: 10.0), + Icon(widget.game.speed!.icon, size: 20), + ], + ], + ), + ], + ), + ), + ); + } +} + +class _WinPercentageChart extends StatelessWidget { + const _WinPercentageChart({ + required this.whiteWins, + required this.draws, + required this.blackWins, + }); + + final int whiteWins; + final int draws; + final int blackWins; + + int percentGames(int games) => + ((games / (whiteWins + draws + blackWins)) * 100).round(); + String label(int percent) => percent < 20 ? '' : '$percent%'; + + @override + Widget build(BuildContext context) { + final percentWhite = percentGames(whiteWins); + final percentDraws = percentGames(draws); + final percentBlack = percentGames(blackWins); + + return ClipRRect( + borderRadius: BorderRadius.circular(5), + child: Row( + children: [ + Expanded( + flex: percentWhite, + child: ColoredBox( + color: _whiteBoxColor(context), + child: Text( + label(percentWhite), + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.black), + ), + ), + ), + Expanded( + flex: percentDraws, + child: ColoredBox( + color: Colors.grey, + child: Text( + label(percentDraws), + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white), + ), + ), + ), + Expanded( + flex: percentBlack, + child: ColoredBox( + color: _blackBoxColor(context), + child: Text( + label(percentBlack), + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.white), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/view/puzzle/puzzle_screen.dart b/lib/src/view/puzzle/puzzle_screen.dart index ef8681c596..63bae946cd 100644 --- a/lib/src/view/puzzle/puzzle_screen.dart +++ b/lib/src/view/puzzle/puzzle_screen.dart @@ -484,12 +484,13 @@ class _BottomBar extends ConsumerWidget { pushPlatformRoute( context, builder: (context) => AnalysisScreen( - pgnOrId: ref.read(ctrlProvider.notifier).makePgn(), options: AnalysisOptions( - isLocalEvaluationAllowed: true, - variant: Variant.standard, orientation: puzzleState.pov, - id: standaloneAnalysisId, + standalone: ( + pgn: ref.read(ctrlProvider.notifier).makePgn(), + isComputerAnalysisAllowed: true, + variant: Variant.standard, + ), initialMoveCursor: 0, ), ), diff --git a/lib/src/view/puzzle/streak_screen.dart b/lib/src/view/puzzle/streak_screen.dart index b682b49ebc..7382bee36c 100644 --- a/lib/src/view/puzzle/streak_screen.dart +++ b/lib/src/view/puzzle/streak_screen.dart @@ -291,12 +291,13 @@ class _BottomBar extends ConsumerWidget { pushPlatformRoute( context, builder: (context) => AnalysisScreen( - pgnOrId: ref.read(ctrlProvider.notifier).makePgn(), options: AnalysisOptions( - isLocalEvaluationAllowed: true, - variant: Variant.standard, orientation: puzzleState.pov, - id: standaloneAnalysisId, + standalone: ( + pgn: ref.read(ctrlProvider.notifier).makePgn(), + isComputerAnalysisAllowed: true, + variant: Variant.standard, + ), initialMoveCursor: 0, ), ), diff --git a/lib/src/view/study/study_bottom_bar.dart b/lib/src/view/study/study_bottom_bar.dart index ade01ed711..2c8d4c7a12 100644 --- a/lib/src/view/study/study_bottom_bar.dart +++ b/lib/src/view/study/study_bottom_bar.dart @@ -165,12 +165,13 @@ class _GamebookBottomBar extends ConsumerWidget { context, rootNavigator: true, builder: (context) => AnalysisScreen( - pgnOrId: state.pgn, options: AnalysisOptions( - isLocalEvaluationAllowed: true, - variant: state.variant, orientation: state.pov, - id: standaloneAnalysisId, + standalone: ( + pgn: state.pgn, + isComputerAnalysisAllowed: true, + variant: state.variant, + ), ), ), ), diff --git a/lib/src/view/study/study_screen.dart b/lib/src/view/study/study_screen.dart index 7a1b246d6a..d7cad0abbf 100644 --- a/lib/src/view/study/study_screen.dart +++ b/lib/src/view/study/study_screen.dart @@ -15,7 +15,7 @@ import 'package:lichess_mobile/src/model/settings/board_preferences.dart'; import 'package:lichess_mobile/src/model/study/study_controller.dart'; import 'package:lichess_mobile/src/model/study/study_preferences.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; -import 'package:lichess_mobile/src/utils/screen.dart'; +import 'package:lichess_mobile/src/view/analysis/analysis_layout.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'; @@ -26,7 +26,6 @@ import 'package:lichess_mobile/src/widgets/adaptive_bottom_sheet.dart'; import 'package:lichess_mobile/src/widgets/buttons.dart'; import 'package:lichess_mobile/src/widgets/list.dart'; import 'package:lichess_mobile/src/widgets/pgn.dart'; -import 'package:lichess_mobile/src/widgets/platform.dart'; import 'package:lichess_mobile/src/widgets/platform_scaffold.dart'; import 'package:logging/logging.dart'; @@ -181,149 +180,68 @@ class _Body extends ConsumerWidget { studyControllerProvider(id) .select((state) => state.requireValue.gamebookActive), ); + final engineGaugeParams = ref.watch( + studyControllerProvider(id) + .select((state) => state.valueOrNull?.engineGaugeParams), + ); + final isComputerAnalysisAllowed = ref.watch( + studyControllerProvider(id) + .select((s) => s.requireValue.isComputerAnalysisAllowed), + ); - return SafeArea( - child: Column( - children: [ - Expanded( - child: LayoutBuilder( - builder: (context, constraints) { - final defaultBoardSize = constraints.biggest.shortestSide; - final isTablet = isTabletOrLarger(context); - final remainingHeight = - constraints.maxHeight - defaultBoardSize; - final isSmallScreen = - remainingHeight < kSmallRemainingHeightLeftBoardThreshold; - final boardSize = isTablet || isSmallScreen - ? defaultBoardSize - kTabletBoardTableSidePadding * 2 - : defaultBoardSize; - - final landscape = constraints.biggest.aspectRatio > 1; - - final engineGaugeParams = ref.watch( - studyControllerProvider(id) - .select((state) => state.valueOrNull?.engineGaugeParams), - ); - - final currentNode = ref.watch( - studyControllerProvider(id) - .select((state) => state.requireValue.currentNode), - ); - - final engineLines = EngineLines( - clientEval: currentNode.eval, - isGameOver: currentNode.position?.isGameOver ?? false, - onTapMove: ref - .read( - studyControllerProvider(id).notifier, - ) - .onUserMove, - ); - - final showEvaluationGauge = ref.watch( - analysisPreferencesProvider - .select((value) => value.showEvaluationGauge), - ); - - final engineGauge = - showEvaluationGauge && engineGaugeParams != null - ? EngineGauge( - params: engineGaugeParams, - displayMode: landscape - ? EngineGaugeDisplayMode.vertical - : EngineGaugeDisplayMode.horizontal, - ) - : null; - - final bottomChild = - gamebookActive ? StudyGamebook(id) : StudyTreeView(id); - - return landscape - ? Row( - mainAxisSize: MainAxisSize.max, - children: [ - Padding( - padding: EdgeInsets.all( - isTablet ? kTabletBoardTableSidePadding : 0.0, - ), - child: Row( - children: [ - _StudyBoard( - id: id, - boardSize: boardSize, - isTablet: isTablet, - ), - if (engineGauge != null) ...[ - const SizedBox(width: 4.0), - engineGauge, - ], - ], - ), - ), - Flexible( - fit: FlexFit.loose, - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - if (engineGaugeParams != null) engineLines, - Expanded( - child: PlatformCard( - clipBehavior: Clip.hardEdge, - borderRadius: const BorderRadius.all( - Radius.circular(4.0), - ), - margin: const EdgeInsets.all( - kTabletBoardTableSidePadding, - ), - semanticContainer: false, - child: bottomChild, - ), - ), - ], - ), - ), - ], + final currentNode = ref.watch( + studyControllerProvider(id) + .select((state) => state.requireValue.currentNode), + ); + + final analysisPrefs = ref.watch(analysisPreferencesProvider); + final showEvaluationGauge = analysisPrefs.showEvaluationGauge; + final numEvalLines = analysisPrefs.numEvalLines; + + final bottomChild = gamebookActive ? StudyGamebook(id) : StudyTreeView(id); + + return DefaultTabController( + length: 1, + child: AnalysisLayout( + boardBuilder: (context, boardSize, borderRadius) => _StudyBoard( + id: id, + boardSize: boardSize, + borderRadius: borderRadius, + ), + engineGaugeBuilder: isComputerAnalysisAllowed && + showEvaluationGauge && + engineGaugeParams != null + ? (context, orientation) { + return orientation == Orientation.portrait + ? EngineGauge( + displayMode: EngineGaugeDisplayMode.horizontal, + params: engineGaugeParams, ) - : Column( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.max, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Padding( - padding: EdgeInsets.all( - isTablet ? kTabletBoardTableSidePadding : 0.0, - ), - child: Column( - children: [ - if (engineGauge != null) ...[ - engineGauge, - engineLines, - ], - _StudyBoard( - id: id, - boardSize: boardSize, - isTablet: isTablet, - ), - ], - ), - ), - Expanded( - child: Padding( - padding: isTablet - ? const EdgeInsets.symmetric( - horizontal: kTabletBoardTableSidePadding, - ) - : EdgeInsets.zero, - child: bottomChild, - ), - ), - ], + : Container( + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4.0), + ), + child: EngineGauge( + displayMode: EngineGaugeDisplayMode.vertical, + params: engineGaugeParams, + ), ); - }, - ), - ), - StudyBottomBar(id: id), - ], + } + : null, + engineLines: isComputerAnalysisAllowed && numEvalLines > 0 + ? EngineLines( + clientEval: currentNode.eval, + isGameOver: currentNode.position?.isGameOver ?? false, + onTapMove: ref + .read( + studyControllerProvider(id).notifier, + ) + .onUserMove, + ) + : null, + bottomBar: StudyBottomBar(id: id), + children: [bottomChild], ), ); } @@ -351,14 +269,14 @@ class _StudyBoard extends ConsumerStatefulWidget { const _StudyBoard({ required this.id, required this.boardSize, - required this.isTablet, + this.borderRadius, }); final StudyId id; final double boardSize; - final bool isTablet; + final BorderRadiusGeometry? borderRadius; @override ConsumerState<_StudyBoard> createState() => _StudyBoardState(); @@ -440,10 +358,10 @@ class _StudyBoardState extends ConsumerState<_StudyBoard> { return Chessboard( size: widget.boardSize, settings: boardPrefs.toBoardSettings().copyWith( - borderRadius: widget.isTablet - ? const BorderRadius.all(Radius.circular(4.0)) - : BorderRadius.zero, - boxShadow: widget.isTablet ? boardShadows : const [], + borderRadius: widget.borderRadius, + boxShadow: widget.borderRadius != null + ? boardShadows + : const [], drawShape: DrawShapeOptions( enable: true, onCompleteShape: _onCompleteShape, diff --git a/lib/src/view/study/study_settings.dart b/lib/src/view/study/study_settings.dart index 2b1527de6a..1064c5a962 100644 --- a/lib/src/view/study/study_settings.dart +++ b/lib/src/view/study/study_settings.dart @@ -22,8 +22,8 @@ class StudySettings extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final studyController = studyControllerProvider(id); - final isLocalEvaluationAllowed = ref.watch( - studyController.select((s) => s.requireValue.isLocalEvaluationAllowed), + final isComputerAnalysisAllowed = ref.watch( + studyController.select((s) => s.requireValue.isComputerAnalysisAllowed), ); final isEngineAvailable = ref.watch( studyController.select((s) => s.requireValue.isEngineAvailable), @@ -37,48 +37,20 @@ class StudySettings extends ConsumerWidget { return BottomSheetScrollableContainer( children: [ - SwitchSettingTile( - title: Text(context.l10n.toggleLocalEvaluation), - value: analysisPrefs.enableLocalEvaluation, - onChanged: isLocalEvaluationAllowed - ? (_) { - ref.read(studyController.notifier).toggleLocalEvaluation(); - } - : null, - ), - PlatformListTile( - title: Text.rich( - TextSpan( - text: '${context.l10n.multipleLines}: ', - style: const TextStyle( - fontWeight: FontWeight.normal, - ), - children: [ - TextSpan( - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 18, - ), - text: analysisPrefs.numEvalLines.toString(), - ), - ], - ), - ), - subtitle: NonLinearSlider( - value: analysisPrefs.numEvalLines, - values: const [0, 1, 2, 3], - onChangeEnd: isEngineAvailable - ? (value) => ref - .read(studyController.notifier) - .setNumEvalLines(value.toInt()) + if (isComputerAnalysisAllowed) ...[ + SwitchSettingTile( + title: Text(context.l10n.toggleLocalEvaluation), + value: analysisPrefs.enableLocalEvaluation, + onChanged: isComputerAnalysisAllowed + ? (_) { + ref.read(studyController.notifier).toggleLocalEvaluation(); + } : null, ), - ), - if (maxEngineCores > 1) PlatformListTile( title: Text.rich( TextSpan( - text: '${context.l10n.cpus}: ', + text: '${context.l10n.multipleLines}: ', style: const TextStyle( fontWeight: FontWeight.normal, ), @@ -88,30 +60,67 @@ class StudySettings extends ConsumerWidget { fontWeight: FontWeight.bold, fontSize: 18, ), - text: analysisPrefs.numEngineCores.toString(), + text: analysisPrefs.numEvalLines.toString(), ), ], ), ), subtitle: NonLinearSlider( - value: analysisPrefs.numEngineCores, - values: List.generate(maxEngineCores, (index) => index + 1), + value: analysisPrefs.numEvalLines, + values: const [0, 1, 2, 3], onChangeEnd: isEngineAvailable ? (value) => ref .read(studyController.notifier) - .setEngineCores(value.toInt()) + .setNumEvalLines(value.toInt()) : null, ), ), - SwitchSettingTile( - title: Text(context.l10n.bestMoveArrow), - value: analysisPrefs.showBestMoveArrow, - onChanged: isEngineAvailable - ? (value) => ref - .read(analysisPreferencesProvider.notifier) - .toggleShowBestMoveArrow() - : null, - ), + if (maxEngineCores > 1) + PlatformListTile( + title: Text.rich( + TextSpan( + text: '${context.l10n.cpus}: ', + style: const TextStyle( + fontWeight: FontWeight.normal, + ), + children: [ + TextSpan( + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + ), + text: analysisPrefs.numEngineCores.toString(), + ), + ], + ), + ), + subtitle: NonLinearSlider( + value: analysisPrefs.numEngineCores, + values: List.generate(maxEngineCores, (index) => index + 1), + onChangeEnd: isEngineAvailable + ? (value) => ref + .read(studyController.notifier) + .setEngineCores(value.toInt()) + : null, + ), + ), + SwitchSettingTile( + title: Text(context.l10n.bestMoveArrow), + value: analysisPrefs.showBestMoveArrow, + onChanged: isEngineAvailable + ? (value) => ref + .read(analysisPreferencesProvider.notifier) + .toggleShowBestMoveArrow() + : null, + ), + SwitchSettingTile( + title: Text(context.l10n.evaluationGauge), + value: analysisPrefs.showEvaluationGauge, + onChanged: (value) => ref + .read(analysisPreferencesProvider.notifier) + .toggleShowEvaluationGauge(), + ), + ], SwitchSettingTile( title: Text(context.l10n.showVariationArrows), value: studyPrefs.showVariationArrows, @@ -119,13 +128,6 @@ class StudySettings extends ConsumerWidget { .read(studyPreferencesProvider.notifier) .toggleShowVariationArrows(), ), - SwitchSettingTile( - title: Text(context.l10n.evaluationGauge), - value: analysisPrefs.showEvaluationGauge, - onChanged: (value) => ref - .read(analysisPreferencesProvider.notifier) - .toggleShowEvaluationGauge(), - ), SwitchSettingTile( title: Text(context.l10n.toggleGlyphAnnotations), value: analysisPrefs.showAnnotations, diff --git a/lib/src/view/tools/load_position_screen.dart b/lib/src/view/tools/load_position_screen.dart index ac3b4d97b7..3c48bf6ad7 100644 --- a/lib/src/view/tools/load_position_screen.dart +++ b/lib/src/view/tools/load_position_screen.dart @@ -86,7 +86,6 @@ class _BodyState extends State<_Body> { context, rootNavigator: true, builder: (context) => AnalysisScreen( - pgnOrId: parsedInput!.pgn, options: parsedInput!.options, ), ) @@ -121,7 +120,7 @@ class _BodyState extends State<_Body> { } } - ({String pgn, String fen, AnalysisOptions options})? get parsedInput { + ({String fen, AnalysisOptions options})? get parsedInput { if (textInput == null || textInput!.trim().isEmpty) { return null; } @@ -130,13 +129,14 @@ class _BodyState extends State<_Body> { try { final pos = Chess.fromSetup(Setup.parseFen(textInput!.trim())); return ( - pgn: '[FEN "${pos.fen}"]', fen: pos.fen, - options: const AnalysisOptions( - isLocalEvaluationAllowed: true, - variant: Variant.standard, + options: AnalysisOptions( orientation: Side.white, - id: standaloneAnalysisId, + standalone: ( + pgn: '[FEN "${pos.fen}"]', + isComputerAnalysisAllowed: true, + variant: Variant.standard, + ), ) ); } catch (_, __) {} @@ -161,14 +161,15 @@ class _BodyState extends State<_Body> { ); return ( - pgn: textInput!, fen: lastPosition.fen, options: AnalysisOptions( - isLocalEvaluationAllowed: true, - variant: rule != null ? Variant.fromRule(rule) : Variant.standard, - initialMoveCursor: mainlineMoves.isEmpty ? 0 : 1, orientation: Side.white, - id: standaloneAnalysisId, + standalone: ( + pgn: textInput!, + isComputerAnalysisAllowed: true, + variant: rule != null ? Variant.fromRule(rule) : Variant.standard, + ), + initialMoveCursor: mainlineMoves.isEmpty ? 0 : 1, ) ); } catch (_, __) {} diff --git a/lib/src/view/tools/tools_tab_screen.dart b/lib/src/view/tools/tools_tab_screen.dart index 8270f84bcc..f00a896a22 100644 --- a/lib/src/view/tools/tools_tab_screen.dart +++ b/lib/src/view/tools/tools_tab_screen.dart @@ -148,12 +148,13 @@ class _Body extends ConsumerWidget { context, rootNavigator: true, builder: (context) => const AnalysisScreen( - pgnOrId: '', options: AnalysisOptions( - isLocalEvaluationAllowed: true, - variant: Variant.standard, orientation: Side.white, - id: standaloneAnalysisId, + standalone: ( + pgn: '', + isComputerAnalysisAllowed: true, + variant: Variant.standard, + ), ), ), ), @@ -166,12 +167,13 @@ class _Body extends ConsumerWidget { context, rootNavigator: true, builder: (context) => const OpeningExplorerScreen( - pgn: '', options: AnalysisOptions( - isLocalEvaluationAllowed: false, - variant: Variant.standard, orientation: Side.white, - id: standaloneOpeningExplorerId, + standalone: ( + pgn: '', + isComputerAnalysisAllowed: false, + variant: Variant.standard, + ), ), ), ) diff --git a/lib/src/widgets/feedback.dart b/lib/src/widgets/feedback.dart index c637d2b7c8..5c64cdb703 100644 --- a/lib/src/widgets/feedback.dart +++ b/lib/src/widgets/feedback.dart @@ -169,7 +169,6 @@ class FullScreenRetryRequest extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ - // TODO translate Text( context.l10n.mobileSomethingWentWrong, style: Styles.sectionTitle, diff --git a/lib/src/widgets/pgn.dart b/lib/src/widgets/pgn.dart index f480db8883..0dff2207db 100644 --- a/lib/src/widgets/pgn.dart +++ b/lib/src/widgets/pgn.dart @@ -195,18 +195,30 @@ class _DebouncedPgnTreeViewState extends ConsumerState { // using the fast replay buttons. @override Widget build(BuildContext context) { - final shouldShowComments = ref.watch( - analysisPreferencesProvider.select((value) => value.showPgnComments), + final withComputerAnalysis = ref.watch( + analysisPreferencesProvider + .select((value) => value.enableComputerAnalysis), ); - final shouldShowAnnotations = ref.watch( - analysisPreferencesProvider.select((value) => value.showAnnotations), - ); + final shouldShowComments = withComputerAnalysis && + ref.watch( + analysisPreferencesProvider.select( + (value) => value.showPgnComments, + ), + ); + + final shouldShowAnnotations = withComputerAnalysis && + ref.watch( + analysisPreferencesProvider.select( + (value) => value.showAnnotations, + ), + ); return _PgnTreeView( root: widget.root, rootComments: widget.pgnRootComments, params: ( + withComputerAnalysis: withComputerAnalysis, shouldShowAnnotations: shouldShowAnnotations, shouldShowComments: shouldShowComments, currentMoveKey: currentMoveKey, @@ -225,6 +237,11 @@ typedef _PgnTreeViewParams = ({ /// Path to the currently selected move in the tree. UciPath pathToCurrentMove, + /// Whether to show NAG, comments, and analysis variations. + /// + /// Takes precedence over [shouldShowAnnotations], and [shouldShowComments], + bool withComputerAnalysis, + /// Whether to show NAG annotations like '!' and '??'. bool shouldShowAnnotations, @@ -239,6 +256,16 @@ typedef _PgnTreeViewParams = ({ PgnTreeNotifier notifier, }); +/// Filter node children when computer analysis is disabled +IList _filteredChildren( + ViewNode node, + bool withComputerAnalysis, +) { + return node.children + .where((c) => withComputerAnalysis || !c.isComputerVariation) + .toIList(); +} + /// Whether to display the sideline inline. /// /// Sidelines are usually rendered on a new line and indented. @@ -251,16 +278,21 @@ bool _displaySideLineAsInline(ViewBranch node, [int depth = 0]) { } /// Returns whether this node has a sideline that should not be displayed inline. -bool _hasNonInlineSideLine(ViewNode node) => - node.children.length > 2 || - (node.children.length == 2 && !_displaySideLineAsInline(node.children[1])); +bool _hasNonInlineSideLine(ViewNode node, _PgnTreeViewParams params) { + final children = _filteredChildren(node, params.withComputerAnalysis); + return children.length > 2 || + (children.length == 2 && !_displaySideLineAsInline(children[1])); +} /// Splits the mainline into parts, where each part is a sequence of moves that are displayed on the same line. /// /// A part ends when a mainline node has a sideline that should not be displayed inline. -Iterable> _mainlineParts(ViewRoot root) => +Iterable> _mainlineParts( + ViewRoot root, + _PgnTreeViewParams params, +) => [root, ...root.mainline] - .splitAfter(_hasNonInlineSideLine) + .splitAfter((n) => _hasNonInlineSideLine(n, params)) .takeWhile((nodes) => nodes.firstOrNull?.children.isNotEmpty == true); class _PgnTreeView extends StatefulWidget { @@ -378,7 +410,8 @@ class _PgnTreeViewState extends State<_PgnTreeView> { void _updateLines({required bool fullRebuild}) { setState(() { if (fullRebuild) { - mainlineParts = _mainlineParts(widget.root).toList(growable: false); + mainlineParts = + _mainlineParts(widget.root, widget.params).toList(growable: false); } subtrees = _buildChangedSubtrees(fullRebuild: fullRebuild); @@ -396,6 +429,8 @@ class _PgnTreeViewState extends State<_PgnTreeView> { super.didUpdateWidget(oldWidget); _updateLines( fullRebuild: oldWidget.root != widget.root || + oldWidget.params.withComputerAnalysis != + widget.params.withComputerAnalysis || oldWidget.params.shouldShowComments != widget.params.shouldShowComments || oldWidget.params.shouldShowAnnotations != @@ -472,7 +507,9 @@ List _buildInlineSideLine({ node, lineInfo: ( type: _LineType.inlineSideline, - startLine: i == 0 || sidelineNodes[i - 1].hasTextComment, + startLine: i == 0 || + (params.shouldShowComments && + sidelineNodes[i - 1].hasTextComment), pathToLine: initialPath, ), pathToNode: pathToNode, @@ -593,7 +630,7 @@ class _SideLinePart extends ConsumerWidget { node.children.first, lineInfo: ( type: _LineType.sideline, - startLine: node.hasTextComment, + startLine: params.shouldShowComments && node.hasTextComment, pathToLine: initialPath, ), pathToNode: path, @@ -628,6 +665,12 @@ class _SideLinePart extends ConsumerWidget { /// A widget that renders part of the mainline. /// /// A part of the mainline is rendered on a single line. See [_mainlineParts]. +/// +/// For example: +/// 1. e4 e5 <-- mainline part +/// |- 1... d5 <-- sideline part +/// |- 1... Nc6 <-- sideline part +/// 2. Nf3 Nc6 (2... a5) 3. Bc4 <-- mainline part class _MainLinePart extends ConsumerWidget { const _MainLinePart({ required this.initialPath, @@ -651,27 +694,36 @@ class _MainLinePart extends ConsumerWidget { return Text.rich( TextSpan( children: nodes - .takeWhile((node) => node.children.isNotEmpty) + .takeWhile( + (node) => _filteredChildren(node, params.withComputerAnalysis) + .isNotEmpty, + ) .mapIndexed( (i, node) { - final mainlineNode = node.children.first; + final children = _filteredChildren( + node, + params.withComputerAnalysis, + ); + final mainlineNode = children.first; final moves = [ _moveWithComment( mainlineNode, lineInfo: ( type: _LineType.mainline, - startLine: i == 0 || (node as ViewBranch).hasTextComment, + startLine: i == 0 || + (params.shouldShowComments && + (node as ViewBranch).hasTextComment), pathToLine: initialPath, ), pathToNode: path, textStyle: textStyle, params: params, ), - if (node.children.length == 2 && - _displaySideLineAsInline(node.children[1])) ...[ + if (children.length == 2 && + _displaySideLineAsInline(children[1])) ...[ _buildInlineSideLine( followsComment: mainlineNode.hasTextComment, - firstNode: node.children[1], + firstNode: children[1], parent: node, initialPath: path, textStyle: textStyle, @@ -715,7 +767,7 @@ class _SideLine extends StatelessWidget { List _getSidelinePartNodes() { final sidelineNodes = [firstNode]; while (sidelineNodes.last.children.isNotEmpty && - !_hasNonInlineSideLine(sidelineNodes.last)) { + !_hasNonInlineSideLine(sidelineNodes.last, params)) { sidelineNodes.add(sidelineNodes.last.children.first); } return sidelineNodes.toList(growable: false); @@ -844,7 +896,7 @@ class _IndentedSideLinesState extends State<_IndentedSideLines> { /// calculate the position of the indents in a post-frame callback. void _redrawIndents() { _sideLinesStartKeys = List.generate( - _visibleSideLines.length + (_hasHiddenLines ? 1 : 0), + _expandedSidelines.length + (_hasCollapsedLines ? 1 : 0), (_) => GlobalKey(), ); WidgetsBinding.instance.addPostFrameCallback((_) { @@ -867,10 +919,11 @@ class _IndentedSideLinesState extends State<_IndentedSideLines> { }); } - bool get _hasHiddenLines => widget.sideLines.any((node) => node.isHidden); + bool get _hasCollapsedLines => + widget.sideLines.any((node) => node.isCollapsed); - Iterable get _visibleSideLines => - widget.sideLines.whereNot((node) => node.isHidden); + Iterable get _expandedSidelines => + widget.sideLines.whereNot((node) => node.isCollapsed); @override void initState() { @@ -888,7 +941,7 @@ class _IndentedSideLinesState extends State<_IndentedSideLines> { @override Widget build(BuildContext context) { - final sideLineWidgets = _visibleSideLines + final sideLineWidgets = _expandedSidelines .mapIndexed( (i, firstSidelineNode) => _SideLine( firstNode: firstSidelineNode, @@ -916,7 +969,7 @@ class _IndentedSideLinesState extends State<_IndentedSideLines> { crossAxisAlignment: CrossAxisAlignment.start, children: [ ...sideLineWidgets, - if (_hasHiddenLines) + if (_hasCollapsedLines) GestureDetector( child: Icon( Icons.add_box, diff --git a/test/view/analysis/analysis_screen_test.dart b/test/view/analysis/analysis_screen_test.dart index 156380c906..bf91a6b889 100644 --- a/test/view/analysis/analysis_screen_test.dart +++ b/test/view/analysis/analysis_screen_test.dart @@ -7,14 +7,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:lichess_mobile/src/model/analysis/analysis_controller.dart'; import 'package:lichess_mobile/src/model/analysis/analysis_preferences.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/perf.dart'; -import 'package:lichess_mobile/src/model/common/speed.dart'; -import 'package:lichess_mobile/src/model/game/archived_game.dart'; -import 'package:lichess_mobile/src/model/game/game_status.dart'; -import 'package:lichess_mobile/src/model/game/player.dart'; import 'package:lichess_mobile/src/model/settings/preferences_storage.dart'; -import 'package:lichess_mobile/src/model/user/user.dart'; import 'package:lichess_mobile/src/view/analysis/analysis_screen.dart'; import 'package:lichess_mobile/src/widgets/bottom_bar_button.dart'; import 'package:lichess_mobile/src/widgets/pgn.dart'; @@ -24,28 +17,26 @@ import '../../test_provider_scope.dart'; void main() { // ignore: avoid_dynamic_calls final sanMoves = jsonDecode(gameResponse)['moves'] as String; - const opening = LightOpening( - eco: 'C20', - name: "King's Pawn Game: Wayward Queen Attack, Kiddie Countergambit", - ); group('Analysis Screen', () { testWidgets('displays correct move and position', (tester) async { final app = await makeTestProviderScopeApp( tester, home: AnalysisScreen( - pgnOrId: sanMoves, options: AnalysisOptions( - isLocalEvaluationAllowed: false, - variant: Variant.standard, - opening: opening, orientation: Side.white, - id: gameData.id, + standalone: ( + pgn: sanMoves, + isComputerAnalysisAllowed: false, + variant: Variant.standard, + ), ), ), ); await tester.pumpWidget(app); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + await tester.pump(const Duration(milliseconds: 1)); expect(find.byType(Chessboard), findsOneWidget); expect(find.byType(PieceWidget), findsNWidgets(25)); @@ -68,18 +59,20 @@ void main() { final app = await makeTestProviderScopeApp( tester, home: AnalysisScreen( - pgnOrId: sanMoves, options: AnalysisOptions( - isLocalEvaluationAllowed: false, - variant: Variant.standard, - opening: opening, orientation: Side.white, - id: gameData.id, + standalone: ( + pgn: sanMoves, + isComputerAnalysisAllowed: false, + variant: Variant.standard, + ), ), ), ); await tester.pumpWidget(app); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + await tester.pump(const Duration(milliseconds: 1)); // cannot go forward expect( @@ -133,19 +126,20 @@ void main() { ), }, home: AnalysisScreen( - pgnOrId: pgn, - options: const AnalysisOptions( - isLocalEvaluationAllowed: false, - variant: Variant.standard, + options: AnalysisOptions( orientation: Side.white, - opening: opening, - id: standaloneAnalysisId, + standalone: ( + pgn: pgn, + isComputerAnalysisAllowed: false, + variant: Variant.standard, + ), ), enableDrawingShapes: false, ), ); await tester.pumpWidget(app); + await tester.pump(const Duration(milliseconds: 1)); } Text parentText(WidgetTester tester, String move) { @@ -461,27 +455,27 @@ void main() { }); } -final gameData = LightArchivedGame( - id: const GameId('qVChCOTc'), - rated: false, - speed: Speed.blitz, - perf: Perf.blitz, - createdAt: DateTime.parse('2023-01-11 14:30:22.389'), - lastMoveAt: DateTime.parse('2023-01-11 14:33:56.416'), - status: GameStatus.mate, - white: const Player(aiLevel: 1), - black: const Player( - user: LightUser( - id: UserId('veloce'), - name: 'veloce', - isPatron: true, - ), - rating: 1435, - ), - variant: Variant.standard, - lastFen: '1r3rk1/p1pb1ppp/3p4/8/1nBN1P2/1P6/PBPP1nPP/R1K1q3 w - - 4 1', - winner: Side.black, -); +// final gameData = LightArchivedGame( +// id: const GameId('qVChCOTc'), +// rated: false, +// speed: Speed.blitz, +// perf: Perf.blitz, +// createdAt: DateTime.parse('2023-01-11 14:30:22.389'), +// lastMoveAt: DateTime.parse('2023-01-11 14:33:56.416'), +// status: GameStatus.mate, +// white: const Player(aiLevel: 1), +// black: const Player( +// user: LightUser( +// id: UserId('veloce'), +// name: 'veloce', +// isPatron: true, +// ), +// rating: 1435, +// ), +// variant: Variant.standard, +// lastFen: '1r3rk1/p1pb1ppp/3p4/8/1nBN1P2/1P6/PBPP1nPP/R1K1q3 w - - 4 1', +// winner: Side.black, +// ); const gameResponse = ''' {"id":"qVChCOTc","rated":false,"variant":"standard","speed":"blitz","perf":"blitz","createdAt":1673443822389,"lastMoveAt":1673444036416,"status":"mate","players":{"white":{"aiLevel":1},"black":{"user":{"name":"veloce","patron":true,"id":"veloce"},"rating":1435,"provisional":true}},"winner":"black","opening":{"eco":"C20","name":"King's Pawn Game: Wayward Queen Attack, Kiddie Countergambit","ply":4},"moves":"e4 e5 Qh5 Nf6 Qxe5+ Be7 b3 d6 Qb5+ Bd7 Qxb7 Nc6 Ba3 Rb8 Qa6 Nxe4 Bb2 O-O Nc3 Nb4 Nf3 Nxa6 Nd5 Nb4 Nxe7+ Qxe7 Nd4 Qf6 f4 Qe7 Ke2 Ng3+ Kd1 Nxh1 Bc4 Nf2+ Kc1 Qe1#","clocks":[18003,18003,17915,17627,17771,16691,17667,16243,17475,15459,17355,14779,17155,13795,16915,13267,14771,11955,14451,10995,14339,10203,13899,9099,12427,8379,12003,7547,11787,6691,11355,6091,11147,5763,10851,5099,10635,4657],"clock":{"initial":180,"increment":0,"totalTime":180}} diff --git a/test/view/opening_explorer/opening_explorer_screen_test.dart b/test/view/opening_explorer/opening_explorer_screen_test.dart index a442d6f50e..662440da3b 100644 --- a/test/view/opening_explorer/opening_explorer_screen_test.dart +++ b/test/view/opening_explorer/opening_explorer_screen_test.dart @@ -40,10 +40,12 @@ void main() { }); const options = AnalysisOptions( - id: standaloneOpeningExplorerId, - isLocalEvaluationAllowed: false, orientation: Side.white, - variant: Variant.standard, + standalone: ( + pgn: '', + isComputerAnalysisAllowed: false, + variant: Variant.standard, + ), ); const name = 'John'; @@ -64,18 +66,17 @@ void main() { (WidgetTester tester) async { final app = await makeTestProviderScopeApp( tester, - home: const OpeningExplorerScreen( - pgn: '', - options: options, - ), + home: const OpeningExplorerScreen(options: options), overrides: [ defaultClientProvider.overrideWithValue(mockClient), ], ); await tester.pumpWidget(app); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + await tester.pump(const Duration(milliseconds: 1)); - // wait for opening explorer data to load - await tester.pump(const Duration(milliseconds: 50)); + // wait for opening explorer data to load (taking debounce delay into account) + await tester.pump(const Duration(milliseconds: 350)); final moves = [ 'e4', @@ -102,8 +103,6 @@ void main() { // find.byType(OpeningExplorerGameTile), // findsNWidgets(2), // ); - - await tester.pump(const Duration(milliseconds: 50)); }, variant: kPlatformVariant, ); @@ -113,10 +112,7 @@ void main() { (WidgetTester tester) async { final app = await makeTestProviderScopeApp( tester, - home: const OpeningExplorerScreen( - pgn: '', - options: options, - ), + home: const OpeningExplorerScreen(options: options), overrides: [ defaultClientProvider.overrideWithValue(mockClient), ], @@ -132,9 +128,11 @@ void main() { }, ); await tester.pumpWidget(app); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + await tester.pump(const Duration(milliseconds: 1)); - // wait for opening explorer data to load - await tester.pump(const Duration(milliseconds: 50)); + // wait for opening explorer data to load (taking debounce delay into account) + await tester.pump(const Duration(milliseconds: 350)); final moves = [ 'd4', @@ -157,8 +155,6 @@ void main() { // find.byType(OpeningExplorerGameTile), // findsOneWidget, // ); - - await tester.pump(const Duration(milliseconds: 50)); }, variant: kPlatformVariant, ); @@ -168,10 +164,7 @@ void main() { (WidgetTester tester) async { final app = await makeTestProviderScopeApp( tester, - home: const OpeningExplorerScreen( - pgn: '', - options: options, - ), + home: const OpeningExplorerScreen(options: options), overrides: [ defaultClientProvider.overrideWithValue(mockClient), ], @@ -188,9 +181,11 @@ void main() { }, ); await tester.pumpWidget(app); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + await tester.pump(const Duration(milliseconds: 1)); - // wait for opening explorer data to load - await tester.pump(const Duration(milliseconds: 50)); + // wait for opening explorer data to load (taking debounce delay into account) + await tester.pump(const Duration(milliseconds: 350)); final moves = [ 'c4', @@ -213,8 +208,6 @@ void main() { // find.byType(OpeningExplorerGameTile), // findsOneWidget, // ); - - await tester.pump(const Duration(milliseconds: 50)); }, variant: kPlatformVariant, );