diff --git a/lib/src/model/broadcast/broadcast.dart b/lib/src/model/broadcast/broadcast.dart index ac8ccdb1e8..8d23cdd157 100644 --- a/lib/src/model/broadcast/broadcast.dart +++ b/lib/src/model/broadcast/broadcast.dart @@ -12,12 +12,14 @@ typedef BroadcastsList = ({ int? nextPage, }); +enum BroadcastResult { whiteWins, blackWins, draw, ongoing, noResultPgnTag } + @freezed class Broadcast with _$Broadcast { const Broadcast._(); const factory Broadcast({ - required BroadcastTournament tour, + required BroadcastTournamentData tour, required BroadcastRound round, required String? group, @@ -32,9 +34,42 @@ class Broadcast with _$Broadcast { String get title => group ?? tour.name; } -typedef BroadcastTournament = ({ +@freezed +class BroadcastTournament with _$BroadcastTournament { + const factory BroadcastTournament({ + required BroadcastTournamentData data, + required IList rounds, + required BroadcastRoundId defaultRoundId, + required IList? group, + }) = _BroadcastTournament; +} + +@freezed +class BroadcastTournamentData with _$BroadcastTournamentData { + const factory BroadcastTournamentData({ + required BroadcastTournamentId id, + required String name, + required String? imageUrl, + required String? description, + required BroadcastTournamentInformation information, + }) = _BroadcastTournamentData; +} + +typedef BroadcastTournamentInformation = ({ + String? format, + String? timeControl, + String? players, + BroadcastTournamentDates? dates, +}); + +typedef BroadcastTournamentDates = ({ + DateTime startsAt, + DateTime? endsAt, +}); + +typedef BroadcastTournamentGroup = ({ + BroadcastTournamentId id, String name, - String? imageUrl, }); @freezed @@ -45,25 +80,31 @@ class BroadcastRound with _$BroadcastRound { required BroadcastRoundId id, required String name, required RoundStatus status, - required DateTime startsAt, + required DateTime? startsAt, }) = _BroadcastRound; } -typedef BroadcastRoundGames = IMap; +typedef BroadcastRoundGames = IMap; @freezed -class BroadcastGameSnapshot with _$BroadcastGameSnapshot { - const BroadcastGameSnapshot._(); +class BroadcastGame with _$BroadcastGame { + const BroadcastGame._(); - const factory BroadcastGameSnapshot({ + const factory BroadcastGame({ + required BroadcastGameId id, required IMap players, required String fen, required Move? lastMove, - required String status, - - /// The amount of time that the player whose turn it is has been thinking since his last move - required Duration? thinkTime, - }) = _BroadcastGameSnapshot; + required BroadcastResult status, + required DateTime updatedClockAt, + }) = _BroadcastGame; + + bool get isOngoing => status == BroadcastResult.ongoing; + bool get isOver => + status == BroadcastResult.draw || + status == BroadcastResult.whiteWins || + status == BroadcastResult.blackWins; + Side get sideToMove => Setup.parseFen(fen).turn; } @freezed diff --git a/lib/src/model/broadcast/broadcast_game_controller.dart b/lib/src/model/broadcast/broadcast_game_controller.dart new file mode 100644 index 0000000000..a0193cb9b4 --- /dev/null +++ b/lib/src/model/broadcast/broadcast_game_controller.dart @@ -0,0 +1,614 @@ +import 'dart:async'; + +import 'package:dartchess/dartchess.dart'; +import 'package:deep_pick/deep_pick.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:freezed_annotation/freezed_annotation.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/broadcast/broadcast_repository.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/node.dart'; +import 'package:lichess_mobile/src/model/common/service/move_feedback.dart'; +import 'package:lichess_mobile/src/model/common/service/sound_service.dart'; +import 'package:lichess_mobile/src/model/common/socket.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/network/http.dart'; +import 'package:lichess_mobile/src/network/socket.dart'; +import 'package:lichess_mobile/src/utils/json.dart'; +import 'package:lichess_mobile/src/utils/rate_limit.dart'; +import 'package:lichess_mobile/src/view/engine/engine_gauge.dart'; +import 'package:lichess_mobile/src/widgets/pgn.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'broadcast_game_controller.freezed.dart'; +part 'broadcast_game_controller.g.dart'; + +@riverpod +class BroadcastGameController extends _$BroadcastGameController + implements PgnTreeNotifier { + static Uri broadcastSocketUri(BroadcastRoundId broadcastRoundId) => + Uri(path: 'study/$broadcastRoundId/socket/v6'); + + StreamSubscription? _subscription; + + late SocketClient _socketClient; + late Root _root; + + final _engineEvalDebounce = Debouncer(const Duration(milliseconds: 150)); + + Timer? _startEngineEvalTimer; + + @override + Future build( + BroadcastRoundId roundId, + BroadcastGameId gameId, + ) async { + _socketClient = ref + .watch(socketPoolProvider) + .open(BroadcastGameController.broadcastSocketUri(roundId)); + + _subscription = _socketClient.stream.listen(_handleSocketEvent); + + final evaluationService = ref.watch(evaluationServiceProvider); + + ref.onDispose(() { + _subscription?.cancel(); + _startEngineEvalTimer?.cancel(); + _engineEvalDebounce.dispose(); + evaluationService.disposeEngine(); + }); + + final pgn = await ref.withClient( + (client) => BroadcastRepository(client).getGame(roundId, gameId), + ); + + final game = PgnGame.parsePgn(pgn); + final pgnHeaders = IMap(game.headers); + final rootComments = IList(game.comments.map((c) => PgnComment.fromPgn(c))); + + _root = Root.fromPgnGame(game); + final currentPath = _root.mainlinePath; + final currentNode = _root.nodeAt(currentPath); + final lastMove = _root.branchAt(_root.mainlinePath)?.sanMove.move; + + // don't use ref.watch here: we don't want to invalidate state when the + // analysis preferences change + final prefs = ref.read(analysisPreferencesProvider); + final broadcastState = BroadcastGameState( + id: gameId, + currentPath: currentPath, + broadcastPath: currentPath, + isOnMainline: _root.isOnMainline(currentPath), + root: _root.view, + currentNode: AnalysisCurrentNode.fromNode(currentNode), + pgnHeaders: pgnHeaders, + pgnRootComments: rootComments, + lastMove: lastMove, + pov: Side.white, + isLocalEvaluationEnabled: prefs.enableLocalEvaluation, + clocks: _makeClocks(currentPath), + ); + + if (broadcastState.isLocalEvaluationEnabled) { + evaluationService + .initEngine( + _evaluationContext, + options: EvaluationOptions( + multiPv: prefs.numEvalLines, + cores: prefs.numEngineCores, + searchTime: ref.read(analysisPreferencesProvider).engineSearchTime, + ), + ) + .then((_) { + _startEngineEvalTimer = Timer(const Duration(milliseconds: 250), () { + _startEngineEval(); + }); + }); + } + + return broadcastState; + } + + void _handleSocketEvent(SocketEvent event) { + if (!state.hasValue) return; + + switch (event.topic) { + // Sent when a node is recevied from the broadcast + case 'addNode': + _handleAddNodeEvent(event); + // Sent when a pgn tag changes + case 'setTags': + _handleSetTagsEvent(event); + } + } + + void _handleAddNodeEvent(SocketEvent event) { + final broadcastGameId = + pick(event.data, 'p', 'chapterId').asBroadcastGameIdOrThrow(); + + // We check if the event is for this game + if (broadcastGameId != gameId) return; + + // The path of the last and current move of the broadcasted game + // Its value is "!" if the path is identical to one of the node that was received + final currentPath = pick(event.data, 'relayPath').asUciPathOrThrow(); + + // We check that the event we received is for the last move of the game + if (currentPath.value != '!') return; + + // The path for the node that was received + final path = pick(event.data, 'p', 'path').asUciPathOrThrow(); + final uciMove = pick(event.data, 'n', 'uci').asUciMoveOrThrow(); + final clock = + pick(event.data, 'n', 'clock').asDurationFromCentiSecondsOrNull(); + + final (newPath, isNewNode) = _root.addMoveAt(path, uciMove, clock: clock); + + if (newPath != null) { + _root.promoteAt(newPath, toMainline: true); + _setPath( + (state.requireValue.broadcastPath == state.requireValue.currentPath) + ? newPath + : state.requireValue.currentPath, + shouldRecomputeRootView: isNewNode, + shouldForceShowVariation: true, + broadcastPath: newPath, + ); + } + } + + void _handleSetTagsEvent(SocketEvent event) { + final broadcastGameId = + pick(event.data, 'chapterId').asBroadcastGameIdOrThrow(); + + // We check if the event is for this game + if (broadcastGameId != gameId) return; + + final pgnHeadersEntries = pick(event.data, 'tags').asListOrThrow( + (header) => MapEntry( + header(0).asStringOrThrow(), + header(1).asStringOrThrow(), + ), + ); + + final pgnHeaders = + state.requireValue.pgnHeaders.addEntries(pgnHeadersEntries); + state = AsyncData( + state.requireValue.copyWith(pgnHeaders: pgnHeaders), + ); + } + + EvaluationContext get _evaluationContext => EvaluationContext( + variant: Variant.standard, + initialPosition: _root.position, + ); + + void onUserMove(NormalMove move) { + if (!state.hasValue) return; + + if (!state.requireValue.position.isLegal(move)) return; + + if (isPromotionPawnMove(state.requireValue.position, move)) { + state = AsyncData(state.requireValue.copyWith(promotionMove: move)); + return; + } + + final (newPath, isNewNode) = _root.addMoveAt( + state.requireValue.currentPath, + move, + ); + if (newPath != null) { + _setPath( + newPath, + shouldRecomputeRootView: isNewNode, + shouldForceShowVariation: true, + ); + } + } + + void onPromotionSelection(Role? role) { + if (!state.hasValue) return; + + if (role == null) { + state = AsyncData(state.requireValue.copyWith(promotionMove: null)); + return; + } + final promotionMove = state.requireValue.promotionMove; + if (promotionMove != null) { + final promotion = promotionMove.withPromotion(role); + onUserMove(promotion); + } + } + + void userNext() { + if (!state.hasValue) return; + + if (!state.requireValue.currentNode.hasChild) return; + _setPath( + state.requireValue.currentPath + + _root.nodeAt(state.requireValue.currentPath).children.first.id, + replaying: true, + ); + } + + void jumpToNthNodeOnMainline(int n) { + UciPath path = _root.mainlinePath; + while (!path.penultimate.isEmpty) { + path = path.penultimate; + } + Node? node = _root.nodeAt(path); + int count = 0; + + while (node != null && count < n) { + if (node.children.isNotEmpty) { + path = path + node.children.first.id; + node = _root.nodeAt(path); + count++; + } else { + break; + } + } + + if (node != null) { + userJump(path); + } + } + + void toggleBoard() { + if (!state.hasValue) return; + + state = AsyncData( + state.requireValue.copyWith(pov: state.requireValue.pov.opposite), + ); + } + + void userPrevious() { + _setPath(state.requireValue.currentPath.penultimate, replaying: true); + } + + @override + void userJump(UciPath path) { + _setPath(path); + } + + @override + void expandVariations(UciPath path) { + if (!state.hasValue) return; + + final node = _root.nodeAt(path); + for (final child in node.children) { + child.isCollapsed = false; + for (final grandChild in child.children) { + grandChild.isCollapsed = false; + } + } + state = AsyncData(state.requireValue.copyWith(root: _root.view)); + } + + @override + void collapseVariations(UciPath path) { + if (!state.hasValue) return; + + final node = _root.nodeAt(path); + + for (final child in node.children) { + child.isCollapsed = true; + } + + state = AsyncData(state.requireValue.copyWith(root: _root.view)); + } + + @override + void promoteVariation(UciPath path, bool toMainline) { + if (!state.hasValue) return; + + _root.promoteAt(path, toMainline: toMainline); + state = AsyncData( + state.requireValue.copyWith( + isOnMainline: _root.isOnMainline(state.requireValue.currentPath), + root: _root.view, + ), + ); + } + + @override + void deleteFromHere(UciPath path) { + _root.deleteAt(path); + _setPath(path.penultimate, shouldRecomputeRootView: true); + } + + Future toggleLocalEvaluation() async { + if (!state.hasValue) return; + + ref + .read(analysisPreferencesProvider.notifier) + .toggleEnableLocalEvaluation(); + + state = AsyncData( + state.requireValue.copyWith( + isLocalEvaluationEnabled: !state.requireValue.isLocalEvaluationEnabled, + ), + ); + + if (state.requireValue.isLocalEvaluationEnabled) { + final prefs = ref.read(analysisPreferencesProvider); + await ref.read(evaluationServiceProvider).initEngine( + _evaluationContext, + options: EvaluationOptions( + multiPv: prefs.numEvalLines, + cores: prefs.numEngineCores, + searchTime: + ref.read(analysisPreferencesProvider).engineSearchTime, + ), + ); + _startEngineEval(); + } else { + _stopEngineEval(); + ref.read(evaluationServiceProvider).disposeEngine(); + } + } + + void setNumEvalLines(int numEvalLines) { + if (!state.hasValue) return; + + ref + .read(analysisPreferencesProvider.notifier) + .setNumEvalLines(numEvalLines); + + ref.read(evaluationServiceProvider).setOptions( + EvaluationOptions( + multiPv: numEvalLines, + cores: ref.read(analysisPreferencesProvider).numEngineCores, + searchTime: ref.read(analysisPreferencesProvider).engineSearchTime, + ), + ); + + _root.updateAll((node) => node.eval = null); + + state = AsyncData( + state.requireValue.copyWith( + currentNode: AnalysisCurrentNode.fromNode( + _root.nodeAt(state.requireValue.currentPath), + ), + ), + ); + + _startEngineEval(); + } + + void setEngineCores(int numEngineCores) { + ref + .read(analysisPreferencesProvider.notifier) + .setEngineCores(numEngineCores); + + ref.read(evaluationServiceProvider).setOptions( + EvaluationOptions( + multiPv: ref.read(analysisPreferencesProvider).numEvalLines, + cores: numEngineCores, + searchTime: ref.read(analysisPreferencesProvider).engineSearchTime, + ), + ); + + _startEngineEval(); + } + + void setEngineSearchTime(Duration searchTime) { + ref + .read(analysisPreferencesProvider.notifier) + .setEngineSearchTime(searchTime); + + ref.read(evaluationServiceProvider).setOptions( + EvaluationOptions( + multiPv: ref.read(analysisPreferencesProvider).numEvalLines, + cores: ref.read(analysisPreferencesProvider).numEngineCores, + searchTime: searchTime, + ), + ); + + _startEngineEval(); + } + + void _setPath( + UciPath path, { + bool shouldForceShowVariation = false, + bool shouldRecomputeRootView = false, + bool replaying = false, + UciPath? broadcastPath, + }) { + if (!state.hasValue) return; + + final pathChange = state.requireValue.currentPath != path; + final currentNode = _root.nodeAt(path); + + // always show variation if the user plays a move + if (shouldForceShowVariation && + currentNode is Branch && + currentNode.isCollapsed) { + _root.updateAt(path, (node) { + if (node is Branch) node.isCollapsed = false; + }); + } + + // root view is only used to display move list, so we need to + // recompute the root view only when the nodelist length changes + // or a variation is hidden/shown + final rootView = shouldForceShowVariation || shouldRecomputeRootView + ? _root.view + : state.requireValue.root; + + final isForward = path.size > state.requireValue.currentPath.size; + if (currentNode is Branch) { + if (!replaying) { + if (isForward) { + final isCheck = currentNode.sanMove.isCheck; + if (currentNode.sanMove.isCapture) { + ref + .read(moveFeedbackServiceProvider) + .captureFeedback(check: isCheck); + } else { + ref.read(moveFeedbackServiceProvider).moveFeedback(check: isCheck); + } + } + } else if (isForward) { + final soundService = ref.read(soundServiceProvider); + if (currentNode.sanMove.isCapture) { + soundService.play(Sound.capture); + } else { + soundService.play(Sound.move); + } + } + state = AsyncData( + state.requireValue.copyWith( + currentPath: path, + broadcastPath: broadcastPath ?? state.requireValue.broadcastPath, + isOnMainline: _root.isOnMainline(path), + currentNode: AnalysisCurrentNode.fromNode(currentNode), + lastMove: currentNode.sanMove.move, + promotionMove: null, + root: rootView, + clocks: _makeClocks(path), + ), + ); + } else { + state = AsyncData( + state.requireValue.copyWith( + currentPath: path, + broadcastPath: broadcastPath ?? state.requireValue.broadcastPath, + isOnMainline: _root.isOnMainline(path), + currentNode: AnalysisCurrentNode.fromNode(currentNode), + lastMove: null, + promotionMove: null, + root: rootView, + clocks: _makeClocks(path), + ), + ); + } + + if (pathChange && state.requireValue.isLocalEvaluationEnabled) { + _debouncedStartEngineEval(); + } + } + + void _startEngineEval() { + if (!state.hasValue) return; + + if (!state.requireValue.isLocalEvaluationEnabled) return; + ref + .read(evaluationServiceProvider) + .start( + state.requireValue.currentPath, + _root.branchesOn(state.requireValue.currentPath).map(Step.fromNode), + initialPositionEval: _root.eval, + shouldEmit: (work) => work.path == state.requireValue.currentPath, + ) + ?.forEach( + (t) => _root.updateAt(t.$1.path, (node) => node.eval = t.$2), + ); + } + + void _debouncedStartEngineEval() { + _engineEvalDebounce(() { + _startEngineEval(); + }); + } + + void _stopEngineEval() { + if (!state.hasValue) return; + + ref.read(evaluationServiceProvider).stop(); + // update the current node with last cached eval + state = AsyncData( + state.requireValue.copyWith( + currentNode: AnalysisCurrentNode.fromNode( + _root.nodeAt(state.requireValue.currentPath), + ), + ), + ); + } + + ({Duration? parentClock, Duration? clock}) _makeClocks(UciPath path) { + final nodeView = _root.nodeAt(path).view; + final parentView = _root.parentAt(path).view; + + return ( + parentClock: (parentView is ViewBranch) ? parentView.clock : null, + clock: (nodeView is ViewBranch) ? nodeView.clock : null, + ); + } +} + +@freezed +class BroadcastGameState with _$BroadcastGameState { + const BroadcastGameState._(); + + const factory BroadcastGameState({ + /// Broadcast game ID + required StringId id, + + /// Immutable view of the whole tree + required ViewRoot root, + + /// The current node in the analysis view. + /// + /// This is an immutable copy of the actual [Node] at the `currentPath`. + /// We don't want to use [Node.view] here because it'd copy the whole tree + /// under the current node and it's expensive. + required AnalysisCurrentNode currentNode, + + /// The path to the current node in the analysis view. + required UciPath currentPath, + + /// The path to the last broadcast move. + required UciPath broadcastPath, + + /// Whether the current path is on the mainline. + required bool isOnMainline, + + /// The side to display the board from. + required Side pov, + + /// Whether the user has enabled local evaluation. + required bool isLocalEvaluationEnabled, + + /// Clocks if available. + ({Duration? parentClock, Duration? clock})? clocks, + + /// The last move played. + Move? lastMove, + + /// Possible promotion move to be played. + NormalMove? promotionMove, + + /// The PGN headers of the game. + required IMap pgnHeaders, + + /// The PGN comments of the game. + /// + /// This field is only used with user submitted PGNS. + IList? pgnRootComments, + }) = _BroadcastGameState; + + IMap> get validMoves => + makeLegalMoves(currentNode.position); + + Position get position => currentNode.position; + bool get canGoNext => currentNode.hasChild; + bool get canGoBack => currentPath.size > UciPath.empty.size; + + /// Whether the game is still ongoing + bool get isOngoing => pgnHeaders['Result'] == '*'; + + /// The path to the current broadcast live move + UciPath? get broadcastLivePath => isOngoing ? broadcastPath : null; + + EngineGaugeParams get engineGaugeParams => ( + orientation: pov, + isLocalEngineAvailable: isLocalEvaluationEnabled, + position: position, + savedEval: currentNode.eval ?? currentNode.serverEval, + ); +} diff --git a/lib/src/model/broadcast/broadcast_providers.dart b/lib/src/model/broadcast/broadcast_providers.dart index 2e46ce4f87..a4e9243a5b 100644 --- a/lib/src/model/broadcast/broadcast_providers.dart +++ b/lib/src/model/broadcast/broadcast_providers.dart @@ -1,5 +1,7 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lichess_mobile/src/model/broadcast/broadcast.dart'; import 'package:lichess_mobile/src/model/broadcast/broadcast_repository.dart'; +import 'package:lichess_mobile/src/model/common/id.dart'; import 'package:lichess_mobile/src/network/http.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -40,3 +42,14 @@ class BroadcastsPaginator extends _$BroadcastsPaginator { ); } } + +@riverpod +Future broadcastTournament( + Ref ref, + BroadcastTournamentId broadcastTournamentId, +) { + return ref.withClient( + (client) => + BroadcastRepository(client).getTournament(broadcastTournamentId), + ); +} diff --git a/lib/src/model/broadcast/broadcast_repository.dart b/lib/src/model/broadcast/broadcast_repository.dart index d461efe9c1..afa5f61d1c 100644 --- a/lib/src/model/broadcast/broadcast_repository.dart +++ b/lib/src/model/broadcast/broadcast_repository.dart @@ -23,17 +23,37 @@ class BroadcastRepository { ); } + Future getTournament( + BroadcastTournamentId broadcastTournamentId, + ) { + return client.readJson( + Uri(path: 'api/broadcast/$broadcastTournamentId'), + headers: {'Accept': 'application/json'}, + mapper: _makeTournamentFromJson, + ); + } + Future getRound( BroadcastRoundId broadcastRoundId, ) { return client.readJson( Uri(path: 'api/broadcast/-/-/$broadcastRoundId'), - // The path parameters with - are the broadcast tournament and round slug + // The path parameters with - are the broadcast tournament and round slugs // They are only used for SEO, so we can safely use - for these parameters headers: {'Accept': 'application/x-ndjson'}, mapper: _makeGamesFromJson, ); } + + Future getGame( + BroadcastRoundId roundId, + BroadcastGameId gameId, + ) { + return client.read( + Uri(path: 'api/study/$roundId/$gameId.pgn'), + headers: {'Accept': 'application/json'}, + ); + } } BroadcastsList _makeBroadcastResponseFromJson( @@ -51,34 +71,75 @@ BroadcastsList _makeBroadcastResponseFromJson( } Broadcast _broadcastFromPick(RequiredPick pick) { - final live = pick('round', 'ongoing').asBoolOrFalse(); - final finished = pick('round', 'finished').asBoolOrFalse(); - final status = live - ? RoundStatus.live - : finished - ? RoundStatus.finished - : RoundStatus.upcoming; final roundId = pick('round', 'id').asBroadcastRoundIdOrThrow(); return Broadcast( - tour: ( - name: pick('tour', 'name').asStringOrThrow(), - imageUrl: pick('tour', 'image').asStringOrNull(), - ), - round: BroadcastRound( - id: roundId, - name: pick('round', 'name').asStringOrThrow(), - status: status, - startsAt: pick('round', 'startsAt') - .asDateTimeFromMillisecondsOrThrow() - .toLocal(), - ), + tour: _tournamentDataFromPick(pick('tour').required()), + round: _roundFromPick(pick('round').required()), group: pick('group').asStringOrNull(), roundToLinkId: pick('roundToLink', 'id').asBroadcastRoundIddOrNull() ?? roundId, ); } +BroadcastTournamentData _tournamentDataFromPick( + RequiredPick pick, +) => + BroadcastTournamentData( + id: pick('id').asBroadcastTournamentIdOrThrow(), + name: pick('name').asStringOrThrow(), + imageUrl: pick('image').asStringOrNull(), + description: pick('description').asStringOrNull(), + information: ( + format: pick('info', 'format').asStringOrNull(), + timeControl: pick('info', 'tc').asStringOrNull(), + players: pick('info', 'players').asStringOrNull(), + dates: pick('dates').letOrNull( + (pick) => ( + startsAt: pick(0).asDateTimeFromMillisecondsOrThrow(), + endsAt: pick(1).asDateTimeFromMillisecondsOrNull(), + ), + ), + ), + ); + +BroadcastTournament _makeTournamentFromJson( + Map json, +) { + return BroadcastTournament( + data: _tournamentDataFromPick(pick(json, 'tour').required()), + rounds: pick(json, 'rounds').asListOrThrow(_roundFromPick).toIList(), + defaultRoundId: pick(json, 'defaultRoundId').asBroadcastRoundIdOrThrow(), + group: pick(json, 'group', 'tours') + .asListOrNull(_tournamentGroupFromPick) + ?.toIList(), + ); +} + +BroadcastTournamentGroup _tournamentGroupFromPick(RequiredPick pick) { + final id = pick('id').asBroadcastTournamentIdOrThrow(); + final name = pick('name').asStringOrThrow(); + + return (id: id, name: name); +} + +BroadcastRound _roundFromPick(RequiredPick pick) { + final live = pick('ongoing').asBoolOrFalse(); + final finished = pick('finished').asBoolOrFalse(); + final status = live + ? RoundStatus.live + : finished + ? RoundStatus.finished + : RoundStatus.upcoming; + + return BroadcastRound( + id: pick('id').asBroadcastRoundIdOrThrow(), + name: pick('name').asStringOrThrow(), + status: status, + startsAt: pick('startsAt').asDateTimeFromMillisecondsOrNull(), + ); +} + BroadcastRoundGames _makeGamesFromJson(Map json) => _gamesFromPick(pick(json).required()); @@ -87,23 +148,42 @@ BroadcastRoundGames _gamesFromPick( ) => IMap.fromEntries(pick('games').asListOrThrow(gameFromPick)); -MapEntry gameFromPick( +MapEntry gameFromPick( RequiredPick pick, -) => - MapEntry( - pick('id').asBroadcastGameIdOrThrow(), - BroadcastGameSnapshot( - players: IMap({ - Side.white: _playerFromPick(pick('players', 0).required()), - Side.black: _playerFromPick(pick('players', 1).required()), - }), - fen: pick('fen').asStringOrNull() ?? - Variant.standard.initialPosition.fen, - lastMove: pick('lastMove').asUciMoveOrNull(), - status: pick('status').asStringOrThrow(), - thinkTime: pick('thinkTime').asDurationFromSecondsOrNull(), - ), - ); +) { + final stringStatus = pick('status').asStringOrNull(); + + final status = (stringStatus == null) + ? BroadcastResult.noResultPgnTag + : switch (stringStatus) { + '½-½' => BroadcastResult.draw, + '1-0' => BroadcastResult.whiteWins, + '0-1' => BroadcastResult.blackWins, + '*' => BroadcastResult.ongoing, + _ => throw FormatException( + "value $stringStatus can't be interpreted as a broadcast result", + ) + }; + + /// The amount of time that the player whose turn it is has been thinking since his last move + final thinkTime = + pick('thinkTime').asDurationFromSecondsOrNull() ?? Duration.zero; + + return MapEntry( + pick('id').asBroadcastGameIdOrThrow(), + BroadcastGame( + id: pick('id').asBroadcastGameIdOrThrow(), + players: IMap({ + Side.white: _playerFromPick(pick('players', 0).required()), + Side.black: _playerFromPick(pick('players', 1).required()), + }), + fen: pick('fen').asStringOrNull() ?? Variant.standard.initialPosition.fen, + lastMove: pick('lastMove').asUciMoveOrNull(), + status: status, + updatedClockAt: DateTime.now().subtract(thinkTime), + ), + ); +} BroadcastPlayer _playerFromPick(RequiredPick pick) { return BroadcastPlayer( diff --git a/lib/src/model/broadcast/broadcast_round_controller.dart b/lib/src/model/broadcast/broadcast_round_controller.dart index ef09edc8ca..66913e3ea6 100644 --- a/lib/src/model/broadcast/broadcast_round_controller.dart +++ b/lib/src/model/broadcast/broadcast_round_controller.dart @@ -27,7 +27,7 @@ class BroadcastRoundController extends _$BroadcastRoundController { @override Future build(BroadcastRoundId broadcastRoundId) async { _socketClient = ref - .read(socketPoolProvider) + .watch(socketPoolProvider) .open(BroadcastRoundController.broadcastSocketUri(broadcastRoundId)); _subscription = _socketClient.stream.listen(_handleSocketEvent); @@ -36,9 +36,11 @@ class BroadcastRoundController extends _$BroadcastRoundController { _subscription?.cancel(); }); - return await ref.withClient( + final games = await ref.withClient( (client) => BroadcastRepository(client).getRound(broadcastRoundId), ); + + return games; } void _handleSocketEvent(SocketEvent event) { @@ -48,7 +50,10 @@ class BroadcastRoundController extends _$BroadcastRoundController { // Sent when a node is recevied from the broadcast case 'addNode': _handleAddNodeEvent(event); - // Sent when a game ends + // Sent when a new board is added + case 'addChapter': + _handleAddChapterEvent(event); + // Sent when the state of games changes case 'chapters': _handleChaptersEvent(event); // Sent when clocks are updated from the broadcast @@ -59,43 +64,45 @@ class BroadcastRoundController extends _$BroadcastRoundController { void _handleAddNodeEvent(SocketEvent event) { // The path of the last and current move of the broadcasted game + // Its value is "!" if the path is identical to one of the node that was received final currentPath = pick(event.data, 'relayPath').asUciPathOrThrow(); - // The path for the node that was received - final path = pick(event.data, 'p', 'path').asUciPathOrThrow(); - final nodeId = pick(event.data, 'n', 'id').asUciCharPairOrThrow(); // We check that the event we received is for the last move of the game - if (currentPath != path + nodeId) return; + if (currentPath.value != '!') return; final broadcastGameId = pick(event.data, 'p', 'chapterId').asBroadcastGameIdOrThrow(); final fen = pick(event.data, 'n', 'fen').asStringOrThrow(); - final playingSide = Setup.parseFen(fen).turn.opposite; + final playingSide = Setup.parseFen(fen).turn; state = AsyncData( state.requireValue.update( broadcastGameId, - (broadcastGameSnapshot) => broadcastGameSnapshot.copyWith( + (broadcastGame) => broadcastGame.copyWith( players: IMap( { - playingSide: broadcastGameSnapshot.players[playingSide]!.copyWith( + playingSide: broadcastGame.players[playingSide]!, + playingSide.opposite: + broadcastGame.players[playingSide.opposite]!.copyWith( clock: pick(event.data, 'n', 'clock') .asDurationFromCentiSecondsOrNull(), ), - playingSide.opposite: - broadcastGameSnapshot.players[playingSide.opposite]!, }, ), fen: fen, lastMove: pick(event.data, 'n', 'uci').asUciMoveOrThrow(), - thinkTime: null, + updatedClockAt: DateTime.now(), ), ), ); } + void _handleAddChapterEvent(SocketEvent event) { + ref.invalidateSelf(); + } + void _handleChaptersEvent(SocketEvent event) { final games = pick(event.data).asListOrThrow(gameFromPick); state = AsyncData(IMap.fromEntries(games)); @@ -104,21 +111,22 @@ class BroadcastRoundController extends _$BroadcastRoundController { void _handleClockEvent(SocketEvent event) { final broadcastGameId = pick(event.data, 'p', 'chapterId').asBroadcastGameIdOrThrow(); - final whiteClock = pick(event.data, 'p', 'relayClocks', 0) - .asDurationFromCentiSecondsOrNull(); - final blackClock = pick(event.data, 'p', 'relayClocks', 1) - .asDurationFromCentiSecondsOrNull(); + final relayClocks = pick(event.data, 'p', 'relayClocks'); + + // We check that the clocks for the broadcast game preview have been updated else we do nothing + if (relayClocks.value == null) return; + state = AsyncData( state.requireValue.update( broadcastGameId, - (broadcastGameSnapshot) => broadcastGameSnapshot.copyWith( + (broadcastsGame) => broadcastsGame.copyWith( players: IMap( { - Side.white: broadcastGameSnapshot.players[Side.white]!.copyWith( - clock: whiteClock, + Side.white: broadcastsGame.players[Side.white]!.copyWith( + clock: relayClocks(0).asDurationFromCentiSecondsOrNull(), ), - Side.black: broadcastGameSnapshot.players[Side.black]!.copyWith( - clock: blackClock, + Side.black: broadcastsGame.players[Side.black]!.copyWith( + clock: relayClocks(1).asDurationFromCentiSecondsOrNull(), ), }, ), diff --git a/lib/src/model/common/id.dart b/lib/src/model/common/id.dart index 813de7f8b4..783c6f3bb6 100644 --- a/lib/src/model/common/id.dart +++ b/lib/src/model/common/id.dart @@ -51,6 +51,8 @@ extension type const ChallengeId(String value) implements StringId { ChallengeId.fromJson(dynamic json) : this(json as String); } +extension type const BroadcastTournamentId(String value) implements StringId {} + extension type const BroadcastRoundId(String value) implements StringId {} extension type const BroadcastGameId(String value) implements StringId {} @@ -159,6 +161,25 @@ extension IDPick on Pick { } } + BroadcastTournamentId asBroadcastTournamentIdOrThrow() { + final value = required().value; + if (value is String) { + return BroadcastTournamentId(value); + } + throw PickException( + "value $value at $debugParsingExit can't be casted to BroadcastTournamentId", + ); + } + + BroadcastTournamentId? asBroadcastTournamentIdOrNull() { + if (value == null) return null; + try { + return asBroadcastTournamentIdOrThrow(); + } catch (_) { + return null; + } + } + BroadcastRoundId asBroadcastRoundIdOrThrow() { final value = required().value; if (value is String) { @@ -184,7 +205,7 @@ extension IDPick on Pick { return BroadcastGameId(value); } throw PickException( - "value $value at $debugParsingExit can't be casted to BroadcastRoundId", + "value $value at $debugParsingExit can't be casted to BroadcastGameId", ); } diff --git a/lib/src/model/common/node.dart b/lib/src/model/common/node.dart index 0747cf4b6a..ce5dbb0fd1 100644 --- a/lib/src/model/common/node.dart +++ b/lib/src/model/common/node.dart @@ -204,6 +204,7 @@ abstract class Node { Move move, { bool prepend = false, bool replace = false, + Duration? clock, }) { final pos = nodeAt(path).position; @@ -218,6 +219,7 @@ abstract class Node { final newNode = Branch( sanMove: SanMove(newSan, convertedMove), position: newPos, + comments: (clock != null) ? [PgnComment(clock: clock)] : null, ); return addNodeAt(path, newNode, prepend: prepend, replace: replace); } diff --git a/lib/src/model/settings/preferences_storage.dart b/lib/src/model/settings/preferences_storage.dart index 0575ad7072..a167695c54 100644 --- a/lib/src/model/settings/preferences_storage.dart +++ b/lib/src/model/settings/preferences_storage.dart @@ -25,7 +25,8 @@ enum PrefCategory { game('preferences.game'), coordinateTraining('preferences.coordinateTraining'), openingExplorer('preferences.opening_explorer'), - puzzle('preferences.puzzle'); + puzzle('preferences.puzzle'), + broadcast('preferences.broadcast'); const PrefCategory(this.storageKey); diff --git a/lib/src/utils/json.dart b/lib/src/utils/json.dart index 6066eaa7b0..b8c013b980 100644 --- a/lib/src/utils/json.dart +++ b/lib/src/utils/json.dart @@ -23,7 +23,7 @@ extension TimeExtension on Pick { return value; } if (value is int) { - return DateTime.fromMillisecondsSinceEpoch(value, isUtc: true); + return DateTime.fromMillisecondsSinceEpoch(value); } throw PickException( "value $value at $debugParsingExit can't be casted to DateTime", diff --git a/lib/src/view/analysis/analysis_board.dart b/lib/src/view/analysis/analysis_board.dart index 8f37f0fe54..66794455b6 100644 --- a/lib/src/view/analysis/analysis_board.dart +++ b/lib/src/view/analysis/analysis_board.dart @@ -17,14 +17,14 @@ class AnalysisBoard extends ConsumerStatefulWidget { const AnalysisBoard( this.options, this.boardSize, { - this.borderRadius, + this.radius, this.enableDrawingShapes = true, this.shouldReplaceChildOnUserMove = false, }); final AnalysisOptions options; final double boardSize; - final BorderRadiusGeometry? borderRadius; + final Radius? radius; final bool enableDrawingShapes; final bool shouldReplaceChildOnUserMove; @@ -106,10 +106,11 @@ class AnalysisBoardState extends ConsumerState { : IMap({sanMove.move.to: annotation}) : null, settings: boardPrefs.toBoardSettings().copyWith( - borderRadius: widget.borderRadius, - boxShadow: widget.borderRadius != null - ? boardShadows - : const [], + borderRadius: (widget.radius != null) + ? BorderRadius.all(widget.radius!) + : null, + boxShadow: + widget.radius != null ? boardShadows : const [], drawShape: DrawShapeOptions( enable: widget.enableDrawingShapes, onCompleteShape: _onCompleteShape, diff --git a/lib/src/view/analysis/analysis_layout.dart b/lib/src/view/analysis/analysis_layout.dart index 80f76e7595..4364f4dcfd 100644 --- a/lib/src/view/analysis/analysis_layout.dart +++ b/lib/src/view/analysis/analysis_layout.dart @@ -11,7 +11,7 @@ import 'package:lichess_mobile/src/widgets/platform.dart'; typedef BoardBuilder = Widget Function( BuildContext context, double boardSize, - BorderRadiusGeometry? borderRadius, + Radius? boardRadius, ); typedef EngineGaugeBuilder = Widget Function( @@ -158,8 +158,7 @@ class AnalysisLayout extends StatelessWidget { ? defaultBoardSize - kTabletBoardTableSidePadding * 2 : defaultBoardSize; - const tabletBoardRadius = - BorderRadius.all(Radius.circular(4.0)); + const tabletBoardRadius = Radius.circular(4.0); // If the aspect ratio is greater than 1, we are in landscape mode. if (aspectRatio > 1) { diff --git a/lib/src/view/analysis/analysis_screen.dart b/lib/src/view/analysis/analysis_screen.dart index ac319d0512..89fb1ed727 100644 --- a/lib/src/view/analysis/analysis_screen.dart +++ b/lib/src/view/analysis/analysis_screen.dart @@ -180,10 +180,10 @@ class _Body extends ConsumerWidget { return AnalysisLayout( tabController: controller, - boardBuilder: (context, boardSize, borderRadius) => AnalysisBoard( + boardBuilder: (context, boardSize, boardRadius) => AnalysisBoard( options, boardSize, - borderRadius: borderRadius, + radius: boardRadius, enableDrawingShapes: enableDrawingShapes, ), engineGaugeBuilder: hasEval && showEvaluationGauge diff --git a/lib/src/view/analysis/tree_view.dart b/lib/src/view/analysis/tree_view.dart index b28e5fac69..477231ca9c 100644 --- a/lib/src/view/analysis/tree_view.dart +++ b/lib/src/view/analysis/tree_view.dart @@ -33,57 +33,28 @@ class AnalysisTreeView extends ConsumerWidget { final enableComputerAnalysis = !options.isLichessGameAnalysis || prefs.enableComputerAnalysis; - return CustomScrollView( - slivers: [ + return ListView( + padding: EdgeInsets.zero, + children: [ if (kOpeningAllowedVariants.contains(variant)) - SliverPersistentHeader( - delegate: _OpeningHeaderDelegate(ctrlProvider), - ), - SliverFillRemaining( - hasScrollBody: false, - child: DebouncedPgnTreeView( - root: root, - currentPath: currentPath, - pgnRootComments: pgnRootComments, - notifier: ref.read(ctrlProvider.notifier), - shouldShowComputerVariations: enableComputerAnalysis, - shouldShowComments: enableComputerAnalysis && prefs.showPgnComments, - shouldShowAnnotations: - enableComputerAnalysis && prefs.showAnnotations, - ), + _OpeningHeader(ctrlProvider), + DebouncedPgnTreeView( + root: root, + currentPath: currentPath, + pgnRootComments: pgnRootComments, + notifier: ref.read(ctrlProvider.notifier), + shouldShowComputerVariations: enableComputerAnalysis, + shouldShowComments: enableComputerAnalysis && prefs.showPgnComments, + shouldShowAnnotations: + enableComputerAnalysis && prefs.showAnnotations, ), ], ); } } -class _OpeningHeaderDelegate extends SliverPersistentHeaderDelegate { - const _OpeningHeaderDelegate(this.ctrlProvider); - - final AnalysisControllerProvider ctrlProvider; - - @override - Widget build( - BuildContext context, - double shrinkOffset, - bool overlapsContent, - ) { - return _Opening(ctrlProvider); - } - - @override - double get minExtent => kOpeningHeaderHeight; - - @override - double get maxExtent => kOpeningHeaderHeight; - - @override - bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) => - true; -} - -class _Opening extends ConsumerWidget { - const _Opening(this.ctrlProvider); +class _OpeningHeader extends ConsumerWidget { + const _OpeningHeader(this.ctrlProvider); final AnalysisControllerProvider ctrlProvider; diff --git a/lib/src/view/broadcast/broadcast_round_screen.dart b/lib/src/view/broadcast/broadcast_boards_tab.dart similarity index 50% rename from lib/src/view/broadcast/broadcast_round_screen.dart rename to lib/src/view/broadcast/broadcast_boards_tab.dart index 4a4d828799..f87280d299 100644 --- a/lib/src/view/broadcast/broadcast_round_screen.dart +++ b/lib/src/view/broadcast/broadcast_boards_tab.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:dartchess/dartchess.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/material.dart'; @@ -12,9 +10,11 @@ import 'package:lichess_mobile/src/network/http.dart'; import 'package:lichess_mobile/src/styles/styles.dart'; import 'package:lichess_mobile/src/utils/duration.dart'; import 'package:lichess_mobile/src/utils/lichess_assets.dart'; +import 'package:lichess_mobile/src/utils/navigation.dart'; import 'package:lichess_mobile/src/utils/screen.dart'; +import 'package:lichess_mobile/src/view/broadcast/broadcast_game_screen.dart'; import 'package:lichess_mobile/src/widgets/board_thumbnail.dart'; -import 'package:lichess_mobile/src/widgets/platform_scaffold.dart'; +import 'package:lichess_mobile/src/widgets/clock.dart'; import 'package:lichess_mobile/src/widgets/shimmer.dart'; // height of 1.0 is important because we need to determine the height of the text @@ -23,58 +23,61 @@ const _kPlayerWidgetTextStyle = TextStyle(fontSize: 13, height: 1.0); const _kPlayerWidgetPadding = EdgeInsets.symmetric(vertical: 5.0); -/// A screen that displays the live games of a broadcast round. -class BroadcastRoundScreen extends StatelessWidget { - final String broadCastTitle; +/// A tab that displays the live games of a broadcast round. +class BroadcastBoardsTab extends ConsumerWidget { final BroadcastRoundId roundId; + final String title; - const BroadcastRoundScreen({ + const BroadcastBoardsTab({ super.key, - required this.broadCastTitle, required this.roundId, + required this.title, }); - @override - Widget build(BuildContext context) { - return PlatformScaffold( - appBar: PlatformAppBar( - title: Text(broadCastTitle), - ), - body: _Body(roundId), - ); - } -} - -class _Body extends ConsumerWidget { - final BroadcastRoundId roundId; - - const _Body(this.roundId); - @override Widget build(BuildContext context, WidgetRef ref) { final games = ref.watch(broadcastRoundControllerProvider(roundId)); - return games.when( - data: (games) => (games.isEmpty) - ? const Text('No games to show for now') - : BroadcastPreview(games: games.values.toIList()), - loading: () => const Shimmer( - child: ShimmerLoading( - isLoading: true, - child: BroadcastPreview(), - ), - ), - error: (error, stackTrace) => Center( - child: Text(error.toString()), - ), + return SafeArea( + bottom: false, + child: switch (games) { + AsyncData(:final value) => (value.isEmpty) + ? const Padding( + padding: Styles.bodyPadding, + child: Text('No boards to show for now'), + ) + : BroadcastPreview( + games: value.values.toIList(), + roundId: roundId, + title: title, + ), + AsyncError(:final error) => Center( + child: Text(error.toString()), + ), + _ => const Shimmer( + child: ShimmerLoading( + isLoading: true, + child: BroadcastPreview( + roundId: BroadcastRoundId(''), + title: '', + ), + ), + ), + }, ); } } class BroadcastPreview extends StatelessWidget { - final IList? games; + final BroadcastRoundId roundId; + final IList? games; + final String title; - const BroadcastPreview({super.key, this.games}); + const BroadcastPreview({ + required this.roundId, + this.games, + required this.title, + }); @override Widget build(BuildContext context) { @@ -92,52 +95,80 @@ class BroadcastPreview extends StatelessWidget { (numberOfBoardsByRow - 1) * boardSpacing) / numberOfBoardsByRow; - return SafeArea( - child: Padding( - padding: Styles.horizontalBodyPadding, - child: GridView.builder( - itemCount: games == null ? numberLoadingBoards : games!.length, - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: numberOfBoardsByRow, - crossAxisSpacing: boardSpacing, - mainAxisSpacing: boardSpacing, - mainAxisExtent: boardWidth + 2 * headerAndFooterHeight, - ), - itemBuilder: (context, index) { - if (games == null) { - return BoardThumbnail.loading( - size: boardWidth, - header: _PlayerWidget.loading(width: boardWidth), - footer: _PlayerWidget.loading(width: boardWidth), - ); - } + return GridView.builder( + padding: Styles.bodyPadding, + itemCount: games == null ? numberLoadingBoards : games!.length, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: numberOfBoardsByRow, + crossAxisSpacing: boardSpacing, + mainAxisSpacing: boardSpacing, + mainAxisExtent: boardWidth + 2 * headerAndFooterHeight, + ), + itemBuilder: (context, index) { + if (games == null) { + return BoardThumbnail.loading( + size: boardWidth, + header: _PlayerWidgetLoading(width: boardWidth), + footer: _PlayerWidgetLoading(width: boardWidth), + ); + } - final game = games![index]; - final playingSide = Setup.parseFen(game.fen).turn; + final game = games![index]; + final playingSide = Setup.parseFen(game.fen).turn; - return BoardThumbnail( - orientation: Side.white, - fen: game.fen, - lastMove: game.lastMove, - size: boardWidth, - header: _PlayerWidget( - width: boardWidth, - player: game.players[Side.black]!, - gameStatus: game.status, - thinkTime: game.thinkTime, - side: Side.black, - playingSide: playingSide, - ), - footer: _PlayerWidget( - width: boardWidth, - player: game.players[Side.white]!, - gameStatus: game.status, - thinkTime: game.thinkTime, - side: Side.white, - playingSide: playingSide, + return BoardThumbnail( + animationDuration: const Duration(milliseconds: 150), + onTap: () { + pushPlatformRoute( + context, + builder: (context) => BroadcastGameScreen( + roundId: roundId, + gameId: game.id, + title: title, ), ); }, + orientation: Side.white, + fen: game.fen, + lastMove: game.lastMove, + size: boardWidth, + header: _PlayerWidget( + width: boardWidth, + game: game, + side: Side.black, + playingSide: playingSide, + ), + footer: _PlayerWidget( + width: boardWidth, + game: game, + side: Side.white, + playingSide: playingSide, + ), + ); + }, + ); + } +} + +class _PlayerWidgetLoading extends StatelessWidget { + const _PlayerWidgetLoading({ + required this.width, + }); + + final double width; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: width, + child: Padding( + padding: _kPlayerWidgetPadding, + child: Container( + height: _kPlayerWidgetTextStyle.fontSize, + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(5), + ), ), ), ); @@ -147,54 +178,20 @@ class BroadcastPreview extends StatelessWidget { class _PlayerWidget extends StatelessWidget { const _PlayerWidget({ required this.width, - required this.player, - required this.gameStatus, - required this.thinkTime, + required this.game, required this.side, required this.playingSide, - }) : _displayShimmerPlaceholder = false; - - const _PlayerWidget.loading({ - required this.width, - }) : player = const BroadcastPlayer( - name: '', - title: null, - rating: null, - clock: null, - federation: null, - ), - gameStatus = '*', - thinkTime = null, - side = Side.white, - playingSide = Side.white, - _displayShimmerPlaceholder = true; + }); - final BroadcastPlayer player; - final String gameStatus; - final Duration? thinkTime; + final BroadcastGame game; final Side side; final Side playingSide; final double width; - final bool _displayShimmerPlaceholder; - @override Widget build(BuildContext context) { - if (_displayShimmerPlaceholder) { - return SizedBox( - width: width, - child: Padding( - padding: _kPlayerWidgetPadding, - child: Container( - height: _kPlayerWidgetTextStyle.fontSize, - decoration: BoxDecoration( - color: Colors.black, - borderRadius: BorderRadius.circular(5), - ), - ), - ), - ); - } + final player = game.players[side]!; + final gameStatus = game.status; return SizedBox( width: width, @@ -243,11 +240,11 @@ class _PlayerWidget extends StatelessWidget { ), ), const SizedBox(width: 5), - if (gameStatus != '*') + if (game.isOver) Text( - (gameStatus == '½-½') + (gameStatus == BroadcastResult.draw) ? '½' - : (gameStatus == '1-0') + : (gameStatus == BroadcastResult.whiteWins) ? side == Side.white ? '1' : '0' @@ -258,12 +255,19 @@ class _PlayerWidget extends StatelessWidget { const TextStyle().copyWith(fontWeight: FontWeight.bold), ) else if (player.clock != null) - if (side == playingSide) - _Clock( - clock: player.clock! - (thinkTime ?? Duration.zero), - ) - else - Text(player.clock!.toHoursMinutesSeconds()), + CountdownClockBuilder( + timeLeft: player.clock!, + active: side == playingSide, + builder: (context, timeLeft) => Text( + timeLeft.toHoursMinutesSeconds(), + style: TextStyle( + color: (side == playingSide) ? Colors.orange[900] : null, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + tickInterval: const Duration(seconds: 1), + clockUpdatedAt: game.updatedClockAt, + ), ], ), ), @@ -271,53 +275,3 @@ class _PlayerWidget extends StatelessWidget { ); } } - -class _Clock extends StatefulWidget { - const _Clock({required this.clock}); - - final Duration clock; - - @override - _ClockState createState() => _ClockState(); -} - -class _ClockState extends State<_Clock> { - Timer? _timer; - late Duration _clock; - - @override - void initState() { - super.initState(); - _clock = widget.clock; - if (_clock.inSeconds <= 0) { - _clock = Duration.zero; - return; - } - _timer = Timer.periodic(const Duration(seconds: 1), (timer) { - setState(() { - _clock = _clock - const Duration(seconds: 1); - }); - if (_clock.inSeconds == 0) { - timer.cancel(); - return; - } - }); - } - - @override - void dispose() { - _timer?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Text( - _clock.toHoursMinutesSeconds(), - style: TextStyle( - color: Colors.orange[900], - fontFeatures: const [FontFeature.tabularFigures()], - ), - ); - } -} diff --git a/lib/src/view/broadcast/broadcast_game_bottom_bar.dart b/lib/src/view/broadcast/broadcast_game_bottom_bar.dart new file mode 100644 index 0000000000..7582fa83b5 --- /dev/null +++ b/lib/src/view/broadcast/broadcast_game_bottom_bar.dart @@ -0,0 +1,71 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lichess_mobile/src/model/broadcast/broadcast_game_controller.dart'; +import 'package:lichess_mobile/src/model/common/id.dart'; +import 'package:lichess_mobile/src/utils/l10n_context.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'; + +class BroadcastGameBottomBar extends ConsumerWidget { + const BroadcastGameBottomBar({ + required this.roundId, + required this.gameId, + }); + + final BroadcastRoundId roundId; + final BroadcastGameId gameId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final ctrlProvider = broadcastGameControllerProvider(roundId, gameId); + final broadcastGameState = ref.watch(ctrlProvider).requireValue; + + return BottomBar( + children: [ + BottomBarButton( + label: context.l10n.flipBoard, + onTap: () { + ref + .read( + ctrlProvider.notifier, + ) + .toggleBoard(); + }, + icon: CupertinoIcons.arrow_2_squarepath, + ), + RepeatButton( + onLongPress: + broadcastGameState.canGoBack ? () => _moveBackward(ref) : null, + child: BottomBarButton( + key: const ValueKey('goto-previous'), + onTap: + broadcastGameState.canGoBack ? () => _moveBackward(ref) : null, + label: 'Previous', + icon: CupertinoIcons.chevron_back, + showTooltip: false, + ), + ), + RepeatButton( + onLongPress: + broadcastGameState.canGoNext ? () => _moveForward(ref) : null, + child: BottomBarButton( + key: const ValueKey('goto-next'), + icon: CupertinoIcons.chevron_forward, + label: context.l10n.next, + onTap: + broadcastGameState.canGoNext ? () => _moveForward(ref) : null, + showTooltip: false, + ), + ), + ], + ); + } + + void _moveForward(WidgetRef ref) => ref + .read(broadcastGameControllerProvider(roundId, gameId).notifier) + .userNext(); + void _moveBackward(WidgetRef ref) => ref + .read(broadcastGameControllerProvider(roundId, gameId).notifier) + .userPrevious(); +} diff --git a/lib/src/view/broadcast/broadcast_game_screen.dart b/lib/src/view/broadcast/broadcast_game_screen.dart new file mode 100644 index 0000000000..8e38a297ff --- /dev/null +++ b/lib/src/view/broadcast/broadcast_game_screen.dart @@ -0,0 +1,500 @@ +import 'package:chessground/chessground.dart'; +import 'package:dartchess/dartchess.dart'; +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:lichess_mobile/src/constants.dart'; +import 'package:lichess_mobile/src/model/analysis/analysis_preferences.dart'; +import 'package:lichess_mobile/src/model/broadcast/broadcast.dart'; +import 'package:lichess_mobile/src/model/broadcast/broadcast_game_controller.dart'; +import 'package:lichess_mobile/src/model/broadcast/broadcast_round_controller.dart'; +import 'package:lichess_mobile/src/model/common/chess.dart'; +import 'package:lichess_mobile/src/model/common/eval.dart'; +import 'package:lichess_mobile/src/model/common/id.dart'; +import 'package:lichess_mobile/src/model/engine/evaluation_service.dart'; +import 'package:lichess_mobile/src/model/settings/board_preferences.dart'; +import 'package:lichess_mobile/src/network/http.dart'; +import 'package:lichess_mobile/src/styles/styles.dart'; +import 'package:lichess_mobile/src/utils/duration.dart'; +import 'package:lichess_mobile/src/utils/l10n_context.dart'; +import 'package:lichess_mobile/src/utils/lichess_assets.dart'; +import 'package:lichess_mobile/src/utils/navigation.dart'; +import 'package:lichess_mobile/src/view/analysis/analysis_layout.dart'; +import 'package:lichess_mobile/src/view/broadcast/broadcast_game_bottom_bar.dart'; +import 'package:lichess_mobile/src/view/broadcast/broadcast_game_settings.dart'; +import 'package:lichess_mobile/src/view/broadcast/broadcast_game_tree_view.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/widgets/buttons.dart'; +import 'package:lichess_mobile/src/widgets/clock.dart'; +import 'package:lichess_mobile/src/widgets/pgn.dart'; +import 'package:lichess_mobile/src/widgets/platform_scaffold.dart'; + +class BroadcastGameScreen extends ConsumerWidget { + final BroadcastRoundId roundId; + final BroadcastGameId gameId; + final String title; + + const BroadcastGameScreen({ + required this.roundId, + required this.gameId, + required this.title, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final broadcastGameState = + ref.watch(broadcastGameControllerProvider(roundId, gameId)); + + return PlatformScaffold( + appBar: PlatformAppBar( + title: Text(title), + actions: [ + AppBarIconButton( + onPressed: (broadcastGameState.hasValue) + ? () { + pushPlatformRoute( + context, + screen: BroadcastGameSettings( + roundId, + gameId, + ), + ); + } + : null, + semanticsLabel: context.l10n.settingsSettings, + icon: const Icon(Icons.settings), + ), + ], + ), + body: switch (broadcastGameState) { + AsyncData() => _Body(roundId, gameId), + AsyncError(:final error) => Center( + child: Text('Cannot load broadcast game: $error'), + ), + _ => const Center(child: CircularProgressIndicator.adaptive()), + }, + ); + } +} + +class _Body extends ConsumerWidget { + final BroadcastRoundId roundId; + final BroadcastGameId gameId; + + const _Body(this.roundId, this.gameId); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final broadcastState = ref + .watch(broadcastGameControllerProvider(roundId, gameId)) + .requireValue; + final analysisPrefs = ref.watch(analysisPreferencesProvider); + final showEvaluationGauge = analysisPrefs.showEvaluationGauge; + final numEvalLines = analysisPrefs.numEvalLines; + + final engineGaugeParams = broadcastState.engineGaugeParams; + final isLocalEvaluationEnabled = broadcastState.isLocalEvaluationEnabled; + final currentNode = broadcastState.currentNode; + + return DefaultTabController( + length: 1, + child: AnalysisLayout( + boardBuilder: (context, boardSize, borderRadius) => + _BroadcastBoardWithHeaders( + roundId, + gameId, + boardSize, + borderRadius, + ), + engineGaugeBuilder: isLocalEvaluationEnabled && showEvaluationGauge + ? (context, orientation) { + return orientation == Orientation.portrait + ? EngineGauge( + displayMode: EngineGaugeDisplayMode.horizontal, + params: engineGaugeParams, + ) + : Container( + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4.0), + ), + child: EngineGauge( + displayMode: EngineGaugeDisplayMode.vertical, + params: engineGaugeParams, + ), + ); + } + : null, + engineLines: isLocalEvaluationEnabled && numEvalLines > 0 + ? EngineLines( + clientEval: currentNode.eval, + isGameOver: currentNode.position.isGameOver, + onTapMove: ref + .read( + broadcastGameControllerProvider(roundId, gameId).notifier, + ) + .onUserMove, + ) + : null, + bottomBar: BroadcastGameBottomBar(roundId: roundId, gameId: gameId), + children: [BroadcastGameTreeView(roundId, gameId)], + ), + ); + } +} + +class _BroadcastBoardWithHeaders extends ConsumerWidget { + final BroadcastRoundId roundId; + final BroadcastGameId gameId; + final double size; + final Radius? radius; + + const _BroadcastBoardWithHeaders( + this.roundId, + this.gameId, + this.size, + this.radius, + ); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Column( + children: [ + _PlayerWidget( + roundId: roundId, + gameId: gameId, + width: size, + widgetPosition: _PlayerWidgetPosition.top, + radius: radius ?? Radius.zero, + ), + _BroadcastBoard(roundId, gameId, size, radius != null), + _PlayerWidget( + roundId: roundId, + gameId: gameId, + width: size, + widgetPosition: _PlayerWidgetPosition.bottom, + radius: radius ?? Radius.zero, + ), + ], + ); + } +} + +class _BroadcastBoard extends ConsumerStatefulWidget { + const _BroadcastBoard( + this.roundId, + this.gameId, + this.boardSize, + this.hasShadow, + ); + + final BroadcastRoundId roundId; + final BroadcastGameId gameId; + final double boardSize; + final bool hasShadow; + + @override + ConsumerState<_BroadcastBoard> createState() => _BroadcastBoardState(); +} + +class _BroadcastBoardState extends ConsumerState<_BroadcastBoard> { + ISet userShapes = ISet(); + + @override + Widget build(BuildContext context) { + final ctrlProvider = + broadcastGameControllerProvider(widget.roundId, widget.gameId); + final broadcastAnalysisState = ref.watch(ctrlProvider).requireValue; + final boardPrefs = ref.watch(boardPreferencesProvider); + final analysisPrefs = ref.watch(analysisPreferencesProvider); + + final evalBestMoves = ref.watch( + engineEvaluationProvider.select((s) => s.eval?.bestMoves), + ); + + final currentNode = broadcastAnalysisState.currentNode; + final annotation = makeAnnotation(currentNode.nags); + + final bestMoves = evalBestMoves ?? currentNode.eval?.bestMoves; + + final sanMove = currentNode.sanMove; + + final ISet bestMoveShapes = analysisPrefs.showBestMoveArrow && + broadcastAnalysisState.isLocalEvaluationEnabled && + bestMoves != null + ? computeBestMoveShapes( + bestMoves, + currentNode.position.turn, + boardPrefs.pieceSet.assets, + ) + : ISet(); + + return Chessboard( + size: widget.boardSize, + fen: broadcastAnalysisState.position.fen, + lastMove: broadcastAnalysisState.lastMove as NormalMove?, + orientation: broadcastAnalysisState.pov, + game: GameData( + playerSide: broadcastAnalysisState.position.isGameOver + ? PlayerSide.none + : broadcastAnalysisState.position.turn == Side.white + ? PlayerSide.white + : PlayerSide.black, + isCheck: boardPrefs.boardHighlights && + broadcastAnalysisState.position.isCheck, + sideToMove: broadcastAnalysisState.position.turn, + validMoves: broadcastAnalysisState.validMoves, + promotionMove: broadcastAnalysisState.promotionMove, + onMove: (move, {isDrop, captured}) => + ref.read(ctrlProvider.notifier).onUserMove(move), + onPromotionSelection: (role) => + ref.read(ctrlProvider.notifier).onPromotionSelection(role), + ), + shapes: userShapes.union(bestMoveShapes), + annotations: + analysisPrefs.showAnnotations && sanMove != null && annotation != null + ? altCastles.containsKey(sanMove.move.uci) + ? IMap({ + Move.parse(altCastles[sanMove.move.uci]!)!.to: annotation, + }) + : IMap({sanMove.move.to: annotation}) + : null, + settings: boardPrefs.toBoardSettings().copyWith( + boxShadow: widget.hasShadow ? boardShadows : const [], + drawShape: DrawShapeOptions( + enable: boardPrefs.enableShapeDrawings, + onCompleteShape: _onCompleteShape, + onClearShapes: _onClearShapes, + newShapeColor: boardPrefs.shapeColor.color, + ), + ), + ); + } + + void _onCompleteShape(Shape shape) { + if (userShapes.any((element) => element == shape)) { + setState(() { + userShapes = userShapes.remove(shape); + }); + return; + } else { + setState(() { + userShapes = userShapes.add(shape); + }); + } + } + + void _onClearShapes() { + setState(() { + userShapes = ISet(); + }); + } +} + +enum _PlayerWidgetPosition { bottom, top } + +class _PlayerWidget extends ConsumerWidget { + const _PlayerWidget({ + required this.roundId, + required this.gameId, + required this.width, + required this.widgetPosition, + required this.radius, + }); + + final BroadcastRoundId roundId; + final BroadcastGameId gameId; + final double width; + final _PlayerWidgetPosition widgetPosition; + final Radius radius; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final broadcastGameState = ref + .watch(broadcastGameControllerProvider(roundId, gameId)) + .requireValue; + final clocks = broadcastGameState.clocks; + final isCursorOnLiveMove = + broadcastGameState.currentPath == broadcastGameState.broadcastLivePath; + final sideToMove = broadcastGameState.position.turn; + final side = switch (widgetPosition) { + _PlayerWidgetPosition.bottom => broadcastGameState.pov, + _PlayerWidgetPosition.top => broadcastGameState.pov.opposite, + }; + final clock = (sideToMove == side) ? clocks?.parentClock : clocks?.clock; + + final game = ref.watch( + broadcastRoundControllerProvider(roundId) + .select((game) => game.requireValue[gameId]!), + ); + final player = game.players[side]!; + final gameStatus = game.status; + + return SizedBox( + width: width, + child: Row( + children: [ + if (game.isOver) + Card( + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: (widgetPosition == _PlayerWidgetPosition.top) + ? radius + : Radius.zero, + bottomLeft: (widgetPosition == _PlayerWidgetPosition.bottom) + ? radius + : Radius.zero, + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0, + vertical: 4.0, + ), + child: Text( + (gameStatus == BroadcastResult.draw) + ? '½' + : (gameStatus == BroadcastResult.whiteWins) + ? side == Side.white + ? '1' + : '0' + : side == Side.black + ? '1' + : '0', + style: + const TextStyle().copyWith(fontWeight: FontWeight.bold), + ), + ), + ), + Expanded( + child: Card( + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: widgetPosition == _PlayerWidgetPosition.top && + !game.isOver + ? radius + : Radius.zero, + topRight: widgetPosition == _PlayerWidgetPosition.top && + clock == null + ? radius + : Radius.zero, + bottomLeft: widgetPosition == _PlayerWidgetPosition.bottom && + !game.isOver + ? radius + : Radius.zero, + bottomRight: widgetPosition == _PlayerWidgetPosition.bottom && + clock == null + ? radius + : Radius.zero, + ), + ), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Row( + children: [ + if (player.federation != null) ...[ + Consumer( + builder: (context, widgetRef, _) { + return SvgPicture.network( + lichessFideFedSrc(player.federation!), + height: 12, + httpClient: + widgetRef.read(defaultClientProvider), + ); + }, + ), + const SizedBox(width: 5), + ], + if (player.title != null) ...[ + Text( + player.title!, + style: const TextStyle().copyWith( + color: context.lichessColors.brag, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 5), + ], + Text( + player.name, + style: const TextStyle().copyWith( + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + ), + if (player.rating != null) ...[ + const SizedBox(width: 5), + Text( + player.rating.toString(), + style: const TextStyle(), + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ], + ), + ), + ), + ), + if (clock != null) + Card( + color: (side == sideToMove) + ? isCursorOnLiveMove + ? Theme.of(context).colorScheme.tertiaryContainer + : Theme.of(context).colorScheme.secondaryContainer + : null, + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topRight: widgetPosition == _PlayerWidgetPosition.top + ? radius + : Radius.zero, + bottomRight: widgetPosition == _PlayerWidgetPosition.bottom + ? radius + : Radius.zero, + ), + ), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + child: CountdownClockBuilder( + timeLeft: clock, + active: side == sideToMove && isCursorOnLiveMove, + builder: (context, timeLeft) => Text( + timeLeft.toHoursMinutesSeconds(), + style: TextStyle( + color: (side == sideToMove) + ? isCursorOnLiveMove + ? Theme.of(context) + .colorScheme + .onTertiaryContainer + : Theme.of(context) + .colorScheme + .onSecondaryContainer + : null, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + tickInterval: const Duration(seconds: 1), + clockUpdatedAt: (side == sideToMove && isCursorOnLiveMove) + ? game.updatedClockAt + : null, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/view/broadcast/broadcast_game_settings.dart b/lib/src/view/broadcast/broadcast_game_settings.dart new file mode 100644 index 0000000000..8fbac642e0 --- /dev/null +++ b/lib/src/view/broadcast/broadcast_game_settings.dart @@ -0,0 +1,83 @@ +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_preferences.dart'; +import 'package:lichess_mobile/src/model/broadcast/broadcast_game_controller.dart'; +import 'package:lichess_mobile/src/model/common/id.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/analysis/stockfish_settings.dart'; +import 'package:lichess_mobile/src/widgets/list.dart'; +import 'package:lichess_mobile/src/widgets/platform_scaffold.dart'; +import 'package:lichess_mobile/src/widgets/settings.dart'; + +class BroadcastGameSettings extends ConsumerWidget { + const BroadcastGameSettings(this.roundId, this.gameId); + + final BroadcastRoundId roundId; + final BroadcastGameId gameId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final broacdcastGameAnalysisController = + broadcastGameControllerProvider(roundId, gameId); + + final analysisPrefs = ref.watch(analysisPreferencesProvider); + final isSoundEnabled = ref.watch( + generalPreferencesProvider.select((pref) => pref.isSoundEnabled), + ); + + return PlatformScaffold( + appBar: PlatformAppBar(title: Text(context.l10n.settingsSettings)), + body: ListView( + children: [ + StockfishSettingsWidget( + onToggleLocalEvaluation: () => ref + .read(broacdcastGameAnalysisController.notifier) + .toggleLocalEvaluation(), + onSetEngineSearchTime: (value) => ref + .read(broacdcastGameAnalysisController.notifier) + .setEngineSearchTime(value), + onSetNumEvalLines: (value) => ref + .read(broacdcastGameAnalysisController.notifier) + .setNumEvalLines(value), + onSetEngineCores: (value) => ref + .read(broacdcastGameAnalysisController.notifier) + .setEngineCores(value), + ), + ListSection( + children: [ + SwitchSettingTile( + title: Text(context.l10n.toggleGlyphAnnotations), + value: analysisPrefs.showAnnotations, + onChanged: (_) => ref + .read(analysisPreferencesProvider.notifier) + .toggleAnnotations(), + ), + SwitchSettingTile( + title: Text(context.l10n.mobileShowComments), + value: analysisPrefs.showPgnComments, + onChanged: (_) => ref + .read(analysisPreferencesProvider.notifier) + .togglePgnComments(), + ), + ], + ), + ListSection( + children: [ + SwitchSettingTile( + title: Text(context.l10n.sound), + value: isSoundEnabled, + onChanged: (value) { + ref + .read(generalPreferencesProvider.notifier) + .toggleSoundEnabled(); + }, + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/src/view/broadcast/broadcast_game_tree_view.dart b/lib/src/view/broadcast/broadcast_game_tree_view.dart new file mode 100644 index 0000000000..f691d03d5e --- /dev/null +++ b/lib/src/view/broadcast/broadcast_game_tree_view.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lichess_mobile/src/model/analysis/analysis_preferences.dart'; +import 'package:lichess_mobile/src/model/broadcast/broadcast_game_controller.dart'; +import 'package:lichess_mobile/src/model/common/id.dart'; +import 'package:lichess_mobile/src/widgets/pgn.dart'; + +const kOpeningHeaderHeight = 32.0; + +class BroadcastGameTreeView extends ConsumerWidget { + const BroadcastGameTreeView( + this.roundId, + this.gameId, + ); + + final BroadcastRoundId roundId; + final BroadcastGameId gameId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final ctrlProvider = broadcastGameControllerProvider(roundId, gameId); + final broadcastGameState = ref.watch(ctrlProvider).requireValue; + + final analysisPrefs = ref.watch(analysisPreferencesProvider); + + return SingleChildScrollView( + child: DebouncedPgnTreeView( + root: broadcastGameState.root, + currentPath: broadcastGameState.currentPath, + broadcastLivePath: broadcastGameState.broadcastLivePath, + pgnRootComments: broadcastGameState.pgnRootComments, + shouldShowAnnotations: analysisPrefs.showAnnotations, + notifier: ref.read(ctrlProvider.notifier), + ), + ); + } +} diff --git a/lib/src/view/broadcast/broadcast_overview_tab.dart b/lib/src/view/broadcast/broadcast_overview_tab.dart new file mode 100644 index 0000000000..f61fe79138 --- /dev/null +++ b/lib/src/view/broadcast/broadcast_overview_tab.dart @@ -0,0 +1,119 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:lichess_mobile/src/model/broadcast/broadcast.dart'; +import 'package:lichess_mobile/src/model/broadcast/broadcast_providers.dart'; +import 'package:lichess_mobile/src/model/common/id.dart'; +import 'package:lichess_mobile/src/styles/styles.dart'; +import 'package:url_launcher/url_launcher.dart'; + +final _dateFormatter = DateFormat.MMMd(); + +/// A tab that displays the overview of a broadcast. +class BroadcastOverviewTab extends ConsumerWidget { + final BroadcastTournamentId tournamentId; + + const BroadcastOverviewTab({super.key, required this.tournamentId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tournament = ref.watch(broadcastTournamentProvider(tournamentId)); + + return SafeArea( + bottom: false, + child: SingleChildScrollView( + child: Padding( + padding: Styles.bodyPadding, + child: switch (tournament) { + AsyncData(:final value) => BroadcastOverviewBody(value), + AsyncError(:final error) => Center( + child: Text('Cannot load game analysis: $error'), + ), + _ => const Center(child: CircularProgressIndicator.adaptive()), + }, + ), + ), + ); + } +} + +class BroadcastOverviewBody extends StatelessWidget { + final BroadcastTournament tournament; + + const BroadcastOverviewBody(this.tournament); + + @override + Widget build(BuildContext context) { + final information = tournament.data.information; + final description = tournament.data.description; + + return Column( + children: [ + Wrap( + alignment: WrapAlignment.center, + children: [ + if (information.dates != null) + BroadcastOverviewCard( + CupertinoIcons.calendar, + information.dates!.endsAt == null + ? _dateFormatter.format(information.dates!.startsAt) + : '${_dateFormatter.format(information.dates!.startsAt)} - ${_dateFormatter.format(information.dates!.endsAt!)}', + ), + if (information.format != null) + BroadcastOverviewCard( + Icons.emoji_events, + '${information.format}', + ), + if (information.timeControl != null) + BroadcastOverviewCard( + CupertinoIcons.stopwatch_fill, + '${information.timeControl}', + ), + if (information.players != null) + BroadcastOverviewCard( + Icons.person, + '${information.players}', + ), + ], + ), + if (description != null) + Padding( + padding: const EdgeInsets.all(16), + child: MarkdownBody( + data: description, + onTapLink: (text, url, title) { + if (url == null) return; + launchUrl(Uri.parse(url)); + }, + ), + ), + ], + ); + } +} + +class BroadcastOverviewCard extends StatelessWidget { + final IconData iconData; + final String text; + + const BroadcastOverviewCard(this.iconData, this.text); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(iconData), + const SizedBox(width: 10), + Flexible(child: Text(text)), + ], + ), + ), + ); + } +} diff --git a/lib/src/view/broadcast/broadcast_screen.dart b/lib/src/view/broadcast/broadcast_screen.dart new file mode 100644 index 0000000000..95f069b157 --- /dev/null +++ b/lib/src/view/broadcast/broadcast_screen.dart @@ -0,0 +1,433 @@ +import 'dart:ui'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lichess_mobile/src/model/broadcast/broadcast.dart'; +import 'package:lichess_mobile/src/model/broadcast/broadcast_providers.dart'; +import 'package:lichess_mobile/src/model/common/id.dart'; +import 'package:lichess_mobile/src/utils/l10n_context.dart'; +import 'package:lichess_mobile/src/view/broadcast/broadcast_boards_tab.dart'; +import 'package:lichess_mobile/src/view/broadcast/broadcast_overview_tab.dart'; +import 'package:lichess_mobile/src/view/broadcast/dropdown_menu.dart' as fixed; +import 'package:lichess_mobile/src/widgets/adaptive_choice_picker.dart'; +import 'package:lichess_mobile/src/widgets/platform.dart'; + +class BroadcastScreen extends StatelessWidget { + final Broadcast broadcast; + + const BroadcastScreen({required this.broadcast}); + + @override + Widget build(BuildContext context) { + return PlatformWidget(androidBuilder: _buildAndroid, iosBuilder: _buildIos); + } + + Widget _buildAndroid(BuildContext context) => + _AndroidScreen(broadcast: broadcast); + + Widget _buildIos(BuildContext context) => + _CupertinoScreen(broadcast: broadcast); +} + +class _AndroidScreen extends StatefulWidget { + final Broadcast broadcast; + + const _AndroidScreen({required this.broadcast}); + + @override + State<_AndroidScreen> createState() => _AndroidScreenState(); +} + +class _AndroidScreenState extends State<_AndroidScreen> + with SingleTickerProviderStateMixin { + late final TabController _tabController; + late BroadcastTournamentId _selectedTournamentId; + late BroadcastRoundId _selectedRoundId; + + @override + void initState() { + super.initState(); + _tabController = TabController(initialIndex: 1, length: 2, vsync: this); + _selectedTournamentId = widget.broadcast.tour.id; + _selectedRoundId = widget.broadcast.roundToLinkId; + } + + void setTournamentId(BroadcastTournamentId tournamentId) { + setState(() { + _selectedTournamentId = tournamentId; + }); + } + + void setRoundId(BroadcastRoundId roundId) { + setState(() { + _selectedRoundId = roundId; + }); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.broadcast.title), + bottom: TabBar( + controller: _tabController, + tabs: [ + Tab(text: context.l10n.broadcastOverview), + Tab(text: context.l10n.broadcastBoards), + ], + ), + ), + body: TabBarView( + controller: _tabController, + children: [ + BroadcastOverviewTab(tournamentId: _selectedTournamentId), + BroadcastBoardsTab( + roundId: _selectedRoundId, + title: widget.broadcast.title, + ), + ], + ), + bottomNavigationBar: BottomAppBar( + child: _AndroidTournamentAndRoundSelector( + tournamentId: _selectedTournamentId, + setTournamentId: setTournamentId, + setRoundId: setRoundId, + ), + ), + ); + } +} + +class _CupertinoScreen extends StatefulWidget { + final Broadcast broadcast; + + const _CupertinoScreen({required this.broadcast}); + + @override + _CupertinoScreenState createState() => _CupertinoScreenState(); +} + +enum _ViewMode { overview, boards } + +class _CupertinoScreenState extends State<_CupertinoScreen> { + _ViewMode _selectedSegment = _ViewMode.boards; + late BroadcastTournamentId _selectedTournamentId; + late BroadcastRoundId _selectedRoundId; + + @override + void initState() { + super.initState(); + _selectedTournamentId = widget.broadcast.tour.id; + _selectedRoundId = widget.broadcast.roundToLinkId; + } + + void setViewMode(_ViewMode mode) { + setState(() { + _selectedSegment = mode; + }); + } + + void setTournamentId(BroadcastTournamentId tournamentId) { + setState(() { + _selectedTournamentId = tournamentId; + }); + } + + void setRoundId(BroadcastRoundId roundId) { + setState(() { + _selectedRoundId = roundId; + }); + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: CupertinoSlidingSegmentedControl<_ViewMode>( + groupValue: _selectedSegment, + children: { + _ViewMode.overview: Text(context.l10n.broadcastOverview), + _ViewMode.boards: Text(context.l10n.broadcastBoards), + }, + onValueChanged: (_ViewMode? view) { + if (view != null) { + setState(() { + _selectedSegment = view; + }); + } + }, + ), + ), + child: Column( + children: [ + Expanded( + child: _selectedSegment == _ViewMode.overview + ? BroadcastOverviewTab(tournamentId: _selectedTournamentId) + : BroadcastBoardsTab( + roundId: _selectedRoundId, + title: widget.broadcast.title, + ), + ), + _IOSTournamentAndRoundSelector( + tournamentId: _selectedTournamentId, + roundId: _selectedRoundId, + setTournamentId: setTournamentId, + setRoundId: setRoundId, + ), + ], + ), + ); + } +} + +class _AndroidTournamentAndRoundSelector extends ConsumerWidget { + final BroadcastTournamentId tournamentId; + final void Function(BroadcastTournamentId) setTournamentId; + final void Function(BroadcastRoundId) setRoundId; + + const _AndroidTournamentAndRoundSelector({ + required this.tournamentId, + required this.setTournamentId, + required this.setRoundId, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tournament = ref.watch(broadcastTournamentProvider(tournamentId)); + + return switch (tournament) { + AsyncData(:final value) => Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + if (value.group != null) + Flexible( + child: // TODO replace with the Flutter framework DropdownMenu when next beta channel is released + fixed.DropdownMenu( + label: const Text('Tournament'), + initialSelection: value.data.id, + dropdownMenuEntries: value.group! + .map( + (tournament) => + // TODO replace with the Flutter framework DropdownMenuEntry when next beta channel is released + fixed.DropdownMenuEntry( + value: tournament.id, + label: tournament.name, + ), + ) + .toList(), + onSelected: (BroadcastTournamentId? value) async { + setTournamentId(value!); + final newTournament = await ref.read( + broadcastTournamentProvider(value).future, + ); + setRoundId(newTournament.defaultRoundId); + }, + ), + ), + Flexible( + child: // TODO replace with the Flutter framework DropdownMenu when next beta channel is released + fixed.DropdownMenu( + label: const Text('Round'), + initialSelection: value.defaultRoundId, + dropdownMenuEntries: value.rounds + .map( + (BroadcastRound round) => + // TODO replace with the Flutter framework DropdownMenuEntry when next beta channel is released + fixed.DropdownMenuEntry( + value: round.id, + label: round.name, + ), + ) + .toList(), + onSelected: (BroadcastRoundId? value) { + setRoundId(value!); + }, + ), + ), + ], + ), + AsyncError(:final error) => Center(child: Text(error.toString())), + _ => const SizedBox.shrink(), + }; + } +} + +const Color _kDefaultToolBarBorderColor = Color(0x4D000000); + +const Border _kDefaultToolBarBorder = Border( + top: BorderSide( + color: _kDefaultToolBarBorderColor, + width: 0.0, // 0.0 means one physical pixel + ), +); + +// Code taken from the Cupertino navigation bar widget +Widget _wrapWithBackground({ + Border? border, + required Color backgroundColor, + Brightness? brightness, + required Widget child, + bool updateSystemUiOverlay = true, +}) { + Widget result = child; + if (updateSystemUiOverlay) { + final bool isDark = backgroundColor.computeLuminance() < 0.179; + final Brightness newBrightness = + brightness ?? (isDark ? Brightness.dark : Brightness.light); + final SystemUiOverlayStyle overlayStyle = switch (newBrightness) { + Brightness.dark => SystemUiOverlayStyle.light, + Brightness.light => SystemUiOverlayStyle.dark, + }; + result = AnnotatedRegion( + value: SystemUiOverlayStyle( + statusBarColor: overlayStyle.statusBarColor, + statusBarBrightness: overlayStyle.statusBarBrightness, + statusBarIconBrightness: overlayStyle.statusBarIconBrightness, + systemStatusBarContrastEnforced: + overlayStyle.systemStatusBarContrastEnforced, + ), + child: result, + ); + } + final DecoratedBox childWithBackground = DecoratedBox( + decoration: BoxDecoration( + border: border, + color: backgroundColor, + ), + child: result, + ); + + if (backgroundColor.a == 0xFF) { + return childWithBackground; + } + + return ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0), + child: childWithBackground, + ), + ); +} + +class _IOSTournamentAndRoundSelector extends ConsumerWidget { + final BroadcastTournamentId tournamentId; + final BroadcastRoundId roundId; + final void Function(BroadcastTournamentId) setTournamentId; + final void Function(BroadcastRoundId) setRoundId; + + const _IOSTournamentAndRoundSelector({ + required this.tournamentId, + required this.roundId, + required this.setTournamentId, + required this.setRoundId, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final backgroundColor = CupertinoTheme.of(context).barBackgroundColor; + final tournament = ref.watch(broadcastTournamentProvider(tournamentId)); + + return switch (tournament) { + AsyncData(:final value) => + + /// It should be replaced with a Flutter toolbar widget once it is implemented. + /// See https://github.com/flutter/flutter/issues/134454 + + _wrapWithBackground( + backgroundColor: backgroundColor, + border: _kDefaultToolBarBorder, + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + spacing: 16.0, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + if (value.group != null) + Flexible( + child: CupertinoButton.tinted( + child: Text( + value.group! + .firstWhere( + (tournament) => tournament.id == tournamentId, + ) + .name, + overflow: TextOverflow.ellipsis, + ), + onPressed: () { + showChoicePicker( + context, + choices: value.group! + .map((tournament) => tournament.id) + .toList(), + labelBuilder: (tournamentId) => Text( + value.group! + .firstWhere( + (tournament) => + tournament.id == tournamentId, + ) + .name, + ), + selectedItem: tournamentId, + onSelectedItemChanged: (tournamentId) async { + setTournamentId(tournamentId); + final newTournament = await ref.read( + broadcastTournamentProvider(tournamentId) + .future, + ); + setRoundId(newTournament.defaultRoundId); + }, + ); + }, + ), + ), + Flexible( + child: CupertinoButton.tinted( + child: Text( + value.rounds + .firstWhere( + (round) => round.id == roundId, + ) + .name, + overflow: TextOverflow.ellipsis, + ), + onPressed: () { + showChoicePicker( + context, + choices: value.rounds + .map( + (round) => round.id, + ) + .toList(), + labelBuilder: (roundId) => Text( + value.rounds + .firstWhere((round) => round.id == roundId) + .name, + ), + selectedItem: roundId, + onSelectedItemChanged: (roundId) { + setRoundId(roundId); + }, + ); + }, + ), + ), + ], + ), + ), + ), + ), + AsyncError(:final error) => Center(child: Text(error.toString())), + _ => const SizedBox.shrink(), + }; + } +} diff --git a/lib/src/view/broadcast/broadcast_tile.dart b/lib/src/view/broadcast/broadcast_tile.dart index 2097846ca9..818d07beed 100644 --- a/lib/src/view/broadcast/broadcast_tile.dart +++ b/lib/src/view/broadcast/broadcast_tile.dart @@ -1,12 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lichess_mobile/src/model/broadcast/broadcast.dart'; import 'package:lichess_mobile/src/styles/transparent_image.dart'; +import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/utils/navigation.dart'; -import 'package:lichess_mobile/src/view/broadcast/broadcast_round_screen.dart'; +import 'package:lichess_mobile/src/view/broadcast/broadcast_screen.dart'; import 'package:lichess_mobile/src/view/broadcast/default_broadcast_image.dart'; import 'package:lichess_mobile/src/widgets/list.dart'; -class BroadcastTile extends StatelessWidget { +class BroadcastTile extends ConsumerWidget { const BroadcastTile({ required this.broadcast, }); @@ -14,7 +16,7 @@ class BroadcastTile extends StatelessWidget { final Broadcast broadcast; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { return PlatformListTile( leading: (broadcast.tour.imageUrl != null) ? FadeInImage.memoryNetwork( @@ -27,10 +29,9 @@ class BroadcastTile extends StatelessWidget { onTap: () { pushPlatformRoute( context, - builder: (context) => BroadcastRoundScreen( - broadCastTitle: broadcast.tour.name, - roundId: broadcast.roundToLinkId, - ), + title: context.l10n.broadcastBroadcasts, + rootNavigator: true, + builder: (context) => BroadcastScreen(broadcast: broadcast), ); }, title: Padding( diff --git a/lib/src/view/broadcast/broadcasts_list_screen.dart b/lib/src/view/broadcast/broadcasts_list_screen.dart index 5fa052feb6..5eb6ea697c 100644 --- a/lib/src/view/broadcast/broadcasts_list_screen.dart +++ b/lib/src/view/broadcast/broadcasts_list_screen.dart @@ -9,13 +9,14 @@ import 'package:lichess_mobile/src/styles/styles.dart'; import 'package:lichess_mobile/src/styles/transparent_image.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/utils/navigation.dart'; -import 'package:lichess_mobile/src/view/broadcast/broadcast_round_screen.dart'; +import 'package:lichess_mobile/src/view/broadcast/broadcast_screen.dart'; import 'package:lichess_mobile/src/view/broadcast/default_broadcast_image.dart'; import 'package:lichess_mobile/src/widgets/buttons.dart'; import 'package:lichess_mobile/src/widgets/platform_scaffold.dart'; import 'package:lichess_mobile/src/widgets/shimmer.dart'; final _dateFormatter = DateFormat.MMMd().add_Hm(); +final _dateFormatterWithYear = DateFormat.yMMMd().add_Hm(); /// A screen that displays a paginated list of broadcasts. class BroadcastsListScreen extends StatelessWidget { @@ -72,7 +73,7 @@ class _BodyState extends ConsumerState<_Body> { if (!broadcasts.hasValue && broadcasts.isLoading) { return const Center( - child: CircularProgressIndicator(), + child: CircularProgressIndicator.adaptive(), ); } @@ -173,7 +174,21 @@ class BroadcastGridItem extends StatelessWidget { BroadcastGridItem.loading() : broadcast = Broadcast( - tour: const (name: '', imageUrl: null), + tour: BroadcastTournamentData( + id: const BroadcastTournamentId(''), + name: '', + imageUrl: null, + description: '', + information: ( + format: '', + timeControl: '', + players: '', + dates: ( + startsAt: DateTime.now(), + endsAt: DateTime.now(), + ), + ), + ), round: BroadcastRound( id: const BroadcastRoundId(''), name: '', @@ -191,10 +206,9 @@ class BroadcastGridItem extends StatelessWidget { onTap: () { pushPlatformRoute( context, - builder: (context) => BroadcastRoundScreen( - broadCastTitle: broadcast.tour.name, - roundId: broadcast.roundToLinkId, - ), + title: context.l10n.broadcastBroadcasts, + rootNavigator: true, + builder: (context) => BroadcastScreen(broadcast: broadcast), ); }, child: Container( @@ -231,7 +245,6 @@ class BroadcastGridItem extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(8.0), child: Column( - mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( @@ -245,6 +258,7 @@ class BroadcastGridItem extends StatelessWidget { ?.copyWith( color: textShade(context, 0.5), ), + overflow: TextOverflow.ellipsis, ), const SizedBox(width: 4.0), ], @@ -256,16 +270,11 @@ class BroadcastGridItem extends StatelessWidget { fontWeight: FontWeight.bold, color: Colors.red, ), + overflow: TextOverflow.ellipsis, ) - else - Text( - _dateFormatter.format(broadcast.round.startsAt), - style: Theme.of(context) - .textTheme - .labelSmall - ?.copyWith( - color: textShade(context, 0.5), - ), + else if (broadcast.round.startsAt != null) + StartsRoundDate( + startsAt: broadcast.round.startsAt!, ), ], ), @@ -290,3 +299,28 @@ class BroadcastGridItem extends StatelessWidget { ); } } + +class StartsRoundDate extends ConsumerWidget { + final DateTime startsAt; + + const StartsRoundDate({required this.startsAt}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final timeBeforeRound = startsAt.difference(DateTime.now()); + + return Text( + (!timeBeforeRound.isNegative && timeBeforeRound.inDays == 0) + ? timeBeforeRound.inHours == 0 + ? 'In ${timeBeforeRound.inMinutes} minutes' // TODO translate with https://github.com/lichess-org/lila/blob/65b28ea8e43e0133df6c7ed40e03c2954f247d1e/translation/source/timeago.xml#L8 + : 'In ${timeBeforeRound.inHours} hours' // TODO translate with https://github.com/lichess-org/lila/blob/65b28ea8e43e0133df6c7ed40e03c2954f247d1e/translation/source/timeago.xml#L12 + : timeBeforeRound.inDays < 365 + ? _dateFormatter.format(startsAt) + : _dateFormatterWithYear.format(startsAt), + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: textShade(context, 0.5), + ), + overflow: TextOverflow.ellipsis, + ); + } +} diff --git a/lib/src/view/broadcast/dropdown_menu.dart b/lib/src/view/broadcast/dropdown_menu.dart new file mode 100644 index 0000000000..d0808de144 --- /dev/null +++ b/lib/src/view/broadcast/dropdown_menu.dart @@ -0,0 +1,1377 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// ignore_for_file: deprecated_member_use + +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; + +// Examples can assume: +// late BuildContext context; +// late FocusNode myFocusNode; + +/// A callback function that returns the list of the items that matches the +/// current applied filter. +/// +/// Used by [DropdownMenu.filterCallback]. +typedef FilterCallback = List> Function( + List> entries, + String filter, +); + +/// A callback function that returns the index of the item that matches the +/// current contents of a text field. +/// +/// If a match doesn't exist then null must be returned. +/// +/// Used by [DropdownMenu.searchCallback]. +typedef SearchCallback = int? Function( + List> entries, + String query, +); + +const double _kMinimumWidth = 112.0; + +const double _kDefaultHorizontalPadding = 12.0; + +/// Defines a [DropdownMenu] menu button that represents one item view in the menu. +/// +/// See also: +/// +/// * [DropdownMenu] +class DropdownMenuEntry { + /// Creates an entry that is used with [DropdownMenu.dropdownMenuEntries]. + const DropdownMenuEntry({ + required this.value, + required this.label, + this.labelWidget, + this.leadingIcon, + this.trailingIcon, + this.enabled = true, + this.style, + }); + + /// the value used to identify the entry. + /// + /// This value must be unique across all entries in a [DropdownMenu]. + final T value; + + /// The label displayed in the center of the menu item. + final String label; + + /// Overrides the default label widget which is `Text(label)`. + /// + /// {@tool dartpad} + /// This sample shows how to override the default label [Text] + /// widget with one that forces the menu entry to appear on one line + /// by specifying [Text.maxLines] and [Text.overflow]. + /// + /// ** See code in examples/api/lib/material/dropdown_menu/dropdown_menu_entry_label_widget.0.dart ** + /// {@end-tool} + final Widget? labelWidget; + + /// An optional icon to display before the label. + final Widget? leadingIcon; + + /// An optional icon to display after the label. + final Widget? trailingIcon; + + /// Whether the menu item is enabled or disabled. + /// + /// The default value is true. If true, the [DropdownMenuEntry.label] will be filled + /// out in the text field of the [DropdownMenu] when this entry is clicked; otherwise, + /// this entry is disabled. + final bool enabled; + + /// Customizes this menu item's appearance. + /// + /// Null by default. + final ButtonStyle? style; +} + +/// A dropdown menu that can be opened from a [TextField]. The selected +/// menu item is displayed in that field. +/// +/// This widget is used to help people make a choice from a menu and put the +/// selected item into the text input field. People can also filter the list based +/// on the text input or search one item in the menu list. +/// +/// The menu is composed of a list of [DropdownMenuEntry]s. People can provide information, +/// such as: label, leading icon or trailing icon for each entry. The [TextField] +/// will be updated based on the selection from the menu entries. The text field +/// will stay empty if the selected entry is disabled. +/// +/// When the dropdown menu has focus, it can be traversed by pressing the up or down key. +/// During the process, the corresponding item will be highlighted and +/// the text field will be updated. Disabled items will be skipped during traversal. +/// +/// The menu can be scrollable if not all items in the list are displayed at once. +/// +/// {@tool dartpad} +/// This sample shows how to display outlined [DropdownMenu] and filled [DropdownMenu]. +/// +/// ** See code in examples/api/lib/material/dropdown_menu/dropdown_menu.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [MenuAnchor], which is a widget used to mark the "anchor" for a set of submenus. +/// The [DropdownMenu] uses a [TextField] as the "anchor". +/// * [TextField], which is a text input widget that uses an [InputDecoration]. +/// * [DropdownMenuEntry], which is used to build the [MenuItemButton] in the [DropdownMenu] list. +class DropdownMenu extends StatefulWidget { + /// Creates a const [DropdownMenu]. + /// + /// The leading and trailing icons in the text field can be customized by using + /// [leadingIcon], [trailingIcon] and [selectedTrailingIcon] properties. They are + /// passed down to the [InputDecoration] properties, and will override values + /// in the [InputDecoration.prefixIcon] and [InputDecoration.suffixIcon]. + /// + /// Except leading and trailing icons, the text field can be configured by the + /// [InputDecorationTheme] property. The menu can be configured by the [menuStyle]. + const DropdownMenu({ + super.key, + this.enabled = true, + this.width, + this.menuHeight, + this.leadingIcon, + this.trailingIcon, + this.label, + this.hintText, + this.helperText, + this.errorText, + this.selectedTrailingIcon, + this.enableFilter = false, + this.enableSearch = true, + this.keyboardType, + this.textStyle, + this.textAlign = TextAlign.start, + this.inputDecorationTheme, + this.menuStyle, + this.controller, + this.initialSelection, + this.onSelected, + this.focusNode, + this.requestFocusOnTap, + this.expandedInsets, + this.filterCallback, + this.searchCallback, + this.alignmentOffset, + required this.dropdownMenuEntries, + this.inputFormatters, + }) : assert(filterCallback == null || enableFilter); + + /// Determine if the [DropdownMenu] is enabled. + /// + /// Defaults to true. + /// + /// {@tool dartpad} + /// This sample demonstrates how the [enabled] and [requestFocusOnTap] properties + /// affect the textfield's hover cursor. + /// + /// ** See code in examples/api/lib/material/dropdown_menu/dropdown_menu.2.dart ** + /// {@end-tool} + final bool enabled; + + /// Determine the width of the [DropdownMenu]. + /// + /// If this is null, the width of the [DropdownMenu] will be the same as the width of the widest + /// menu item plus the width of the leading/trailing icon. + final double? width; + + /// Determine the height of the menu. + /// + /// If this is null, the menu will display as many items as possible on the screen. + final double? menuHeight; + + /// An optional Icon at the front of the text input field. + /// + /// Defaults to null. If this is not null, the menu items will have extra paddings to be aligned + /// with the text in the text field. + final Widget? leadingIcon; + + /// An optional icon at the end of the text field. + /// + /// Defaults to an [Icon] with [Icons.arrow_drop_down]. + final Widget? trailingIcon; + + /// Optional widget that describes the input field. + /// + /// When the input field is empty and unfocused, the label is displayed on + /// top of the input field (i.e., at the same location on the screen where + /// text may be entered in the input field). When the input field receives + /// focus (or if the field is non-empty), the label moves above, either + /// vertically adjacent to, or to the center of the input field. + /// + /// Defaults to null. + final Widget? label; + + /// Text that suggests what sort of input the field accepts. + /// + /// Defaults to null; + final String? hintText; + + /// Text that provides context about the [DropdownMenu]'s value, such + /// as how the value will be used. + /// + /// If non-null, the text is displayed below the input field, in + /// the same location as [errorText]. If a non-null [errorText] value is + /// specified then the helper text is not shown. + /// + /// Defaults to null; + /// + /// See also: + /// + /// * [InputDecoration.helperText], which is the text that provides context about the [InputDecorator.child]'s value. + final String? helperText; + + /// Text that appears below the input field and the border to show the error message. + /// + /// If non-null, the border's color animates to red and the [helperText] is not shown. + /// + /// Defaults to null; + /// + /// See also: + /// + /// * [InputDecoration.errorText], which is the text that appears below the [InputDecorator.child] and the border. + final String? errorText; + + /// An optional icon at the end of the text field to indicate that the text + /// field is pressed. + /// + /// Defaults to an [Icon] with [Icons.arrow_drop_up]. + final Widget? selectedTrailingIcon; + + /// Determine if the menu list can be filtered by the text input. + /// + /// Defaults to false. + final bool enableFilter; + + /// Determine if the first item that matches the text input can be highlighted. + /// + /// Defaults to true as the search function could be commonly used. + final bool enableSearch; + + /// The type of keyboard to use for editing the text. + /// + /// Defaults to [TextInputType.text]. + final TextInputType? keyboardType; + + /// The text style for the [TextField] of the [DropdownMenu]; + /// + /// Defaults to the overall theme's [TextTheme.bodyLarge] + /// if the dropdown menu theme's value is null. + final TextStyle? textStyle; + + /// The text align for the [TextField] of the [DropdownMenu]. + /// + /// Defaults to [TextAlign.start]. + final TextAlign textAlign; + + /// Defines the default appearance of [InputDecoration] to show around the text field. + /// + /// By default, shows a outlined text field. + final InputDecorationTheme? inputDecorationTheme; + + /// The [MenuStyle] that defines the visual attributes of the menu. + /// + /// The default width of the menu is set to the width of the text field. + final MenuStyle? menuStyle; + + /// Controls the text being edited or selected in the menu. + /// + /// If null, this widget will create its own [TextEditingController]. + final TextEditingController? controller; + + /// The value used to for an initial selection. + /// + /// Defaults to null. + final T? initialSelection; + + /// The callback is called when a selection is made. + /// + /// Defaults to null. If null, only the text field is updated. + final ValueChanged? onSelected; + + /// Defines the keyboard focus for this widget. + /// + /// The [focusNode] is a long-lived object that's typically managed by a + /// [StatefulWidget] parent. See [FocusNode] for more information. + /// + /// To give the keyboard focus to this widget, provide a [focusNode] and then + /// use the current [FocusScope] to request the focus: + /// + /// ```dart + /// FocusScope.of(context).requestFocus(myFocusNode); + /// ``` + /// + /// This happens automatically when the widget is tapped. + /// + /// To be notified when the widget gains or loses the focus, add a listener + /// to the [focusNode]: + /// + /// ```dart + /// myFocusNode.addListener(() { print(myFocusNode.hasFocus); }); + /// ``` + /// + /// If null, this widget will create its own [FocusNode]. + /// + /// ## Keyboard + /// + /// Requesting the focus will typically cause the keyboard to be shown + /// if it's not showing already. + /// + /// On Android, the user can hide the keyboard - without changing the focus - + /// with the system back button. They can restore the keyboard's visibility + /// by tapping on a text field. The user might hide the keyboard and + /// switch to a physical keyboard, or they might just need to get it + /// out of the way for a moment, to expose something it's + /// obscuring. In this case requesting the focus again will not + /// cause the focus to change, and will not make the keyboard visible. + /// + /// If this is non-null, the behaviour of [requestFocusOnTap] is overridden + /// by the [FocusNode.canRequestFocus] property. + final FocusNode? focusNode; + + /// Determine if the dropdown button requests focus and the on-screen virtual + /// keyboard is shown in response to a touch event. + /// + /// Ignored if a [focusNode] is explicitly provided (in which case, + /// [FocusNode.canRequestFocus] controls the behavior). + /// + /// Defaults to null, which enables platform-specific behavior: + /// + /// * On mobile platforms, acts as if set to false; tapping on the text + /// field and opening the menu will not cause a focus request and the + /// virtual keyboard will not appear. + /// + /// * On desktop platforms, acts as if set to true; the dropdown takes the + /// focus when activated. + /// + /// Set this to true or false explicitly to override the default behavior. + /// + /// {@tool dartpad} + /// This sample demonstrates how the [enabled] and [requestFocusOnTap] properties + /// affect the textfield's hover cursor. + /// + /// ** See code in examples/api/lib/material/dropdown_menu/dropdown_menu.2.dart ** + /// {@end-tool} + final bool? requestFocusOnTap; + + /// Descriptions of the menu items in the [DropdownMenu]. + /// + /// This is a required parameter. It is recommended that at least one [DropdownMenuEntry] + /// is provided. If this is an empty list, the menu will be empty and only + /// contain space for padding. + final List> dropdownMenuEntries; + + /// Defines the menu text field's width to be equal to its parent's width + /// plus the horizontal width of the specified insets. + /// + /// If this property is null, the width of the text field will be determined + /// by the width of menu items or [DropdownMenu.width]. If this property is not null, + /// the text field's width will match the parent's width plus the specified insets. + /// If the value of this property is [EdgeInsets.zero], the width of the text field will be the same + /// as its parent's width. + /// + /// The [expandedInsets]' top and bottom are ignored, only its left and right + /// properties are used. + /// + /// Defaults to null. + final EdgeInsetsGeometry? expandedInsets; + + /// When [DropdownMenu.enableFilter] is true, this callback is used to + /// compute the list of filtered items. + /// + /// {@tool snippet} + /// + /// In this example the `filterCallback` returns the items that contains the + /// trimmed query. + /// + /// ```dart + /// DropdownMenu( + /// enableFilter: true, + /// filterCallback: (List> entries, String filter) { + /// final String trimmedFilter = filter.trim().toLowerCase(); + /// if (trimmedFilter.isEmpty) { + /// return entries; + /// } + /// + /// return entries + /// .where((DropdownMenuEntry entry) => + /// entry.label.toLowerCase().contains(trimmedFilter), + /// ) + /// .toList(); + /// }, + /// dropdownMenuEntries: const >[], + /// ) + /// ``` + /// {@end-tool} + /// + /// Defaults to null. If this parameter is null and the + /// [DropdownMenu.enableFilter] property is set to true, the default behavior + /// will return a filtered list. The filtered list will contain items + /// that match the text provided by the input field, with a case-insensitive + /// comparison. When this is not null, `enableFilter` must be set to true. + final FilterCallback? filterCallback; + + /// When [DropdownMenu.enableSearch] is true, this callback is used to compute + /// the index of the search result to be highlighted. + /// + /// {@tool snippet} + /// + /// In this example the `searchCallback` returns the index of the search result + /// that exactly matches the query. + /// + /// ```dart + /// DropdownMenu( + /// searchCallback: (List> entries, String query) { + /// if (query.isEmpty) { + /// return null; + /// } + /// final int index = entries.indexWhere((DropdownMenuEntry entry) => entry.label == query); + /// + /// return index != -1 ? index : null; + /// }, + /// dropdownMenuEntries: const >[], + /// ) + /// ``` + /// {@end-tool} + /// + /// Defaults to null. If this is null and [DropdownMenu.enableSearch] is true, + /// the default function will return the index of the first matching result + /// which contains the contents of the text input field. + final SearchCallback? searchCallback; + + /// Optional input validation and formatting overrides. + /// + /// Formatters are run in the provided order when the user changes the text + /// this widget contains. When this parameter changes, the new formatters will + /// not be applied until the next time the user inserts or deletes text. + /// Formatters don't run when the text is changed + /// programmatically via [controller]. + /// + /// See also: + /// + /// * [TextEditingController], which implements the [Listenable] interface + /// and notifies its listeners on [TextEditingValue] changes. + final List? inputFormatters; + + /// {@macro flutter.material.MenuAnchor.alignmentOffset} + final Offset? alignmentOffset; + + @override + State> createState() => _DropdownMenuState(); +} + +class _DropdownMenuState extends State> { + final GlobalKey _anchorKey = GlobalKey(); + final GlobalKey _leadingKey = GlobalKey(); + late List buttonItemKeys; + final MenuController _controller = MenuController(); + bool _enableFilter = false; + late bool _enableSearch; + late List> filteredEntries; + List? _initialMenu; + int? currentHighlight; + double? leadingPadding; + bool _menuHasEnabledItem = false; + TextEditingController? _localTextEditingController; + + TextEditingValue get _initialTextEditingValue { + for (final DropdownMenuEntry entry in filteredEntries) { + if (entry.value == widget.initialSelection) { + return TextEditingValue( + text: entry.label, + selection: TextSelection.collapsed(offset: entry.label.length), + ); + } + } + return TextEditingValue.empty; + } + + @override + void initState() { + super.initState(); + if (widget.controller != null) { + _localTextEditingController = widget.controller; + } else { + _localTextEditingController = TextEditingController(); + } + _enableSearch = widget.enableSearch; + filteredEntries = widget.dropdownMenuEntries; + buttonItemKeys = List.generate( + filteredEntries.length, + (int index) => GlobalKey(), + ); + _menuHasEnabledItem = + filteredEntries.any((DropdownMenuEntry entry) => entry.enabled); + _localTextEditingController?.value = _initialTextEditingValue; + + refreshLeadingPadding(); + } + + @override + void dispose() { + if (widget.controller == null) { + _localTextEditingController?.dispose(); + _localTextEditingController = null; + } + super.dispose(); + } + + @override + void didUpdateWidget(DropdownMenu oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + if (widget.controller != null) { + _localTextEditingController?.dispose(); + } + _localTextEditingController = + widget.controller ?? TextEditingController(); + } + if (oldWidget.enableFilter != widget.enableFilter) { + if (!widget.enableFilter) { + _enableFilter = false; + } + } + if (oldWidget.enableSearch != widget.enableSearch) { + if (!widget.enableSearch) { + _enableSearch = widget.enableSearch; + currentHighlight = null; + } + } + if (oldWidget.dropdownMenuEntries != widget.dropdownMenuEntries) { + currentHighlight = null; + filteredEntries = widget.dropdownMenuEntries; + buttonItemKeys = List.generate( + filteredEntries.length, + (int index) => GlobalKey(), + ); + _menuHasEnabledItem = + filteredEntries.any((DropdownMenuEntry entry) => entry.enabled); + // If the text field content matches one of the new entries do not rematch the initialSelection. + final bool isCurrentSelectionValid = filteredEntries.any( + (DropdownMenuEntry entry) => + entry.label == _localTextEditingController?.text, + ); + if (!isCurrentSelectionValid) { + _localTextEditingController?.value = _initialTextEditingValue; + } + } + if (oldWidget.leadingIcon != widget.leadingIcon) { + refreshLeadingPadding(); + } + if (oldWidget.initialSelection != widget.initialSelection) { + _localTextEditingController?.value = _initialTextEditingValue; + } + } + + bool canRequestFocus() { + return widget.focusNode?.canRequestFocus ?? + widget.requestFocusOnTap ?? + switch (Theme.of(context).platform) { + TargetPlatform.iOS || + TargetPlatform.android || + TargetPlatform.fuchsia => + false, + TargetPlatform.macOS || + TargetPlatform.linux || + TargetPlatform.windows => + true, + }; + } + + void refreshLeadingPadding() { + WidgetsBinding.instance.addPostFrameCallback( + (_) { + if (!mounted) { + return; + } + setState(() { + leadingPadding = getWidth(_leadingKey); + }); + }, + debugLabel: 'DropdownMenu.refreshLeadingPadding', + ); + } + + void scrollToHighlight() { + WidgetsBinding.instance.addPostFrameCallback( + (_) { + final BuildContext? highlightContext = + buttonItemKeys[currentHighlight!].currentContext; + if (highlightContext != null) { + Scrollable.of(highlightContext) + .position + .ensureVisible(highlightContext.findRenderObject()!); + } + }, + debugLabel: 'DropdownMenu.scrollToHighlight', + ); + } + + double? getWidth(GlobalKey key) { + final BuildContext? context = key.currentContext; + if (context != null) { + final RenderBox box = context.findRenderObject()! as RenderBox; + return box.hasSize ? box.size.width : null; + } + return null; + } + + List> filter( + List> entries, + TextEditingController textEditingController, + ) { + final String filterText = textEditingController.text.toLowerCase(); + return entries + .where( + (DropdownMenuEntry entry) => + entry.label.toLowerCase().contains(filterText), + ) + .toList(); + } + + bool _shouldUpdateCurrentHighlight(List> entries) { + final String searchText = + _localTextEditingController!.value.text.toLowerCase(); + if (searchText.isEmpty) { + return true; + } + + // When `entries` are filtered by filter algorithm, currentHighlight may exceed the valid range of `entries` and should be updated. + if (currentHighlight == null || currentHighlight! >= entries.length) { + return true; + } + + if (entries[currentHighlight!].label.toLowerCase().contains(searchText)) { + return false; + } + + return true; + } + + int? search( + List> entries, + TextEditingController textEditingController, + ) { + final String searchText = textEditingController.value.text.toLowerCase(); + if (searchText.isEmpty) { + return null; + } + + final int index = entries.indexWhere( + (DropdownMenuEntry entry) => + entry.label.toLowerCase().contains(searchText), + ); + + return index != -1 ? index : null; + } + + List _buildButtons( + List> filteredEntries, + TextDirection textDirection, { + int? focusedIndex, + bool enableScrollToHighlight = true, + }) { + final List result = []; + for (int i = 0; i < filteredEntries.length; i++) { + final DropdownMenuEntry entry = filteredEntries[i]; + + // By default, when the text field has a leading icon but a menu entry doesn't + // have one, the label of the entry should have extra padding to be aligned + // with the text in the text input field. When both the text field and the + // menu entry have leading icons, the menu entry should remove the extra + // paddings so its leading icon will be aligned with the leading icon of + // the text field. + final double padding = entry.leadingIcon == null + ? (leadingPadding ?? _kDefaultHorizontalPadding) + : _kDefaultHorizontalPadding; + ButtonStyle effectiveStyle = entry.style ?? + switch (textDirection) { + TextDirection.rtl => MenuItemButton.styleFrom( + padding: EdgeInsets.only( + left: _kDefaultHorizontalPadding, + right: padding, + ), + ), + TextDirection.ltr => MenuItemButton.styleFrom( + padding: EdgeInsets.only( + left: padding, + right: _kDefaultHorizontalPadding, + ), + ), + }; + + final ButtonStyle? themeStyle = MenuButtonTheme.of(context).style; + + final WidgetStateProperty? effectiveForegroundColor = + entry.style?.foregroundColor ?? themeStyle?.foregroundColor; + final WidgetStateProperty? effectiveIconColor = + entry.style?.iconColor ?? themeStyle?.iconColor; + final WidgetStateProperty? effectiveOverlayColor = + entry.style?.overlayColor ?? themeStyle?.overlayColor; + final WidgetStateProperty? effectiveBackgroundColor = + entry.style?.backgroundColor ?? themeStyle?.backgroundColor; + + // Simulate the focused state because the text field should always be focused + // during traversal. Include potential MenuItemButton theme in the focus + // simulation for all colors in the theme. + if (entry.enabled && i == focusedIndex) { + // Query the Material 3 default style. + // TODO(bleroux): replace once a standard way for accessing defaults will be defined. + // See: https://github.com/flutter/flutter/issues/130135. + final ButtonStyle defaultStyle = + const MenuItemButton().defaultStyleOf(context); + + Color? resolveFocusedColor( + WidgetStateProperty? colorStateProperty, + ) { + return colorStateProperty + ?.resolve({WidgetState.focused}); + } + + final Color focusedForegroundColor = resolveFocusedColor( + effectiveForegroundColor ?? defaultStyle.foregroundColor!, + )!; + final Color focusedIconColor = + resolveFocusedColor(effectiveIconColor ?? defaultStyle.iconColor!)!; + final Color focusedOverlayColor = resolveFocusedColor( + effectiveOverlayColor ?? defaultStyle.overlayColor!, + )!; + // For the background color we can't rely on the default style which is transparent. + // Defaults to onSurface.withOpacity(0.12). + final Color focusedBackgroundColor = + resolveFocusedColor(effectiveBackgroundColor) ?? + Theme.of(context).colorScheme.onSurface.withOpacity(0.12); + + effectiveStyle = effectiveStyle.copyWith( + backgroundColor: + WidgetStatePropertyAll(focusedBackgroundColor), + foregroundColor: + WidgetStatePropertyAll(focusedForegroundColor), + iconColor: WidgetStatePropertyAll(focusedIconColor), + overlayColor: WidgetStatePropertyAll(focusedOverlayColor), + ); + } else { + effectiveStyle = effectiveStyle.copyWith( + backgroundColor: effectiveBackgroundColor, + foregroundColor: effectiveForegroundColor, + iconColor: effectiveIconColor, + overlayColor: effectiveOverlayColor, + ); + } + + Widget label = entry.labelWidget ?? Text(entry.label); + if (widget.width != null) { + final double horizontalPadding = padding + _kDefaultHorizontalPadding; + label = ConstrainedBox( + constraints: + BoxConstraints(maxWidth: widget.width! - horizontalPadding), + child: label, + ); + } + + final Widget menuItemButton = MenuItemButton( + key: enableScrollToHighlight ? buttonItemKeys[i] : null, + style: effectiveStyle, + leadingIcon: entry.leadingIcon, + trailingIcon: entry.trailingIcon, + onPressed: entry.enabled && widget.enabled + ? () { + _localTextEditingController?.value = TextEditingValue( + text: entry.label, + selection: + TextSelection.collapsed(offset: entry.label.length), + ); + currentHighlight = widget.enableSearch ? i : null; + widget.onSelected?.call(entry.value); + _enableFilter = false; + } + : null, + requestFocusOnHover: false, + child: label, + ); + result.add(menuItemButton); + } + + return result; + } + + void handleUpKeyInvoke(_) { + setState(() { + if (!widget.enabled || !_menuHasEnabledItem || !_controller.isOpen) { + return; + } + _enableFilter = false; + _enableSearch = false; + currentHighlight ??= 0; + currentHighlight = (currentHighlight! - 1) % filteredEntries.length; + while (!filteredEntries[currentHighlight!].enabled) { + currentHighlight = (currentHighlight! - 1) % filteredEntries.length; + } + final String currentLabel = filteredEntries[currentHighlight!].label; + _localTextEditingController?.value = TextEditingValue( + text: currentLabel, + selection: TextSelection.collapsed(offset: currentLabel.length), + ); + }); + } + + void handleDownKeyInvoke(_) { + setState(() { + if (!widget.enabled || !_menuHasEnabledItem || !_controller.isOpen) { + return; + } + _enableFilter = false; + _enableSearch = false; + currentHighlight ??= -1; + currentHighlight = (currentHighlight! + 1) % filteredEntries.length; + while (!filteredEntries[currentHighlight!].enabled) { + currentHighlight = (currentHighlight! + 1) % filteredEntries.length; + } + final String currentLabel = filteredEntries[currentHighlight!].label; + _localTextEditingController?.value = TextEditingValue( + text: currentLabel, + selection: TextSelection.collapsed(offset: currentLabel.length), + ); + }); + } + + void handlePressed(MenuController controller) { + if (controller.isOpen) { + currentHighlight = null; + controller.close(); + } else { + // close to open + if (_localTextEditingController!.text.isNotEmpty) { + _enableFilter = false; + } + controller.open(); + } + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final TextDirection textDirection = Directionality.of(context); + _initialMenu ??= _buildButtons( + widget.dropdownMenuEntries, + textDirection, + enableScrollToHighlight: false, + ); + final DropdownMenuThemeData theme = DropdownMenuTheme.of(context); + final DropdownMenuThemeData defaults = _DropdownMenuDefaultsM3(context); + + if (_enableFilter) { + filteredEntries = widget.filterCallback + ?.call(filteredEntries, _localTextEditingController!.text) ?? + filter(widget.dropdownMenuEntries, _localTextEditingController!); + } else { + filteredEntries = widget.dropdownMenuEntries; + } + _menuHasEnabledItem = + filteredEntries.any((DropdownMenuEntry entry) => entry.enabled); + + if (_enableSearch) { + if (widget.searchCallback != null) { + currentHighlight = widget.searchCallback!( + filteredEntries, + _localTextEditingController!.text, + ); + } else { + final bool shouldUpdateCurrentHighlight = + _shouldUpdateCurrentHighlight(filteredEntries); + if (shouldUpdateCurrentHighlight) { + currentHighlight = + search(filteredEntries, _localTextEditingController!); + } + } + if (currentHighlight != null) { + scrollToHighlight(); + } + } + + final List menu = _buildButtons( + filteredEntries, + textDirection, + focusedIndex: currentHighlight, + ); + + final TextStyle? effectiveTextStyle = + widget.textStyle ?? theme.textStyle ?? defaults.textStyle; + + MenuStyle? effectiveMenuStyle = + widget.menuStyle ?? theme.menuStyle ?? defaults.menuStyle!; + + final double? anchorWidth = getWidth(_anchorKey); + if (widget.width != null) { + effectiveMenuStyle = effectiveMenuStyle.copyWith( + minimumSize: WidgetStatePropertyAll(Size(widget.width!, 0.0)), + ); + } else if (anchorWidth != null) { + effectiveMenuStyle = effectiveMenuStyle.copyWith( + minimumSize: WidgetStatePropertyAll(Size(anchorWidth, 0.0)), + ); + } + + if (widget.menuHeight != null) { + effectiveMenuStyle = effectiveMenuStyle.copyWith( + maximumSize: WidgetStatePropertyAll( + Size(double.infinity, widget.menuHeight!), + ), + ); + } + final InputDecorationTheme effectiveInputDecorationTheme = + widget.inputDecorationTheme ?? + theme.inputDecorationTheme ?? + defaults.inputDecorationTheme!; + + final MouseCursor? effectiveMouseCursor = switch (widget.enabled) { + true => + canRequestFocus() ? SystemMouseCursors.text : SystemMouseCursors.click, + false => null, + }; + + Widget menuAnchor = MenuAnchor( + style: effectiveMenuStyle, + alignmentOffset: widget.alignmentOffset, + controller: _controller, + menuChildren: menu, + crossAxisUnconstrained: false, + builder: + (BuildContext context, MenuController controller, Widget? child) { + assert(_initialMenu != null); + final Widget trailingButton = Padding( + padding: const EdgeInsets.all(4.0), + child: IconButton( + isSelected: controller.isOpen, + icon: widget.trailingIcon ?? const Icon(Icons.arrow_drop_down), + selectedIcon: + widget.selectedTrailingIcon ?? const Icon(Icons.arrow_drop_up), + onPressed: !widget.enabled + ? null + : () { + handlePressed(controller); + }, + ), + ); + + final Widget leadingButton = Padding( + padding: const EdgeInsets.all(8.0), + child: widget.leadingIcon ?? const SizedBox.shrink(), + ); + + final Widget textField = TextField( + key: _anchorKey, + enabled: widget.enabled, + mouseCursor: effectiveMouseCursor, + focusNode: widget.focusNode, + canRequestFocus: canRequestFocus(), + enableInteractiveSelection: canRequestFocus(), + readOnly: !canRequestFocus(), + keyboardType: widget.keyboardType, + textAlign: widget.textAlign, + textAlignVertical: TextAlignVertical.center, + style: effectiveTextStyle, + controller: _localTextEditingController, + onEditingComplete: () { + if (currentHighlight != null) { + final DropdownMenuEntry entry = + filteredEntries[currentHighlight!]; + if (entry.enabled) { + _localTextEditingController?.value = TextEditingValue( + text: entry.label, + selection: + TextSelection.collapsed(offset: entry.label.length), + ); + widget.onSelected?.call(entry.value); + } + } else { + widget.onSelected?.call(null); + } + if (!widget.enableSearch) { + currentHighlight = null; + } + controller.close(); + }, + onTap: !widget.enabled + ? null + : () { + handlePressed(controller); + }, + onChanged: (String text) { + controller.open(); + setState(() { + filteredEntries = widget.dropdownMenuEntries; + _enableFilter = widget.enableFilter; + _enableSearch = widget.enableSearch; + }); + }, + inputFormatters: widget.inputFormatters, + decoration: InputDecoration( + label: widget.label, + hintText: widget.hintText, + helperText: widget.helperText, + errorText: widget.errorText, + prefixIcon: widget.leadingIcon != null + ? SizedBox(key: _leadingKey, child: widget.leadingIcon) + : null, + suffixIcon: trailingButton, + ).applyDefaults(effectiveInputDecorationTheme), + ); + + if (widget.expandedInsets != null) { + // If [expandedInsets] is not null, the width of the text field should depend + // on its parent width. So we don't need to use `_DropdownMenuBody` to + // calculate the children's width. + return textField; + } + + return Shortcuts( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.arrowLeft): + ExtendSelectionByCharacterIntent( + forward: false, + collapseSelection: true, + ), + SingleActivator(LogicalKeyboardKey.arrowRight): + ExtendSelectionByCharacterIntent( + forward: true, + collapseSelection: true, + ), + SingleActivator(LogicalKeyboardKey.arrowUp): _ArrowUpIntent(), + SingleActivator(LogicalKeyboardKey.arrowDown): _ArrowDownIntent(), + }, + child: _DropdownMenuBody( + width: widget.width, + children: [ + textField, + ..._initialMenu!.map( + (Widget item) => + ExcludeFocus(excluding: !controller.isOpen, child: item), + ), + trailingButton, + leadingButton, + ], + ), + ); + }, + ); + + if (widget.expandedInsets case final EdgeInsetsGeometry padding) { + menuAnchor = Padding( + // Clamp the top and bottom padding to 0. + padding: padding.clamp( + EdgeInsets.zero, + const EdgeInsets.only( + left: double.infinity, + right: double.infinity, + ).add( + const EdgeInsetsDirectional.only( + end: double.infinity, + start: double.infinity, + ), + ), + ), + child: menuAnchor, + ); + } + + return Actions( + actions: >{ + _ArrowUpIntent: CallbackAction<_ArrowUpIntent>( + onInvoke: handleUpKeyInvoke, + ), + _ArrowDownIntent: CallbackAction<_ArrowDownIntent>( + onInvoke: handleDownKeyInvoke, + ), + }, + child: menuAnchor, + ); + } +} + +// `DropdownMenu` dispatches these private intents on arrow up/down keys. +// They are needed instead of the typical `DirectionalFocusIntent`s because +// `DropdownMenu` does not really navigate the focus tree upon arrow up/down +// keys: the focus stays on the text field and the menu items are given fake +// highlights as if they are focused. Using `DirectionalFocusIntent`s will cause +// the action to be processed by `EditableText`. +class _ArrowUpIntent extends Intent { + const _ArrowUpIntent(); +} + +class _ArrowDownIntent extends Intent { + const _ArrowDownIntent(); +} + +class _DropdownMenuBody extends MultiChildRenderObjectWidget { + const _DropdownMenuBody({ + super.children, + this.width, + }); + + final double? width; + + @override + _RenderDropdownMenuBody createRenderObject(BuildContext context) { + return _RenderDropdownMenuBody( + width: width, + ); + } + + @override + void updateRenderObject( + BuildContext context, + _RenderDropdownMenuBody renderObject, + ) { + renderObject.width = width; + } +} + +class _DropdownMenuBodyParentData extends ContainerBoxParentData {} + +class _RenderDropdownMenuBody extends RenderBox + with + ContainerRenderObjectMixin, + RenderBoxContainerDefaultsMixin { + _RenderDropdownMenuBody({ + double? width, + }) : _width = width; + + double? get width => _width; + double? _width; + set width(double? value) { + if (_width == value) { + return; + } + _width = value; + markNeedsLayout(); + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! _DropdownMenuBodyParentData) { + child.parentData = _DropdownMenuBodyParentData(); + } + } + + @override + void performLayout() { + final BoxConstraints constraints = this.constraints; + double maxWidth = 0.0; + double? maxHeight; + RenderBox? child = firstChild; + + final double intrinsicWidth = + width ?? getMaxIntrinsicWidth(constraints.maxHeight); + final double widthConstraint = + math.min(intrinsicWidth, constraints.maxWidth); + final BoxConstraints innerConstraints = BoxConstraints( + maxWidth: widthConstraint, + maxHeight: getMaxIntrinsicHeight(widthConstraint), + ); + while (child != null) { + if (child == firstChild) { + child.layout(innerConstraints, parentUsesSize: true); + maxHeight ??= child.size.height; + final _DropdownMenuBodyParentData childParentData = + child.parentData! as _DropdownMenuBodyParentData; + assert(child.parentData == childParentData); + child = childParentData.nextSibling; + continue; + } + child.layout(innerConstraints, parentUsesSize: true); + final _DropdownMenuBodyParentData childParentData = + child.parentData! as _DropdownMenuBodyParentData; + childParentData.offset = Offset.zero; + maxWidth = math.max(maxWidth, child.size.width); + maxHeight ??= child.size.height; + assert(child.parentData == childParentData); + child = childParentData.nextSibling; + } + + assert(maxHeight != null); + maxWidth = math.max(_kMinimumWidth, maxWidth); + size = constraints.constrain(Size(width ?? maxWidth, maxHeight!)); + } + + @override + void paint(PaintingContext context, Offset offset) { + final RenderBox? child = firstChild; + if (child != null) { + final _DropdownMenuBodyParentData childParentData = + child.parentData! as _DropdownMenuBodyParentData; + context.paintChild(child, offset + childParentData.offset); + } + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + final BoxConstraints constraints = this.constraints; + double maxWidth = 0.0; + double? maxHeight; + RenderBox? child = firstChild; + final double intrinsicWidth = + width ?? getMaxIntrinsicWidth(constraints.maxHeight); + final double widthConstraint = + math.min(intrinsicWidth, constraints.maxWidth); + final BoxConstraints innerConstraints = BoxConstraints( + maxWidth: widthConstraint, + maxHeight: getMaxIntrinsicHeight(widthConstraint), + ); + + while (child != null) { + if (child == firstChild) { + final Size childSize = child.getDryLayout(innerConstraints); + maxHeight ??= childSize.height; + final _DropdownMenuBodyParentData childParentData = + child.parentData! as _DropdownMenuBodyParentData; + assert(child.parentData == childParentData); + child = childParentData.nextSibling; + continue; + } + final Size childSize = child.getDryLayout(innerConstraints); + final _DropdownMenuBodyParentData childParentData = + child.parentData! as _DropdownMenuBodyParentData; + childParentData.offset = Offset.zero; + maxWidth = math.max(maxWidth, childSize.width); + maxHeight ??= childSize.height; + assert(child.parentData == childParentData); + child = childParentData.nextSibling; + } + + assert(maxHeight != null); + maxWidth = math.max(_kMinimumWidth, maxWidth); + return constraints.constrain(Size(width ?? maxWidth, maxHeight!)); + } + + @override + double computeMinIntrinsicWidth(double height) { + RenderBox? child = firstChild; + double width = 0; + while (child != null) { + if (child == firstChild) { + final _DropdownMenuBodyParentData childParentData = + child.parentData! as _DropdownMenuBodyParentData; + child = childParentData.nextSibling; + continue; + } + final double maxIntrinsicWidth = child.getMinIntrinsicWidth(height); + if (child == lastChild) { + width += maxIntrinsicWidth; + } + if (child == childBefore(lastChild!)) { + width += maxIntrinsicWidth; + } + width = math.max(width, maxIntrinsicWidth); + final _DropdownMenuBodyParentData childParentData = + child.parentData! as _DropdownMenuBodyParentData; + child = childParentData.nextSibling; + } + + return math.max(width, _kMinimumWidth); + } + + @override + double computeMaxIntrinsicWidth(double height) { + RenderBox? child = firstChild; + double width = 0; + while (child != null) { + if (child == firstChild) { + final _DropdownMenuBodyParentData childParentData = + child.parentData! as _DropdownMenuBodyParentData; + child = childParentData.nextSibling; + continue; + } + final double maxIntrinsicWidth = child.getMaxIntrinsicWidth(height); + // Add the width of leading Icon. + if (child == lastChild) { + width += maxIntrinsicWidth; + } + // Add the width of trailing Icon. + if (child == childBefore(lastChild!)) { + width += maxIntrinsicWidth; + } + width = math.max(width, maxIntrinsicWidth); + final _DropdownMenuBodyParentData childParentData = + child.parentData! as _DropdownMenuBodyParentData; + child = childParentData.nextSibling; + } + + return math.max(width, _kMinimumWidth); + } + + @override + double computeMinIntrinsicHeight(double width) { + final RenderBox? child = firstChild; + double width = 0; + if (child != null) { + width = math.max(width, child.getMinIntrinsicHeight(width)); + } + return width; + } + + @override + double computeMaxIntrinsicHeight(double width) { + final RenderBox? child = firstChild; + double width = 0; + if (child != null) { + width = math.max(width, child.getMaxIntrinsicHeight(width)); + } + return width; + } + + @override + bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { + final RenderBox? child = firstChild; + if (child != null) { + final _DropdownMenuBodyParentData childParentData = + child.parentData! as _DropdownMenuBodyParentData; + final bool isHit = result.addWithPaintOffset( + offset: childParentData.offset, + position: position, + hitTest: (BoxHitTestResult result, Offset transformed) { + assert(transformed == position - childParentData.offset); + return child.hitTest(result, position: transformed); + }, + ); + if (isHit) { + return true; + } + } + return false; + } +} + +// Hand coded defaults. These will be updated once we have tokens/spec. +class _DropdownMenuDefaultsM3 extends DropdownMenuThemeData { + _DropdownMenuDefaultsM3(this.context); + + final BuildContext context; + late final ThemeData _theme = Theme.of(context); + + @override + TextStyle? get textStyle => _theme.textTheme.bodyLarge; + + @override + MenuStyle get menuStyle { + return const MenuStyle( + minimumSize: WidgetStatePropertyAll(Size(_kMinimumWidth, 0.0)), + maximumSize: WidgetStatePropertyAll(Size.infinite), + visualDensity: VisualDensity.standard, + ); + } + + @override + InputDecorationTheme get inputDecorationTheme { + return const InputDecorationTheme(border: OutlineInputBorder()); + } +} diff --git a/lib/src/view/opening_explorer/opening_explorer_screen.dart b/lib/src/view/opening_explorer/opening_explorer_screen.dart index 6d3cb21479..555933c877 100644 --- a/lib/src/view/opening_explorer/opening_explorer_screen.dart +++ b/lib/src/view/opening_explorer/opening_explorer_screen.dart @@ -31,7 +31,7 @@ const _kTableRowPadding = EdgeInsets.symmetric( horizontal: _kTableRowHorizontalPadding, vertical: _kTableRowVerticalPadding, ); -const _kTabletBoardRadius = BorderRadius.all(Radius.circular(4.0)); +const _kTabletBoardRadius = Radius.circular(4.0); class OpeningExplorerScreen extends ConsumerStatefulWidget { const OpeningExplorerScreen({required this.options}); @@ -364,7 +364,7 @@ class _OpeningExplorerView extends StatelessWidget { child: AnalysisBoard( options, boardSize, - borderRadius: isTablet ? _kTabletBoardRadius : null, + radius: isTablet ? _kTabletBoardRadius : null, shouldReplaceChildOnUserMove: true, ), ), diff --git a/lib/src/view/study/study_screen.dart b/lib/src/view/study/study_screen.dart index 9b6f6fecf1..4fba7b4a09 100644 --- a/lib/src/view/study/study_screen.dart +++ b/lib/src/view/study/study_screen.dart @@ -210,10 +210,10 @@ class _Body extends ConsumerWidget { return DefaultTabController( length: 1, child: AnalysisLayout( - boardBuilder: (context, boardSize, borderRadius) => _StudyBoard( + boardBuilder: (context, boardSize, boardRadius) => _StudyBoard( id: id, boardSize: boardSize, - borderRadius: borderRadius, + radius: boardRadius, ), engineGaugeBuilder: isComputerAnalysisAllowed && showEvaluationGauge && @@ -278,14 +278,14 @@ class _StudyBoard extends ConsumerStatefulWidget { const _StudyBoard({ required this.id, required this.boardSize, - this.borderRadius, + this.radius, }); final StudyId id; final double boardSize; - final BorderRadiusGeometry? borderRadius; + final Radius? radius; @override ConsumerState<_StudyBoard> createState() => _StudyBoardState(); @@ -367,10 +367,11 @@ class _StudyBoardState extends ConsumerState<_StudyBoard> { return Chessboard( size: widget.boardSize, settings: boardPrefs.toBoardSettings().copyWith( - borderRadius: widget.borderRadius, - boxShadow: widget.borderRadius != null - ? boardShadows - : const [], + borderRadius: (widget.radius != null) + ? BorderRadius.all(widget.radius!) + : null, + boxShadow: + widget.radius != null ? boardShadows : const [], drawShape: DrawShapeOptions( enable: true, onCompleteShape: _onCompleteShape, diff --git a/lib/src/view/watch/watch_tab_screen.dart b/lib/src/view/watch/watch_tab_screen.dart index 3a881d123d..d9358d6e89 100644 --- a/lib/src/view/watch/watch_tab_screen.dart +++ b/lib/src/view/watch/watch_tab_screen.dart @@ -85,8 +85,7 @@ class _WatchScreenState extends ConsumerState { } List get watchTabWidgets => const [ - // TODO: show widget when broadcasts feature is ready - //_BroadcastWidget(), + _BroadcastWidget(), _WatchTvWidget(), _StreamerWidget(), ]; @@ -176,15 +175,12 @@ class _WatchScreenState extends ConsumerState { Future _refreshData(WidgetRef ref) { return Future.wait([ - // TODO uncomment when broadcasts feature is ready - // ref.refresh(broadcastsPaginatorProvider.future), + ref.refresh(broadcastsPaginatorProvider.future), ref.refresh(featuredChannelsProvider.future), ref.refresh(liveStreamersProvider.future), ]); } -// TODO remove this ignore comment when broadcasts feature is ready -// ignore: unused_element class _BroadcastWidget extends ConsumerWidget { const _BroadcastWidget(); diff --git a/lib/src/widgets/clock.dart b/lib/src/widgets/clock.dart index 7de23af3e0..19f653ea1c 100644 --- a/lib/src/widgets/clock.dart +++ b/lib/src/widgets/clock.dart @@ -287,7 +287,8 @@ class _CountdownClockState extends State { void didUpdateWidget(CountdownClockBuilder oldClock) { super.didUpdateWidget(oldClock); - if (widget.clockUpdatedAt != oldClock.clockUpdatedAt) { + if (widget.timeLeft != oldClock.timeLeft || + widget.clockUpdatedAt != oldClock.clockUpdatedAt) { timeLeft = widget.timeLeft; } diff --git a/lib/src/widgets/list.dart b/lib/src/widgets/list.dart index d456f32c7b..6afad801cf 100644 --- a/lib/src/widgets/list.dart +++ b/lib/src/widgets/list.dart @@ -85,9 +85,11 @@ class ListSection extends StatelessWidget { child: Column( children: [ if (header != null) - // ignore: avoid-wrapping-in-padding Padding( - padding: const EdgeInsets.symmetric(vertical: 10.0), + padding: const EdgeInsets.symmetric( + vertical: 10.0, + horizontal: 16.0, + ), child: Container( width: double.infinity, height: 25, @@ -98,9 +100,11 @@ class ListSection extends StatelessWidget { ), ), for (int i = 0; i < children.length; i++) - // ignore: avoid-wrapping-in-padding Padding( - padding: const EdgeInsets.symmetric(vertical: 10.0), + padding: const EdgeInsets.symmetric( + vertical: 10.0, + horizontal: 16.0, + ), child: Container( width: double.infinity, height: 50, diff --git a/lib/src/widgets/pgn.dart b/lib/src/widgets/pgn.dart index 9ba8dafed8..0cd4f75812 100644 --- a/lib/src/widgets/pgn.dart +++ b/lib/src/widgets/pgn.dart @@ -110,6 +110,7 @@ class DebouncedPgnTreeView extends ConsumerStatefulWidget { const DebouncedPgnTreeView({ required this.root, required this.currentPath, + this.broadcastLivePath, required this.pgnRootComments, required this.notifier, this.shouldShowComputerVariations = true, @@ -123,6 +124,9 @@ class DebouncedPgnTreeView extends ConsumerStatefulWidget { /// Path to the currently selected move in the tree final UciPath currentPath; + /// Path to the last live move in the tree if it is a broadcast game + final UciPath? broadcastLivePath; + /// Comments associated with the root node final IList? pgnRootComments; @@ -149,13 +153,17 @@ class _DebouncedPgnTreeViewState extends ConsumerState { final currentMoveKey = GlobalKey(); final _debounce = Debouncer(kFastReplayDebounceDelay); - /// Path to the currently selected move in the tree. When widget.currentPath changes rapidly, we debounce the change to avoid rebuilding the whole tree on every move. + /// Path to the currently selected move in the tree. When widget.currentPath changes rapidly, we debounce the change to avoid rebuilding the whole tree on every played move. late UciPath pathToCurrentMove; + /// Path to the last live move in the tree if it is a broadcast game. When widget.broadcastLivePath changes rapidly, we debounce the change to avoid rebuilding the whole tree on every received move. + late UciPath? pathToBroadcastLiveMove; + @override void initState() { super.initState(); pathToCurrentMove = widget.currentPath; + pathToBroadcastLiveMove = widget.broadcastLivePath; WidgetsBinding.instance.addPostFrameCallback((_) { if (currentMoveKey.currentContext != null) { Scrollable.ensureVisible( @@ -177,24 +185,32 @@ class _DebouncedPgnTreeViewState extends ConsumerState { void didUpdateWidget(covariant DebouncedPgnTreeView oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.currentPath != widget.currentPath) { - // debouncing the current path change to avoid rebuilding when using - // the fast replay buttons + if (oldWidget.currentPath != widget.currentPath || + oldWidget.broadcastLivePath != widget.broadcastLivePath) { + // debouncing the current and broadcast live path changes to avoid rebuilding when using + // the fast replay buttons or when receiving a lot of broadcast moves in a short time _debounce(() { setState(() { - pathToCurrentMove = widget.currentPath; - }); - WidgetsBinding.instance.addPostFrameCallback((_) { - if (currentMoveKey.currentContext != null) { - Scrollable.ensureVisible( - currentMoveKey.currentContext!, - duration: const Duration(milliseconds: 200), - curve: Curves.easeIn, - alignment: 0.5, - alignmentPolicy: ScrollPositionAlignmentPolicy.explicit, - ); + if (oldWidget.currentPath != widget.currentPath) { + pathToCurrentMove = widget.currentPath; + } + if (oldWidget.broadcastLivePath != widget.broadcastLivePath) { + pathToBroadcastLiveMove = widget.broadcastLivePath; } }); + if (oldWidget.currentPath != widget.currentPath) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (currentMoveKey.currentContext != null) { + Scrollable.ensureVisible( + currentMoveKey.currentContext!, + duration: const Duration(milliseconds: 200), + curve: Curves.easeIn, + alignment: 0.5, + alignmentPolicy: ScrollPositionAlignmentPolicy.explicit, + ); + } + }); + } }); } } @@ -217,6 +233,7 @@ class _DebouncedPgnTreeViewState extends ConsumerState { shouldShowComments: widget.shouldShowComments, currentMoveKey: currentMoveKey, pathToCurrentMove: pathToCurrentMove, + pathToBroadcastLiveMove: pathToBroadcastLiveMove, notifier: widget.notifier, ), ); @@ -231,6 +248,9 @@ typedef _PgnTreeViewParams = ({ /// Path to the currently selected move in the tree. UciPath pathToCurrentMove, + /// Path to the last live move in the tree if it is a broadcast game + UciPath? pathToBroadcastLiveMove, + /// Whether to show analysis variations. bool shouldShowComputerVariations, @@ -1026,6 +1046,28 @@ class InlineMove extends ConsumerWidget { bool get isCurrentMove => params.pathToCurrentMove == path; + bool get isBroadcastLiveMove => params.pathToBroadcastLiveMove == path; + + BoxDecoration? _boxDecoration( + BuildContext context, + bool isCurrentMove, + bool isLiveMove, + ) { + return (isCurrentMove || isLiveMove) + ? BoxDecoration( + color: isCurrentMove + ? Theme.of(context).platform == TargetPlatform.iOS + ? CupertinoColors.systemGrey3.resolveFrom(context) + : Theme.of(context).focusColor + : null, + shape: BoxShape.rectangle, + borderRadius: borderRadius, + border: + isLiveMove ? Border.all(width: 2, color: Colors.orange) : null, + ) + : null; + } + @override Widget build(BuildContext context, WidgetRef ref) { final pieceNotation = ref.watch(pieceNotationProvider).maybeWhen( @@ -1091,15 +1133,7 @@ class InlineMove extends ConsumerWidget { }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0), - decoration: isCurrentMove - ? BoxDecoration( - color: Theme.of(context).platform == TargetPlatform.iOS - ? CupertinoColors.systemGrey3.resolveFrom(context) - : Theme.of(context).focusColor, - shape: BoxShape.rectangle, - borderRadius: borderRadius, - ) - : null, + decoration: _boxDecoration(context, isCurrentMove, isBroadcastLiveMove), child: Text.rich( TextSpan( children: [ diff --git a/pubspec.lock b/pubspec.lock index 5dfd5490ff..d9f3beebd2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -608,6 +608,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_markdown: + dependency: "direct main" + description: + name: flutter_markdown + sha256: f0e599ba89c9946c8e051780f0ec99aba4ba15895e0380a7ab68f420046fc44e + url: "https://pub.dev" + source: hosted + version: "0.7.4+1" flutter_native_splash: dependency: "direct main" description: @@ -907,6 +915,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.3-main.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: ef2a1298144e3f985cc736b22e0ccdaf188b5b3970648f2d9dc13efd1d9df051 + url: "https://pub.dev" + source: hosted + version: "7.2.2" matcher: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 5422a4dbcc..79bb3041db 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,6 +40,7 @@ dependencies: flutter_local_notifications: ^18.0.1 flutter_localizations: sdk: flutter + flutter_markdown: ^0.7.3+1 flutter_native_splash: ^2.3.5 flutter_riverpod: ^2.3.4 flutter_secure_storage: ^9.2.0 diff --git a/test/model/game/game_socket_events_test.dart b/test/model/game/game_socket_events_test.dart index 7bb4597c5c..3bbae4e59b 100644 --- a/test/model/game/game_socket_events_test.dart +++ b/test/model/game/game_socket_events_test.dart @@ -30,8 +30,7 @@ void main() { expect( game.meta, GameMeta( - createdAt: - DateTime.fromMillisecondsSinceEpoch(1685698678928, isUtc: true), + createdAt: DateTime.fromMillisecondsSinceEpoch(1685698678928), rated: false, variant: Variant.standard, speed: Speed.classical, diff --git a/test/widgets/clock_test.dart b/test/widgets/clock_test.dart index 73e20078f6..7bdf3759a1 100644 --- a/test/widgets/clock_test.dart +++ b/test/widgets/clock_test.dart @@ -115,7 +115,7 @@ void main() { expect(find.text('0:00.0'), findsOneWidget); }); - testWidgets('do not update if clockUpdatedAt is same', + testWidgets('do not update if timeLeft and clockUpdatedAt are same', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( @@ -138,7 +138,7 @@ void main() { await tester.pumpWidget( MaterialApp( home: CountdownClockBuilder( - timeLeft: const Duration(seconds: 11), + timeLeft: const Duration(seconds: 10), active: true, builder: clockBuilder, ),