diff --git a/lib/src/model/broadcast/broadcast.dart b/lib/src/model/broadcast/broadcast.dart index 3f653c091e..387520b382 100644 --- a/lib/src/model/broadcast/broadcast.dart +++ b/lib/src/model/broadcast/broadcast.dart @@ -1,8 +1,13 @@ import 'package:dartchess/dartchess.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/material.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:intl/intl.dart'; import 'package:lichess_mobile/src/model/common/chess.dart'; import 'package:lichess_mobile/src/model/common/id.dart'; +import 'package:lichess_mobile/src/styles/lichess_icons.dart'; +import 'package:lichess_mobile/src/styles/styles.dart'; +import 'package:lichess_mobile/src/utils/l10n_context.dart'; part 'broadcast.freezed.dart'; @@ -52,6 +57,12 @@ enum BroadcastResult { noResultPgnTag => false, _ => true, }; + + Color? colorFor(Side side, BuildContext context) => switch (this) { + whiteWins => side == Side.white ? context.lichessColors.good : context.lichessColors.error, + blackWins => side == Side.white ? context.lichessColors.error : context.lichessColors.good, + _ => null, + }; } @freezed @@ -119,6 +130,33 @@ typedef BroadcastTournamentGroup = ({ bool live, }); +typedef BroadcastCustomPointsPerColor = ({double win, double draw}); +typedef BroadcastCustomScoring = BySide; + +extension BroadcastCustomScoringExt on BroadcastCustomScoring { + String pointsForResult(Side side, BroadcastResult result) { + final customScore = + (side == Side.white + ? switch (result) { + BroadcastResult.whiteWins => this[side]?.win, + BroadcastResult.draw || BroadcastResult.whiteHalfWins => this[side]?.draw, + _ => 0.0, + } + : switch (result) { + BroadcastResult.blackWins => this[side]?.win, + BroadcastResult.draw || BroadcastResult.blackHalfWins => this[side]?.draw, + _ => 0.0, + }) ?? + 0.0; + return customScore == 0.5 + ? result.resultToString(side) // '½' looks nicer than '0.5' + : NumberFormat('0.##').format(customScore); + } +} + +String resultString(BroadcastCustomScoring? customScoring, Side side, BroadcastResult result) => + customScoring?.pointsForResult(side, result) ?? result.resultToString(side); + @freezed sealed class BroadcastRound with _$BroadcastRound { const factory BroadcastRound({ @@ -129,6 +167,7 @@ sealed class BroadcastRound with _$BroadcastRound { required DateTime? startsAt, required DateTime? finishedAt, required bool startsAfterPrevious, + required BroadcastCustomScoring? customScoring, }) = _BroadcastRound; } @@ -153,7 +192,7 @@ sealed class BroadcastGame with _$BroadcastGame { const factory BroadcastGame({ required BroadcastGameId id, - required IMap players, + required BySide players, required String fen, required Move? lastMove, required Duration? thinkTime, @@ -221,8 +260,9 @@ sealed class BroadcastPlayerWithOverallResult with _$BroadcastPlayerWithOverallR required int played, required double? score, required int? rank, - required int? ratingDiff, - required int? performance, + required StatByFideTC? ratingsMap, + required StatByFideTC? ratingDiffs, + required StatByFideTC? performances, required IList? tieBreaks, required String? team, }) = _BroadcastPlayerWithOverallResult; @@ -230,7 +270,27 @@ sealed class BroadcastPlayerWithOverallResult with _$BroadcastPlayerWithOverallR typedef BroadcastTieBreakDetail = ({String extendedCode, String description, double points}); -typedef BroadcastFideData = ({({int? standard, int? rapid, int? blitz}) ratings, int? birthYear}); +typedef StatByFideTC = IMap; + +enum BroadcastFideTC { + standard, + rapid, + blitz; + + IconData get icon => switch (this) { + BroadcastFideTC.standard => LichessIcons.classical, + BroadcastFideTC.rapid => LichessIcons.rapid, + BroadcastFideTC.blitz => LichessIcons.blitz, + }; + + String i18nName(BuildContext context) => switch (this) { + BroadcastFideTC.standard => context.l10n.classical, + BroadcastFideTC.rapid => context.l10n.rapid, + BroadcastFideTC.blitz => context.l10n.blitz, + }; +} + +typedef BroadcastFideData = ({StatByFideTC ratings, int? birthYear}); typedef BroadcastPlayerWithGameResults = ({ BroadcastPlayerWithOverallResult playerWithOverallResult, @@ -238,7 +298,19 @@ typedef BroadcastPlayerWithGameResults = ({ IList games, }); -enum BroadcastPoints { one, half, zero } +enum BroadcastPoints { + one, + half, + zero; + + BroadcastResult resultFor(Side side) => switch (this) { + BroadcastPoints.one => + side == Side.white ? BroadcastResult.whiteWins : BroadcastResult.blackWins, + BroadcastPoints.zero => + side == Side.white ? BroadcastResult.blackWins : BroadcastResult.whiteWins, + BroadcastPoints.half => BroadcastResult.draw, + }; +} @freezed sealed class BroadcastPlayerGameResult with _$BroadcastPlayerGameResult { @@ -246,7 +318,9 @@ sealed class BroadcastPlayerGameResult with _$BroadcastPlayerGameResult { required BroadcastRoundId roundId, required BroadcastGameId gameId, required Side color, + required BroadcastFideTC fideTC, required BroadcastPoints? points, + required double? customPoints, required int? ratingDiff, required BroadcastPlayer opponent, }) = _BroadcastPlayerGameResult; diff --git a/lib/src/model/broadcast/broadcast_repository.dart b/lib/src/model/broadcast/broadcast_repository.dart index cf73c01783..149127524a 100644 --- a/lib/src/model/broadcast/broadcast_repository.dart +++ b/lib/src/model/broadcast/broadcast_repository.dart @@ -201,6 +201,14 @@ BroadcastTournamentGroup _tournamentGroupFromPick(RequiredPick pick) { return (id: id, name: name, active: active, live: live); } +BroadcastCustomPointsPerColor _customPointsPerColorFromPick(RequiredPick pick) => + (win: pick('win').asDoubleOrThrow(), draw: pick('draw').asDoubleOrThrow()); + +BroadcastCustomScoring _customScoringFromPick(RequiredPick pick) => { + Side.white: _customPointsPerColorFromPick(pick('white').required()), + Side.black: _customPointsPerColorFromPick(pick('black').required()), +}.lock; + BroadcastRound _roundFromPick(RequiredPick pick) { final live = pick('ongoing').asBoolOrFalse(); final finished = pick('finished').asBoolOrFalse(); @@ -218,6 +226,7 @@ BroadcastRound _roundFromPick(RequiredPick pick) { startsAt: pick('startsAt').asDateTimeFromMillisecondsOrNull(), finishedAt: pick('finishedAt').asDateTimeFromMillisecondsOrNull(), startsAfterPrevious: pick('startsAfterPrevious').asBoolOrFalse(), + customScoring: pick('customScoring').letOrNull(_customScoringFromPick), ); } @@ -326,8 +335,9 @@ BroadcastPlayerWithOverallResult _playerWithOverallResultFromPick(RequiredPick p played: pick('played').asIntOrThrow(), score: pick('score').asDoubleOrNull(), rank: pick('rank').asIntOrNull(), - ratingDiff: pick('ratingDiff').asIntOrNull(), - performance: pick('performance').asIntOrNull(), + ratingsMap: pick('ratingsMap').letOrNull(pickStats), + ratingDiffs: pick('ratingDiffs').letOrNull(pickStats), + performances: pick('performances').letOrNull(pickStats), tieBreaks: pick('tiebreaks').asListOrNull(_tieBreakDetailFromPick)?.toIList(), team: pick('team').asStringOrNull(), ); @@ -351,15 +361,36 @@ BroadcastPlayerWithGameResults _makePlayerWithGameResultsFromJson(Map Center( child: Center(child: Text('Could not load broadcast: $error')), @@ -83,6 +84,7 @@ class BroadcastPreview extends ConsumerStatefulWidget { required this.title, required this.tournamentSlug, required this.roundSlug, + required this.customScoring, }); // A circular progress indicator is used instead of shimmers currently @@ -92,7 +94,8 @@ class BroadcastPreview extends ConsumerStatefulWidget { games = null, title = '', tournamentSlug = '', - roundSlug = ''; + roundSlug = '', + customScoring = null; final BroadcastTournamentId tournamentId; final BroadcastRoundId roundId; @@ -100,6 +103,7 @@ class BroadcastPreview extends ConsumerStatefulWidget { final String title; final String tournamentSlug; final String roundSlug; + final BroadcastCustomScoring? customScoring; @override ConsumerState createState() => _BroadcastPreviewState(); } @@ -210,6 +214,7 @@ class _BroadcastPreviewState extends ConsumerState { boardSize: boardSize, boardWithMaybeEvalBarWidth: boardWithMaybeEvalBarWidth, playingSide: playingSide, + customScoring: widget.customScoring, ); }, childCount: games == null ? numberLoadingBoards : games.length), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( @@ -238,6 +243,7 @@ class ObservedBoardThumbnail extends ConsumerStatefulWidget { required this.boardSize, required this.boardWithMaybeEvalBarWidth, required this.playingSide, + required this.customScoring, }); final BroadcastRoundId roundId; @@ -250,6 +256,7 @@ class ObservedBoardThumbnail extends ConsumerStatefulWidget { final double boardSize; final double boardWithMaybeEvalBarWidth; final Side playingSide; + final BroadcastCustomScoring? customScoring; @override ConsumerState createState() => _ObservedBoardThumbnailState(); @@ -311,12 +318,14 @@ class _ObservedBoardThumbnailState extends ConsumerState game: widget.game, side: Side.black, playingSide: widget.playingSide, + customScoring: widget.customScoring, ), footer: _PlayerWidget( width: widget.boardWithMaybeEvalBarWidth, game: widget.game, side: Side.white, playingSide: widget.playingSide, + customScoring: widget.customScoring, ), ), ); @@ -349,12 +358,14 @@ class _PlayerWidget extends StatelessWidget { required this.game, required this.side, required this.playingSide, + required this.customScoring, }); final BroadcastGame game; final Side side; final Side playingSide; final double width; + final BroadcastCustomScoring? customScoring; @override Widget build(BuildContext context) { @@ -377,8 +388,11 @@ class _PlayerWidget extends StatelessWidget { const SizedBox(width: 5), if (game.isOver) Text( - game.status.resultToString(side), - style: const TextStyle().copyWith(fontWeight: FontWeight.bold), + resultString(customScoring, side, game.status), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontWeight: .bold, + color: game.status.colorFor(side, context), + ), ) else if (clock != null) CountdownClockBuilder( diff --git a/lib/src/view/broadcast/broadcast_carousel.dart b/lib/src/view/broadcast/broadcast_carousel.dart index 92ce539c0e..84a320176b 100644 --- a/lib/src/view/broadcast/broadcast_carousel.dart +++ b/lib/src/view/broadcast/broadcast_carousel.dart @@ -175,6 +175,7 @@ class BroadcastCarouselItem extends ConsumerStatefulWidget { startsAt: null, finishedAt: null, startsAfterPrevious: false, + customScoring: null, ), group: null, roundToLinkId: BroadcastRoundId(''), diff --git a/lib/src/view/broadcast/broadcast_game_screen.dart b/lib/src/view/broadcast/broadcast_game_screen.dart index 009a4a9ddc..d7bb72a16e 100644 --- a/lib/src/view/broadcast/broadcast_game_screen.dart +++ b/lib/src/view/broadcast/broadcast_game_screen.dart @@ -2,6 +2,7 @@ import 'package:dartchess/dartchess.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lichess_mobile/src/model/broadcast/broadcast.dart'; import 'package:lichess_mobile/src/model/broadcast/broadcast_analysis_controller.dart'; import 'package:lichess_mobile/src/model/broadcast/broadcast_preferences.dart'; import 'package:lichess_mobile/src/model/broadcast/broadcast_repository.dart'; @@ -476,7 +477,10 @@ class _PlayerWidget extends ConsumerWidget { final pastClocks = broadcastAnalysisState.clocks; final pastClock = (sideToMove == side) ? pastClocks?.parentClock : pastClocks?.clock; - + final customScoring = switch (ref.watch(broadcastRoundCustomScoringProvider(roundId))) { + AsyncValue(value: final customScoring?, hasValue: true) => customScoring, + _ => null, + }; return GestureDetector( onTap: () { if (player.id != null) { @@ -504,8 +508,11 @@ class _PlayerWidget extends ConsumerWidget { children: [ if (game.isOver) ...[ Text( - game.status.resultToString(side), - style: const TextStyle(fontWeight: FontWeight.bold), + resultString(customScoring, side, game.status), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: .bold, + color: game.status.colorFor(side, context), + ), ), const SizedBox(width: 16.0), ], diff --git a/lib/src/view/broadcast/broadcast_game_screen_providers.dart b/lib/src/view/broadcast/broadcast_game_screen_providers.dart index c50000a6aa..de0548100a 100644 --- a/lib/src/view/broadcast/broadcast_game_screen_providers.dart +++ b/lib/src/view/broadcast/broadcast_game_screen_providers.dart @@ -48,3 +48,9 @@ final broadcastGameScreenTitleProvider = FutureProvider.autoDispose final round = await ref.watch(broadcastRoundControllerProvider(roundId).future); return round.round.name; }, name: 'BroadcastGameScreenTitleProvider'); + +final broadcastRoundCustomScoringProvider = FutureProvider.autoDispose + .family((Ref ref, BroadcastRoundId roundId) async { + final round = await ref.watch(broadcastRoundControllerProvider(roundId).future); + return round.round.customScoring; + }, name: 'BroadcastRoundCustomScoringProvider'); diff --git a/lib/src/view/broadcast/broadcast_list_tile.dart b/lib/src/view/broadcast/broadcast_list_tile.dart index 42712e8da2..d682403516 100644 --- a/lib/src/view/broadcast/broadcast_list_tile.dart +++ b/lib/src/view/broadcast/broadcast_list_tile.dart @@ -45,6 +45,7 @@ class BroadcastListTile extends StatelessWidget { startsAt: null, finishedAt: null, startsAfterPrevious: false, + customScoring: null, ), group: null, roundToLinkId: BroadcastRoundId(''), diff --git a/lib/src/view/broadcast/broadcast_player_results_screen.dart b/lib/src/view/broadcast/broadcast_player_results_screen.dart index 7f878edf81..a8a9e6d96b 100644 --- a/lib/src/view/broadcast/broadcast_player_results_screen.dart +++ b/lib/src/view/broadcast/broadcast_player_results_screen.dart @@ -139,6 +139,7 @@ class _Body extends ConsumerWidget { final BroadcastPlayerWithOverallResult(:tieBreaks) = playerWithOverallResult; final showRatingDiff = games.any((result) => result.ratingDiff != null); + final showTCIcon = games.map((g) => g.fideTC).toSet().length > 1; final indexWidth = max(8.0 + games.length.toString().length * 10.0, 28.0); final gamesSectionHeader = ColoredBox( @@ -188,6 +189,7 @@ class _Body extends ConsumerWidget { index: index, indexWidth: indexWidth, showRatingDiff: showRatingDiff, + showTCIcon: showTCIcon, ); }, ); @@ -210,13 +212,13 @@ class _OverallStatPlayer extends StatelessWidget { final BroadcastPlayerWithGameResults(:playerWithOverallResult, :fideData, :games) = playerWithGameResults; final birthYear = fideData.birthYear; - final (:standard, :rapid, :blitz) = fideData.ratings; final BroadcastPlayerWithOverallResult( :player, :score, :played, - :performance, - :ratingDiff, + :performances, + :ratingsMap, + :ratingDiffs, :team, ) = playerWithOverallResult; final BroadcastPlayer(:federation, :fideId) = player; @@ -300,29 +302,24 @@ class _OverallStatPlayer extends StatelessWidget { ), ], ), - if (standard != null || rapid != null || blitz != null) + if (fideData.ratings.isNotEmpty) Row( mainAxisAlignment: MainAxisAlignment.center, spacing: cardSpacing, - children: [ - if (standard != null) - SizedBox( - width: statWidth, - child: _StatCard(context.l10n.classical, value: standard.toString()), - ), - if (rapid != null) - SizedBox( - width: statWidth, - child: _StatCard(context.l10n.rapid, value: rapid.toString()), - ), - if (blitz != null) - SizedBox( - width: statWidth, - child: _StatCard(context.l10n.blitz, value: blitz.toString()), - ), - ], + children: BroadcastFideTC.values + .map((tc) { + final rating = fideData.ratings.get(tc); + if (rating != null) { + return SizedBox( + width: statWidth, + child: _StatCard(tc.i18nName(context), value: rating.toString()), + ); + } + }) + .nonNulls + .toList(), ), - if (score != null || performance != null || ratingDiff != null) + if (score != null || performances != null || ratingDiffs != null) Row( mainAxisAlignment: MainAxisAlignment.center, spacing: cardSpacing, @@ -335,17 +332,55 @@ class _OverallStatPlayer extends StatelessWidget { value: '${NumberFormat('0.#').format(score)} / $played', ), ), - if (performance != null) + if (performances != null) SizedBox( width: statWidth, - child: _StatCard(context.l10n.performance, value: performance.toString()), + child: _StatCard( + context.l10n.performance, + child: Column( + mainAxisSize: .min, + children: performances + .mapTo( + (tc, p) => Row( + mainAxisAlignment: .center, + spacing: 4.0, + children: [ + if (performances.length > 1) Icon(tc.icon, size: 16), + Text( + '$p${games.count((g) => g.fideTC == tc) < 4 ? '?' : ''}', + style: const TextStyle(fontSize: 16), + ), + ], + ), + ) + .toList(), + ), + ), ), - if (ratingDiff != null) + if (ratingDiffs != null) SizedBox( width: statWidth, child: _StatCard( context.l10n.broadcastRatingDiff, - child: ProgressionWidget(ratingDiff, fontSize: 18.0), + child: Column( + mainAxisSize: .min, + children: ratingDiffs + .mapTo( + (tc, diff) => Row( + mainAxisAlignment: .center, + spacing: 4.0, + children: [ + if (ratingDiffs.length > 1) Icon(tc.icon, size: 16), + Text( + ratingsMap?.get(tc)?.toString() ?? '', + style: const TextStyle(fontSize: 16), + ), + ProgressionWidget(diff, fontSize: 16.0), + ], + ), + ) + .toList(), + ), ), ), ], @@ -403,6 +438,7 @@ class _GameResultListTile extends StatelessWidget { required this.index, required this.indexWidth, required this.showRatingDiff, + required this.showTCIcon, }); final BroadcastPlayerGameResult playerGameResult; @@ -410,11 +446,20 @@ class _GameResultListTile extends StatelessWidget { final int index; final double indexWidth; final bool showRatingDiff; + final bool showTCIcon; @override Widget build(BuildContext context) { - final BroadcastPlayerGameResult(:roundId, :gameId, :color, :points, :ratingDiff, :opponent) = - playerGameResult; + final BroadcastPlayerGameResult( + :roundId, + :gameId, + :color, + :points, + :customPoints, + :ratingDiff, + :opponent, + :fideTC, + ) = playerGameResult; final BroadcastPlayer(:federation, :rating) = opponent; final pic = opponent.fideId != null ? tournament.photos?.get(opponent.fideId!) : null; @@ -450,7 +495,7 @@ class _GameResultListTile extends StatelessWidget { ) : null, trailing: SizedBox( - width: 60, + width: showTCIcon ? 72 : 60, child: Row( mainAxisSize: .min, mainAxisAlignment: .center, @@ -476,6 +521,7 @@ class _GameResultListTile extends StatelessWidget { ), ), ), + if (showTCIcon) SizedBox(width: 12, child: Icon(fideTC.icon, size: 15)), SizedBox( width: 30, child: Column( @@ -486,13 +532,13 @@ class _GameResultListTile extends StatelessWidget { mainAxisSize: .min, children: [ Text( - switch (points) { - .one => '1', - .half => '½', - .zero => '0', - _ => '*', - }, - style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: .bold), + customPoints != null && customPoints != 0.5 + ? NumberFormat('0.##').format(customPoints) + : points?.resultFor(color).resultToString(color) ?? '*', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: .bold, + color: points?.resultFor(color).colorFor(color, context), + ), ), ], ), diff --git a/lib/src/view/broadcast/broadcast_players_tab.dart b/lib/src/view/broadcast/broadcast_players_tab.dart index d5192ff622..32854ade99 100644 --- a/lib/src/view/broadcast/broadcast_players_tab.dart +++ b/lib/src/view/broadcast/broadcast_players_tab.dart @@ -366,7 +366,8 @@ class BroadcastPlayerRow extends StatelessWidget { Widget build(BuildContext context) { final BroadcastPlayerWithOverallResult( :player, - :ratingDiff, + :ratingDiffs, + :ratingsMap, :score, :played, :rank, @@ -417,25 +418,39 @@ class BroadcastPlayerRow extends StatelessWidget { ), ], ), - subtitle: federation != null - ? Row( - mainAxisSize: .min, - children: [ - Image.asset('assets/images/fide-fed/$federation.png', height: 12), - const SizedBox(width: 5), - if (rating != null) - Text( - rating.toString(), - style: const TextStyle( - fontSize: 13, - fontFeatures: [FontFeature.tabularFigures()], + subtitle: Row( + mainAxisSize: .min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (federation != null) Image.asset('assets/images/fide-fed/$federation.png', height: 12), + if (ratingsMap != null) + Column( + mainAxisAlignment: .start, + crossAxisAlignment: CrossAxisAlignment.start, + children: ratingsMap + .mapTo( + (tc, rating) => Row( + mainAxisAlignment: .start, + spacing: 4.0, + children: [ + const SizedBox(width: 4), + if (ratingsMap.length > 1) Icon(tc.icon, size: 14), + Text( + rating.toString(), + style: const TextStyle( + fontSize: 13, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + if (ratingDiffs != null && ratingDiffs.get(tc) != null) + ProgressionWidget(ratingDiffs.get(tc)!, fontSize: 13), + ], ), - ), - const SizedBox(width: 4), - if (ratingDiff != null) ProgressionWidget(ratingDiff, fontSize: 13), - ], - ) - : null, + ) + .toList(), + ), + ], + ), trailing: rating != null || score != null ? SizedBox( width: 35, diff --git a/lib/src/view/broadcast/broadcast_teams_tab.dart b/lib/src/view/broadcast/broadcast_teams_tab.dart index 211e6cc02b..c62fa0ca42 100644 --- a/lib/src/view/broadcast/broadcast_teams_tab.dart +++ b/lib/src/view/broadcast/broadcast_teams_tab.dart @@ -2,6 +2,7 @@ import 'package:dartchess/dartchess.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; import 'package:lichess_mobile/src/model/broadcast/broadcast.dart'; import 'package:lichess_mobile/src/model/broadcast/broadcast_preferences.dart'; import 'package:lichess_mobile/src/model/broadcast/broadcast_providers.dart'; @@ -84,6 +85,7 @@ class BroadcastTeamsList extends ConsumerWidget { roundSlug: value.round.slug, title: value.round.name, showEvaluationGauge: showEvaluationGauges, + customScoring: value.round.customScoring, ); }, ), @@ -103,6 +105,7 @@ class _TeamMatchCard extends StatelessWidget { required this.roundSlug, required this.title, required this.showEvaluationGauge, + required this.customScoring, }); final BroadcastTeamMatch match; @@ -113,6 +116,16 @@ class _TeamMatchCard extends StatelessWidget { final String roundSlug; final String title; final bool showEvaluationGauge; + final BroadcastCustomScoring? customScoring; + + bool get matchFinished => games.everyEntry((e) => e.value.isOver); + BroadcastResult? get matchStatus => matchFinished + ? match.team1.points > match.team2.points + ? BroadcastResult.whiteWins + : match.team1.points < match.team2.points + ? BroadcastResult.blackWins + : BroadcastResult.draw + : null; @override Widget build(BuildContext context) { @@ -139,9 +152,25 @@ class _TeamMatchCard extends StatelessWidget { Container( width: _kScoreContainerWidth, alignment: Alignment.center, - child: Text( - '${match.team1.points % 1 == 0 ? match.team1.points.toInt() : match.team1.points} - ${match.team2.points % 1 == 0 ? match.team2.points.toInt() : match.team2.points}', - style: const TextStyle(fontWeight: FontWeight.bold), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + NumberFormat('#.#').format(match.team1.points), + style: TextStyle( + fontWeight: FontWeight.bold, + color: matchStatus?.colorFor(Side.white, context), + ), + ), + const Text(' - '), + Text( + NumberFormat('#.#').format(match.team2.points), + style: TextStyle( + fontWeight: FontWeight.bold, + color: matchStatus?.colorFor(Side.black, context), + ), + ), + ], ), ), Expanded( @@ -173,6 +202,7 @@ class _TeamMatchCard extends StatelessWidget { roundSlug: roundSlug, title: title, showEvaluationGauge: showEvaluationGauge, + customScoring: customScoring, ); }), ], @@ -192,6 +222,7 @@ class _GameRow extends ConsumerStatefulWidget { required this.roundSlug, required this.title, required this.showEvaluationGauge, + required this.customScoring, }); final BroadcastGame game; @@ -203,6 +234,7 @@ class _GameRow extends ConsumerStatefulWidget { final String roundSlug; final String title; final bool showEvaluationGauge; + final BroadcastCustomScoring? customScoring; @override ConsumerState<_GameRow> createState() => _GameRowState(); } @@ -223,7 +255,7 @@ class _GameRowState extends ConsumerState<_GameRow> { final team1Player = widget.teamGame.pov == Side.white ? whitePlayer : blackPlayer; final team2Player = widget.teamGame.pov == Side.white ? blackPlayer : whitePlayer; - final resultString = _getGameResult(widget.game, widget.teamGame.pov); + final result = _getGameResultTexts(widget.game, widget.teamGame.pov, widget.customScoring); final whiteWinningChances = widget.game.isOngoing && (widget.game.cp != null || widget.game.mate != null) @@ -293,7 +325,10 @@ class _GameRowState extends ConsumerState<_GameRow> { color: Colors.grey.withValues(alpha: 0.6), ), ) - : Container(alignment: Alignment.center, child: Text(resultString)), + : Container( + alignment: Alignment.center, + child: Row(mainAxisAlignment: .center, children: result), + ), ), SizedBox(width: isTablet ? _kTabletSpacing : _kPhoneSpacing), Expanded( @@ -307,14 +342,24 @@ class _GameRowState extends ConsumerState<_GameRow> { ); } - String _getGameResult(BroadcastGame game, Side teamPov) { + List _getGameResultTexts( + BroadcastGame game, + Side teamPov, + BroadcastCustomScoring? customScoring, + ) { if (!game.isOver) { - return '*'; + return [const Text('*')]; + } + Text povResult(BroadcastGame game, Side pov, BroadcastCustomScoring? customScoring) { + return Text( + resultString(customScoring, pov, game.status), + style: TextStyle(color: game.status.colorFor(pov, context)), + ); } - final team1Result = game.status.resultToString(teamPov); - final team2Result = game.status.resultToString(teamPov.opposite); - return '$team1Result-$team2Result'; + final team1Result = povResult(game, teamPov, customScoring); + final team2Result = povResult(game, teamPov.opposite, customScoring); + return [team1Result, const Text('-'), team2Result]; } } diff --git a/test/view/broadcast/broadcast_round_screen_test.dart b/test/view/broadcast/broadcast_round_screen_test.dart index 2ec8fff013..352332390a 100644 --- a/test/view/broadcast/broadcast_round_screen_test.dart +++ b/test/view/broadcast/broadcast_round_screen_test.dart @@ -482,7 +482,22 @@ void main() { expect(find.text('Team C'), findsOneWidget); expect(find.text('Team D'), findsOneWidget); - expect(find.text('1 - 0'), findsOneWidget); + expect( + find.byWidgetPredicate((widget) { + if (widget is Row) { + final children = widget.children; + return children.length == 3 && + children[0] is Text && + (children[0] as Text).data == '1' && + children[1] is Text && + (children[1] as Text).data == ' - ' && + children[2] is Text && + (children[2] as Text).data == '0'; + } + return false; + }), + findsOneWidget, + ); expect(find.byType(Card), findsNWidgets(2)); }); @@ -577,6 +592,7 @@ final _finishedBroadcast = Broadcast( startsAt: DateTime.fromMillisecondsSinceEpoch(1735687229333), finishedAt: DateTime.fromMillisecondsSinceEpoch(1735689805106), startsAfterPrevious: true, + customScoring: null, ), roundToLinkId: const BroadcastRoundId('S5VCwuVn'), group: 'FIDE World Rapid & Blitz Championships 2024', @@ -1009,6 +1025,7 @@ final _upcomingBroadcast = Broadcast( startsAt: DateTime.fromMillisecondsSinceEpoch(1736526600000), finishedAt: null, startsAfterPrevious: false, + customScoring: null, ), roundToLinkId: const BroadcastRoundId('UN587WBI'), group: null, @@ -1183,6 +1200,7 @@ final _liveBroadcast = Broadcast( startsAt: null, finishedAt: null, startsAfterPrevious: false, + customScoring: null, ), roundToLinkId: const BroadcastRoundId('00000000'), group: null, @@ -2164,6 +2182,7 @@ final _liveTeamBroadcast = Broadcast( startsAt: null, finishedAt: null, startsAfterPrevious: false, + customScoring: null, ), roundToLinkId: const BroadcastRoundId('00000000'), group: null,