From 9b899d6a1575eeda4154b52d715bcfa0e6168c33 Mon Sep 17 00:00:00 2001 From: Hajar ELHILALI Date: Thu, 28 May 2026 22:26:51 +0200 Subject: [PATCH 1/2] add stats, win rate & h2h sections to opponent profile --- .../game/chat/game_chat_message.freezed.dart | 6 +- .../game/chat/game_chat_notifier.g.dart | 2 +- .../data/models/head_to_head.dart | 16 + .../data/models/head_to_head.freezed.dart | 283 ++++++++++++++++++ .../data/models/head_to_head.g.dart | 20 ++ .../data/repositories/user_repository.dart | 16 + .../opponent_profile_screen.dart | 9 + .../providers/head_to_head_provider.dart | 10 + .../providers/head_to_head_provider.g.dart | 84 ++++++ .../opponent_profile_provider.g.dart | 2 +- .../providers/opponent_stats_provider.dart | 10 + .../providers/opponent_stats_provider.g.dart | 85 ++++++ .../opponent_win_trend_provider.dart | 10 + .../opponent_win_trend_provider.g.dart | 87 ++++++ .../widgets/opponent_h2h_section.dart | 233 ++++++++++++++ .../widgets/opponent_stats_section.dart | 163 ++++++++++ .../widgets/opponent_win_rate_section.dart | 229 ++++++++++++++ 17 files changed, 1262 insertions(+), 3 deletions(-) create mode 100644 client/deepdame/lib/features/opponent_profile/data/models/head_to_head.dart create mode 100644 client/deepdame/lib/features/opponent_profile/data/models/head_to_head.freezed.dart create mode 100644 client/deepdame/lib/features/opponent_profile/data/models/head_to_head.g.dart create mode 100644 client/deepdame/lib/features/opponent_profile/presentation/providers/head_to_head_provider.dart create mode 100644 client/deepdame/lib/features/opponent_profile/presentation/providers/head_to_head_provider.g.dart create mode 100644 client/deepdame/lib/features/opponent_profile/presentation/providers/opponent_stats_provider.dart create mode 100644 client/deepdame/lib/features/opponent_profile/presentation/providers/opponent_stats_provider.g.dart create mode 100644 client/deepdame/lib/features/opponent_profile/presentation/providers/opponent_win_trend_provider.dart create mode 100644 client/deepdame/lib/features/opponent_profile/presentation/providers/opponent_win_trend_provider.g.dart create mode 100644 client/deepdame/lib/features/opponent_profile/presentation/widgets/opponent_h2h_section.dart create mode 100644 client/deepdame/lib/features/opponent_profile/presentation/widgets/opponent_stats_section.dart create mode 100644 client/deepdame/lib/features/opponent_profile/presentation/widgets/opponent_win_rate_section.dart diff --git a/client/deepdame/lib/features/game/chat/game_chat_message.freezed.dart b/client/deepdame/lib/features/game/chat/game_chat_message.freezed.dart index 85394d9..8063032 100644 --- a/client/deepdame/lib/features/game/chat/game_chat_message.freezed.dart +++ b/client/deepdame/lib/features/game/chat/game_chat_message.freezed.dart @@ -15,7 +15,9 @@ T _$identity(T value) => value; /// @nodoc mixin _$GameChatMessage { - String get sender; String get content;@JsonKey(name: 'timestamp') DateTime get createdAt; String get gameId; + String get sender; String get content;// Freezed processes @JsonKey on constructor params; analyzer doesn't know. +// ignore: invalid_annotation_target +@JsonKey(name: 'timestamp') DateTime get createdAt; String get gameId; /// Create a copy of GameChatMessage /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -217,6 +219,8 @@ class _GameChatMessage implements GameChatMessage { @override final String sender; @override final String content; +// Freezed processes @JsonKey on constructor params; analyzer doesn't know. +// ignore: invalid_annotation_target @override@JsonKey(name: 'timestamp') final DateTime createdAt; @override final String gameId; diff --git a/client/deepdame/lib/features/game/chat/game_chat_notifier.g.dart b/client/deepdame/lib/features/game/chat/game_chat_notifier.g.dart index eec27d2..9d7f456 100644 --- a/client/deepdame/lib/features/game/chat/game_chat_notifier.g.dart +++ b/client/deepdame/lib/features/game/chat/game_chat_notifier.g.dart @@ -155,7 +155,7 @@ final class GameChatServiceProvider } } -String _$gameChatServiceHash() => r'ab14af831968aa29a3324645abb016c148b4a140'; +String _$gameChatServiceHash() => r'242306b48042ac92f850389f6a25caa066ddcf3c'; final class GameChatServiceFamily extends $Family with $FunctionalFamilyOverride, String> { diff --git a/client/deepdame/lib/features/opponent_profile/data/models/head_to_head.dart b/client/deepdame/lib/features/opponent_profile/data/models/head_to_head.dart new file mode 100644 index 0000000..377c40d --- /dev/null +++ b/client/deepdame/lib/features/opponent_profile/data/models/head_to_head.dart @@ -0,0 +1,16 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'head_to_head.freezed.dart'; +part 'head_to_head.g.dart'; + +@freezed +abstract class HeadToHead with _$HeadToHead { + const factory HeadToHead({ + required int myWins, + required int theirWins, + required int totalGames, + }) = _HeadToHead; + + factory HeadToHead.fromJson(Map json) => + _$HeadToHeadFromJson(json); +} diff --git a/client/deepdame/lib/features/opponent_profile/data/models/head_to_head.freezed.dart b/client/deepdame/lib/features/opponent_profile/data/models/head_to_head.freezed.dart new file mode 100644 index 0000000..a7bbbb1 --- /dev/null +++ b/client/deepdame/lib/features/opponent_profile/data/models/head_to_head.freezed.dart @@ -0,0 +1,283 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'head_to_head.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$HeadToHead { + + int get myWins; int get theirWins; int get totalGames; +/// Create a copy of HeadToHead +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$HeadToHeadCopyWith get copyWith => _$HeadToHeadCopyWithImpl(this as HeadToHead, _$identity); + + /// Serializes this HeadToHead to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is HeadToHead&&(identical(other.myWins, myWins) || other.myWins == myWins)&&(identical(other.theirWins, theirWins) || other.theirWins == theirWins)&&(identical(other.totalGames, totalGames) || other.totalGames == totalGames)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,myWins,theirWins,totalGames); + +@override +String toString() { + return 'HeadToHead(myWins: $myWins, theirWins: $theirWins, totalGames: $totalGames)'; +} + + +} + +/// @nodoc +abstract mixin class $HeadToHeadCopyWith<$Res> { + factory $HeadToHeadCopyWith(HeadToHead value, $Res Function(HeadToHead) _then) = _$HeadToHeadCopyWithImpl; +@useResult +$Res call({ + int myWins, int theirWins, int totalGames +}); + + + + +} +/// @nodoc +class _$HeadToHeadCopyWithImpl<$Res> + implements $HeadToHeadCopyWith<$Res> { + _$HeadToHeadCopyWithImpl(this._self, this._then); + + final HeadToHead _self; + final $Res Function(HeadToHead) _then; + +/// Create a copy of HeadToHead +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? myWins = null,Object? theirWins = null,Object? totalGames = null,}) { + return _then(_self.copyWith( +myWins: null == myWins ? _self.myWins : myWins // ignore: cast_nullable_to_non_nullable +as int,theirWins: null == theirWins ? _self.theirWins : theirWins // ignore: cast_nullable_to_non_nullable +as int,totalGames: null == totalGames ? _self.totalGames : totalGames // ignore: cast_nullable_to_non_nullable +as int, + )); +} + +} + + +/// Adds pattern-matching-related methods to [HeadToHead]. +extension HeadToHeadPatterns on HeadToHead { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _HeadToHead value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _HeadToHead() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _HeadToHead value) $default,){ +final _that = this; +switch (_that) { +case _HeadToHead(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _HeadToHead value)? $default,){ +final _that = this; +switch (_that) { +case _HeadToHead() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( int myWins, int theirWins, int totalGames)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _HeadToHead() when $default != null: +return $default(_that.myWins,_that.theirWins,_that.totalGames);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( int myWins, int theirWins, int totalGames) $default,) {final _that = this; +switch (_that) { +case _HeadToHead(): +return $default(_that.myWins,_that.theirWins,_that.totalGames);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( int myWins, int theirWins, int totalGames)? $default,) {final _that = this; +switch (_that) { +case _HeadToHead() when $default != null: +return $default(_that.myWins,_that.theirWins,_that.totalGames);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _HeadToHead implements HeadToHead { + const _HeadToHead({required this.myWins, required this.theirWins, required this.totalGames}); + factory _HeadToHead.fromJson(Map json) => _$HeadToHeadFromJson(json); + +@override final int myWins; +@override final int theirWins; +@override final int totalGames; + +/// Create a copy of HeadToHead +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$HeadToHeadCopyWith<_HeadToHead> get copyWith => __$HeadToHeadCopyWithImpl<_HeadToHead>(this, _$identity); + +@override +Map toJson() { + return _$HeadToHeadToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _HeadToHead&&(identical(other.myWins, myWins) || other.myWins == myWins)&&(identical(other.theirWins, theirWins) || other.theirWins == theirWins)&&(identical(other.totalGames, totalGames) || other.totalGames == totalGames)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,myWins,theirWins,totalGames); + +@override +String toString() { + return 'HeadToHead(myWins: $myWins, theirWins: $theirWins, totalGames: $totalGames)'; +} + + +} + +/// @nodoc +abstract mixin class _$HeadToHeadCopyWith<$Res> implements $HeadToHeadCopyWith<$Res> { + factory _$HeadToHeadCopyWith(_HeadToHead value, $Res Function(_HeadToHead) _then) = __$HeadToHeadCopyWithImpl; +@override @useResult +$Res call({ + int myWins, int theirWins, int totalGames +}); + + + + +} +/// @nodoc +class __$HeadToHeadCopyWithImpl<$Res> + implements _$HeadToHeadCopyWith<$Res> { + __$HeadToHeadCopyWithImpl(this._self, this._then); + + final _HeadToHead _self; + final $Res Function(_HeadToHead) _then; + +/// Create a copy of HeadToHead +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? myWins = null,Object? theirWins = null,Object? totalGames = null,}) { + return _then(_HeadToHead( +myWins: null == myWins ? _self.myWins : myWins // ignore: cast_nullable_to_non_nullable +as int,theirWins: null == theirWins ? _self.theirWins : theirWins // ignore: cast_nullable_to_non_nullable +as int,totalGames: null == totalGames ? _self.totalGames : totalGames // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + +// dart format on diff --git a/client/deepdame/lib/features/opponent_profile/data/models/head_to_head.g.dart b/client/deepdame/lib/features/opponent_profile/data/models/head_to_head.g.dart new file mode 100644 index 0000000..bb53005 --- /dev/null +++ b/client/deepdame/lib/features/opponent_profile/data/models/head_to_head.g.dart @@ -0,0 +1,20 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'head_to_head.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_HeadToHead _$HeadToHeadFromJson(Map json) => _HeadToHead( + myWins: (json['myWins'] as num).toInt(), + theirWins: (json['theirWins'] as num).toInt(), + totalGames: (json['totalGames'] as num).toInt(), +); + +Map _$HeadToHeadToJson(_HeadToHead instance) => + { + 'myWins': instance.myWins, + 'theirWins': instance.theirWins, + 'totalGames': instance.totalGames, + }; diff --git a/client/deepdame/lib/features/opponent_profile/data/repositories/user_repository.dart b/client/deepdame/lib/features/opponent_profile/data/repositories/user_repository.dart index 0c22c46..e7d5e88 100644 --- a/client/deepdame/lib/features/opponent_profile/data/repositories/user_repository.dart +++ b/client/deepdame/lib/features/opponent_profile/data/repositories/user_repository.dart @@ -1,4 +1,6 @@ import 'package:deepdame/core/network/dio_client.dart'; +import 'package:deepdame/features/home/data/models/player_stats.dart'; +import 'package:deepdame/features/opponent_profile/data/models/head_to_head.dart'; import 'package:deepdame/features/opponent_profile/data/models/public_user_profile.dart'; import 'package:dio/dio.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -21,4 +23,18 @@ class UserRepository { ); return PublicUserProfile.fromJson(response.data!); } + + Future getOpponentStats(String userId) async { + final response = await _dio.get>( + '/api/v1/stats/$userId', + ); + return PlayerStats.fromJson(response.data!); + } + + Future getHeadToHead(String opponentId) async { + final response = await _dio.get>( + '/api/v1/stats/head-to-head/$opponentId', + ); + return HeadToHead.fromJson(response.data!); + } } diff --git a/client/deepdame/lib/features/opponent_profile/opponent_profile_screen.dart b/client/deepdame/lib/features/opponent_profile/opponent_profile_screen.dart index 5df81ce..6f9732e 100644 --- a/client/deepdame/lib/features/opponent_profile/opponent_profile_screen.dart +++ b/client/deepdame/lib/features/opponent_profile/opponent_profile_screen.dart @@ -1,6 +1,9 @@ import 'package:deepdame/features/opponent_profile/presentation/providers/opponent_profile_provider.dart'; import 'package:deepdame/features/opponent_profile/presentation/widgets/opponent_app_bar.dart'; +import 'package:deepdame/features/opponent_profile/presentation/widgets/opponent_h2h_section.dart'; import 'package:deepdame/features/opponent_profile/presentation/widgets/opponent_header_section.dart'; +import 'package:deepdame/features/opponent_profile/presentation/widgets/opponent_stats_section.dart'; +import 'package:deepdame/features/opponent_profile/presentation/widgets/opponent_win_rate_section.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -56,6 +59,12 @@ class OpponentProfileScreen extends ConsumerWidget { const SizedBox(height: 40), Center(child: OpponentHeaderSection(profile: profile)), const SizedBox(height: 24), + OpponentStatsSection(userId: userId), + const SizedBox(height: 28), + OpponentWinRateSection(userId: userId), + const SizedBox(height: 28), + OpponentH2hSection(userId: userId, opponentProfile: profile), + const SizedBox(height: 32), ], ), ), diff --git a/client/deepdame/lib/features/opponent_profile/presentation/providers/head_to_head_provider.dart b/client/deepdame/lib/features/opponent_profile/presentation/providers/head_to_head_provider.dart new file mode 100644 index 0000000..5bee4ff --- /dev/null +++ b/client/deepdame/lib/features/opponent_profile/presentation/providers/head_to_head_provider.dart @@ -0,0 +1,10 @@ +import 'package:deepdame/features/opponent_profile/data/models/head_to_head.dart'; +import 'package:deepdame/features/opponent_profile/data/repositories/user_repository.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'head_to_head_provider.g.dart'; + +@riverpod +Future headToHeadData(Ref ref, String userId) { + return ref.read(userRepositoryProvider).getHeadToHead(userId); +} diff --git a/client/deepdame/lib/features/opponent_profile/presentation/providers/head_to_head_provider.g.dart b/client/deepdame/lib/features/opponent_profile/presentation/providers/head_to_head_provider.g.dart new file mode 100644 index 0000000..cc73377 --- /dev/null +++ b/client/deepdame/lib/features/opponent_profile/presentation/providers/head_to_head_provider.g.dart @@ -0,0 +1,84 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'head_to_head_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(headToHeadData) +final headToHeadDataProvider = HeadToHeadDataFamily._(); + +final class HeadToHeadDataProvider + extends + $FunctionalProvider< + AsyncValue, + HeadToHead, + FutureOr + > + with $FutureModifier, $FutureProvider { + HeadToHeadDataProvider._({ + required HeadToHeadDataFamily super.from, + required String super.argument, + }) : super( + retry: null, + name: r'headToHeadDataProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$headToHeadDataHash(); + + @override + String toString() { + return r'headToHeadDataProvider' + '' + '($argument)'; + } + + @$internal + @override + $FutureProviderElement $createElement($ProviderPointer pointer) => + $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + final argument = this.argument as String; + return headToHeadData(ref, argument); + } + + @override + bool operator ==(Object other) { + return other is HeadToHeadDataProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$headToHeadDataHash() => r'a9a92291dfdfa325020d630d49499db188e7befc'; + +final class HeadToHeadDataFamily extends $Family + with $FunctionalFamilyOverride, String> { + HeadToHeadDataFamily._() + : super( + retry: null, + name: r'headToHeadDataProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + HeadToHeadDataProvider call(String userId) => + HeadToHeadDataProvider._(argument: userId, from: this); + + @override + String toString() => r'headToHeadDataProvider'; +} diff --git a/client/deepdame/lib/features/opponent_profile/presentation/providers/opponent_profile_provider.g.dart b/client/deepdame/lib/features/opponent_profile/presentation/providers/opponent_profile_provider.g.dart index e08d973..749b7c0 100644 --- a/client/deepdame/lib/features/opponent_profile/presentation/providers/opponent_profile_provider.g.dart +++ b/client/deepdame/lib/features/opponent_profile/presentation/providers/opponent_profile_provider.g.dart @@ -66,7 +66,7 @@ final class OpponentProfileProvider } } -String _$opponentProfileHash() => r'bc0ae9bf6645a64c7389501e7031ad29ca3279d4'; +String _$opponentProfileHash() => r'cbb014627457a8baf03bf4b990022b619843bfd2'; final class OpponentProfileFamily extends $Family with $FunctionalFamilyOverride, String> { diff --git a/client/deepdame/lib/features/opponent_profile/presentation/providers/opponent_stats_provider.dart b/client/deepdame/lib/features/opponent_profile/presentation/providers/opponent_stats_provider.dart new file mode 100644 index 0000000..85b5f51 --- /dev/null +++ b/client/deepdame/lib/features/opponent_profile/presentation/providers/opponent_stats_provider.dart @@ -0,0 +1,10 @@ +import 'package:deepdame/features/home/data/models/player_stats.dart'; +import 'package:deepdame/features/opponent_profile/data/repositories/user_repository.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'opponent_stats_provider.g.dart'; + +@riverpod +Future opponentStats(Ref ref, String userId) { + return ref.read(userRepositoryProvider).getOpponentStats(userId); +} diff --git a/client/deepdame/lib/features/opponent_profile/presentation/providers/opponent_stats_provider.g.dart b/client/deepdame/lib/features/opponent_profile/presentation/providers/opponent_stats_provider.g.dart new file mode 100644 index 0000000..0742077 --- /dev/null +++ b/client/deepdame/lib/features/opponent_profile/presentation/providers/opponent_stats_provider.g.dart @@ -0,0 +1,85 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'opponent_stats_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(opponentStats) +final opponentStatsProvider = OpponentStatsFamily._(); + +final class OpponentStatsProvider + extends + $FunctionalProvider< + AsyncValue, + PlayerStats, + FutureOr + > + with $FutureModifier, $FutureProvider { + OpponentStatsProvider._({ + required OpponentStatsFamily super.from, + required String super.argument, + }) : super( + retry: null, + name: r'opponentStatsProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$opponentStatsHash(); + + @override + String toString() { + return r'opponentStatsProvider' + '' + '($argument)'; + } + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + final argument = this.argument as String; + return opponentStats(ref, argument); + } + + @override + bool operator ==(Object other) { + return other is OpponentStatsProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$opponentStatsHash() => r'd419babdc708dde0888720fc9d6b0e00c141594f'; + +final class OpponentStatsFamily extends $Family + with $FunctionalFamilyOverride, String> { + OpponentStatsFamily._() + : super( + retry: null, + name: r'opponentStatsProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + OpponentStatsProvider call(String userId) => + OpponentStatsProvider._(argument: userId, from: this); + + @override + String toString() => r'opponentStatsProvider'; +} diff --git a/client/deepdame/lib/features/opponent_profile/presentation/providers/opponent_win_trend_provider.dart b/client/deepdame/lib/features/opponent_profile/presentation/providers/opponent_win_trend_provider.dart new file mode 100644 index 0000000..f43e086 --- /dev/null +++ b/client/deepdame/lib/features/opponent_profile/presentation/providers/opponent_win_trend_provider.dart @@ -0,0 +1,10 @@ +import 'package:deepdame/features/profile/data/models/monthly_win_rate.dart'; +import 'package:deepdame/features/profile/data/repositories/win_trend_repository.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'opponent_win_trend_provider.g.dart'; + +@riverpod +Future> opponentWinTrend(Ref ref, String userId) { + return ref.read(winTrendRepositoryProvider).getWinTrend(userId); +} diff --git a/client/deepdame/lib/features/opponent_profile/presentation/providers/opponent_win_trend_provider.g.dart b/client/deepdame/lib/features/opponent_profile/presentation/providers/opponent_win_trend_provider.g.dart new file mode 100644 index 0000000..b6e2113 --- /dev/null +++ b/client/deepdame/lib/features/opponent_profile/presentation/providers/opponent_win_trend_provider.g.dart @@ -0,0 +1,87 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'opponent_win_trend_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(opponentWinTrend) +final opponentWinTrendProvider = OpponentWinTrendFamily._(); + +final class OpponentWinTrendProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + FutureOr> + > + with + $FutureModifier>, + $FutureProvider> { + OpponentWinTrendProvider._({ + required OpponentWinTrendFamily super.from, + required String super.argument, + }) : super( + retry: null, + name: r'opponentWinTrendProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$opponentWinTrendHash(); + + @override + String toString() { + return r'opponentWinTrendProvider' + '' + '($argument)'; + } + + @$internal + @override + $FutureProviderElement> $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + final argument = this.argument as String; + return opponentWinTrend(ref, argument); + } + + @override + bool operator ==(Object other) { + return other is OpponentWinTrendProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$opponentWinTrendHash() => r'7b0d8e6f8ba6be55035104cd710733f0ad0b658f'; + +final class OpponentWinTrendFamily extends $Family + with $FunctionalFamilyOverride>, String> { + OpponentWinTrendFamily._() + : super( + retry: null, + name: r'opponentWinTrendProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + OpponentWinTrendProvider call(String userId) => + OpponentWinTrendProvider._(argument: userId, from: this); + + @override + String toString() => r'opponentWinTrendProvider'; +} diff --git a/client/deepdame/lib/features/opponent_profile/presentation/widgets/opponent_h2h_section.dart b/client/deepdame/lib/features/opponent_profile/presentation/widgets/opponent_h2h_section.dart new file mode 100644 index 0000000..9c9842b --- /dev/null +++ b/client/deepdame/lib/features/opponent_profile/presentation/widgets/opponent_h2h_section.dart @@ -0,0 +1,233 @@ +import 'package:deepdame/features/home/presentation/widgets/skeleton_wrapper.dart'; +import 'package:deepdame/features/opponent_profile/data/models/head_to_head.dart'; +import 'package:deepdame/features/opponent_profile/data/models/public_user_profile.dart'; +import 'package:deepdame/features/opponent_profile/presentation/providers/head_to_head_provider.dart'; +import 'package:deepdame/features/user/application/user_controller.dart'; +import 'package:deepdame/features/user/data/user_data.dart'; +import 'package:deepdame/shared/profile_icon.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class OpponentH2hSection extends ConsumerWidget { + const OpponentH2hSection({ + required this.userId, + required this.opponentProfile, + super.key, + }); + + final String userId; + final PublicUserProfile opponentProfile; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final h2hAsync = ref.watch(headToHeadDataProvider(userId)); + final currentUserAsync = ref.watch(userControllerProvider); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Head to Head vs You', + style: textTheme.titleSmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.55), + fontWeight: FontWeight.w600, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 12), + h2hAsync.when( + loading: () => const _H2hSkeleton(), + error: (_, _) => const SizedBox.shrink(), + data: (h2h) => currentUserAsync.when( + loading: () => const _H2hSkeleton(), + error: (_, _) => const SizedBox.shrink(), + data: (currentUser) => _H2hContent( + h2h: h2h, + currentUser: currentUser, + opponentProfile: opponentProfile, + ), + ), + ), + ], + ); + } +} + +class _H2hContent extends StatelessWidget { + const _H2hContent({ + required this.h2h, + required this.currentUser, + required this.opponentProfile, + }); + + final HeadToHead h2h; + final UserData currentUser; + final PublicUserProfile opponentProfile; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final total = h2h.totalGames; + final barValue = total > 0 ? h2h.myWins / total : 0.0; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: colorScheme.onSurface.withValues(alpha: 0.09), + ), + ), + child: Column( + children: [ + Row( + children: [ + ProfileIcon.small( + username: currentUser.username, + imageUrl: currentUser.imageUrl, + ), + const SizedBox(width: 10), + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(5), + child: LinearProgressIndicator( + value: barValue, + minHeight: 10, + backgroundColor: colorScheme.primary.withValues( + alpha: 0.15, + ), + valueColor: AlwaysStoppedAnimation( + colorScheme.primary, + ), + ), + ), + ), + const SizedBox(width: 10), + ProfileIcon.small( + username: opponentProfile.username, + imageUrl: opponentProfile.imageUrl, + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Text( + '${h2h.myWins} ${h2h.myWins == 1 ? 'win' : 'wins'}', + style: textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + Expanded( + child: Center( + child: Text( + '${h2h.totalGames} ${h2h.totalGames == 1 ? 'game' : 'games'}', + style: textTheme.labelSmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.45), + fontWeight: FontWeight.w500, + ), + ), + ), + ), + Text( + '${h2h.theirWins} ${h2h.theirWins == 1 ? 'win' : 'wins'}', + style: textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), + ), + ], + ), + ], + ), + ); + } +} + +class _H2hSkeleton extends StatelessWidget { + const _H2hSkeleton(); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final ph = colorScheme.onSurface.withValues(alpha: 0.08); + + return SkeletonWrapper( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: ph), + ), + child: Column( + children: [ + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration(color: ph, shape: BoxShape.circle), + ), + const SizedBox(width: 10), + Expanded( + child: Container( + height: 10, + decoration: BoxDecoration( + color: ph, + borderRadius: BorderRadius.circular(5), + ), + ), + ), + const SizedBox(width: 10), + Container( + width: 36, + height: 36, + decoration: BoxDecoration(color: ph, shape: BoxShape.circle), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + Container( + width: 40, + height: 10, + decoration: BoxDecoration( + color: ph, + borderRadius: BorderRadius.circular(4), + ), + ), + Expanded( + child: Center( + child: Container( + width: 48, + height: 10, + decoration: BoxDecoration( + color: ph, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ), + Container( + width: 40, + height: 10, + decoration: BoxDecoration( + color: ph, + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/client/deepdame/lib/features/opponent_profile/presentation/widgets/opponent_stats_section.dart b/client/deepdame/lib/features/opponent_profile/presentation/widgets/opponent_stats_section.dart new file mode 100644 index 0000000..30a0ea8 --- /dev/null +++ b/client/deepdame/lib/features/opponent_profile/presentation/widgets/opponent_stats_section.dart @@ -0,0 +1,163 @@ +import 'package:deepdame/features/home/data/models/player_stats.dart'; +import 'package:deepdame/features/home/presentation/widgets/skeleton_wrapper.dart'; +import 'package:deepdame/features/opponent_profile/presentation/providers/opponent_stats_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class OpponentStatsSection extends ConsumerWidget { + const OpponentStatsSection({required this.userId, super.key}); + + final String userId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ref + .watch(opponentStatsProvider(userId)) + .when( + loading: () => const _StatsSkeleton(), + error: (_, _) => const SizedBox.shrink(), + data: (stats) => _StatsContent(stats: stats), + ); + } +} + +class _StatsContent extends StatelessWidget { + const _StatsContent({required this.stats}); + + final PlayerStats stats; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final pct = (stats.winRatioAllTime * 100).toStringAsFixed(0); + + return Row( + children: [ + Expanded( + child: _StatItem( + value: stats.totalPlayed.toString(), + label: 'Played', + color: colorScheme.onSurface, + ), + ), + _VerticalDivider(), + Expanded( + child: _StatItem( + value: stats.totalWins.toString(), + label: 'Won', + color: colorScheme.primary, + ), + ), + _VerticalDivider(), + Expanded( + child: _StatItem( + value: stats.totalLosses.toString(), + label: 'Lost', + color: colorScheme.tertiary, + ), + ), + _VerticalDivider(), + Expanded( + child: _StatItem( + value: '$pct%', + label: 'Rate', + color: colorScheme.primary, + ), + ), + ], + ); + } +} + +class _StatItem extends StatelessWidget { + const _StatItem({ + required this.value, + required this.label, + required this.color, + }); + + final String value; + final String label; + final Color color; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Column( + children: [ + Text( + value, + style: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w800, + color: color, + fontSize: 20, + ), + ), + const SizedBox(height: 3), + Text( + label, + style: textTheme.labelSmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.5), + fontWeight: FontWeight.w500, + fontSize: 11, + ), + ), + ], + ); + } +} + +class _VerticalDivider extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container( + width: 1, + height: 36, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08), + ); + } +} + +class _StatsSkeleton extends StatelessWidget { + const _StatsSkeleton(); + + @override + Widget build(BuildContext context) { + final ph = Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08); + + return SkeletonWrapper( + child: Row( + children: [ + for (int i = 0; i < 4; i++) ...[ + Expanded( + child: Column( + children: [ + Container( + width: 36, + height: 20, + decoration: BoxDecoration( + color: ph, + borderRadius: BorderRadius.circular(6), + ), + ), + const SizedBox(height: 6), + Container( + width: 28, + height: 10, + decoration: BoxDecoration( + color: ph, + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + ), + if (i < 3) Container(width: 1, height: 36, color: ph), + ], + ], + ), + ); + } +} diff --git a/client/deepdame/lib/features/opponent_profile/presentation/widgets/opponent_win_rate_section.dart b/client/deepdame/lib/features/opponent_profile/presentation/widgets/opponent_win_rate_section.dart new file mode 100644 index 0000000..5bb71ce --- /dev/null +++ b/client/deepdame/lib/features/opponent_profile/presentation/widgets/opponent_win_rate_section.dart @@ -0,0 +1,229 @@ +import 'package:deepdame/features/home/presentation/widgets/skeleton_wrapper.dart'; +import 'package:deepdame/features/opponent_profile/presentation/providers/opponent_win_trend_provider.dart'; +import 'package:deepdame/features/profile/data/models/monthly_win_rate.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class OpponentWinRateSection extends ConsumerWidget { + const OpponentWinRateSection({required this.userId, super.key}); + + final String userId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Win Rate Trend', + style: textTheme.titleSmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.55), + fontWeight: FontWeight.w600, + letterSpacing: 0.2, + ), + ), + const SizedBox(height: 12), + ref + .watch(opponentWinTrendProvider(userId)) + .when( + loading: () => const _WinRateSkeleton(), + error: (_, _) => const SizedBox.shrink(), + data: (trend) => _WinRateChart(trend: trend), + ), + ], + ); + } +} + +class _WinRateChart extends StatelessWidget { + const _WinRateChart({required this.trend}); + + final List trend; + + static const _monthLabels = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + + String _label(String month) { + final m = int.tryParse(month.split('-').last) ?? 1; + return _monthLabels[m - 1]; + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Container( + padding: const EdgeInsets.fromLTRB(12, 16, 12, 8), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: colorScheme.onSurface.withValues(alpha: 0.09), + ), + ), + child: SizedBox( + height: 155, + child: BarChart( + BarChartData( + alignment: BarChartAlignment.spaceAround, + maxY: 100, + minY: 0, + barTouchData: BarTouchData( + enabled: true, + touchTooltipData: BarTouchTooltipData( + getTooltipColor: (_) => colorScheme.surfaceContainerHigh, + tooltipBorderRadius: BorderRadius.circular(8), + getTooltipItem: (group, groupIndex, rod, rodIndex) { + final entry = trend[groupIndex]; + if (entry.played == 0) return null; + return BarTooltipItem( + '${rod.toY.toStringAsFixed(0)}%', + textTheme.labelSmall!.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ); + }, + ), + ), + titlesData: FlTitlesData( + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 28, + getTitlesWidget: (value, _) { + final i = value.toInt(); + if (i < 0 || i >= trend.length) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + _label(trend[i].month), + style: textTheme.labelSmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.45), + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ); + }, + ), + ), + leftTitles: const AxisTitles(), + rightTitles: const AxisTitles(), + topTitles: const AxisTitles(), + ), + gridData: const FlGridData(show: false), + borderData: FlBorderData(show: false), + barGroups: trend.asMap().entries.map((e) { + final hasData = e.value.played > 0; + return BarChartGroupData( + x: e.key, + barRods: [ + BarChartRodData( + toY: hasData ? e.value.winRate * 100 : 4, + color: hasData ? colorScheme.primary : Colors.transparent, + width: 22, + borderRadius: BorderRadius.circular(6), + backDrawRodData: BackgroundBarChartRodData( + show: true, + toY: 100, + color: colorScheme.onSurface.withValues(alpha: 0.06), + ), + ), + ], + ); + }).toList(), + ), + ), + ), + ); + } +} + +class _WinRateSkeleton extends StatelessWidget { + const _WinRateSkeleton(); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final ph = colorScheme.onSurface.withValues(alpha: 0.08); + + return SkeletonWrapper( + child: Container( + padding: const EdgeInsets.fromLTRB(12, 16, 12, 8), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: ph), + ), + child: SizedBox( + height: 155, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _SkeletonBar(heightFraction: 0.45, ph: ph), + _SkeletonBar(heightFraction: 0.30, ph: ph), + _SkeletonBar(heightFraction: 0.60, ph: ph), + _SkeletonBar(heightFraction: 0.75, ph: ph), + _SkeletonBar(heightFraction: 0.55, ph: ph), + _SkeletonBar(heightFraction: 0.80, ph: ph), + ], + ), + ), + ), + ); + } +} + +class _SkeletonBar extends StatelessWidget { + const _SkeletonBar({required this.heightFraction, required this.ph}); + + final double heightFraction; + final Color ph; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Container( + width: 22, + height: 118 * heightFraction, + decoration: BoxDecoration( + color: ph, + borderRadius: BorderRadius.circular(6), + ), + ), + const SizedBox(height: 8), + Container( + width: 20, + height: 10, + decoration: BoxDecoration( + color: ph, + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ); + } +} From 64db640c4af7ebb7e031ecbb89fe494ac1991605 Mon Sep 17 00:00:00 2001 From: ABD'E <123735349+abdellah-darni@users.noreply.github.com> Date: Thu, 28 May 2026 21:35:26 +0100 Subject: [PATCH 2/2] Update pubspec.yaml --- client/deepdame/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/deepdame/pubspec.yaml b/client/deepdame/pubspec.yaml index a61e628..639dcd8 100644 --- a/client/deepdame/pubspec.yaml +++ b/client/deepdame/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 2.0.25+26 +version: 2.0.26+27 environment: sdk: ^3.10.4