diff --git a/lib/src/model/user/user.dart b/lib/src/model/user/user.dart index aa442d99a8..eb92682a66 100644 --- a/lib/src/model/user/user.dart +++ b/lib/src/model/user/user.dart @@ -358,3 +358,25 @@ class UserPerfGame with _$UserPerfGame { ) : null; } + +@immutable +class UserRatingHistoryPerf { + final String perf; + final IList points; + + const UserRatingHistoryPerf({ + required this.perf, + required this.points, + }); +} + +@immutable +class UserRatingHistoryPoint { + final DateTime date; + final int elo; + + const UserRatingHistoryPoint({ + required this.date, + required this.elo, + }); +} diff --git a/lib/src/model/user/user_repository.dart b/lib/src/model/user/user_repository.dart index a065a98387..2325b6bca0 100644 --- a/lib/src/model/user/user_repository.dart +++ b/lib/src/model/user/user_repository.dart @@ -74,6 +74,37 @@ class UserRepository { mapper: _autocompleteFromJson, ); } + + Future> getRatingHistory(UserId id) { + return client.readJsonList( + Uri.parse('$kLichessHost/api/user/$id/rating-history'), + mapper: _ratingHistoryFromJson, + ); + } +} + +UserRatingHistoryPerf _ratingHistoryFromJson( + Map json, +) => + _ratingHistoryFromPick(pick(json).required()); + +UserRatingHistoryPerf _ratingHistoryFromPick( + RequiredPick perf, +) { + return UserRatingHistoryPerf( + perf: perf('name').asStringOrThrow(), + points: perf('points').asListOrThrow((point) { + final values = point.asListOrThrow((point) => point.asIntOrThrow()); + return UserRatingHistoryPoint( + date: DateTime( + values[0], + values[1], + values[2], + ), + elo: values[3], + ); + }).toIList(), + ); } // -- diff --git a/lib/src/model/user/user_repository_providers.dart b/lib/src/model/user/user_repository_providers.dart index e8246a3b4e..0bf13afa7e 100644 --- a/lib/src/model/user/user_repository_providers.dart +++ b/lib/src/model/user/user_repository_providers.dart @@ -103,3 +103,13 @@ Future> autoCompleteUser( (client) => UserRepository(client).autocompleteUser(term), ); } + +@riverpod +Future> userRatingHistory( + UserRatingHistoryRef ref, { + required UserId id, +}) async { + return ref.withClient( + (client) => UserRepository(client).getRatingHistory(id), + ); +} diff --git a/lib/src/view/user/perf_stats_screen.dart b/lib/src/view/user/perf_stats_screen.dart index 1bc0173c9b..987e276a1d 100644 --- a/lib/src/view/user/perf_stats_screen.dart +++ b/lib/src/view/user/perf_stats_screen.dart @@ -1,8 +1,10 @@ import 'package:collection/collection.dart'; import 'package:dartchess/dartchess.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:intl/intl.dart'; @@ -108,233 +110,241 @@ class _Body extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final perfStats = ref.watch(userPerfStatsProvider(id: user.id, perf: perf)); + final score = ref.watch(userRatingHistoryProvider(id: user.id)); final loggedInUser = ref.watch(authSessionProvider); - const statGroupSpace = SizedBox(height: 15.0); const subStatSpace = SizedBox(height: 10); return perfStats.when( data: (data) { - return SafeArea( - child: ListView( - padding: Styles.verticalBodyPadding, - scrollDirection: Axis.vertical, - children: [ - Padding( - padding: Styles.horizontalBodyPadding, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - Text( - '${context.l10n.rating} ', - style: Styles.sectionTitle, - ), - RatingWidget( - rating: data.rating, - deviation: data.deviation, - provisional: data.provisional, - style: _mainValueStyle, - ), - ], + return score.when( + data: (history) { + return SafeArea( + child: ListView( + padding: Styles.bodyPadding, + scrollDirection: Axis.vertical, + children: [ + AspectRatio( + aspectRatio: 16 / 9, + child: _EloChart( + history + .where((element) => element.perf == perf.title) + .first, ), - if (data.percentile != null) + ), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ Text( - (loggedInUser != null && - loggedInUser.user.id == user.id) - ? context.l10n - .youAreBetterThanPercentOfPerfTypePlayers( - '${data.percentile!.toStringAsFixed(2)}%', - perf.title, - ) - : context.l10n - .userIsBetterThanPercentOfPerfTypePlayers( - user.username, - '${data.percentile!.toStringAsFixed(2)}%', - perf.title, - ), - style: TextStyle(color: textShade(context, 0.7)), + '${context.l10n.rating} ', + style: Styles.sectionTitle, ), - ], - ), - ), - subStatSpace, - // The number '12' here is not arbitrary, since the API returns the progression for the last 12 games (as far as I know). - StatCard( - context.l10n - .perfStatProgressOverLastXGames('12') - .replaceAll(':', ''), - padding: Styles.horizontalBodyPadding, - child: _ProgressionWidget(data.progress), - ), - StatCardRow([ - StatCard( - context.l10n.rank, - value: data.rank == null - ? '?' - : NumberFormat.decimalPattern(Intl.getCurrentLocale()) - .format(data.rank), - ), - StatCard( - context.l10n - .perfStatRatingDeviation('') - .replaceAll(': .', ''), - value: data.deviation.toStringAsFixed(2), - ), - ]), - StatCardRow([ - StatCard( - context.l10n.perfStatHighestRating('').replaceAll(':', ''), - child: _RatingWidget( - data.highestRating, - data.highestRatingGame, - LichessColors.good, - ), - ), - StatCard( - context.l10n.perfStatLowestRating('').replaceAll(':', ''), - child: _RatingWidget( - data.lowestRating, - data.lowestRatingGame, - LichessColors.red, + RatingWidget( + rating: data.rating, + deviation: data.deviation, + provisional: data.provisional, + style: _mainValueStyle, + ), + ], ), - ), - ]), - statGroupSpace, - Padding( - padding: Styles.horizontalBodyPadding, - child: Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ + if (data.percentile != null) Text( - '${context.l10n.perfStatTotalGames} '.localizeNumbers(), - style: Styles.sectionTitle, + (loggedInUser != null && loggedInUser.user.id == user.id) + ? context.l10n + .youAreBetterThanPercentOfPerfTypePlayers( + '${data.percentile!.toStringAsFixed(2)}%', + perf.title, + ) + : context.l10n + .userIsBetterThanPercentOfPerfTypePlayers( + user.username, + '${data.percentile!.toStringAsFixed(2)}%', + perf.title, + ), + style: TextStyle(color: textShade(context, 0.7)), ), - Text( - data.totalGames.toString().localizeNumbers(), - style: _mainValueStyle, - ), - ], - ), - ), - subStatSpace, - StatCardRow([ - StatCard( - context.l10n.wins, - child: _PercentageValueWidget( - data.wonGames, - data.totalGames, - color: LichessColors.good, - ), - ), - StatCard( - context.l10n.draws, - child: _PercentageValueWidget( - data.drawnGames, - data.totalGames, - color: textShade(context, _customOpacity), - isShaded: true, + subStatSpace, + // The number '12' here is not arbitrary, since the API returns the progression for the last 12 games (as far as I know). + StatCard( + context.l10n + .perfStatProgressOverLastXGames('12') + .replaceAll(':', ''), + child: _ProgressionWidget(data.progress), ), - ), - StatCard( - context.l10n.losses, - child: _PercentageValueWidget( - data.lostGames, - data.totalGames, - color: LichessColors.red, + StatCardRow([ + StatCard( + context.l10n.rank, + value: data.rank == null + ? '?' + : NumberFormat.decimalPattern(Intl.getCurrentLocale()) + .format(data.rank), + ), + StatCard( + context.l10n + .perfStatRatingDeviation('') + .replaceAll(': .', ''), + value: data.deviation.toStringAsFixed(2), + ), + ]), + StatCardRow([ + StatCard( + context.l10n + .perfStatHighestRating('') + .replaceAll(':', ''), + child: _RatingWidget( + data.highestRating, + data.highestRatingGame, + LichessColors.good, + ), + ), + StatCard( + context.l10n.perfStatLowestRating('').replaceAll(':', ''), + child: _RatingWidget( + data.lowestRating, + data.lowestRatingGame, + LichessColors.red, + ), + ), + ]), + statGroupSpace, + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + '${context.l10n.perfStatTotalGames} '.localizeNumbers(), + style: Styles.sectionTitle, + ), + Text( + data.totalGames.toString().localizeNumbers(), + style: _mainValueStyle, + ), + ], ), - ), - ]), - StatCardRow([ - StatCard( - context.l10n.rated, - child: _PercentageValueWidget( - data.ratedGames, - data.totalGames, + subStatSpace, + StatCardRow([ + StatCard( + context.l10n.wins, + child: _PercentageValueWidget( + data.wonGames, + data.totalGames, + color: LichessColors.good, + ), + ), + StatCard( + context.l10n.draws, + child: _PercentageValueWidget( + data.drawnGames, + data.totalGames, + color: textShade(context, _customOpacity), + isShaded: true, + ), + ), + StatCard( + context.l10n.losses, + child: _PercentageValueWidget( + data.lostGames, + data.totalGames, + color: LichessColors.red, + ), + ), + ]), + StatCardRow([ + StatCard( + context.l10n.rated, + child: _PercentageValueWidget( + data.ratedGames, + data.totalGames, + ), + ), + StatCard( + context.l10n.tournament, + child: _PercentageValueWidget( + data.tournamentGames, + data.totalGames, + ), + ), + StatCard( + context.l10n.perfStatBerserkedGames.replaceAll( + ' ${context.l10n.games.toLowerCase()}', ''), + child: _PercentageValueWidget( + data.berserkGames, + data.totalGames, + ), + ), + StatCard( + context.l10n.perfStatDisconnections, + child: _PercentageValueWidget( + data.disconnections, + data.totalGames, + ), + ), + ]), + StatCardRow([ + StatCard( + context.l10n.averageOpponent, + value: data.avgOpponent == null + ? '?' + : data.avgOpponent.toString(), + ), + StatCard( + context.l10n.perfStatTimeSpentPlaying, + value: data.timePlayed + .toDaysHoursMinutes(AppLocalizations.of(context)), + ), + ]), + StatCard( + context.l10n.perfStatWinningStreak, + child: _StreakWidget( + data.maxWinStreak, + data.curWinStreak, + color: LichessColors.good, + ), ), - ), - StatCard( - context.l10n.tournament, - child: _PercentageValueWidget( - data.tournamentGames, - data.totalGames, + StatCard( + context.l10n.perfStatLosingStreak, + child: _StreakWidget( + data.maxLossStreak, + data.curLossStreak, + color: LichessColors.red, + ), ), - ), - StatCard( - context.l10n.perfStatBerserkedGames - .replaceAll(' ${context.l10n.games.toLowerCase()}', ''), - child: _PercentageValueWidget( - data.berserkGames, - data.totalGames, + StatCard( + context.l10n.perfStatGamesInARow, + child: + _StreakWidget(data.maxPlayStreak, data.curPlayStreak), ), - ), - StatCard( - context.l10n.perfStatDisconnections, - child: _PercentageValueWidget( - data.disconnections, - data.totalGames, + StatCard( + context.l10n.perfStatMaxTimePlaying, + child: + _StreakWidget(data.maxTimeStreak, data.curTimeStreak), ), - ), - ]), - StatCardRow([ - StatCard( - context.l10n.averageOpponent, - value: data.avgOpponent == null - ? '?' - : data.avgOpponent.toString(), - ), - StatCard( - context.l10n.perfStatTimeSpentPlaying, - value: data.timePlayed - .toDaysHoursMinutes(AppLocalizations.of(context)), - ), - ]), - StatCard( - padding: Styles.horizontalBodyPadding, - context.l10n.perfStatWinningStreak, - child: _StreakWidget( - data.maxWinStreak, - data.curWinStreak, - color: LichessColors.good, - ), - ), - StatCard( - padding: Styles.horizontalBodyPadding, - context.l10n.perfStatLosingStreak, - child: _StreakWidget( - data.maxLossStreak, - data.curLossStreak, - color: LichessColors.red, - ), - ), - StatCard( - padding: Styles.horizontalBodyPadding, - context.l10n.perfStatGamesInARow, - child: _StreakWidget(data.maxPlayStreak, data.curPlayStreak), - ), - StatCard( - padding: Styles.horizontalBodyPadding, - context.l10n.perfStatMaxTimePlaying, - child: _StreakWidget(data.maxTimeStreak, data.curTimeStreak), + if (data.bestWins != null && data.bestWins!.isNotEmpty) ...[ + statGroupSpace, + _GameListWidget( + games: data.bestWins!, + perf: perf, + user: user, + header: Text( + context.l10n.perfStatBestRated, + style: Styles.sectionTitle, + ), + ), + ], + ], ), - if (data.bestWins != null && data.bestWins!.isNotEmpty) ...[ - statGroupSpace, - _GameListWidget( - games: data.bestWins!, - perf: perf, - user: user, - header: Text( - context.l10n.perfStatBestRated, - style: Styles.sectionTitle, - ), - ), - ], - ], - ), + ); + }, + error: (error, stackTrace) { + debugPrint( + 'SEVERE: [PerfStatsScreen] could not load data; $error\n$stackTrace', + ); + return const Text('Could not load graph'); + }, + loading: () { + return const CenterLoadingIndicator(); + }, ); }, error: (error, stackTrace) { @@ -655,3 +665,66 @@ class _GameListTile extends StatelessWidget { ); } } + +class _EloChart extends StatelessWidget { + final UserRatingHistoryPerf value; + + const _EloChart(this.value); + + @override + Widget build(BuildContext context) { + return LineChart( + LineChartData( + lineBarsData: [ + LineChartBarData( + spots: value.points + .map( + (element) => FlSpot( + element.date.millisecondsSinceEpoch.toDouble(), + element.elo.toDouble(), + ), + ) + .toList(), + dotData: const FlDotData(show: false), + ), + ], + titlesData: FlTitlesData( + rightTitles: + const AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: + const AxisTitles(sideTitles: SideTitles(showTitles: false)), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: false, + getTitlesWidget: bottomTitleWidgets, + ), + ), + leftTitles: const AxisTitles( + sideTitles: SideTitles( + showTitles: false, + getTitlesWidget: leftTitleWidgets, + ), + ), + ), + gridData: const FlGridData(show: false), + ), + ); + } + + Widget bottomTitleWidgets(double value, TitleMeta meta) { + final dateFormatted = DateFormat('dd/MM') + .format(DateTime.fromMillisecondsSinceEpoch(value.toInt())); + + return SideTitleWidget( + axisSide: meta.axisSide, + child: Text(dateFormatted), + ); + } +} + +Widget leftTitleWidgets(double value, TitleMeta meta) { + return SideTitleWidget( + axisSide: meta.axisSide, + child: Text(value.toInt().toString()), + ); +} diff --git a/lib/src/widgets/stat_card.dart b/lib/src/widgets/stat_card.dart index 18d3bd57ae..04527305c3 100644 --- a/lib/src/widgets/stat_card.dart +++ b/lib/src/widgets/stat_card.dart @@ -79,16 +79,13 @@ class StatCardRow extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: Styles.horizontalBodyPadding, - child: IntrinsicHeight( - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: _divideRow(cards) - .map((e) => Expanded(child: e)) - .toList(growable: false), - ), + return IntrinsicHeight( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: _divideRow(cards) + .map((e) => Expanded(child: e)) + .toList(growable: false), ), ); }