diff --git a/lib/src/model/broadcast/broadcast.dart b/lib/src/model/broadcast/broadcast.dart index fff18f2d78..98ae1eb062 100644 --- a/lib/src/model/broadcast/broadcast.dart +++ b/lib/src/model/broadcast/broadcast.dart @@ -139,6 +139,18 @@ class BroadcastPlayerExtended with _$BroadcastPlayerExtended { }) = _BroadcastPlayerExtended; } +enum BroadcastPoints { one, half, zero } + +@freezed +class BroadcastPlayerResult with _$BroadcastPlayerResult { + const factory BroadcastPlayerResult({ + required Side color, + required BroadcastPoints? points, + required int? ratingDiff, + required BroadcastPlayer opponent, + }) = _BroadcastPlayerResult; +} + enum RoundStatus { live, finished, diff --git a/lib/src/model/broadcast/broadcast_providers.dart b/lib/src/model/broadcast/broadcast_providers.dart index a7e02edc8d..fefcc0331d 100644 --- a/lib/src/model/broadcast/broadcast_providers.dart +++ b/lib/src/model/broadcast/broadcast_providers.dart @@ -65,6 +65,18 @@ Future> broadcastPlayers( ); } +@riverpod +Future> broadcastPlayerResult( + Ref ref, + BroadcastTournamentId broadcastTournamentId, + FideId fideId, +) { + return ref.withClient( + (client) => BroadcastRepository(client) + .getPlayerResults(broadcastTournamentId, fideId), + ); +} + @Riverpod(keepAlive: true) BroadcastImageWorkerFactory broadcastImageWorkerFactory(Ref ref) { return const BroadcastImageWorkerFactory(); diff --git a/lib/src/model/broadcast/broadcast_repository.dart b/lib/src/model/broadcast/broadcast_repository.dart index 67dec06f47..236a4fc75e 100644 --- a/lib/src/model/broadcast/broadcast_repository.dart +++ b/lib/src/model/broadcast/broadcast_repository.dart @@ -57,6 +57,16 @@ class BroadcastRepository { mapper: _makePlayerFromJson, ); } + + Future> getPlayerResults( + BroadcastTournamentId tournamentId, + FideId fideId, + ) { + return client.readJson( + Uri(path: 'broadcast/$tournamentId/players/$fideId'), + mapper: _makePlayerResultsFromJson, + ); + } } BroadcastList _makeBroadcastResponseFromJson( @@ -79,7 +89,7 @@ Broadcast _broadcastFromPick(RequiredPick pick) { round: _roundFromPick(pick('round').required()), group: pick('group').asStringOrNull(), roundToLinkId: - pick('roundToLink', 'id').asBroadcastRoundIddOrNull() ?? roundId, + pick('roundToLink', 'id').asBroadcastRoundIdOrNull() ?? roundId, ); } @@ -221,3 +231,28 @@ BroadcastPlayerExtended _playerExtendedFromPick(RequiredPick pick) { ratingDiff: pick('ratingDiff').asIntOrNull(), ); } + +IList _makePlayerResultsFromJson( + Map json, +) { + return pick(json, 'games').asListOrThrow(_makePlayerResultFromPick).toIList(); +} + +BroadcastPlayerResult _makePlayerResultFromPick(RequiredPick pick) { + final pointsString = pick('points').asStringOrNull(); + BroadcastPoints? points; + if (pointsString == '1') { + points = BroadcastPoints.one; + } else if (pointsString == '1/2') { + points = BroadcastPoints.half; + } else if (pointsString == '0') { + points = BroadcastPoints.zero; + } + + return BroadcastPlayerResult( + color: pick('color').asSideOrThrow(), + ratingDiff: pick('ratingDiff').asIntOrNull(), + points: points, + opponent: _playerFromPick(pick('opponent').required()), + ); +} diff --git a/lib/src/model/common/id.dart b/lib/src/model/common/id.dart index b41a4e82a6..9d1884aca8 100644 --- a/lib/src/model/common/id.dart +++ b/lib/src/model/common/id.dart @@ -8,6 +8,8 @@ extension type const StringId(String value) { bool startsWith(String prefix) => value.startsWith(prefix); } +extension type const IntId(int value) {} + extension type const GameAnyId._(String value) implements StringId { GameAnyId(this.value) : assert(value.length == 8 || value.length == 12); GameId get gameId => GameId(value.substring(0, 8)); @@ -65,7 +67,7 @@ extension type const StudyChapterId(String value) implements StringId { StudyChapterId.fromJson(dynamic json) : this(json as String); } -extension type const FideId(String value) implements StringId {} +extension type const FideId(int value) implements IntId {} extension IDPick on Pick { UserId asUserIdOrThrow() { @@ -192,7 +194,7 @@ extension IDPick on Pick { ); } - BroadcastRoundId? asBroadcastRoundIddOrNull() { + BroadcastRoundId? asBroadcastRoundIdOrNull() { if (value == null) return null; try { return asBroadcastRoundIdOrThrow(); @@ -211,7 +213,7 @@ extension IDPick on Pick { ); } - BroadcastGameId? asBroadcastGameIddOrNull() { + BroadcastGameId? asBroadcastGameIdOrNull() { if (value == null) return null; try { return asBroadcastGameIdOrThrow(); @@ -232,7 +234,7 @@ extension IDPick on Pick { FideId asFideIdOrThrow() { final value = required().value; - if (value is String) { + if (value is int) { return FideId(value); } throw PickException( diff --git a/lib/src/view/broadcast/broadcast_player_screen.dart b/lib/src/view/broadcast/broadcast_player_screen.dart new file mode 100644 index 0000000000..527b92c861 --- /dev/null +++ b/lib/src/view/broadcast/broadcast_player_screen.dart @@ -0,0 +1,147 @@ +import 'dart:math'; + +import 'package:dartchess/dartchess.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_providers.dart'; +import 'package:lichess_mobile/src/model/common/id.dart'; +import 'package:lichess_mobile/src/view/broadcast/broadcast_player_widget.dart'; +import 'package:lichess_mobile/src/widgets/platform_scaffold.dart'; +import 'package:lichess_mobile/src/widgets/progression_widget.dart'; + +class BroadcastPlayerScreen extends StatelessWidget { + final BroadcastTournamentId tournamentId; + final FideId fideId; + const BroadcastPlayerScreen( + this.tournamentId, + this.fideId, + ); + + @override + Widget build(BuildContext context) { + return PlatformScaffold( + appBar: const PlatformAppBar( + title: Text('Player details'), + ), + body: _Body(tournamentId, fideId), + ); + } +} + +const _kTableRowPadding = EdgeInsets.symmetric( + vertical: 12.0, +); + +class _Body extends ConsumerWidget { + final BroadcastTournamentId tournamentId; + final FideId fideId; + + const _Body(this.tournamentId, this.fideId); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final playersResults = + ref.watch(broadcastPlayerResultProvider(tournamentId, fideId)); + + switch (playersResults) { + case AsyncData(value: final playerResults): + final indexWidth = + max(8.0 + playerResults.length.toString().length * 10.0, 28.0); + + return ListView.builder( + itemCount: playerResults.length, + itemBuilder: (context, index) { + final playerResult = playerResults[index]; + return ColoredBox( + color: index.isEven + ? Theme.of(context).colorScheme.surfaceContainerLow + : Theme.of(context).colorScheme.surfaceContainerHigh, + child: Padding( + padding: _kTableRowPadding, + child: Row( + children: [ + SizedBox( + width: indexWidth, + child: Center(child: Text((index + 1).toString())), + ), + Expanded( + flex: 5, + child: BroadcastPlayerWidget( + federation: playerResult.opponent.federation, + title: playerResult.opponent.title, + name: playerResult.opponent.name, + ), + ), + Expanded( + flex: 3, + child: (playerResult.opponent.rating != null) + ? Center( + child: + Text(playerResult.opponent.rating.toString()), + ) + : const SizedBox.shrink(), + ), + SizedBox( + width: 30, + child: Center( + child: Container( + width: 15, + height: 15, + decoration: BoxDecoration( + border: (Theme.of(context).brightness == + Brightness.light && + playerResult.color == Side.white || + Theme.of(context).brightness == + Brightness.dark && + playerResult.color == Side.black) + ? Border.all( + width: 2.0, + color: + Theme.of(context).colorScheme.outline, + ) + : null, + shape: BoxShape.circle, + color: switch (playerResult.color) { + Side.white => Colors.white.withValues(alpha: 0.9), + Side.black => Colors.black.withValues(alpha: 0.9), + }, + ), + ), + ), + ), + SizedBox( + width: 30, + child: Center( + child: Text( + switch (playerResult.points) { + BroadcastPoints.one => '1', + BroadcastPoints.half => '½', + BroadcastPoints.zero => '0', + _ => '*' + }, + ), + ), + ), + SizedBox( + width: 38, + child: (playerResult.ratingDiff != null) + ? ProgressionWidget( + playerResult.ratingDiff!, + fontSize: 15, + ) + : null, + ), + ], + ), + ), + ); + }, + ); + case AsyncError(:final error): + return Text('Cannot load player data: $error'); + case _: + return const CircularProgressIndicator.adaptive(); + } + } +} diff --git a/lib/src/view/broadcast/broadcast_player_widget.dart b/lib/src/view/broadcast/broadcast_player_widget.dart index 598cc62f46..3097c29d25 100644 --- a/lib/src/view/broadcast/broadcast_player_widget.dart +++ b/lib/src/view/broadcast/broadcast_player_widget.dart @@ -53,7 +53,6 @@ class BroadcastPlayerWidget extends ConsumerWidget { const SizedBox(width: 5), Text( rating.toString(), - style: const TextStyle(), overflow: TextOverflow.ellipsis, ), ], diff --git a/lib/src/view/broadcast/broadcast_players_tab.dart b/lib/src/view/broadcast/broadcast_players_tab.dart index 9088af4e2d..5f23c87bfe 100644 --- a/lib/src/view/broadcast/broadcast_players_tab.dart +++ b/lib/src/view/broadcast/broadcast_players_tab.dart @@ -8,6 +8,8 @@ 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:lichess_mobile/src/utils/l10n_context.dart'; +import 'package:lichess_mobile/src/utils/navigation.dart'; +import 'package:lichess_mobile/src/view/broadcast/broadcast_player_screen.dart'; import 'package:lichess_mobile/src/view/broadcast/broadcast_player_widget.dart'; import 'package:lichess_mobile/src/widgets/progression_widget.dart'; @@ -27,7 +29,7 @@ class BroadcastPlayersTab extends ConsumerWidget { final players = ref.watch(broadcastPlayersProvider(tournamentId)); return switch (players) { - AsyncData(value: final players) => PlayersList(players), + AsyncData(value: final players) => PlayersList(players, tournamentId), AsyncError(:final error) => SliverPadding( padding: edgeInsets, sliver: SliverFillRemaining( @@ -55,9 +57,10 @@ const _kHeaderTextStyle = TextStyle(fontWeight: FontWeight.bold, overflow: TextOverflow.ellipsis); class PlayersList extends ConsumerStatefulWidget { - const PlayersList(this.players); + const PlayersList(this.players, this.tournamentId); final IList players; + final BroadcastTournamentId tournamentId; @override ConsumerState createState() => _PlayersListState(); @@ -177,53 +180,67 @@ class _PlayersListState extends ConsumerState { ); } else { final player = players[index - 1]; - return Container( - decoration: BoxDecoration( - color: index.isEven + return GestureDetector( + onTap: () { + if (player.fideId != null) { + pushPlatformRoute( + context, + builder: (context) => BroadcastPlayerScreen( + widget.tournamentId, + player.fideId!, + ), + ); + } + }, + child: ColoredBox( + color: (index - 1).isEven ? Theme.of(context).colorScheme.surfaceContainerLow : Theme.of(context).colorScheme.surfaceContainerHigh, - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Padding( - padding: _kTableRowPadding, - child: BroadcastPlayerWidget( - federation: player.federation, - title: player.title, - name: player.name, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Padding( + padding: _kTableRowPadding, + child: BroadcastPlayerWidget( + federation: player.federation, + title: player.title, + name: player.name, + ), ), ), - ), - SizedBox( - width: eloWidth, - child: Padding( - padding: _kTableRowPadding, - child: Row( - children: [ - if (player.rating != null) ...[ - Text(player.rating.toString()), - const SizedBox(width: 5), - if (player.ratingDiff != null) - ProgressionWidget(player.ratingDiff!, fontSize: 14), + SizedBox( + width: eloWidth, + child: Padding( + padding: _kTableRowPadding, + child: Row( + children: [ + if (player.rating != null) ...[ + Text(player.rating.toString()), + const SizedBox(width: 5), + if (player.ratingDiff != null) + ProgressionWidget( + player.ratingDiff!, + fontSize: 14, + ), + ], ], - ], + ), ), ), - ), - SizedBox( - width: scoreWidth, - child: Padding( - padding: _kTableRowPadding, - child: (player.score != null) - ? Text( - '${player.score!.toStringAsFixed((player.score! == player.score!.roundToDouble()) ? 0 : 1)}/${player.played}', - ) - : const SizedBox.shrink(), + SizedBox( + width: scoreWidth, + child: Padding( + padding: _kTableRowPadding, + child: (player.score != null) + ? Text( + '${player.score!.toStringAsFixed((player.score! == player.score!.roundToDouble()) ? 0 : 1)}/${player.played}', + ) + : const SizedBox.shrink(), + ), ), - ), - ], + ], + ), ), ); }