diff --git a/lib/src/model/broadcast/broadcast.dart b/lib/src/model/broadcast/broadcast.dart index e662e90090..fff18f2d78 100644 --- a/lib/src/model/broadcast/broadcast.dart +++ b/lib/src/model/broadcast/broadcast.dart @@ -75,8 +75,6 @@ typedef BroadcastTournamentGroup = ({ @freezed class BroadcastRound with _$BroadcastRound { - const BroadcastRound._(); - const factory BroadcastRound({ required BroadcastRoundId id, required String name, @@ -117,17 +115,30 @@ class BroadcastGame with _$BroadcastGame { @freezed class BroadcastPlayer with _$BroadcastPlayer { - const BroadcastPlayer._(); - const factory BroadcastPlayer({ required String name, required String? title, required int? rating, required Duration? clock, required String? federation, + required FideId? fideId, }) = _BroadcastPlayer; } +@freezed +class BroadcastPlayerExtended with _$BroadcastPlayerExtended { + const factory BroadcastPlayerExtended({ + required String name, + required String? title, + required int? rating, + required String? federation, + required FideId? fideId, + required int played, + required double? score, + required int? ratingDiff, + }) = _BroadcastPlayerExtended; +} + enum RoundStatus { live, finished, diff --git a/lib/src/model/broadcast/broadcast_providers.dart b/lib/src/model/broadcast/broadcast_providers.dart index 0c9f685d32..a7e02edc8d 100644 --- a/lib/src/model/broadcast/broadcast_providers.dart +++ b/lib/src/model/broadcast/broadcast_providers.dart @@ -1,3 +1,4 @@ +import 'package:fast_immutable_collections/fast_immutable_collections.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_repository.dart'; @@ -54,6 +55,16 @@ Future broadcastTournament( ); } +@riverpod +Future> broadcastPlayers( + Ref ref, + BroadcastTournamentId tournamentId, +) { + return ref.withClient( + (client) => BroadcastRepository(client).getPlayers(tournamentId), + ); +} + @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 05ddfe2b49..67dec06f47 100644 --- a/lib/src/model/broadcast/broadcast_repository.dart +++ b/lib/src/model/broadcast/broadcast_repository.dart @@ -18,7 +18,6 @@ class BroadcastRepository { path: '/api/broadcast/top', queryParameters: {'page': page.toString()}, ), - headers: {'Accept': 'application/json'}, mapper: _makeBroadcastResponseFromJson, ); } @@ -28,7 +27,6 @@ class BroadcastRepository { ) { return client.readJson( Uri(path: 'api/broadcast/$broadcastTournamentId'), - headers: {'Accept': 'application/json'}, mapper: _makeTournamentFromJson, ); } @@ -40,7 +38,6 @@ class BroadcastRepository { Uri(path: 'api/broadcast/-/-/$broadcastRoundId'), // 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: _makeRoundWithGamesFromJson, ); } @@ -51,6 +48,15 @@ class BroadcastRepository { ) { return client.read(Uri(path: 'api/study/$roundId/$gameId.pgn')); } + + Future> getPlayers( + BroadcastTournamentId tournamentId, + ) { + return client.readJsonList( + Uri(path: '/broadcast/$tournamentId/players'), + mapper: _makePlayerFromJson, + ); + } } BroadcastList _makeBroadcastResponseFromJson( @@ -195,5 +201,23 @@ BroadcastPlayer _playerFromPick(RequiredPick pick) { rating: pick('rating').asIntOrNull(), clock: pick('clock').asDurationFromCentiSecondsOrNull(), federation: pick('fed').asStringOrNull(), + fideId: pick('fideId').asFideIdOrNull(), + ); +} + +BroadcastPlayerExtended _makePlayerFromJson(Map json) { + return _playerExtendedFromPick(pick(json).required()); +} + +BroadcastPlayerExtended _playerExtendedFromPick(RequiredPick pick) { + return BroadcastPlayerExtended( + name: pick('name').asStringOrThrow(), + title: pick('title').asStringOrNull(), + rating: pick('rating').asIntOrNull(), + federation: pick('fed').asStringOrNull(), + fideId: pick('fideId').asFideIdOrNull(), + played: pick('played').asIntOrThrow(), + score: pick('score').asDoubleOrNull(), + ratingDiff: pick('ratingDiff').asIntOrNull(), ); } diff --git a/lib/src/model/common/id.dart b/lib/src/model/common/id.dart index 783c6f3bb6..b41a4e82a6 100644 --- a/lib/src/model/common/id.dart +++ b/lib/src/model/common/id.dart @@ -65,6 +65,8 @@ 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 IDPick on Pick { UserId asUserIdOrThrow() { final value = required().value; @@ -227,4 +229,23 @@ extension IDPick on Pick { "value $value at $debugParsingExit can't be casted to StudyId", ); } + + FideId asFideIdOrThrow() { + final value = required().value; + if (value is String) { + return FideId(value); + } + throw PickException( + "value $value at $debugParsingExit can't be casted to FideId", + ); + } + + FideId? asFideIdOrNull() { + if (value == null) return null; + try { + return asFideIdOrThrow(); + } catch (_) { + return null; + } + } } diff --git a/lib/src/view/broadcast/broadcast_boards_tab.dart b/lib/src/view/broadcast/broadcast_boards_tab.dart index 89cfc2f49a..f04cbe4d07 100644 --- a/lib/src/view/broadcast/broadcast_boards_tab.dart +++ b/lib/src/view/broadcast/broadcast_boards_tab.dart @@ -2,18 +2,16 @@ 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/flutter_svg.dart'; import 'package:lichess_mobile/src/model/broadcast/broadcast.dart'; import 'package:lichess_mobile/src/model/broadcast/broadcast_round_controller.dart'; import 'package:lichess_mobile/src/model/common/id.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/utils/screen.dart'; import 'package:lichess_mobile/src/view/broadcast/broadcast_game_screen.dart'; +import 'package:lichess_mobile/src/view/broadcast/broadcast_player_widget.dart'; import 'package:lichess_mobile/src/widgets/board_thumbnail.dart'; import 'package:lichess_mobile/src/widgets/clock.dart'; import 'package:lichess_mobile/src/widgets/shimmer.dart'; @@ -34,34 +32,42 @@ class BroadcastBoardsTab extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final edgeInsets = MediaQuery.paddingOf(context) - + (Theme.of(context).platform == TargetPlatform.iOS + ? EdgeInsets.only(top: MediaQuery.paddingOf(context).top) + : EdgeInsets.zero) + + Styles.bodyPadding; final round = ref.watch(broadcastRoundControllerProvider(roundId)); - return switch (round) { - AsyncData(:final value) => value.games.isEmpty - ? SliverPadding( - padding: const EdgeInsets.only(top: 16.0), - sliver: SliverToBoxAdapter( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.info, size: 30), - Text(context.l10n.broadcastNoBoardsYet), - ], + return SliverPadding( + padding: edgeInsets, + sliver: switch (round) { + AsyncData(:final value) => value.games.isEmpty + ? SliverPadding( + padding: const EdgeInsets.only(top: 16.0), + sliver: SliverToBoxAdapter( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.info, size: 30), + Text(context.l10n.broadcastNoBoardsYet), + ], + ), ), + ) + : BroadcastPreview( + games: value.games.values.toIList(), + roundId: roundId, + title: value.round.name, ), - ) - : BroadcastPreview( - games: value.games.values.toIList(), - roundId: roundId, - title: value.round.name, + AsyncError(:final error) => SliverFillRemaining( + child: Center( + child: Text('Could not load broadcast: $error'), ), - AsyncError(:final error) => SliverFillRemaining( - child: Center( - child: Text('Could not load broadcast: $error'), ), - ), - _ => BroadcastPreview.loading(roundId: roundId), - }; + _ => BroadcastPreview.loading(roundId: roundId), + }, + ); } } @@ -210,40 +216,11 @@ class _PlayerWidget extends StatelessWidget { mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Flexible( - child: Row( - mainAxisSize: MainAxisSize.min, - 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), - ], - Flexible( - child: Text( - player.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], + Expanded( + child: BroadcastPlayerWidget( + federation: player.federation, + title: player.title, + name: player.name, ), ), const SizedBox(width: 5), diff --git a/lib/src/view/broadcast/broadcast_game_screen.dart b/lib/src/view/broadcast/broadcast_game_screen.dart index ceda09ccb9..75e656fc22 100644 --- a/lib/src/view/broadcast/broadcast_game_screen.dart +++ b/lib/src/view/broadcast/broadcast_game_screen.dart @@ -3,7 +3,6 @@ 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'; @@ -14,16 +13,15 @@ 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/broadcast/broadcast_player_widget.dart'; import 'package:lichess_mobile/src/view/engine/engine_gauge.dart'; import 'package:lichess_mobile/src/view/engine/engine_lines.dart'; import 'package:lichess_mobile/src/view/opening_explorer/opening_explorer_view.dart'; @@ -386,40 +384,16 @@ class _PlayerWidget extends ConsumerWidget { ), const SizedBox(width: 16.0), ], - if (player.federation != null) ...[ - SvgPicture.network( - lichessFideFedSrc(player.federation!), - height: 12, - httpClient: ref.read(defaultClientProvider), + Expanded( + child: BroadcastPlayerWidget( + federation: player.federation, + title: player.title, + name: player.name, + rating: player.rating, + textStyle: + const TextStyle().copyWith(fontWeight: FontWeight.bold), ), - 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, - ), - ], - const Spacer(), if (clock != null) Container( height: kAnalysisBoardHeaderOrFooterHeight, diff --git a/lib/src/view/broadcast/broadcast_overview_tab.dart b/lib/src/view/broadcast/broadcast_overview_tab.dart index 20cc020cc9..0208fed424 100644 --- a/lib/src/view/broadcast/broadcast_overview_tab.dart +++ b/lib/src/view/broadcast/broadcast_overview_tab.dart @@ -6,6 +6,7 @@ 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:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/widgets/buttons.dart'; import 'package:lichess_mobile/src/widgets/platform.dart'; @@ -26,77 +27,91 @@ class BroadcastOverviewTab extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final edgeInsets = MediaQuery.paddingOf(context) - + (Theme.of(context).platform == TargetPlatform.iOS + ? EdgeInsets.only(top: MediaQuery.paddingOf(context).top) + : EdgeInsets.zero) + + Styles.bodyPadding; final tournament = ref.watch(broadcastTournamentProvider(tournamentId)); switch (tournament) { case AsyncData(value: final tournament): final information = tournament.data.information; final description = tournament.data.description; - return SliverList( - delegate: SliverChildListDelegate( - [ - if (tournament.data.imageUrl != null) ...[ - Image.network(tournament.data.imageUrl!), - const SizedBox(height: 16.0), - ], - 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.location != null) - _BroadcastOverviewCard( - Icons.public, - '${information.location}', - ), - if (information.players != null) - _BroadcastOverviewCard( - Icons.person, - '${information.players}', - ), - if (information.website != null) - _BroadcastOverviewCard( - Icons.link, - context.l10n.broadcastOfficialWebsite, - information.website, - ), + return SliverPadding( + padding: edgeInsets, + sliver: SliverList( + delegate: SliverChildListDelegate( + [ + if (tournament.data.imageUrl != null) ...[ + Image.network(tournament.data.imageUrl!), + const SizedBox(height: 16.0), ], - ), - if (description != null) ...[ - const SizedBox(height: 16), - MarkdownBody( - data: description, - onTapLink: (text, url, title) { - if (url == null) return; - launchUrl(Uri.parse(url)); - }, + 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.location != null) + _BroadcastOverviewCard( + Icons.public, + '${information.location}', + ), + if (information.players != null) + _BroadcastOverviewCard( + Icons.person, + '${information.players}', + ), + if (information.website != null) + _BroadcastOverviewCard( + Icons.link, + context.l10n.broadcastOfficialWebsite, + information.website, + ), + ], ), + if (description != null) ...[ + const SizedBox(height: 16), + MarkdownBody( + data: description, + onTapLink: (text, url, title) { + if (url == null) return; + launchUrl(Uri.parse(url)); + }, + ), + ], ], - ], + ), ), ); case AsyncError(:final error): - return SliverFillRemaining( - child: Center(child: Text('Cannot load broadcast data: $error')), + return SliverPadding( + padding: edgeInsets, + sliver: SliverFillRemaining( + child: Center(child: Text('Cannot load broadcast data: $error')), + ), ); case _: - return const SliverFillRemaining( - child: Center(child: CircularProgressIndicator.adaptive()), + return SliverPadding( + padding: edgeInsets, + sliver: const SliverFillRemaining( + child: Center(child: CircularProgressIndicator.adaptive()), + ), ); } } diff --git a/lib/src/view/broadcast/broadcast_player_widget.dart b/lib/src/view/broadcast/broadcast_player_widget.dart new file mode 100644 index 0000000000..598cc62f46 --- /dev/null +++ b/lib/src/view/broadcast/broadcast_player_widget.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:lichess_mobile/src/network/http.dart'; +import 'package:lichess_mobile/src/styles/styles.dart'; +import 'package:lichess_mobile/src/utils/lichess_assets.dart'; + +class BroadcastPlayerWidget extends ConsumerWidget { + const BroadcastPlayerWidget({ + required this.federation, + required this.title, + required this.name, + this.rating, + this.textStyle, + }); + + final String? federation; + final String? title; + final int? rating; + final String name; + final TextStyle? textStyle; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Row( + children: [ + if (federation != null) ...[ + SvgPicture.network( + lichessFideFedSrc(federation!), + height: 12, + httpClient: ref.read(defaultClientProvider), + ), + const SizedBox(width: 5), + ], + if (title != null) ...[ + Text( + title!, + style: const TextStyle().copyWith( + color: context.lichessColors.brag, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 5), + ], + Flexible( + child: Text( + name, + style: textStyle, + overflow: TextOverflow.ellipsis, + ), + ), + if (rating != null) ...[ + 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 new file mode 100644 index 0000000000..1105463580 --- /dev/null +++ b/lib/src/view/broadcast/broadcast_players_tab.dart @@ -0,0 +1,303 @@ +import 'dart:math'; + +import 'package:fast_immutable_collections/fast_immutable_collections.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/styles/styles.dart'; +import 'package:lichess_mobile/src/utils/l10n_context.dart'; +import 'package:lichess_mobile/src/view/broadcast/broadcast_player_widget.dart'; +import 'package:lichess_mobile/src/widgets/progression_widget.dart'; +import 'package:lichess_mobile/src/widgets/shimmer.dart'; + +/// A tab that displays the players participating in a broadcast tournament. +class BroadcastPlayersTab extends ConsumerWidget { + const BroadcastPlayersTab({required this.tournamentId}); + + final BroadcastTournamentId tournamentId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final edgeInsets = MediaQuery.paddingOf(context) - + (Theme.of(context).platform == TargetPlatform.iOS + ? EdgeInsets.only(top: MediaQuery.paddingOf(context).top) + : EdgeInsets.zero) + + Styles.bodyPadding; + final players = ref.watch(broadcastPlayersProvider(tournamentId)); + + return switch (players) { + AsyncData(value: final players) => PlayersList(players), + AsyncError(:final error) => SliverPadding( + padding: edgeInsets, + sliver: SliverFillRemaining( + child: Center(child: Text('Cannot load players data: $error')), + ), + ), + _ => PlayersList.loading(), + }; + } +} + +enum _SortingTypes { player, elo, score } + +const _kTableRowVerticalPadding = 12.0; +const _kTableRowHorizontalPadding = 8.0; +const _kTableRowPadding = EdgeInsets.symmetric( + horizontal: _kTableRowHorizontalPadding, + vertical: _kTableRowVerticalPadding, +); +const _kHeaderTextStyle = + TextStyle(fontWeight: FontWeight.bold, overflow: TextOverflow.ellipsis); + +class PlayersList extends ConsumerStatefulWidget { + const PlayersList(this.players) : _isLoading = false; + + PlayersList.loading() + : players = List.generate( + 10, + (_) => const BroadcastPlayerExtended( + name: '', + title: null, + rating: null, + federation: null, + fideId: null, + played: 0, + score: null, + ratingDiff: null, + ), + ).toIList(), + _isLoading = true; + + final IList players; + final bool _isLoading; + + @override + ConsumerState createState() => _PlayersListState(); +} + +class _PlayersListState extends ConsumerState { + late IList players; + _SortingTypes currentSort = _SortingTypes.score; + bool reverse = false; + + void sort(_SortingTypes newSort, {bool toggleReverse = false}) { + final compare = switch (newSort) { + _SortingTypes.player => + (BroadcastPlayerExtended a, BroadcastPlayerExtended b) => + a.name.compareTo(b.name), + _SortingTypes.elo => + (BroadcastPlayerExtended a, BroadcastPlayerExtended b) { + if (a.rating == null) return 1; + if (b.rating == null) return -1; + return b.rating!.compareTo(a.rating!); + }, + _SortingTypes.score => + (BroadcastPlayerExtended a, BroadcastPlayerExtended b) { + if (a.score == null) return 1; + if (b.score == null) return -1; + return b.score!.compareTo(a.score!); + } + }; + + setState(() { + if (currentSort != newSort) { + reverse = false; + } else { + reverse = toggleReverse ? !reverse : reverse; + } + currentSort = newSort; + players = reverse ? players.sortReversed(compare) : players.sort(compare); + }); + } + + @override + void initState() { + super.initState(); + players = widget.players; + sort(_SortingTypes.score); + } + + @override + void didUpdateWidget(PlayersList oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.players != widget.players) { + players = widget.players; + sort(_SortingTypes.score); + } + } + + @override + Widget build(BuildContext context) { + final double eloWidth = max(MediaQuery.sizeOf(context).width * 0.2, 100); + final double scoreWidth = max(MediaQuery.sizeOf(context).width * 0.15, 70); + + return SliverList.builder( + itemCount: players.length + 1, + itemBuilder: (context, index) { + if (widget._isLoading) { + return ShimmerLoading( + isLoading: true, + child: Container( + height: 50, + decoration: BoxDecoration( + color: index.isEven + ? Theme.of(context).colorScheme.surfaceContainerLow + : Theme.of(context).colorScheme.surfaceContainerHigh, + ), + ), + ); + } + + if (index == 0) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: _TableTitleCell( + title: Text(context.l10n.player, style: _kHeaderTextStyle), + onTap: () => sort( + _SortingTypes.player, + toggleReverse: currentSort == _SortingTypes.player, + ), + sortIcon: (currentSort == _SortingTypes.player) + ? (reverse + ? Icons.keyboard_arrow_up + : Icons.keyboard_arrow_down) + : null, + ), + ), + SizedBox( + width: eloWidth, + child: _TableTitleCell( + title: const Text('Elo', style: _kHeaderTextStyle), + onTap: () => sort( + _SortingTypes.elo, + toggleReverse: currentSort == _SortingTypes.elo, + ), + sortIcon: (currentSort == _SortingTypes.elo) + ? (reverse + ? Icons.keyboard_arrow_up + : Icons.keyboard_arrow_down) + : null, + ), + ), + SizedBox( + width: scoreWidth, + child: _TableTitleCell( + title: Text( + context.l10n.broadcastScore, + style: _kHeaderTextStyle, + ), + onTap: () => sort( + _SortingTypes.score, + toggleReverse: currentSort == _SortingTypes.score, + ), + sortIcon: (currentSort == _SortingTypes.score) + ? (reverse + ? Icons.keyboard_arrow_up + : Icons.keyboard_arrow_down) + : null, + ), + ), + ], + ); + } else { + final player = players[index - 1]; + return Container( + decoration: BoxDecoration( + color: index.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, + ), + ), + ), + 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(), + ), + ), + ], + ), + ); + } + }, + ); + } +} + +class _TableTitleCell extends StatelessWidget { + const _TableTitleCell({ + required this.title, + required this.onTap, + this.sortIcon, + }); + + final Widget title; + final void Function() onTap; + final IconData? sortIcon; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 44, + child: GestureDetector( + onTap: onTap, + child: Padding( + padding: _kTableRowPadding, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: title, + ), + if (sortIcon != null) + Text( + String.fromCharCode(sortIcon!.codePoint), + style: _kHeaderTextStyle.copyWith( + fontSize: 16, + fontFamily: sortIcon!.fontFamily, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/view/broadcast/broadcast_round_screen.dart b/lib/src/view/broadcast/broadcast_round_screen.dart index 48445e1465..476f16f566 100644 --- a/lib/src/view/broadcast/broadcast_round_screen.dart +++ b/lib/src/view/broadcast/broadcast_round_screen.dart @@ -12,6 +12,7 @@ import 'package:lichess_mobile/src/styles/styles.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/broadcast_players_tab.dart'; import 'package:lichess_mobile/src/widgets/adaptive_bottom_sheet.dart'; import 'package:lichess_mobile/src/widgets/bottom_bar.dart'; import 'package:lichess_mobile/src/widgets/buttons.dart'; @@ -27,7 +28,7 @@ class BroadcastRoundScreen extends ConsumerStatefulWidget { _BroadcastRoundScreenState createState() => _BroadcastRoundScreenState(); } -enum _CupertinoView { overview, boards } +enum _CupertinoView { overview, boards, players } class _BroadcastRoundScreenState extends ConsumerState with SingleTickerProviderStateMixin { @@ -41,7 +42,7 @@ class _BroadcastRoundScreenState extends ConsumerState @override void initState() { super.initState(); - _tabController = TabController(initialIndex: 0, length: 2, vsync: this); + _tabController = TabController(initialIndex: 0, length: 3, vsync: this); _selectedTournamentId = widget.broadcast.tour.id; _selectedRoundId = widget.broadcast.roundToLinkId; } @@ -113,6 +114,7 @@ class _BroadcastRoundScreenState extends ConsumerState children: { _CupertinoView.overview: Text(context.l10n.broadcastOverview), _CupertinoView.boards: Text(context.l10n.broadcastBoards), + _CupertinoView.players: Text(context.l10n.players), }, onValueChanged: (_CupertinoView? view) { if (view != null) { @@ -132,21 +134,28 @@ class _BroadcastRoundScreenState extends ConsumerState child: Column( children: [ Expanded( - child: selectedTab == _CupertinoView.overview - ? _TabView( - cupertinoTabSwitcher: tabSwitcher, - sliver: BroadcastOverviewTab( - broadcast: widget.broadcast, - tournamentId: _selectedTournamentId, - ), - ) - : _TabView( - cupertinoTabSwitcher: tabSwitcher, - sliver: BroadcastBoardsTab( - roundId: _selectedRoundId ?? - tournament.defaultRoundId, - ), + child: switch (selectedTab) { + _CupertinoView.overview => _TabView( + cupertinoTabSwitcher: tabSwitcher, + sliver: BroadcastOverviewTab( + broadcast: widget.broadcast, + tournamentId: _selectedTournamentId, ), + ), + _CupertinoView.boards => _TabView( + cupertinoTabSwitcher: tabSwitcher, + sliver: BroadcastBoardsTab( + roundId: + _selectedRoundId ?? tournament.defaultRoundId, + ), + ), + _CupertinoView.players => _TabView( + cupertinoTabSwitcher: tabSwitcher, + sliver: BroadcastPlayersTab( + tournamentId: _selectedTournamentId, + ), + ), + }, ), _BottomBar( tournament: tournament, @@ -171,6 +180,7 @@ class _BroadcastRoundScreenState extends ConsumerState tabs: [ Tab(text: context.l10n.broadcastOverview), Tab(text: context.l10n.broadcastBoards), + Tab(text: context.l10n.players), ], ), ), @@ -188,6 +198,11 @@ class _BroadcastRoundScreenState extends ConsumerState roundId: _selectedRoundId ?? tournament.defaultRoundId, ), ), + _TabView( + sliver: BroadcastPlayersTab( + tournamentId: _selectedTournamentId, + ), + ), ], ), bottomNavigationBar: _BottomBar( @@ -222,11 +237,6 @@ class _TabView extends StatelessWidget { @override Widget build(BuildContext context) { - final edgeInsets = MediaQuery.paddingOf(context) - - (cupertinoTabSwitcher != null - ? EdgeInsets.only(top: MediaQuery.paddingOf(context).top) - : EdgeInsets.zero) + - Styles.bodyPadding; return Shimmer( child: CustomScrollView( slivers: [ @@ -236,10 +246,7 @@ class _TabView extends StatelessWidget { EdgeInsets.only(top: MediaQuery.paddingOf(context).top), sliver: SliverToBoxAdapter(child: cupertinoTabSwitcher), ), - SliverPadding( - padding: edgeInsets, - sliver: sliver, - ), + sliver, ], ), ); diff --git a/lib/src/view/user/perf_stats_screen.dart b/lib/src/view/user/perf_stats_screen.dart index 9db550fd7d..b26f90f383 100644 --- a/lib/src/view/user/perf_stats_screen.dart +++ b/lib/src/view/user/perf_stats_screen.dart @@ -17,7 +17,6 @@ import 'package:lichess_mobile/src/model/game/game_repository.dart'; import 'package:lichess_mobile/src/model/user/user.dart'; import 'package:lichess_mobile/src/model/user/user_repository_providers.dart'; import 'package:lichess_mobile/src/network/http.dart'; -import 'package:lichess_mobile/src/styles/lichess_icons.dart'; import 'package:lichess_mobile/src/styles/styles.dart'; import 'package:lichess_mobile/src/utils/duration.dart'; import 'package:lichess_mobile/src/utils/l10n_context.dart'; @@ -31,6 +30,7 @@ import 'package:lichess_mobile/src/widgets/feedback.dart'; import 'package:lichess_mobile/src/widgets/list.dart'; import 'package:lichess_mobile/src/widgets/platform.dart'; import 'package:lichess_mobile/src/widgets/platform_scaffold.dart'; +import 'package:lichess_mobile/src/widgets/progression_widget.dart'; import 'package:lichess_mobile/src/widgets/rating.dart'; import 'package:lichess_mobile/src/widgets/stat_card.dart'; import 'package:lichess_mobile/src/widgets/user_full_name.dart'; @@ -213,7 +213,7 @@ class _Body extends ConsumerWidget { context.l10n .perfStatProgressOverLastXGames('12') .replaceAll(':', ''), - child: _ProgressionWidget(data.progress), + child: ProgressionWidget(data.progress), ), StatCardRow([ if (data.rank != null) @@ -424,49 +424,6 @@ class _Body extends ConsumerWidget { } } -class _ProgressionWidget extends StatelessWidget { - final int progress; - - const _ProgressionWidget(this.progress); - - @override - Widget build(BuildContext context) { - const progressionFontSize = 20.0; - - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (progress != 0) ...[ - Icon( - progress > 0 - ? LichessIcons.arrow_full_upperright - : LichessIcons.arrow_full_lowerright, - color: progress > 0 - ? context.lichessColors.good - : context.lichessColors.error, - ), - Text( - progress.abs().toString(), - style: TextStyle( - color: progress > 0 - ? context.lichessColors.good - : context.lichessColors.error, - fontSize: progressionFontSize, - ), - ), - ] else - Text( - '0', - style: TextStyle( - color: textShade(context, _customOpacity), - fontSize: progressionFontSize, - ), - ), - ], - ); - } -} - class _UserGameWidget extends StatelessWidget { final UserPerfGame? game; diff --git a/lib/src/widgets/progression_widget.dart b/lib/src/widgets/progression_widget.dart new file mode 100644 index 0000000000..544c48ecb1 --- /dev/null +++ b/lib/src/widgets/progression_widget.dart @@ -0,0 +1,48 @@ +import 'package:flutter/widgets.dart'; +import 'package:lichess_mobile/src/styles/lichess_icons.dart'; +import 'package:lichess_mobile/src/styles/styles.dart'; + +const _customOpacity = 0.6; + +class ProgressionWidget extends StatelessWidget { + final int progress; + final double fontSize; + + const ProgressionWidget(this.progress, {this.fontSize = 20}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (progress != 0) ...[ + Icon( + progress > 0 + ? LichessIcons.arrow_full_upperright + : LichessIcons.arrow_full_lowerright, + size: fontSize, + color: progress > 0 + ? context.lichessColors.good + : context.lichessColors.error, + ), + Text( + progress.abs().toString(), + style: TextStyle( + color: progress > 0 + ? context.lichessColors.good + : context.lichessColors.error, + fontSize: fontSize, + ), + ), + ] else + Text( + '0', + style: TextStyle( + color: textShade(context, _customOpacity), + fontSize: fontSize, + ), + ), + ], + ); + } +}