diff --git a/lib/src/model/game/chat_controller.dart b/lib/src/model/game/chat_controller.dart index d9e711e7d5..06d6d4c6b2 100644 --- a/lib/src/model/game/chat_controller.dart +++ b/lib/src/model/game/chat_controller.dart @@ -7,6 +7,7 @@ import 'package:lichess_mobile/src/db/database.dart'; import 'package:lichess_mobile/src/model/common/id.dart'; import 'package:lichess_mobile/src/model/common/socket.dart'; import 'package:lichess_mobile/src/model/game/game_controller.dart'; +import 'package:lichess_mobile/src/model/game/message_presets.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:sqflite/sqflite.dart'; @@ -18,6 +19,22 @@ String _storeKey(GameFullId id) => 'game.$id'; @riverpod class ChatController extends _$ChatController { + static const Map> _presetMessages = { + PresetMessageGroup.start: [ + (label: 'HI', value: 'Hello'), + (label: 'GL', value: 'Good luck'), + (label: 'HF', value: 'Have fun!'), + (label: 'U2', value: 'You too!'), + ], + PresetMessageGroup.end: [ + (label: 'GG', value: 'Good game'), + (label: 'WP', value: 'Well played'), + (label: 'TY', value: 'Thank you'), + (label: 'GTG', value: "I've got to go"), + (label: 'BYE', value: 'Bye!'), + ], + }; + StreamSubscription? _subscription; late SocketClient _socketClient; @@ -34,22 +51,47 @@ class ChatController extends _$ChatController { _subscription?.cancel(); }); - final messages = await _socketClient.stream - .firstWhere((event) => event.topic == 'full') - .then( - (event) => pick(event.data, 'chat', 'lines') - .asListOrNull(_messageFromPick) - ?.toIList(), - ); - final readMessagesCount = await _getReadMessagesCount(); + final messages = await _getMessages(); + + final presetMessageGroup = await ref.watch( + gameControllerProvider(id).selectAsync( + (gameState) => PresetMessageGroup.fromGame(gameState.game), + ), + ); return ChatState( + alreadySynced: true, messages: messages ?? IList(), unreadMessages: (messages?.length ?? 0) - readMessagesCount, + chatPresets: ( + presets: _presetMessages, + alreadySaid: [], + currentPresetMessageGroup: presetMessageGroup + ), ); } + Future?> + _getMessages() { + // Once underlying socket has been opened the 'full' sync message will only be received once + // so when the provider state is rebuilt we should use the existing state + final alreadySynced = state.value?.alreadySynced == true; + final IList? existingMessages = state.value?.messages; + + if (alreadySynced) { + return Future.value(existingMessages); + } else { + return _socketClient.stream.firstWhere((event) { + return event.topic == 'full'; + }).then( + (event) => pick(event.data, 'chat', 'lines') + .asListOrNull(_messageFromPick) + ?.toIList(), + ); + } + } + /// Sends a message to the chat. void sendMessage(String message) { _socketClient.send( @@ -58,6 +100,22 @@ class ChatController extends _$ChatController { ); } + // Sends a chat preset to the chat and marks it as sent + void sendPreset(PresetMessage message) { + sendMessage(message.value); + + state = state.whenData((s) { + final state = s.copyWith( + chatPresets: ( + alreadySaid: [...s.chatPresets.alreadySaid, message], + currentPresetMessageGroup: s.chatPresets.currentPresetMessageGroup, + presets: s.chatPresets.presets + ), + ); + return state; + }); + } + /// Resets the unread messages count to 0 and saves the number of read messages. Future markMessagesAsRead() async { if (state.hasValue) { @@ -126,11 +184,9 @@ class ChatController extends _$ChatController { final data = event.data as Map; final message = data['t'] as String; final username = data['u'] as String?; + final colour = data['c'] as String?; _addMessage( - ( - message: message, - username: username, - ), + (message: message, username: username, colour: colour), ); } } @@ -141,16 +197,25 @@ class ChatState with _$ChatState { const ChatState._(); const factory ChatState({ + required bool alreadySynced, required IList messages, required int unreadMessages, + required ChatPresets chatPresets, }) = _ChatState; } -typedef Message = ({String? username, String message}); +typedef ChatPresets = ({ + Map> presets, + List alreadySaid, + PresetMessageGroup? currentPresetMessageGroup +}); + +typedef Message = ({String? username, String? colour, String message}); Message _messageFromPick(RequiredPick pick) { return ( message: pick('t').asStringOrThrow(), username: pick('u').asStringOrNull(), + colour: pick('c').asStringOrNull(), ); } diff --git a/lib/src/model/game/message_presets.dart b/lib/src/model/game/message_presets.dart new file mode 100644 index 0000000000..9ed6cbabaa --- /dev/null +++ b/lib/src/model/game/message_presets.dart @@ -0,0 +1,19 @@ +import 'package:lichess_mobile/src/model/game/game_status.dart'; +import 'package:lichess_mobile/src/model/game/playable_game.dart'; + +enum PresetMessageGroup { + start, + end; + + static PresetMessageGroup? fromGame(PlayableGame game) { + if (game.status.value <= GameStatus.mate.value && game.steps.length < 4) { + return start; + } else if (game.status.value >= GameStatus.mate.value) { + return end; + } else { + return null; + } + } +} + +typedef PresetMessage = ({String label, String value}); diff --git a/lib/src/view/game/message_screen.dart b/lib/src/view/game/message_screen.dart index 3b91dbb864..dba09dde02 100644 --- a/lib/src/view/game/message_screen.dart +++ b/lib/src/view/game/message_screen.dart @@ -4,12 +4,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lichess_mobile/src/model/auth/auth_session.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/game/game_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/view/game/preset_messages.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'; @@ -100,6 +102,10 @@ class _Body extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final chatStateAsync = ref.watch(chatControllerProvider(id)); + final gameStateAsync = ref.watch(gameControllerProvider(id)); + + final chatState = chatStateAsync.value; + final myColour = gameStateAsync.value?.game.youAre; return Column( mainAxisSize: MainAxisSize.max, @@ -118,9 +124,14 @@ class _Body extends ConsumerWidget { itemBuilder: (context, index) { final message = chatState.messages[chatState.messages.length - index - 1]; + + final isMyMessage = message.username != null + ? message.username == me?.name + : (message.colour == myColour?.name); + return (message.username == 'lichess') ? _MessageAction(message: message.message) - : (message.username == me?.name) + : isMyMessage ? _MessageBubble( you: true, message: message.message, @@ -140,6 +151,18 @@ class _Body extends ConsumerWidget { ), ), ), + // Only show presets if the player is participating in the game and the presets state has become available + if (myColour != null && chatState != null) + PresetMessages( + alreadySaid: chatState.chatPresets.alreadySaid, + presetMessageGroup: chatState.chatPresets.currentPresetMessageGroup, + presetMessages: chatState.chatPresets.presets, + sendChatPreset: (presetMessage) => ref + .read(chatControllerProvider(id).notifier) + .sendPreset(presetMessage), + ) + else + const SizedBox.shrink(), _ChatBottomBar(id: id), ], ); diff --git a/lib/src/view/game/preset_messages.dart b/lib/src/view/game/preset_messages.dart new file mode 100644 index 0000000000..28f91ced52 --- /dev/null +++ b/lib/src/view/game/preset_messages.dart @@ -0,0 +1,52 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lichess_mobile/src/model/game/message_presets.dart'; +import 'package:lichess_mobile/src/widgets/buttons.dart'; + +class PresetMessages extends ConsumerWidget { + final List alreadySaid; + final PresetMessageGroup? presetMessageGroup; + final Map> presetMessages; + final void Function(PresetMessage presetMessage) sendChatPreset; + + const PresetMessages({ + required this.alreadySaid, + required this.presetMessageGroup, + required this.presetMessages, + required this.sendChatPreset, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final messages = presetMessages[presetMessageGroup] ?? []; + + if (messages.isEmpty || alreadySaid.length >= 2) { + return const SizedBox.shrink(); + } + + final notAlreadySaid = + messages.where((message) => !alreadySaid.contains(message)); + + return Row( + children: notAlreadySaid + .map((preset) => _renderPresetMessageButton(preset, ref)) + .toList(), + ); + } + + Widget _renderPresetMessageButton(PresetMessage preset, WidgetRef ref) { + return Padding( + padding: const EdgeInsets.all(4.0), + child: SecondaryButton( + semanticsLabel: preset.label, + onPressed: () { + sendChatPreset(preset); + }, + child: Text( + preset.label, + textAlign: TextAlign.center, + ), + ), + ); + } +}