diff --git a/lib/src/model/game/chat_controller.dart b/lib/src/model/game/chat_controller.dart new file mode 100644 index 0000000000..1e1fb1f53e --- /dev/null +++ b/lib/src/model/game/chat_controller.dart @@ -0,0 +1,87 @@ +import 'dart:async'; + +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:lichess_mobile/src/model/auth/auth_socket.dart'; +import 'package:lichess_mobile/src/model/common/id.dart'; +import 'package:lichess_mobile/src/model/common/socket.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +part 'chat_controller.freezed.dart'; +part 'chat_controller.g.dart'; + +@riverpod +class ChatController extends _$ChatController { + StreamSubscription? _socketSubscription; + + AuthSocket get _socket => ref.read(authSocketProvider); + + @override + ChatState build(ID chatContext) { + _socketSubscription = _socket.stream.listen(_handleSocketTopic); + + ref.onDispose(() { + _socketSubscription?.cancel(); + }); + + return ChatState( + messages: IList(), + unreadMessages: 0, + ); + } + + void setMessages(IList messages) { + state = ChatState( + messages: messages, + unreadMessages: 0, + ); + } + + void onUserMessage(String message) { + _socket.send( + 'talk', + message, + ); + } + + void resetUnreadMessages() { + state = state.copyWith(unreadMessages: 0); + } + + void _handleSocketTopic(SocketEvent event) { + switch (event.topic) { + // Called when a message is received + case 'message': + final data = event.data as Map; + final message = data["t"] as String; + final username = data["u"] as String; + state = state.copyWith( + messages: state.messages.add( + Message( + message: message, + username: username, + ), + ), + unreadMessages: state.unreadMessages + 1, + ); + } + } +} + +@freezed +class ChatState with _$ChatState { + const ChatState._(); + + const factory ChatState({ + required IList messages, + required int unreadMessages, + }) = _ChatState; +} + +@immutable +class Message { + final String username; + final String message; + + const Message({required this.username, required this.message}); +} diff --git a/lib/src/model/game/game_controller.dart b/lib/src/model/game/game_controller.dart index 6ca23961f3..e55ca502c9 100644 --- a/lib/src/model/game/game_controller.dart +++ b/lib/src/model/game/game_controller.dart @@ -17,6 +17,7 @@ import 'package:lichess_mobile/src/model/common/socket.dart'; import 'package:lichess_mobile/src/model/common/speed.dart'; import 'package:lichess_mobile/src/model/correspondence/correspondence_game_storage.dart'; import 'package:lichess_mobile/src/model/correspondence/offline_correspondence_game.dart'; +import 'package:lichess_mobile/src/model/game/chat_controller.dart'; import 'package:lichess_mobile/src/model/game/game.dart'; import 'package:lichess_mobile/src/model/game/game_socket_events.dart'; import 'package:lichess_mobile/src/model/game/game_status.dart'; @@ -59,6 +60,7 @@ class GameController extends _$GameController { @override Future build(GameFullId gameFullId) { final socket = ref.watch(authSocketProvider); + final chatNotifier = ref.watch(chatControllerProvider(gameFullId).notifier); final (stream, _) = socket.connect(Uri(path: '/play/$gameFullId/v6')); _socketEventVersion = null; _socketSubscription?.cancel(); @@ -70,18 +72,22 @@ class GameController extends _$GameController { _transientMoveTimer?.cancel(); }); - return stream.firstWhere((e) => e.topic == 'full').then((event) { - final fullEvent = - GameFullEvent.fromJson(event.data as Map); + return stream.firstWhere((e) => e.topic == 'full').then( + (event) { + final fullEvent = + GameFullEvent.fromJson(event.data as Map); - _socketEventVersion = fullEvent.socketEventVersion; + _socketEventVersion = fullEvent.socketEventVersion; - return GameState( - game: fullEvent.game, - stepCursor: fullEvent.game.steps.length - 1, - stopClockWaitingForServerAck: false, - ); - }); + chatNotifier.setMessages(fullEvent.game.messages); + + return GameState( + game: fullEvent.game, + stepCursor: fullEvent.game.steps.length - 1, + stopClockWaitingForServerAck: false, + ); + }, + ); } void onUserMove(Move move, {bool? isDrop, bool? isPremove}) { @@ -431,6 +437,10 @@ class GameController extends _$GameController { _socketEventVersion = fullEvent.socketEventVersion; _lastMoveTime = null; + ref + .read(chatControllerProvider(gameFullId).notifier) + .setMessages(fullEvent.game.messages); + state = AsyncValue.data( GameState( game: fullEvent.game, diff --git a/lib/src/model/game/playable_game.dart b/lib/src/model/game/playable_game.dart index ac226d83ec..7cc52612f8 100644 --- a/lib/src/model/game/playable_game.dart +++ b/lib/src/model/game/playable_game.dart @@ -7,6 +7,7 @@ import 'package:lichess_mobile/src/model/common/chess.dart'; import 'package:lichess_mobile/src/model/common/id.dart'; import 'package:lichess_mobile/src/model/common/perf.dart'; import 'package:lichess_mobile/src/model/common/speed.dart'; +import 'package:lichess_mobile/src/model/game/chat_controller.dart'; import 'package:lichess_mobile/src/model/game/material_diff.dart'; import 'package:lichess_mobile/src/model/user/user.dart'; import 'package:lichess_mobile/src/utils/json.dart'; @@ -38,6 +39,7 @@ class PlayableGame required Player black, required bool moretimeable, required bool takebackable, + required IList messages, /// The side that the current player is playing as. This is null if viewing /// the game as a spectator. @@ -141,6 +143,7 @@ PlayableGame _playableGameFromPick(RequiredPick pick) { ); } } + final messages = pick('chat', 'lines').asListOrThrow(_messageFromPick); return PlayableGame( id: requiredGamePick('id').asGameIdOrThrow(), @@ -174,6 +177,7 @@ PlayableGame _playableGameFromPick(RequiredPick pick) { }, ), rematch: pick('game', 'rematch').asGameIdOrNull(), + messages: messages.toIList(), ); } @@ -248,3 +252,10 @@ CorrespondenceClockData _correspondenceClockDataFromPick(RequiredPick pick) { black: pick('black').asDurationFromSecondsOrThrow(), ); } + +Message _messageFromPick(RequiredPick pick) { + return Message( + message: pick('t').asStringOrThrow(), + username: pick('u').asStringOrThrow(), + ); +} diff --git a/lib/src/view/game/game_body.dart b/lib/src/view/game/game_body.dart index d171b489df..6b4e8ceb3a 100644 --- a/lib/src/view/game/game_body.dart +++ b/lib/src/view/game/game_body.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:chessground/chessground.dart' as cg; import 'package:collection/collection.dart'; @@ -10,6 +11,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lichess_mobile/src/model/account/account_repository.dart'; import 'package:lichess_mobile/src/model/common/id.dart'; import 'package:lichess_mobile/src/model/common/speed.dart'; +import 'package:lichess_mobile/src/model/game/chat_controller.dart'; import 'package:lichess_mobile/src/model/game/game_controller.dart'; import 'package:lichess_mobile/src/model/game/game_repository_providers.dart'; import 'package:lichess_mobile/src/model/lobby/game_seek.dart'; @@ -21,10 +23,12 @@ import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/utils/navigation.dart'; import 'package:lichess_mobile/src/view/analysis/analysis_screen.dart'; import 'package:lichess_mobile/src/view/game/correspondence_clock_widget.dart'; +import 'package:lichess_mobile/src/view/game/message_screen.dart'; import 'package:lichess_mobile/src/widgets/adaptive_action_sheet.dart'; import 'package:lichess_mobile/src/widgets/board_table.dart'; import 'package:lichess_mobile/src/widgets/buttons.dart'; import 'package:lichess_mobile/src/widgets/countdown_clock.dart'; +import 'package:lichess_mobile/src/widgets/user_full_name.dart'; import 'package:lichess_mobile/src/widgets/yes_no_dialog.dart'; import 'game_common_widgets.dart'; @@ -342,6 +346,7 @@ class _GameBottomBar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final ongoingGames = ref.watch(ongoingGamesProvider); + final chatState = ref.watch(chatControllerProvider(id)); return Container( padding: Styles.horizontalBodyPadding, @@ -523,9 +528,62 @@ class _GameBottomBar extends ConsumerWidget { orElse: () => null, ), ), - // TODO replace this space with chat button - const SizedBox( - width: 44.0, + Stack( + children: [ + BottomBarButton( + label: context.l10n.chat, + shortLabel: context.l10n.chat, + onTap: () { + pushPlatformRoute( + context, + fullscreenDialog: true, + builder: (BuildContext context) { + return MessageScreen( + title: UserFullNameWidget( + user: gameState.game.opponent?.user, + ), + me: gameState.game.me?.user, + chatContext: id, + ); + }, + ); + }, + icon: defaultTargetPlatform == TargetPlatform.iOS + ? CupertinoIcons.chat_bubble + : Icons.chat_bubble_outline, + ), + if (chatState.unreadMessages > 0) + Positioned( + top: 2.0, + right: 2.0, + child: Stack( + alignment: Alignment.center, + children: [ + Icon( + Icons.brightness_1, + size: 20.0, + color: defaultTargetPlatform == TargetPlatform.iOS + ? CupertinoColors.activeBlue.resolveFrom(context) + : Theme.of(context).colorScheme.primary, + ), + FittedBox( + fit: BoxFit.contain, + child: Text( + math.min(9, chatState.unreadMessages).toString(), + style: TextStyle( + color: defaultTargetPlatform == TargetPlatform.iOS + ? Colors.white + : Theme.of(context).colorScheme.onPrimary, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ) + else + const SizedBox.shrink(), + ], ), RepeatButton( onLongPress: diff --git a/lib/src/view/game/lobby_game_screen.dart b/lib/src/view/game/lobby_game_screen.dart index 5e7a8f8ef1..0453be9868 100644 --- a/lib/src/view/game/lobby_game_screen.dart +++ b/lib/src/view/game/lobby_game_screen.dart @@ -65,6 +65,7 @@ class _GameScreenState extends ConsumerState Widget _androidBuilder(BuildContext context) { return Scaffold( + resizeToAvoidBottomInset: false, appBar: GameAppBar(seek: widget.seek), body: _LoadLobbyGame( seek: widget.seek, @@ -76,6 +77,7 @@ class _GameScreenState extends ConsumerState Widget _iosBuilder(BuildContext context) { return CupertinoPageScaffold( + resizeToAvoidBottomInset: false, navigationBar: GameCupertinoNavBar(seek: widget.seek), child: _LoadLobbyGame( seek: widget.seek, diff --git a/lib/src/view/game/message_screen.dart b/lib/src/view/game/message_screen.dart new file mode 100644 index 0000000000..07cb6315ea --- /dev/null +++ b/lib/src/view/game/message_screen.dart @@ -0,0 +1,288 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lichess_mobile/src/model/common/id.dart'; +import 'package:lichess_mobile/src/model/game/chat_controller.dart'; +import 'package:lichess_mobile/src/model/settings/brightness.dart'; +import 'package:lichess_mobile/src/model/user/user.dart'; +import 'package:lichess_mobile/src/navigation.dart'; +import 'package:lichess_mobile/src/styles/lichess_colors.dart'; +import 'package:lichess_mobile/src/styles/styles.dart'; +import 'package:lichess_mobile/src/utils/l10n_context.dart'; +import 'package:lichess_mobile/src/widgets/adaptive_text_field.dart'; +import 'package:lichess_mobile/src/widgets/buttons.dart'; +import 'package:lichess_mobile/src/widgets/platform.dart'; + +class MessageScreen extends ConsumerStatefulWidget { + final ID chatContext; + final Widget title; + final LightUser? me; + + const MessageScreen({ + required this.chatContext, + required this.title, + this.me, + }); + + @override + ConsumerState createState() => _MessageScreenState(); +} + +class _MessageScreenState extends ConsumerState with RouteAware { + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final route = ModalRoute.of(context); + if (route != null && route is PageRoute) { + rootNavPageRouteObserver.subscribe(this, route); + } + } + + @override + void dispose() { + rootNavPageRouteObserver.unsubscribe(this); + super.dispose(); + } + + @override + void didPop() { + ref + .read(chatControllerProvider(widget.chatContext).notifier) + .resetUnreadMessages(); + super.didPop(); + } + + @override + Widget build(BuildContext context) { + final body = _Body(me: widget.me, chatContext: widget.chatContext); + + return PlatformWidget( + androidBuilder: (context) => + _androidBuilder(context: context, body: body), + iosBuilder: (context) => _iosBuilder(context: context, body: body), + ); + } + + Widget _androidBuilder({ + required BuildContext context, + required Widget body, + }) { + return Scaffold( + appBar: AppBar( + title: widget.title, + centerTitle: true, + ), + body: body, + ); + } + + Widget _iosBuilder({ + required BuildContext context, + required Widget body, + }) { + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: widget.title, + ), + child: body, + ); + } +} + +class _Body extends ConsumerWidget { + final ID chatContext; + final LightUser? me; + + const _Body({ + required this.chatContext, + required this.me, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final chatState = ref.watch(chatControllerProvider(chatContext)); + + return Column( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: ListView.builder( + // remove the automatic bottom padding of the ListView, which on iOS + // corresponds to the safe area insets + // and which is here taken care of by the _ChatBottomBar + padding: MediaQuery.of(context).padding.copyWith(bottom: 0), + reverse: true, + itemCount: chatState.messages.length, + itemBuilder: (context, index) { + final message = + chatState.messages[chatState.messages.length - index - 1]; + return (message.username == "lichess") + ? _MessageAction(message: message.message) + : (message.username == me?.name) + ? _MessageBubble( + you: true, + message: message.message, + ) + : _MessageBubble( + you: false, + message: message.message, + ); + }, + ), + ), + ), + _ChatBottomBar(chatContext: chatContext), + ], + ); + } +} + +class _MessageBubble extends ConsumerWidget { + final bool you; + final String message; + + const _MessageBubble({required this.you, required this.message}); + + Color _bubbleColor(BuildContext context, Brightness brightness) => + defaultTargetPlatform == TargetPlatform.iOS + ? you + ? LichessColors.green + : CupertinoColors.systemGrey4.resolveFrom(context) + : you + ? Theme.of(context).colorScheme.secondaryContainer + : brightness == Brightness.light + ? lighten(LichessColors.grey) + : darken(LichessColors.grey, 0.5); + + Color _textColor(BuildContext context, Brightness brightness) => + defaultTargetPlatform == TargetPlatform.iOS + ? you + ? Colors.white + : CupertinoColors.label.resolveFrom(context) + : you + ? Theme.of(context).colorScheme.onSecondaryContainer + : brightness == Brightness.light + ? Colors.black + : Colors.white; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final brightness = ref.watch(currentBrightnessProvider); + + return FractionallySizedBox( + alignment: you ? Alignment.centerRight : Alignment.centerLeft, + widthFactor: 0.9, + child: Align( + alignment: you ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 16.0), + padding: const EdgeInsets.symmetric(vertical: 10.0, horizontal: 15.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16.0), + color: _bubbleColor(context, brightness), + ), + child: Text( + message, + style: TextStyle( + color: _textColor(context, brightness), + ), + ), + ), + ), + ); + } +} + +class _MessageAction extends StatelessWidget { + final String message; + + const _MessageAction({required this.message}); + + @override + Widget build(BuildContext context) { + return FractionallySizedBox( + widthFactor: 0.8, + child: Center( + child: Container( + margin: const EdgeInsets.symmetric(vertical: 10.0, horizontal: 16.0), + child: Text( + message, + textAlign: TextAlign.center, + style: const TextStyle(fontStyle: FontStyle.italic), + ), + ), + ), + ); + } +} + +class _ChatBottomBar extends ConsumerStatefulWidget { + final ID chatContext; + const _ChatBottomBar({required this.chatContext}); + + @override + ConsumerState createState() => _ChatBottomBarState(); +} + +class _ChatBottomBarState extends ConsumerState<_ChatBottomBar> { + final _textController = TextEditingController(); + + @override + void dispose() { + _textController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final sendButton = ValueListenableBuilder( + valueListenable: _textController, + builder: (context, value, child) => PlatformIconButton( + onTap: value.text.isNotEmpty + ? () { + ref + .read(chatControllerProvider(widget.chatContext).notifier) + .onUserMessage(_textController.text); + _textController.clear(); + } + : null, + icon: Icons.send, + padding: EdgeInsets.zero, + semanticsLabel: context.l10n.send, + ), + ); + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), + child: AdaptiveTextField( + materialDecoration: InputDecoration( + contentPadding: + const EdgeInsets.symmetric(vertical: 10.0, horizontal: 15.0), + suffixIcon: sendButton, + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(20.0)), + ), + hintText: context.l10n.talkInChat, + ), + cupertinoDecoration: BoxDecoration( + border: Border.all( + color: CupertinoColors.separator.resolveFrom(context), + ), + borderRadius: const BorderRadius.all(Radius.circular(30.0)), + ), + controller: _textController, + keyboardType: TextInputType.text, + minLines: 1, + maxLines: 4, + placeholder: context.l10n.talkInChat, + suffix: sendButton, + enableSuggestions: true, + ), + ), + ); + } +} diff --git a/lib/src/view/game/standalone_game_screen.dart b/lib/src/view/game/standalone_game_screen.dart index c4d0d5eedb..81ddb95aab 100644 --- a/lib/src/view/game/standalone_game_screen.dart +++ b/lib/src/view/game/standalone_game_screen.dart @@ -82,6 +82,7 @@ class _StandaloneGameScreenState extends ConsumerState required GameFullId gameId, }) { return Scaffold( + resizeToAvoidBottomInset: false, appBar: GameAppBar(id: gameId), body: GameBody( initialStandAloneId: widget.initialId, @@ -97,6 +98,7 @@ class _StandaloneGameScreenState extends ConsumerState required GameFullId gameId, }) { return CupertinoPageScaffold( + resizeToAvoidBottomInset: false, navigationBar: GameCupertinoNavBar(id: gameId), child: GameBody( initialStandAloneId: widget.initialId, diff --git a/lib/src/widgets/adaptive_text_field.dart b/lib/src/widgets/adaptive_text_field.dart index 46618893e1..8b0a8a4ac0 100644 --- a/lib/src/widgets/adaptive_text_field.dart +++ b/lib/src/widgets/adaptive_text_field.dart @@ -20,6 +20,7 @@ class AdaptiveTextField extends StatelessWidget { this.readOnly = false, this.enableSuggestions = false, this.autofocus = false, + this.suffix, this.cupertinoDecoration, this.materialDecoration, super.key, @@ -40,6 +41,8 @@ class AdaptiveTextField extends StatelessWidget { final void Function(String)? onChanged; final void Function(String)? onSubmitted; final GestureTapCallback? onTap; + final Widget? + suffix; //used only for iOS, suffix should be put in InputDecoration for android final BoxDecoration? cupertinoDecoration; final InputDecoration? materialDecoration; @@ -65,6 +68,11 @@ class AdaptiveTextField extends StatelessWidget { readOnly: readOnly, enableSuggestions: enableSuggestions, autofocus: autofocus, + suffix: suffix, + cursorColor: const CupertinoDynamicColor.withBrightness( + color: CupertinoColors.activeBlue, + darkColor: CupertinoColors.activeOrange, + ), ); default: return TextField( diff --git a/lib/src/widgets/user_full_name.dart b/lib/src/widgets/user_full_name.dart index ec6406449b..438f243d13 100644 --- a/lib/src/widgets/user_full_name.dart +++ b/lib/src/widgets/user_full_name.dart @@ -57,7 +57,6 @@ class UserFullNameWidget extends ConsumerWidget { aiLevel.toString(), ) : context.l10n.anonymous); - return Row( mainAxisSize: MainAxisSize.min, children: [ @@ -95,8 +94,8 @@ class UserFullNameWidget extends ConsumerWidget { CachedNetworkImage( imageUrl: lichessFlairSrc(user!.flair!), errorWidget: (_, __, ___) => kEmptyWidget, - width: 16, - height: 16, + width: DefaultTextStyle.of(context).style.fontSize, + height: DefaultTextStyle.of(context).style.fontSize, ), ], if (shouldShowRating && ratingStr != null) ...[ diff --git a/test/model/game/game_socket_events_test.dart b/test/model/game/game_socket_events_test.dart index dc65ffbb63..4b1eeefa81 100644 --- a/test/model/game/game_socket_events_test.dart +++ b/test/model/game/game_socket_events_test.dart @@ -59,6 +59,20 @@ const _gameJson = ''' "expiration": { "idleMillis": 245, "millisToMove": 30000 + }, + "chat": { + "lines": [ + { + "u": "Zidrox", + "t": "Good luck", + "f": "people.man-singer" + }, + { + "u": "lichess", + "t": "Takeback accepted", + "f": "activity.lichess" + } + ] } } ''';