diff --git a/client/deepdame/lib/core/routing/app_router.dart b/client/deepdame/lib/core/routing/app_router.dart index b1a3ee0..46d1a38 100644 --- a/client/deepdame/lib/core/routing/app_router.dart +++ b/client/deepdame/lib/core/routing/app_router.dart @@ -16,6 +16,7 @@ import 'package:deepdame/features/home/data/models/open_game.dart'; import 'package:deepdame/features/home/home_screen.dart'; import 'package:deepdame/features/home/lobby_screen.dart'; import 'package:deepdame/features/onboarding/onboarding_screen.dart'; +import 'package:deepdame/features/opponent_profile/opponent_profile_screen.dart'; import 'package:deepdame/features/profile/profile_screen.dart'; import 'package:deepdame/features/settings/settings_screen.dart'; import 'package:deepdame/features/settings/theme_picker_screen.dart'; @@ -125,6 +126,14 @@ final appRouter = GoRouter( path: '/user', builder: (context, state) => const UserScreen(), ), + GoRoute( + name: 'opponent-profile', + path: '/opponent-profile/:userId', + builder: (context, state) { + final userId = state.pathParameters['userId']!; + return OpponentProfileScreen(userId: userId); + }, + ), GoRoute( name: 'settings', path: '/settings', diff --git a/client/deepdame/lib/features/friends/presentation/providers/sent_requests_provider.dart b/client/deepdame/lib/features/friends/presentation/providers/sent_requests_provider.dart new file mode 100644 index 0000000..a85d4b3 --- /dev/null +++ b/client/deepdame/lib/features/friends/presentation/providers/sent_requests_provider.dart @@ -0,0 +1,15 @@ +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'sent_requests_provider.g.dart'; + +@Riverpod(keepAlive: true) +class SentFriendRequests extends _$SentFriendRequests { + @override + Set build() => {}; + + void add(String userId) { + state = {...state, userId}; + } + + bool contains(String userId) => state.contains(userId); +} diff --git a/client/deepdame/lib/features/friends/presentation/providers/sent_requests_provider.g.dart b/client/deepdame/lib/features/friends/presentation/providers/sent_requests_provider.g.dart new file mode 100644 index 0000000..21ca776 --- /dev/null +++ b/client/deepdame/lib/features/friends/presentation/providers/sent_requests_provider.g.dart @@ -0,0 +1,63 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'sent_requests_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(SentFriendRequests) +final sentFriendRequestsProvider = SentFriendRequestsProvider._(); + +final class SentFriendRequestsProvider + extends $NotifierProvider> { + SentFriendRequestsProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'sentFriendRequestsProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$sentFriendRequestsHash(); + + @$internal + @override + SentFriendRequests create() => SentFriendRequests(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(Set value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); + } +} + +String _$sentFriendRequestsHash() => + r'db94b3fa5d1524075d8026acf6f03fe8b93f8aa9'; + +abstract class _$SentFriendRequests extends $Notifier> { + Set build(); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref, Set>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, Set>, + Set, + Object?, + Object? + >; + element.handleCreate(ref, build); + } +} diff --git a/client/deepdame/lib/features/friends/presentation/widgets/add_friend_sheet.dart b/client/deepdame/lib/features/friends/presentation/widgets/add_friend_sheet.dart index 676521c..cc4546e 100644 --- a/client/deepdame/lib/features/friends/presentation/widgets/add_friend_sheet.dart +++ b/client/deepdame/lib/features/friends/presentation/widgets/add_friend_sheet.dart @@ -4,9 +4,11 @@ import 'package:deepdame/core/network/stomp_service.dart'; import 'package:deepdame/features/friends/data/models/user_search_result.dart'; import 'package:deepdame/features/friends/data/repositories/friends_repository.dart'; import 'package:deepdame/features/friends/presentation/providers/friends_provider.dart'; +import 'package:deepdame/features/friends/presentation/providers/sent_requests_provider.dart'; import 'package:deepdame/shared/profile_icon.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; class AddFriendSheet extends ConsumerStatefulWidget { const AddFriendSheet({super.key}); @@ -70,7 +72,15 @@ class _AddFriendSheetState extends ConsumerState { } void _sendRequest(UserSearchResult result) { - ref.read(stompServiceProvider).send('/app/invite/friend/${result.id}'); + final stomp = ref.read(stompServiceProvider); + if (!stomp.isConnected) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Connection lost. Try again.')), + ); + return; + } + stomp.send('/app/invite/friend/${result.id}'); + ref.read(sentFriendRequestsProvider.notifier).add(result.id); setState(() { _results = _results .map( @@ -252,16 +262,33 @@ class _AddFriendSheetState extends ConsumerState { } if (_results.isNotEmpty) { + final sentIds = ref.watch(sentFriendRequestsProvider); + final friendIds = + ref.watch(friendsProvider).asData?.value.map((f) => f.id).toSet() ?? + {}; return ConstrainedBox( constraints: const BoxConstraints(maxHeight: 300), child: ListView.builder( shrinkWrap: true, itemCount: _results.length, - itemBuilder: (_, i) => _SearchResultTile( - result: _results[i], - onAdd: () => _sendRequest(_results[i]), - onAccept: () => _acceptRequest(_results[i]), - ), + itemBuilder: (_, i) { + final result = _results[i]; + final effectiveRelationship = friendIds.contains(result.id) + ? RelationshipStatus.friends + : result.relationship == RelationshipStatus.none && + sentIds.contains(result.id) + ? RelationshipStatus.requestSent + : result.relationship; + return _SearchResultTile( + result: result.copyWith(relationship: effectiveRelationship), + onAdd: () => _sendRequest(result), + onAccept: () => _acceptRequest(result), + onView: () => context.pushNamed( + 'opponent-profile', + pathParameters: {'userId': result.id}, + ), + ); + }, ), ); } @@ -296,11 +323,13 @@ class _SearchResultTile extends StatelessWidget { required this.result, required this.onAdd, required this.onAccept, + required this.onView, }); final UserSearchResult result; final VoidCallback onAdd; final VoidCallback onAccept; + final VoidCallback onView; @override Widget build(BuildContext context) { @@ -336,6 +365,29 @@ class _SearchResultTile extends StatelessWidget { ), ), ), + OutlinedButton( + onPressed: onView, + style: OutlinedButton.styleFrom( + foregroundColor: colorScheme.onSurface.withValues(alpha: 0.7), + side: BorderSide( + color: colorScheme.onSurface.withValues(alpha: 0.25), + ), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + textStyle: textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + child: const Text('View'), + ), + const SizedBox(width: 6), _RelationshipBadge( relationship: result.relationship, onAdd: onAdd, diff --git a/client/deepdame/lib/features/friends/presentation/widgets/friend_tile.dart b/client/deepdame/lib/features/friends/presentation/widgets/friend_tile.dart index 6e1c77e..9f1468a 100644 --- a/client/deepdame/lib/features/friends/presentation/widgets/friend_tile.dart +++ b/client/deepdame/lib/features/friends/presentation/widgets/friend_tile.dart @@ -5,6 +5,7 @@ import 'package:deepdame/features/friends/presentation/providers/friends_provide import 'package:deepdame/shared/profile_icon.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; class FriendTile extends ConsumerStatefulWidget { const FriendTile({ @@ -131,9 +132,10 @@ class _FriendTileState extends ConsumerState { } } - void _onView() { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Profile coming soon')), + Future _onView() async { + await context.pushNamed( + 'opponent-profile', + pathParameters: {'userId': widget.friend.id}, ); } diff --git a/client/deepdame/lib/features/opponent_profile/data/models/public_user_profile.dart b/client/deepdame/lib/features/opponent_profile/data/models/public_user_profile.dart new file mode 100644 index 0000000..33376de --- /dev/null +++ b/client/deepdame/lib/features/opponent_profile/data/models/public_user_profile.dart @@ -0,0 +1,17 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'public_user_profile.freezed.dart'; +part 'public_user_profile.g.dart'; + +@freezed +abstract class PublicUserProfile with _$PublicUserProfile { + const factory PublicUserProfile({ + required String id, + required String username, + required bool online, + String? imageUrl, + }) = _PublicUserProfile; + + factory PublicUserProfile.fromJson(Map json) => + _$PublicUserProfileFromJson(json); +} diff --git a/client/deepdame/lib/features/opponent_profile/data/models/public_user_profile.freezed.dart b/client/deepdame/lib/features/opponent_profile/data/models/public_user_profile.freezed.dart new file mode 100644 index 0000000..190e163 --- /dev/null +++ b/client/deepdame/lib/features/opponent_profile/data/models/public_user_profile.freezed.dart @@ -0,0 +1,286 @@ +// 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 'public_user_profile.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$PublicUserProfile { + + String get id; String get username; bool get online; String? get imageUrl; +/// Create a copy of PublicUserProfile +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$PublicUserProfileCopyWith get copyWith => _$PublicUserProfileCopyWithImpl(this as PublicUserProfile, _$identity); + + /// Serializes this PublicUserProfile to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is PublicUserProfile&&(identical(other.id, id) || other.id == id)&&(identical(other.username, username) || other.username == username)&&(identical(other.online, online) || other.online == online)&&(identical(other.imageUrl, imageUrl) || other.imageUrl == imageUrl)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,username,online,imageUrl); + +@override +String toString() { + return 'PublicUserProfile(id: $id, username: $username, online: $online, imageUrl: $imageUrl)'; +} + + +} + +/// @nodoc +abstract mixin class $PublicUserProfileCopyWith<$Res> { + factory $PublicUserProfileCopyWith(PublicUserProfile value, $Res Function(PublicUserProfile) _then) = _$PublicUserProfileCopyWithImpl; +@useResult +$Res call({ + String id, String username, bool online, String? imageUrl +}); + + + + +} +/// @nodoc +class _$PublicUserProfileCopyWithImpl<$Res> + implements $PublicUserProfileCopyWith<$Res> { + _$PublicUserProfileCopyWithImpl(this._self, this._then); + + final PublicUserProfile _self; + final $Res Function(PublicUserProfile) _then; + +/// Create a copy of PublicUserProfile +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? username = null,Object? online = null,Object? imageUrl = freezed,}) { + return _then(_self.copyWith( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,username: null == username ? _self.username : username // ignore: cast_nullable_to_non_nullable +as String,online: null == online ? _self.online : online // ignore: cast_nullable_to_non_nullable +as bool,imageUrl: freezed == imageUrl ? _self.imageUrl : imageUrl // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [PublicUserProfile]. +extension PublicUserProfilePatterns on PublicUserProfile { +/// 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( _PublicUserProfile value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _PublicUserProfile() 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( _PublicUserProfile value) $default,){ +final _that = this; +switch (_that) { +case _PublicUserProfile(): +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( _PublicUserProfile value)? $default,){ +final _that = this; +switch (_that) { +case _PublicUserProfile() 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( String id, String username, bool online, String? imageUrl)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _PublicUserProfile() when $default != null: +return $default(_that.id,_that.username,_that.online,_that.imageUrl);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( String id, String username, bool online, String? imageUrl) $default,) {final _that = this; +switch (_that) { +case _PublicUserProfile(): +return $default(_that.id,_that.username,_that.online,_that.imageUrl);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( String id, String username, bool online, String? imageUrl)? $default,) {final _that = this; +switch (_that) { +case _PublicUserProfile() when $default != null: +return $default(_that.id,_that.username,_that.online,_that.imageUrl);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _PublicUserProfile implements PublicUserProfile { + const _PublicUserProfile({required this.id, required this.username, required this.online, this.imageUrl}); + factory _PublicUserProfile.fromJson(Map json) => _$PublicUserProfileFromJson(json); + +@override final String id; +@override final String username; +@override final bool online; +@override final String? imageUrl; + +/// Create a copy of PublicUserProfile +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$PublicUserProfileCopyWith<_PublicUserProfile> get copyWith => __$PublicUserProfileCopyWithImpl<_PublicUserProfile>(this, _$identity); + +@override +Map toJson() { + return _$PublicUserProfileToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _PublicUserProfile&&(identical(other.id, id) || other.id == id)&&(identical(other.username, username) || other.username == username)&&(identical(other.online, online) || other.online == online)&&(identical(other.imageUrl, imageUrl) || other.imageUrl == imageUrl)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,id,username,online,imageUrl); + +@override +String toString() { + return 'PublicUserProfile(id: $id, username: $username, online: $online, imageUrl: $imageUrl)'; +} + + +} + +/// @nodoc +abstract mixin class _$PublicUserProfileCopyWith<$Res> implements $PublicUserProfileCopyWith<$Res> { + factory _$PublicUserProfileCopyWith(_PublicUserProfile value, $Res Function(_PublicUserProfile) _then) = __$PublicUserProfileCopyWithImpl; +@override @useResult +$Res call({ + String id, String username, bool online, String? imageUrl +}); + + + + +} +/// @nodoc +class __$PublicUserProfileCopyWithImpl<$Res> + implements _$PublicUserProfileCopyWith<$Res> { + __$PublicUserProfileCopyWithImpl(this._self, this._then); + + final _PublicUserProfile _self; + final $Res Function(_PublicUserProfile) _then; + +/// Create a copy of PublicUserProfile +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? username = null,Object? online = null,Object? imageUrl = freezed,}) { + return _then(_PublicUserProfile( +id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable +as String,username: null == username ? _self.username : username // ignore: cast_nullable_to_non_nullable +as String,online: null == online ? _self.online : online // ignore: cast_nullable_to_non_nullable +as bool,imageUrl: freezed == imageUrl ? _self.imageUrl : imageUrl // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + + +} + +// dart format on diff --git a/client/deepdame/lib/features/opponent_profile/data/models/public_user_profile.g.dart b/client/deepdame/lib/features/opponent_profile/data/models/public_user_profile.g.dart new file mode 100644 index 0000000..3871889 --- /dev/null +++ b/client/deepdame/lib/features/opponent_profile/data/models/public_user_profile.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'public_user_profile.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_PublicUserProfile _$PublicUserProfileFromJson(Map json) => + _PublicUserProfile( + id: json['id'] as String, + username: json['username'] as String, + online: json['online'] as bool, + imageUrl: json['imageUrl'] as String?, + ); + +Map _$PublicUserProfileToJson(_PublicUserProfile instance) => + { + 'id': instance.id, + 'username': instance.username, + 'online': instance.online, + 'imageUrl': instance.imageUrl, + }; 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 new file mode 100644 index 0000000..0c22c46 --- /dev/null +++ b/client/deepdame/lib/features/opponent_profile/data/repositories/user_repository.dart @@ -0,0 +1,24 @@ +import 'package:deepdame/core/network/dio_client.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'; + +part 'user_repository.g.dart'; + +@riverpod +UserRepository userRepository(Ref ref) { + return UserRepository(ref.watch(dioProvider)); +} + +class UserRepository { + UserRepository(this._dio); + + final Dio _dio; + + Future getUser(String userId) async { + final response = await _dio.get>( + '/api/v1/user/$userId', + ); + return PublicUserProfile.fromJson(response.data!); + } +} diff --git a/client/deepdame/lib/features/opponent_profile/data/repositories/user_repository.g.dart b/client/deepdame/lib/features/opponent_profile/data/repositories/user_repository.g.dart new file mode 100644 index 0000000..4822266 --- /dev/null +++ b/client/deepdame/lib/features/opponent_profile/data/repositories/user_repository.g.dart @@ -0,0 +1,51 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(userRepository) +final userRepositoryProvider = UserRepositoryProvider._(); + +final class UserRepositoryProvider + extends $FunctionalProvider + with $Provider { + UserRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'userRepositoryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$userRepositoryHash(); + + @$internal + @override + $ProviderElement $createElement($ProviderPointer pointer) => + $ProviderElement(pointer); + + @override + UserRepository create(Ref ref) { + return userRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(UserRepository value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$userRepositoryHash() => r'08857b9b2d45b6cea0baab7b8549844ff84a1f4a'; diff --git a/client/deepdame/lib/features/opponent_profile/opponent_profile_screen.dart b/client/deepdame/lib/features/opponent_profile/opponent_profile_screen.dart new file mode 100644 index 0000000..5df81ce --- /dev/null +++ b/client/deepdame/lib/features/opponent_profile/opponent_profile_screen.dart @@ -0,0 +1,66 @@ +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_header_section.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class OpponentProfileScreen extends ConsumerWidget { + const OpponentProfileScreen({required this.userId, super.key}); + + final String userId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final profileAsync = ref.watch(opponentProfileProvider(userId)); + + return Scaffold( + appBar: OpponentAppBar(userId: userId), + body: profileAsync.when( + loading: () => const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + SizedBox(height: 40), + Center(child: OpponentHeaderSkeleton()), + ], + ), + ), + error: (_, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Failed to load profile', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.45), + ), + ), + const SizedBox(height: 8), + TextButton( + onPressed: () => ref.invalidate( + opponentProfileProvider(userId), + ), + child: const Text('Retry'), + ), + ], + ), + ), + data: (profile) => SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 40), + Center(child: OpponentHeaderSection(profile: profile)), + const SizedBox(height: 24), + ], + ), + ), + ), + ), + ); + } +} diff --git a/client/deepdame/lib/features/opponent_profile/presentation/providers/opponent_profile_provider.dart b/client/deepdame/lib/features/opponent_profile/presentation/providers/opponent_profile_provider.dart new file mode 100644 index 0000000..88613ad --- /dev/null +++ b/client/deepdame/lib/features/opponent_profile/presentation/providers/opponent_profile_provider.dart @@ -0,0 +1,10 @@ +import 'package:deepdame/features/opponent_profile/data/models/public_user_profile.dart'; +import 'package:deepdame/features/opponent_profile/data/repositories/user_repository.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'opponent_profile_provider.g.dart'; + +@riverpod +Future opponentProfile(Ref ref, String userId) { + return ref.read(userRepositoryProvider).getUser(userId); +} 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 new file mode 100644 index 0000000..e08d973 --- /dev/null +++ b/client/deepdame/lib/features/opponent_profile/presentation/providers/opponent_profile_provider.g.dart @@ -0,0 +1,87 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'opponent_profile_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(opponentProfile) +final opponentProfileProvider = OpponentProfileFamily._(); + +final class OpponentProfileProvider + extends + $FunctionalProvider< + AsyncValue, + PublicUserProfile, + FutureOr + > + with + $FutureModifier, + $FutureProvider { + OpponentProfileProvider._({ + required OpponentProfileFamily super.from, + required String super.argument, + }) : super( + retry: null, + name: r'opponentProfileProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$opponentProfileHash(); + + @override + String toString() { + return r'opponentProfileProvider' + '' + '($argument)'; + } + + @$internal + @override + $FutureProviderElement $createElement( + $ProviderPointer pointer, + ) => $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + final argument = this.argument as String; + return opponentProfile(ref, argument); + } + + @override + bool operator ==(Object other) { + return other is OpponentProfileProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$opponentProfileHash() => r'bc0ae9bf6645a64c7389501e7031ad29ca3279d4'; + +final class OpponentProfileFamily extends $Family + with $FunctionalFamilyOverride, String> { + OpponentProfileFamily._() + : super( + retry: null, + name: r'opponentProfileProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + OpponentProfileProvider call(String userId) => + OpponentProfileProvider._(argument: userId, from: this); + + @override + String toString() => r'opponentProfileProvider'; +} diff --git a/client/deepdame/lib/features/opponent_profile/presentation/widgets/opponent_app_bar.dart b/client/deepdame/lib/features/opponent_profile/presentation/widgets/opponent_app_bar.dart new file mode 100644 index 0000000..fea6471 --- /dev/null +++ b/client/deepdame/lib/features/opponent_profile/presentation/widgets/opponent_app_bar.dart @@ -0,0 +1,156 @@ +import 'package:deepdame/core/network/stomp_service.dart'; +import 'package:deepdame/features/friends/data/repositories/friends_repository.dart'; +import 'package:deepdame/features/friends/presentation/providers/friends_provider.dart'; +import 'package:deepdame/features/friends/presentation/providers/sent_requests_provider.dart'; +import 'package:deepdame/features/opponent_profile/presentation/providers/opponent_profile_provider.dart'; +import 'package:deepdame/shared/status_pill.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class OpponentAppBar extends ConsumerStatefulWidget + implements PreferredSizeWidget { + const OpponentAppBar({required this.userId, super.key}); + + final String userId; + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); + + @override + ConsumerState createState() => _OpponentAppBarState(); +} + +class _OpponentAppBarState extends ConsumerState { + void _sendFriendRequest() { + final stomp = ref.read(stompServiceProvider); + if (!stomp.isConnected) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Connection lost. Try again.')), + ); + return; + } + stomp.send('/app/invite/friend/${widget.userId}'); + ref.read(sentFriendRequestsProvider.notifier).add(widget.userId); + } + + Future _acceptRequest(String requestId) async { + try { + await ref.read(friendsRepositoryProvider).acceptRequest(requestId); + ref + ..invalidate(friendRequestsProvider) + ..invalidate(friendsProvider); + } on Exception { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Failed to accept request. Try again.'), + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + final profileAsync = ref.watch(opponentProfileProvider(widget.userId)); + final isFriend = + ref + .watch(friendsProvider) + .asData + ?.value + .any((f) => f.id == widget.userId) ?? + false; + final requestSent = ref + .watch(sentFriendRequestsProvider) + .contains(widget.userId); + final pendingRequest = ref + .watch(friendRequestsProvider) + .asData + ?.value + .where((r) => r.sender.id == widget.userId) + .firstOrNull; + + return AppBar( + leading: const BackButton(), + centerTitle: false, + title: profileAsync.when( + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + data: (p) => Text( + p.username, + style: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + color: colorScheme.onSurface, + ), + ), + ), + actions: [ + if (!isFriend) ...[ + if (pendingRequest != null) + TextButton( + onPressed: () => _acceptRequest(pendingRequest.id), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text( + 'Accept', + style: textTheme.labelSmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + ) + else if (requestSent) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + 'Sent', + style: textTheme.labelSmall?.copyWith( + color: colorScheme.onSurface.withValues(alpha: 0.45), + fontWeight: FontWeight.w500, + ), + ), + ) + else + OutlinedButton( + onPressed: _sendFriendRequest, + style: OutlinedButton.styleFrom( + side: BorderSide(color: colorScheme.primary), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Text( + '+ Friend', + style: textTheme.labelSmall?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 8), + ], + GestureDetector( + onTap: () => ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Game invite coming soon')), + ), + child: const StatusPill.invite(), + ), + const SizedBox(width: 16), + ], + ); + } +} diff --git a/client/deepdame/lib/features/opponent_profile/presentation/widgets/opponent_header_section.dart b/client/deepdame/lib/features/opponent_profile/presentation/widgets/opponent_header_section.dart new file mode 100644 index 0000000..a5dc697 --- /dev/null +++ b/client/deepdame/lib/features/opponent_profile/presentation/widgets/opponent_header_section.dart @@ -0,0 +1,120 @@ +import 'package:deepdame/core/theme/themes/extensions/status_pill_colors.dart'; +import 'package:deepdame/features/home/presentation/widgets/skeleton_wrapper.dart'; +import 'package:deepdame/features/opponent_profile/data/models/public_user_profile.dart'; +import 'package:deepdame/shared/profile_icon.dart'; +import 'package:deepdame/shared/status_pill.dart'; +import 'package:flutter/material.dart'; + +class OpponentHeaderSection extends StatelessWidget { + const OpponentHeaderSection({required this.profile, super.key}); + + final PublicUserProfile profile; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final textTheme = Theme.of(context).textTheme; + + return Column( + children: [ + _AvatarWithOnlineDot( + username: profile.username, + imageUrl: profile.imageUrl, + online: profile.online, + ), + const SizedBox(height: 12), + Text( + profile.username, + style: textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + color: colorScheme.onSurface, + ), + ), + const SizedBox(height: 6), + if (profile.online) + const StatusPill.online() + else + const StatusPill.offline(), + ], + ); + } +} + +class OpponentHeaderSkeleton extends StatelessWidget { + const OpponentHeaderSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + final ph = Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08); + + return SkeletonWrapper( + child: Column( + children: [ + Container( + width: 90, + height: 90, + decoration: BoxDecoration(color: ph, shape: BoxShape.circle), + ), + const SizedBox(height: 12), + Container( + width: 110, + height: 18, + decoration: BoxDecoration( + color: ph, + borderRadius: BorderRadius.circular(8), + ), + ), + const SizedBox(height: 8), + Container( + width: 64, + height: 24, + decoration: BoxDecoration( + color: ph, + borderRadius: BorderRadius.circular(20), + ), + ), + ], + ), + ); + } +} + +class _AvatarWithOnlineDot extends StatelessWidget { + const _AvatarWithOnlineDot({ + required this.username, + required this.online, + this.imageUrl, + }); + + final String username; + final String? imageUrl; + final bool online; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Stack( + clipBehavior: Clip.none, + children: [ + ProfileIcon.large(username: username, imageUrl: imageUrl), + if (online) + Positioned( + bottom: 2, + right: 2, + child: Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: Theme.of( + context, + ).extension()!.onlineText, + shape: BoxShape.circle, + border: Border.all(color: colorScheme.surface, width: 2.5), + ), + ), + ), + ], + ); + } +} diff --git a/client/deepdame/lib/features/profile/widgets/profile_recent_games_section.dart b/client/deepdame/lib/features/profile/widgets/profile_recent_games_section.dart index 46fe5b9..2b976f7 100644 --- a/client/deepdame/lib/features/profile/widgets/profile_recent_games_section.dart +++ b/client/deepdame/lib/features/profile/widgets/profile_recent_games_section.dart @@ -2,6 +2,7 @@ import 'package:deepdame/features/home/presentation/widgets/skeleton_wrapper.dar import 'package:deepdame/features/profile/data/models/game_history_entry.dart'; import 'package:deepdame/features/profile/presentation/providers/game_history_provider.dart'; import 'package:deepdame/shared/profile_icon.dart'; +import 'package:deepdame/shared/status_pill.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -80,8 +81,6 @@ class _GameHistoryTile extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; - final badgeColor = entry.won ? colorScheme.primary : colorScheme.tertiary; - return Container( margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), @@ -125,20 +124,7 @@ class _GameHistoryTile extends StatelessWidget { ), ), const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: badgeColor.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(20), - ), - child: Text( - entry.won ? 'Win' : 'Loss', - style: textTheme.labelSmall?.copyWith( - color: badgeColor, - fontWeight: FontWeight.w700, - ), - ), - ), + if (entry.won) const StatusPill.win() else const StatusPill.loss(), const SizedBox(width: 10), Text( '${entry.moveCount} moves',