diff --git a/client/deepdame/lib/features/game/chat/game_chat_message.dart b/client/deepdame/lib/features/game/chat/game_chat_message.dart new file mode 100644 index 0000000..8ec7730 --- /dev/null +++ b/client/deepdame/lib/features/game/chat/game_chat_message.dart @@ -0,0 +1,19 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'game_chat_message.freezed.dart'; +part 'game_chat_message.g.dart'; + +@freezed +abstract class GameChatMessage with _$GameChatMessage { + const factory GameChatMessage({ + required String sender, + required String content, + // Freezed processes @JsonKey on constructor params; analyzer doesn't know. + // ignore: invalid_annotation_target + @JsonKey(name: 'timestamp') required DateTime createdAt, + required String gameId, + }) = _GameChatMessage; + + factory GameChatMessage.fromJson(Map json) => + _$GameChatMessageFromJson(json); +} 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 new file mode 100644 index 0000000..85394d9 --- /dev/null +++ b/client/deepdame/lib/features/game/chat/game_chat_message.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 'game_chat_message.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$GameChatMessage { + + String get sender; String get content;@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) +@pragma('vm:prefer-inline') +$GameChatMessageCopyWith get copyWith => _$GameChatMessageCopyWithImpl(this as GameChatMessage, _$identity); + + /// Serializes this GameChatMessage to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is GameChatMessage&&(identical(other.sender, sender) || other.sender == sender)&&(identical(other.content, content) || other.content == content)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.gameId, gameId) || other.gameId == gameId)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,sender,content,createdAt,gameId); + +@override +String toString() { + return 'GameChatMessage(sender: $sender, content: $content, createdAt: $createdAt, gameId: $gameId)'; +} + + +} + +/// @nodoc +abstract mixin class $GameChatMessageCopyWith<$Res> { + factory $GameChatMessageCopyWith(GameChatMessage value, $Res Function(GameChatMessage) _then) = _$GameChatMessageCopyWithImpl; +@useResult +$Res call({ + String sender, String content,@JsonKey(name: 'timestamp') DateTime createdAt, String gameId +}); + + + + +} +/// @nodoc +class _$GameChatMessageCopyWithImpl<$Res> + implements $GameChatMessageCopyWith<$Res> { + _$GameChatMessageCopyWithImpl(this._self, this._then); + + final GameChatMessage _self; + final $Res Function(GameChatMessage) _then; + +/// Create a copy of GameChatMessage +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? sender = null,Object? content = null,Object? createdAt = null,Object? gameId = null,}) { + return _then(_self.copyWith( +sender: null == sender ? _self.sender : sender // ignore: cast_nullable_to_non_nullable +as String,content: null == content ? _self.content : content // ignore: cast_nullable_to_non_nullable +as String,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as DateTime,gameId: null == gameId ? _self.gameId : gameId // ignore: cast_nullable_to_non_nullable +as String, + )); +} + +} + + +/// Adds pattern-matching-related methods to [GameChatMessage]. +extension GameChatMessagePatterns on GameChatMessage { +/// 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( _GameChatMessage value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _GameChatMessage() 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( _GameChatMessage value) $default,){ +final _that = this; +switch (_that) { +case _GameChatMessage(): +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( _GameChatMessage value)? $default,){ +final _that = this; +switch (_that) { +case _GameChatMessage() 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 sender, String content, @JsonKey(name: 'timestamp') DateTime createdAt, String gameId)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _GameChatMessage() when $default != null: +return $default(_that.sender,_that.content,_that.createdAt,_that.gameId);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 sender, String content, @JsonKey(name: 'timestamp') DateTime createdAt, String gameId) $default,) {final _that = this; +switch (_that) { +case _GameChatMessage(): +return $default(_that.sender,_that.content,_that.createdAt,_that.gameId);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 sender, String content, @JsonKey(name: 'timestamp') DateTime createdAt, String gameId)? $default,) {final _that = this; +switch (_that) { +case _GameChatMessage() when $default != null: +return $default(_that.sender,_that.content,_that.createdAt,_that.gameId);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _GameChatMessage implements GameChatMessage { + const _GameChatMessage({required this.sender, required this.content, @JsonKey(name: 'timestamp') required this.createdAt, required this.gameId}); + factory _GameChatMessage.fromJson(Map json) => _$GameChatMessageFromJson(json); + +@override final String sender; +@override final String content; +@override@JsonKey(name: 'timestamp') final DateTime createdAt; +@override final String gameId; + +/// Create a copy of GameChatMessage +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$GameChatMessageCopyWith<_GameChatMessage> get copyWith => __$GameChatMessageCopyWithImpl<_GameChatMessage>(this, _$identity); + +@override +Map toJson() { + return _$GameChatMessageToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _GameChatMessage&&(identical(other.sender, sender) || other.sender == sender)&&(identical(other.content, content) || other.content == content)&&(identical(other.createdAt, createdAt) || other.createdAt == createdAt)&&(identical(other.gameId, gameId) || other.gameId == gameId)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,sender,content,createdAt,gameId); + +@override +String toString() { + return 'GameChatMessage(sender: $sender, content: $content, createdAt: $createdAt, gameId: $gameId)'; +} + + +} + +/// @nodoc +abstract mixin class _$GameChatMessageCopyWith<$Res> implements $GameChatMessageCopyWith<$Res> { + factory _$GameChatMessageCopyWith(_GameChatMessage value, $Res Function(_GameChatMessage) _then) = __$GameChatMessageCopyWithImpl; +@override @useResult +$Res call({ + String sender, String content,@JsonKey(name: 'timestamp') DateTime createdAt, String gameId +}); + + + + +} +/// @nodoc +class __$GameChatMessageCopyWithImpl<$Res> + implements _$GameChatMessageCopyWith<$Res> { + __$GameChatMessageCopyWithImpl(this._self, this._then); + + final _GameChatMessage _self; + final $Res Function(_GameChatMessage) _then; + +/// Create a copy of GameChatMessage +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? sender = null,Object? content = null,Object? createdAt = null,Object? gameId = null,}) { + return _then(_GameChatMessage( +sender: null == sender ? _self.sender : sender // ignore: cast_nullable_to_non_nullable +as String,content: null == content ? _self.content : content // ignore: cast_nullable_to_non_nullable +as String,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable +as DateTime,gameId: null == gameId ? _self.gameId : gameId // ignore: cast_nullable_to_non_nullable +as String, + )); +} + + +} + +// dart format on diff --git a/client/deepdame/lib/features/game/chat/game_chat_message.g.dart b/client/deepdame/lib/features/game/chat/game_chat_message.g.dart new file mode 100644 index 0000000..dff1616 --- /dev/null +++ b/client/deepdame/lib/features/game/chat/game_chat_message.g.dart @@ -0,0 +1,23 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'game_chat_message.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_GameChatMessage _$GameChatMessageFromJson(Map json) => + _GameChatMessage( + sender: json['sender'] as String, + content: json['content'] as String, + createdAt: DateTime.parse(json['timestamp'] as String), + gameId: json['gameId'] as String, + ); + +Map _$GameChatMessageToJson(_GameChatMessage instance) => + { + 'sender': instance.sender, + 'content': instance.content, + 'timestamp': instance.createdAt.toIso8601String(), + 'gameId': instance.gameId, + }; diff --git a/client/deepdame/lib/features/game/chat/game_chat_notifier.dart b/client/deepdame/lib/features/game/chat/game_chat_notifier.dart new file mode 100644 index 0000000..bb339ac --- /dev/null +++ b/client/deepdame/lib/features/game/chat/game_chat_notifier.dart @@ -0,0 +1,64 @@ +import 'dart:async'; + +import 'package:deepdame/features/game/chat/game_chat_message.dart'; +import 'package:deepdame/features/game/chat/game_chat_repository.dart'; +import 'package:deepdame/features/user/application/user_controller.dart'; +import 'package:flutter/foundation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'game_chat_notifier.g.dart'; + +@riverpod +class GameChatMessages extends _$GameChatMessages { + @override + List build(String gameId) => []; + + // Riverpod notifiers use void methods, not setters, for state mutations. + // ignore: use_setters_to_change_properties + void setHistory(List msgs) => state = msgs; + + void addMessage(GameChatMessage msg) => state = [...state, msg]; +} + +@riverpod +Future gameChatService(Ref ref, String gameId) async { + final repo = ref.watch(gameChatRepositoryProvider); + + try { + final history = await repo.getHistory(gameId); + ref.read(gameChatMessagesProvider(gameId).notifier).setHistory(history); + } on Object catch (e) { + debugPrint('gameChatService: failed to load history: $e'); + } + + final sub = repo + .watchMessages(gameId) + .listen( + (msg) { + ref.read(gameChatMessagesProvider(gameId).notifier).addMessage(msg); + final myUsername = ref + .read(userControllerProvider) + .asData + ?.value + .username; + if (myUsername == null || msg.sender != myUsername) { + ref.read(gameChatUnreadProvider(gameId).notifier).markUnread(); + } + }, + onError: (Object e, StackTrace st) => + debugPrint('gameChatService: stream error: $e'), + cancelOnError: false, + ); + + ref.onDispose(() => unawaited(sub.cancel())); +} + +@riverpod +class GameChatUnread extends _$GameChatUnread { + @override + bool build(String gameId) => false; + + void markUnread() => state = true; + + void clear() => state = false; +} 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 new file mode 100644 index 0000000..eec27d2 --- /dev/null +++ b/client/deepdame/lib/features/game/chat/game_chat_notifier.g.dart @@ -0,0 +1,266 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'game_chat_notifier.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(GameChatMessages) +final gameChatMessagesProvider = GameChatMessagesFamily._(); + +final class GameChatMessagesProvider + extends $NotifierProvider> { + GameChatMessagesProvider._({ + required GameChatMessagesFamily super.from, + required String super.argument, + }) : super( + retry: null, + name: r'gameChatMessagesProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$gameChatMessagesHash(); + + @override + String toString() { + return r'gameChatMessagesProvider' + '' + '($argument)'; + } + + @$internal + @override + GameChatMessages create() => GameChatMessages(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(List value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider>(value), + ); + } + + @override + bool operator ==(Object other) { + return other is GameChatMessagesProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$gameChatMessagesHash() => r'835a2cdb241ef36271e0c7f7e95c3a1b9a1738b4'; + +final class GameChatMessagesFamily extends $Family + with + $ClassFamilyOverride< + GameChatMessages, + List, + List, + List, + String + > { + GameChatMessagesFamily._() + : super( + retry: null, + name: r'gameChatMessagesProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + GameChatMessagesProvider call(String gameId) => + GameChatMessagesProvider._(argument: gameId, from: this); + + @override + String toString() => r'gameChatMessagesProvider'; +} + +abstract class _$GameChatMessages extends $Notifier> { + late final _$args = ref.$arg as String; + String get gameId => _$args; + + List build(String gameId); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref, List>; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, List>, + List, + Object?, + Object? + >; + element.handleCreate(ref, () => build(_$args)); + } +} + +@ProviderFor(gameChatService) +final gameChatServiceProvider = GameChatServiceFamily._(); + +final class GameChatServiceProvider + extends $FunctionalProvider, void, FutureOr> + with $FutureModifier, $FutureProvider { + GameChatServiceProvider._({ + required GameChatServiceFamily super.from, + required String super.argument, + }) : super( + retry: null, + name: r'gameChatServiceProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$gameChatServiceHash(); + + @override + String toString() { + return r'gameChatServiceProvider' + '' + '($argument)'; + } + + @$internal + @override + $FutureProviderElement $createElement($ProviderPointer pointer) => + $FutureProviderElement(pointer); + + @override + FutureOr create(Ref ref) { + final argument = this.argument as String; + return gameChatService(ref, argument); + } + + @override + bool operator ==(Object other) { + return other is GameChatServiceProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$gameChatServiceHash() => r'ab14af831968aa29a3324645abb016c148b4a140'; + +final class GameChatServiceFamily extends $Family + with $FunctionalFamilyOverride, String> { + GameChatServiceFamily._() + : super( + retry: null, + name: r'gameChatServiceProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + GameChatServiceProvider call(String gameId) => + GameChatServiceProvider._(argument: gameId, from: this); + + @override + String toString() => r'gameChatServiceProvider'; +} + +@ProviderFor(GameChatUnread) +final gameChatUnreadProvider = GameChatUnreadFamily._(); + +final class GameChatUnreadProvider + extends $NotifierProvider { + GameChatUnreadProvider._({ + required GameChatUnreadFamily super.from, + required String super.argument, + }) : super( + retry: null, + name: r'gameChatUnreadProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$gameChatUnreadHash(); + + @override + String toString() { + return r'gameChatUnreadProvider' + '' + '($argument)'; + } + + @$internal + @override + GameChatUnread create() => GameChatUnread(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(bool value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } + + @override + bool operator ==(Object other) { + return other is GameChatUnreadProvider && other.argument == argument; + } + + @override + int get hashCode { + return argument.hashCode; + } +} + +String _$gameChatUnreadHash() => r'671e67ef39310b644927185621f47e270db9a630'; + +final class GameChatUnreadFamily extends $Family + with $ClassFamilyOverride { + GameChatUnreadFamily._() + : super( + retry: null, + name: r'gameChatUnreadProvider', + dependencies: null, + $allTransitiveDependencies: null, + isAutoDispose: true, + ); + + GameChatUnreadProvider call(String gameId) => + GameChatUnreadProvider._(argument: gameId, from: this); + + @override + String toString() => r'gameChatUnreadProvider'; +} + +abstract class _$GameChatUnread extends $Notifier { + late final _$args = ref.$arg as String; + String get gameId => _$args; + + bool build(String gameId); + @$mustCallSuper + @override + void runBuild() { + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + bool, + Object?, + Object? + >; + element.handleCreate(ref, () => build(_$args)); + } +} diff --git a/client/deepdame/lib/features/game/chat/game_chat_repository.dart b/client/deepdame/lib/features/game/chat/game_chat_repository.dart new file mode 100644 index 0000000..60530a0 --- /dev/null +++ b/client/deepdame/lib/features/game/chat/game_chat_repository.dart @@ -0,0 +1,42 @@ +import 'package:deepdame/core/network/dio_client.dart'; +import 'package:deepdame/core/network/stomp_service.dart'; +import 'package:deepdame/features/game/chat/game_chat_message.dart'; +import 'package:dio/dio.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'game_chat_repository.g.dart'; + +@riverpod +GameChatRepository gameChatRepository(Ref ref) { + return GameChatRepository( + ref.watch(dioProvider), + ref.watch(stompServiceProvider), + ); +} + +class GameChatRepository { + GameChatRepository(this._dio, this._stomp); + + final Dio _dio; + final StompService _stomp; + + Future> getHistory(String gameId) async { + try { + final response = await _dio.get>( + '/api/v1/game/$gameId/chat', + ); + return (response.data ?? const []) + .map((json) => GameChatMessage.fromJson(json as Map)) + .toList(); + } on DioException catch (e) { + throw Exception('Failed to load chat history: ${e.message}'); + } + } + + void sendMessage(String gameId, String content) => + _stomp.send('/app/game/$gameId/chat', {'content': content}); + + Stream watchMessages(String gameId) => _stomp + .subscribe('/topic/game/$gameId/chat') + .map(GameChatMessage.fromJson); +} diff --git a/client/deepdame/lib/features/game/chat/game_chat_repository.g.dart b/client/deepdame/lib/features/game/chat/game_chat_repository.g.dart new file mode 100644 index 0000000..ee9cfb7 --- /dev/null +++ b/client/deepdame/lib/features/game/chat/game_chat_repository.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'game_chat_repository.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(gameChatRepository) +final gameChatRepositoryProvider = GameChatRepositoryProvider._(); + +final class GameChatRepositoryProvider + extends + $FunctionalProvider< + GameChatRepository, + GameChatRepository, + GameChatRepository + > + with $Provider { + GameChatRepositoryProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'gameChatRepositoryProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$gameChatRepositoryHash(); + + @$internal + @override + $ProviderElement $createElement( + $ProviderPointer pointer, + ) => $ProviderElement(pointer); + + @override + GameChatRepository create(Ref ref) { + return gameChatRepository(ref); + } + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(GameChatRepository value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$gameChatRepositoryHash() => + r'cc8cec6a64bb5e807f4a1e86d1d7e22873adcc04'; diff --git a/client/deepdame/lib/features/game/chat/game_chat_sheet.dart b/client/deepdame/lib/features/game/chat/game_chat_sheet.dart new file mode 100644 index 0000000..0d5b2e0 --- /dev/null +++ b/client/deepdame/lib/features/game/chat/game_chat_sheet.dart @@ -0,0 +1,239 @@ +import 'dart:async'; + +import 'package:deepdame/features/chat/data/message.dart'; +import 'package:deepdame/features/chat/presentation/chat_message.dart'; +import 'package:deepdame/features/game/chat/game_chat_notifier.dart'; +import 'package:deepdame/features/game/chat/game_chat_repository.dart'; +import 'package:deepdame/features/user/application/user_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class GameChatSheet extends ConsumerStatefulWidget { + const GameChatSheet({ + required this.gameId, + required this.opponentName, + this.opponentImageUrl, + super.key, + }); + + final String gameId; + final String opponentName; + final String? opponentImageUrl; + + @override + ConsumerState createState() => _GameChatSheetState(); +} + +class _GameChatSheetState extends ConsumerState { + final _scrollController = ScrollController(); + final _textController = TextEditingController(); + bool _canSend = false; + + @override + void initState() { + super.initState(); + _textController.addListener(_onTextChanged); + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(gameChatUnreadProvider(widget.gameId).notifier).clear(); + _scrollToBottom(); + }); + } + + @override + void dispose() { + _scrollController.dispose(); + _textController.dispose(); + super.dispose(); + } + + void _onTextChanged() { + final canSend = _textController.text.trim().isNotEmpty; + if (canSend != _canSend) setState(() => _canSend = canSend); + } + + bool _isNearBottom() { + if (!_scrollController.hasClients) return true; + final pos = _scrollController.position; + return pos.pixels >= pos.maxScrollExtent - 80; + } + + void _scrollToBottom() { + if (!_scrollController.hasClients) return; + unawaited( + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ), + ); + } + + void _sendMessage() { + final content = _textController.text.trim(); + if (content.isEmpty) return; + ref.read(gameChatRepositoryProvider).sendMessage(widget.gameId, content); + _textController.clear(); + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + final messages = ref.watch(gameChatMessagesProvider(widget.gameId)); + final myUser = ref.watch(userControllerProvider).asData?.value; + final myUsername = myUser?.username; + final myImageUrl = myUser?.imageUrl; + + ref.listen(gameChatMessagesProvider(widget.gameId), (prev, next) { + if (next.length > (prev?.length ?? 0) && _isNearBottom()) { + WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToBottom()); + } + }); + + // Lift the entire sheet above the keyboard so the input bar is never + // covered. The inner padding then only needs the safe-area inset. + final keyboardInset = MediaQuery.of(context).viewInsets.bottom; + + return Padding( + padding: EdgeInsets.only(bottom: keyboardInset), + child: DraggableScrollableSheet( + initialChildSize: 0.55, + minChildSize: 0.3, + maxChildSize: 0.9, + expand: false, + builder: (_, _) { + return Container( + decoration: BoxDecoration( + color: scheme.surface, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(20), + ), + ), + child: Column( + children: [ + // Grabber pill + Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: scheme.onSurface.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + // Title + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Game Chat', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + ), + // Message list + Expanded( + child: messages.isEmpty + ? Center( + child: Text( + 'No messages yet. Say something!', + style: TextStyle( + color: scheme.onSurface.withValues(alpha: 0.45), + ), + ), + ) + : ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.only(bottom: 8), + itemCount: messages.length, + itemBuilder: (_, i) { + final msg = messages[i]; + final isOwn = msg.sender == myUsername; + final avatarUrl = isOwn + ? myImageUrl + : widget.opponentImageUrl; + final isFirst = + i == 0 || messages[i - 1].sender != msg.sender; + final isLast = + i == messages.length - 1 || + messages[i + 1].sender != msg.sender; + return ChatMessage( + message: Message( + message: msg.content, + user: { + 'username': msg.sender, + 'imageUrl': avatarUrl, + }, + createdAt: msg.createdAt, + ), + isOwn: isOwn, + isFirstInGroup: isFirst, + isLastInGroup: isLast, + ); + }, + ), + ), + // Input bar — sheet is already above the keyboard via the + // outer Padding, so only the safe-area inset is needed here. + Padding( + padding: EdgeInsets.fromLTRB( + 12, + 8, + 12, + MediaQuery.of(context).padding.bottom + 8, + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _textController, + maxLength: 500, + maxLines: null, + textInputAction: TextInputAction.send, + onSubmitted: (_) { + if (_canSend) _sendMessage(); + }, + decoration: InputDecoration( + hintText: 'Message...', + counterText: '', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: scheme.onSurface.withValues(alpha: 0.07), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + ), + ), + ), + const SizedBox(width: 8), + FloatingActionButton.small( + heroTag: 'game_chat_send', + onPressed: _canSend ? _sendMessage : null, + backgroundColor: _canSend + ? scheme.primary + : scheme.onSurface.withValues(alpha: 0.12), + foregroundColor: _canSend + ? scheme.onPrimary + : scheme.onSurface.withValues(alpha: 0.38), + elevation: 0, + child: const Icon(Icons.send_rounded), + ), + ], + ), + ), + ], + ), + ); + }, + ), + ); + } +} diff --git a/client/deepdame/lib/features/game/presentaion/screens/game_screen.dart b/client/deepdame/lib/features/game/presentaion/screens/game_screen.dart index 554a37a..14c8717 100644 --- a/client/deepdame/lib/features/game/presentaion/screens/game_screen.dart +++ b/client/deepdame/lib/features/game/presentaion/screens/game_screen.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'package:deepdame/core/audio/sound_service.dart'; +import 'package:deepdame/features/game/chat/game_chat_notifier.dart'; +import 'package:deepdame/features/game/chat/game_chat_sheet.dart'; import 'package:deepdame/features/game/engine/model/piece_type.dart'; import 'package:deepdame/features/game/presentaion/providers/game_notifier.dart'; import 'package:deepdame/features/game/presentaion/providers/turn_timer_provider.dart'; @@ -37,6 +39,9 @@ class GameScreen extends ConsumerWidget { ), ); final userAsync = ref.watch(userControllerProvider); + // Keep the chat subscription alive for the full game-screen lifetime. + ref.watch(gameChatServiceProvider(gameId)); + final hasUnread = ref.watch(gameChatUnreadProvider(gameId)); ref ..listen( @@ -107,7 +112,11 @@ class GameScreen extends ConsumerWidget { } } }, - ); + ) + // Keep GameChatMessages alive while the game screen is open so the + // message list survives sheet open/close cycles. ref.listen does not + // rebuild the game screen on incoming messages (unlike ref.watch). + ..listen(gameChatMessagesProvider(gameId), (_, _) {}); return Scaffold( extendBodyBehindAppBar: true, @@ -151,17 +160,46 @@ class GameScreen extends ConsumerWidget { ), floatingActionButton: FloatingActionButton.small( onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Chat coming soon'), - duration: Duration(seconds: 2), + ref.read(gameChatUnreadProvider(gameId).notifier).clear(); + unawaited( + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => GameChatSheet( + gameId: gameId, + opponentName: params.opponentName, + opponentImageUrl: params.opponentImageUrl, + ), ), ); }, backgroundColor: Theme.of(context).colorScheme.primary, foregroundColor: Theme.of(context).colorScheme.onPrimary, tooltip: 'Chat', - child: const Icon(Icons.chat_bubble_outline), + child: Stack( + clipBehavior: Clip.none, + children: [ + const Icon(Icons.chat_bubble_outline), + if (hasUnread) + Positioned( + top: -3, + right: -3, + child: Container( + width: 9, + height: 9, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.error, + shape: BoxShape.circle, + border: Border.all( + color: Theme.of(context).colorScheme.primary, + width: 1.5, + ), + ), + ), + ), + ], + ), ), body: Stack( children: [ diff --git a/client/deepdame/pubspec.yaml b/client/deepdame/pubspec.yaml index fd0749e..0ed5de5 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.22+23 +version: 2.0.23+24 environment: sdk: ^3.10.4 diff --git a/server/DeepDame/src/main/java/com/deepdame/controller/GameChatController.java b/server/DeepDame/src/main/java/com/deepdame/controller/GameChatController.java new file mode 100644 index 0000000..65f3a6d --- /dev/null +++ b/server/DeepDame/src/main/java/com/deepdame/controller/GameChatController.java @@ -0,0 +1,45 @@ +package com.deepdame.controller; + +import com.deepdame.dto.game.GameDto; +import com.deepdame.dto.redis.GameChatMessage; +import com.deepdame.exception.ForbiddenException; +import com.deepdame.security.CustomUserDetails; +import com.deepdame.service.game.GameService; +import com.deepdame.service.gameChat.GameChatStorageService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/v1/game") +@RequiredArgsConstructor +public class GameChatController { + + private final GameChatStorageService gameChatStorageService; + private final GameService gameService; + + @GetMapping("/{gameId}/chat") + public ResponseEntity> getGameChat( + @AuthenticationPrincipal CustomUserDetails principal, + @PathVariable UUID gameId + ) { + UUID userId = principal.getUser().getId(); + GameDto game = gameService.findById(gameId); + + boolean isBlack = userId.equals(game.getPlayerBlackId()); + boolean isWhite = userId.equals(game.getPlayerWhiteId()); + + if (!isBlack && !isWhite) { + throw new ForbiddenException("Not a participant in this game"); + } + + return ResponseEntity.ok(gameChatStorageService.getMessages(gameId)); + } +} diff --git a/server/DeepDame/src/main/java/com/deepdame/exception/ForbiddenException.java b/server/DeepDame/src/main/java/com/deepdame/exception/ForbiddenException.java new file mode 100644 index 0000000..af249ba --- /dev/null +++ b/server/DeepDame/src/main/java/com/deepdame/exception/ForbiddenException.java @@ -0,0 +1,7 @@ +package com.deepdame.exception; + +public class ForbiddenException extends RuntimeException { + public ForbiddenException(String message) { + super(message); + } +} diff --git a/server/DeepDame/src/main/java/com/deepdame/exception/GlobalExceptionHandler.java b/server/DeepDame/src/main/java/com/deepdame/exception/GlobalExceptionHandler.java index 0437ad5..0da3dcb 100644 --- a/server/DeepDame/src/main/java/com/deepdame/exception/GlobalExceptionHandler.java +++ b/server/DeepDame/src/main/java/com/deepdame/exception/GlobalExceptionHandler.java @@ -82,6 +82,13 @@ public ResponseEntity> handleDataIntegrityException(DataInte return new ResponseEntity<>(errorResponse, HttpStatus.CONFLICT); } + @ExceptionHandler(ForbiddenException.class) + public ResponseEntity> handleForbiddenException(ForbiddenException ex) { + Map errorResponse = new HashMap<>(); + errorResponse.put("error", ex.getMessage()); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(errorResponse); + } + @ExceptionHandler(Unauthorized.class) public ResponseEntity> handleUnauthorized(Unauthorized ex) { Map errorResponse = new HashMap<>(); diff --git a/server/DeepDame/src/main/java/com/deepdame/service/game/GameServiceImpl.java b/server/DeepDame/src/main/java/com/deepdame/service/game/GameServiceImpl.java index bd40822..3a2933c 100644 --- a/server/DeepDame/src/main/java/com/deepdame/service/game/GameServiceImpl.java +++ b/server/DeepDame/src/main/java/com/deepdame/service/game/GameServiceImpl.java @@ -20,6 +20,7 @@ import com.deepdame.service.ai.AiOrchestrator; import com.deepdame.service.cache.GameCacheService; import com.deepdame.service.cache.RedisNotificationService; +import com.deepdame.service.gameChat.GameChatStorageService; import com.deepdame.service.statistic.StatisticsService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -44,6 +45,7 @@ public class GameServiceImpl implements GameService{ private final StatisticsService statisticsService; private final UserRepository userRepository; private final RedisNotificationService redisNotificationService; + private final GameChatStorageService gameChatStorageService; private final GameEngine gameEngine; @@ -327,6 +329,7 @@ private void handleGameOver(GameDocument doc) { gameRepository.save(doc); gameCacheService.deleteGame(doc.getId()); + gameChatStorageService.clearChat(doc.getId()); gameCacheService.removeFromLobby(doc.getId()); redisNotificationService.sendMessage(LobbyUpdate.removed(doc.getId()), "lobby-updates"); diff --git a/server/DeepDame/src/main/java/com/deepdame/service/gameChat/GameChatStorageService.java b/server/DeepDame/src/main/java/com/deepdame/service/gameChat/GameChatStorageService.java new file mode 100644 index 0000000..28f8aa6 --- /dev/null +++ b/server/DeepDame/src/main/java/com/deepdame/service/gameChat/GameChatStorageService.java @@ -0,0 +1,15 @@ +package com.deepdame.service.gameChat; + +import com.deepdame.dto.redis.GameChatMessage; + +import java.util.List; +import java.util.UUID; + +public interface GameChatStorageService { + + void storeMessage(UUID gameId, GameChatMessage message); + + List getMessages(UUID gameId); + + void clearChat(UUID gameId); +} diff --git a/server/DeepDame/src/main/java/com/deepdame/service/gameChat/GameChatStorageServiceImpl.java b/server/DeepDame/src/main/java/com/deepdame/service/gameChat/GameChatStorageServiceImpl.java new file mode 100644 index 0000000..b3d274c --- /dev/null +++ b/server/DeepDame/src/main/java/com/deepdame/service/gameChat/GameChatStorageServiceImpl.java @@ -0,0 +1,62 @@ +package com.deepdame.service.gameChat; + +import com.deepdame.dto.redis.GameChatMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; +import tools.jackson.databind.ObjectMapper; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GameChatStorageServiceImpl implements GameChatStorageService { + + private final StringRedisTemplate stringTemplate; + private final ObjectMapper objectMapper; + + private static final String KEY_PREFIX = "chat:game:"; + private static final long CHAT_TTL_HOURS = 24; + + @Override + public void storeMessage(UUID gameId, GameChatMessage message) { + String key = KEY_PREFIX + gameId; + try { + String json = objectMapper.writeValueAsString(message); + stringTemplate.opsForList().rightPush(key, json); + stringTemplate.expire(key, CHAT_TTL_HOURS, TimeUnit.HOURS); + } catch (Exception e) { + log.error("Failed to store game chat message for game {}: {}", gameId, e.getMessage()); + } + } + + @Override + public List getMessages(UUID gameId) { + String key = KEY_PREFIX + gameId; + List entries = stringTemplate.opsForList().range(key, 0, -1); + + if (entries == null || entries.isEmpty()) { + return List.of(); + } + + List messages = new ArrayList<>(entries.size()); + for (String entry : entries) { + try { + messages.add(objectMapper.readValue(entry, GameChatMessage.class)); + } catch (Exception e) { + log.warn("Skipping malformed game chat entry for game {}: {}", gameId, e.getMessage()); + } + } + return messages; + } + + @Override + public void clearChat(UUID gameId) { + stringTemplate.delete(KEY_PREFIX + gameId); + } +} diff --git a/server/DeepDame/src/main/java/com/deepdame/websockets/GameSocketController.java b/server/DeepDame/src/main/java/com/deepdame/websockets/GameSocketController.java index 6637745..eb5d07f 100644 --- a/server/DeepDame/src/main/java/com/deepdame/websockets/GameSocketController.java +++ b/server/DeepDame/src/main/java/com/deepdame/websockets/GameSocketController.java @@ -12,6 +12,7 @@ import com.deepdame.security.CustomUserDetails; import com.deepdame.service.cache.RedisNotificationService; import com.deepdame.service.game.GameService; +import com.deepdame.service.gameChat.GameChatStorageService; import com.deepdame.service.user.UserService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -36,6 +37,7 @@ public class GameSocketController { private final UserService userService; private final SimpMessagingTemplate messagingTemplate; private final RedisNotificationService redisNotificationService; + private final GameChatStorageService gameChatStorageService; @MessageMapping("/game/create") public void createGame(@AuthenticationPrincipal CustomUserDetails user, @Payload CreateGameRequest request){ @@ -167,7 +169,15 @@ public void surrenderGame(@AuthenticationPrincipal CustomUserDetails user, @Dest @MessageMapping("/game/{gameId}/chat") public void sendGameMessage(@AuthenticationPrincipal CustomUserDetails user, @DestinationVariable UUID gameId, @Payload ChatRequest request) { - String username = user.getUsername(); + if (user.getUser().getBannedFromChat() || user.getUser().getBannedFromApp()) { + throw new WsUnauthorized("Banned users cannot chat"); + } + + if (request.content() == null || request.content().isBlank() || request.content().length() > 500) { + throw new WsUnauthorized("Invalid message"); + } + + String username = user.getUser().getUsername(); UUID userId = user.getUser().getId(); GameDto game = gameService.findById(gameId); @@ -186,8 +196,8 @@ public void sendGameMessage(@AuthenticationPrincipal CustomUserDetails user, @De gameId ); + gameChatStorageService.storeMessage(gameId, response); redisNotificationService.sendMessage(response, "game-chat"); - } private void broadcastAiMove(GameDto game, Move userMove){