From 630cd2fde79e91a8d3a28e4b72fef714325a4f1f Mon Sep 17 00:00:00 2001 From: Nutcake Date: Mon, 1 May 2023 17:34:34 +0200 Subject: [PATCH] Implement message sending and streamed receiving --- lib/api_client.dart | 73 +++-------- lib/main.dart | 59 +-------- lib/models/authentication_data.dart | 4 + lib/models/message.dart | 37 ++++-- lib/neos_hub.dart | 190 ++++++++++++++++++++++++++++ lib/widgets/home_screen.dart | 2 +- lib/widgets/login_screen.dart | 9 +- lib/widgets/messages.dart | 179 ++++++++++++++------------ pubspec.lock | 2 +- pubspec.yaml | 1 + 10 files changed, 351 insertions(+), 205 deletions(-) create mode 100644 lib/neos_hub.dart diff --git a/lib/api_client.dart b/lib/api_client.dart index 523ea58..477f674 100644 --- a/lib/api_client.dart +++ b/lib/api_client.dart @@ -1,17 +1,11 @@ +import 'dart:async'; import 'dart:convert'; -import 'dart:developer'; -import 'package:contacts_plus/models/message.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:http/http.dart' as http; import 'package:contacts_plus/models/authentication_data.dart'; -import 'package:signalr_netcore/http_connection_options.dart'; -import 'package:signalr_netcore/hub_connection.dart'; -import 'package:signalr_netcore/hub_connection_builder.dart'; -import 'package:signalr_netcore/ihub_protocol.dart'; -import 'package:signalr_netcore/web_supporting_http_client.dart'; import 'package:uuid/uuid.dart'; -import 'package:logging/logging.dart'; import 'config.dart'; @@ -21,13 +15,8 @@ class ApiClient { static const String tokenKey = "token"; static const String passwordKey = "password"; - ApiClient({required AuthenticationData authenticationData}) : _authenticationData = authenticationData { - if (_authenticationData.isAuthenticated) { - //hub.start(); - } - } + ApiClient({required AuthenticationData authenticationData}) : _authenticationData = authenticationData; - late final NeosHub hub = NeosHub(token: authorizationHeader.values.first); final AuthenticationData _authenticationData; String get userId => _authenticationData.userId; @@ -103,9 +92,7 @@ class ApiClient { } } - Map get authorizationHeader => { - "Authorization": "neos ${_authenticationData.userId}:${_authenticationData.token}" - }; + Map get authorizationHeader => _authenticationData.authorizationHeader; static Uri buildFullUri(String path) => Uri.parse("${Config.apiBaseUrl}/api$path"); @@ -135,48 +122,22 @@ class ApiClient { } } -class NeosHub { - late final HubConnection hubConnection; - final Logger _logger = Logger("NeosHub"); +class ClientHolder extends InheritedWidget { + final ApiClient client; - NeosHub({required String token}) { - hubConnection = HubConnectionBuilder() - .withUrl( - Config.neosHubUrl, - options: HttpConnectionOptions( - headers: MessageHeaders() - ..setHeaderValue("Authorization", token), - httpClient: WebSupportingHttpClient( - _logger, - ), - logger: _logger, - logMessageContent: true - ), - ).withAutomaticReconnect().build(); - hubConnection.onreconnecting(({error}) { - log("onreconnecting called with error $error"); - }); - hubConnection.onreconnected(({connectionId}) { - log("onreconnected called"); - }); - hubConnection.on("ReceiveMessage", _handleReceiveMessage); + ClientHolder({super.key, required AuthenticationData authenticationData, required super.child}) + : client = ApiClient(authenticationData: authenticationData); + + static ClientHolder? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); } - void start() { - hubConnection.start()?.onError((error, stackTrace) => log(error.toString())).whenComplete(() { - log("Hub connection established"); - }); + static ClientHolder of(BuildContext context) { + final ClientHolder? result = maybeOf(context); + assert(result != null, 'No AuthenticatedClient found in context'); + return result!; } - Future sendMessage(Message message) async { - await hubConnection.send("SendMessage", args: [message.toMap()]); - } - - void _handleReceiveMessage(List? params) { - log("Message received."); - if (params == null) return; - for(var obj in params) { - log("$obj"); - } - } + @override + bool updateShouldNotify(covariant ClientHolder oldWidget) => oldWidget.client != client; } diff --git a/lib/main.dart b/lib/main.dart index 6342ec5..3fba588 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,5 @@ import 'package:contacts_plus/models/message.dart'; +import 'package:contacts_plus/neos_hub.dart'; import 'package:contacts_plus/widgets/home_screen.dart'; import 'package:contacts_plus/widgets/login_screen.dart'; import 'package:flutter/material.dart'; @@ -23,10 +24,11 @@ class _ContactsPlusState extends State { @override Widget build(BuildContext context) { - return ClientHolder( + return HubHolder( + messageCache: _messageCache, authenticationData: _authData, - child: MessageCacheHolder( - messageCache: _messageCache, + child: ClientHolder( + authenticationData: _authData, child: MaterialApp( debugShowCheckedModeBanner: false, title: 'Contacts+', @@ -38,7 +40,7 @@ class _ContactsPlusState extends State { home: _authData.isAuthenticated ? const HomeScreen() : LoginScreen( - onLoginSuccessful: (AuthenticationData authData) { + onLoginSuccessful: (AuthenticationData authData) async { if (authData.isAuthenticated) { setState(() { _authData = authData; @@ -51,52 +53,3 @@ class _ContactsPlusState extends State { ); } } - -class ClientHolder extends InheritedWidget { - final ApiClient client; - - ClientHolder({super.key, required AuthenticationData authenticationData, required super.child}) - : client = ApiClient(authenticationData: authenticationData); - - static ClientHolder? maybeOf(BuildContext context) { - return context.dependOnInheritedWidgetOfExactType(); - } - - static ClientHolder of(BuildContext context) { - final ClientHolder? result = maybeOf(context); - assert(result != null, 'No AuthenticatedClient found in context'); - return result!; - } - - @override - bool updateShouldNotify(covariant ClientHolder oldWidget) => oldWidget.client != client; -} - - -class MessageCacheHolder extends InheritedWidget { - const MessageCacheHolder({super.key, required Map messageCache, required super.child}) - : _messageCache = messageCache; - - final Map _messageCache; - - MessageCache? getCache(String index) => _messageCache[index]; - - void setCache(String index, List messages) { - _messageCache[index]?.invalidate(); - _messageCache[index] = MessageCache(messages: messages); - } - - static MessageCacheHolder? maybeOf(BuildContext context) { - return context.dependOnInheritedWidgetOfExactType(); - } - - static MessageCacheHolder of(BuildContext context) { - final MessageCacheHolder? result = maybeOf(context); - assert(result != null, 'No MessageCacheHolder found in context'); - return result!; - } - - @override - bool updateShouldNotify(covariant InheritedWidget oldWidget) => false; - -} \ No newline at end of file diff --git a/lib/models/authentication_data.dart b/lib/models/authentication_data.dart index 8af1366..8891daa 100644 --- a/lib/models/authentication_data.dart +++ b/lib/models/authentication_data.dart @@ -20,4 +20,8 @@ class AuthenticationData { } factory AuthenticationData.unauthenticated() => _unauthenticated; + + Map get authorizationHeader => { + "Authorization": "neos $userId:$token" + }; } \ No newline at end of file diff --git a/lib/models/message.dart b/lib/models/message.dart index cfb7688..7c1e42b 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -29,18 +29,25 @@ enum MessageType { } } -class Message { +enum MessageState { + local, + sent, + read, +} + +class Message extends Comparable { final String id; final String recipientId; final String senderId; final MessageType type; final String content; final DateTime sendTime; + final MessageState state; Message({required this.id, required this.recipientId, required this.senderId, required this.type, - required this.content, required this.sendTime}); + required this.content, required this.sendTime, this.state=MessageState.local}); - factory Message.fromMap(Map map) { + factory Message.fromMap(Map map, {MessageState? withState}) { final typeString = (map["messageType"] as String?) ?? ""; final type = MessageType.fromName(typeString); if (type == MessageType.unknown && typeString.isNotEmpty) { @@ -53,6 +60,17 @@ class Message { type: type, content: map["content"], sendTime: DateTime.parse(map["sendTime"]), + state: withState ?? (map["readTime"] != null ? MessageState.read : MessageState.local) + ); + } + + Message copy() => copyWith(); + + Message copyWith({String? id, String? recipientId, String? senderId, MessageType? type, String? content, + DateTime? sendTime, MessageState? state}) { + return Message(id: id ?? this.id, recipientId: recipientId ?? this.recipientId, senderId: senderId ?? this.senderId, + type: type ?? this.type, content: content ?? this.content, sendTime: sendTime ?? this.sendTime, + state: state ?? this.state ); } @@ -69,19 +87,18 @@ class Message { static String generateId() { return "MSG-${const Uuid().v4()}"; } + + @override + int compareTo(other) { + return other.sendTime.compareTo(sendTime); + } } class MessageCache { - late final Timer _timer; final List _messages; - bool get isValid => _timer.isActive; List get messages => _messages; MessageCache({required List messages}) - : _messages = messages, _timer = Timer(const Duration(seconds: Config.messageCacheValiditySeconds),() {}); - - void invalidate() { - _timer.cancel(); - } + : _messages = messages; } \ No newline at end of file diff --git a/lib/neos_hub.dart b/lib/neos_hub.dart new file mode 100644 index 0000000..a4329c1 --- /dev/null +++ b/lib/neos_hub.dart @@ -0,0 +1,190 @@ + +import 'dart:convert'; +import 'dart:developer'; +import 'package:contacts_plus/models/authentication_data.dart'; +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; + +import 'package:contacts_plus/api_client.dart'; +import 'package:contacts_plus/config.dart'; +import 'package:contacts_plus/models/message.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; + +enum EventType { + unknown, + message, +} + +enum EventTarget { + unknown, + messageSent, + messageReceived, + messagesRead; + + factory EventTarget.parse(String? text) { + if (text == null) return EventTarget.unknown; + return EventTarget.values.firstWhere((element) => element.name.toLowerCase() == text.toLowerCase(), + orElse: () => EventTarget.unknown, + ); + } +} + +class NeosHub { + static const String eofChar = ""; + static const String _negotiationPacket = "{\"protocol\":\"json\", \"version\":1}$eofChar"; + final AuthenticationData _authenticationData; + final Map _messageCache; + final Map _updateListeners = {}; + WebSocketChannel? _wsChannel; + + NeosHub({required AuthenticationData authenticationData, required Map messageCache}) + : _authenticationData = authenticationData, _messageCache = messageCache { + start(); + } + + MessageCache? getCache(String index) => _messageCache[index]; + + void setCache(String index, List messages) { + _messageCache[index] = MessageCache(messages: messages); + } + + Future start() async { + if (!_authenticationData.isAuthenticated) { + log("Hub not authenticated."); + return; + } + final response = await http.post( + Uri.parse("${Config.neosHubUrl}/negotiate"), + headers: _authenticationData.authorizationHeader, + ); + + ApiClient.checkResponse(response); + final body = jsonDecode(response.body); + final url = (body["url"] as String?)?.replaceFirst("https://", "wss://"); + final wsToken = body["accessToken"]; + + if (url == null || wsToken == null) { + throw "Invalid response from server"; + } + + _wsChannel = WebSocketChannel.connect(Uri.parse("$url&access_token=$wsToken")); + _wsChannel!.stream.listen(_handleEvent); + _wsChannel!.sink.add(_negotiationPacket); + log("[Hub]: Connected!"); + } + + void registerListener(String userId, Function function) => _updateListeners[userId] = function; + void unregisterListener(String userId) => _updateListeners.remove(userId); + void notifyListener(String userId) => _updateListeners[userId]?.call(); + + void _handleEvent(event) { + final body = jsonDecode((event.toString().replaceAll(eofChar, ""))); + final int rawType = body["type"] ?? 0; + if (rawType > EventType.values.length) { + log("[Hub]: Unhandled event type $rawType: $body"); + return; + } + switch (EventType.values[rawType]) { + case EventType.unknown: + log("[Hub]: Unknown event received: $rawType"); + break; + case EventType.message: + _handleMessageEvent(body); + break; + } + } + + void _handleMessageEvent(body) { + final target = EventTarget.parse(body["target"]); + final args = body["arguments"]; + switch (target) { + case EventTarget.unknown: + log("Unknown event-target in message: $body"); + return; + case EventTarget.messageSent: + final msg = args[0]; + final message = Message.fromMap(msg, withState: MessageState.sent); + var cache = getCache(message.recipientId); + if (cache == null) { + setCache(message.recipientId, [message]); + } else { + // Possible race condition + final existingIndex = cache.messages.indexWhere((element) => element.id == message.id); + if (existingIndex == -1) { + cache.messages.add(message); + } else { + cache.messages[existingIndex] = message; + } + cache.messages.sort(); + } + notifyListener(message.recipientId); + break; + case EventTarget.messageReceived: + final msg = args[0]; + final message = Message.fromMap(msg); + var cache = getCache(message.senderId); + if (cache == null) { + setCache(message.senderId, [message]); + } else { + cache.messages.add(message); + cache.messages.sort(); + } + notifyListener(message.senderId); + break; + case EventTarget.messagesRead: + final messageIds = args[0]["ids"] as List; + final recipientId = args[0]["recipientId"]; + final cache = getCache(recipientId ?? ""); + if (cache == null) return; + for (var id in messageIds) { + final idx = cache.messages.indexWhere((element) => element.id == id); + if (idx == -1) continue; + cache.messages[idx] = cache.messages[idx].copyWith(state: MessageState.read); + } + notifyListener(recipientId); + break; + } + } + + void sendMessage(Message message) { + if (_wsChannel == null) throw "Neos Hub is not connected"; + final msgBody = message.toMap(); + final data = { + "type": EventType.message.index, + "target": "SendMessage", + "arguments": [ + msgBody + ], + }; + _wsChannel!.sink.add(jsonEncode(data)+eofChar); + var cache = _messageCache[message.recipientId]; + if (cache == null) { + setCache(message.recipientId, [message]); + cache = getCache(message.recipientId); + } else { + cache.messages.add(message); + } + notifyListener(message.recipientId); + } +} + +class HubHolder extends InheritedWidget { + HubHolder({super.key, required AuthenticationData authenticationData, required Map messageCache, required super.child}) + : hub = NeosHub(authenticationData: authenticationData, messageCache: messageCache); + + final NeosHub hub; + + static HubHolder? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } + + static HubHolder of(BuildContext context) { + final HubHolder? result = maybeOf(context); + assert(result != null, 'No HubHolder found in context'); + return result!; + } + + @override + bool updateShouldNotify(covariant HubHolder oldWidget) => hub._authenticationData != oldWidget.hub._authenticationData + || hub._messageCache != oldWidget.hub._messageCache; +} \ No newline at end of file diff --git a/lib/widgets/home_screen.dart b/lib/widgets/home_screen.dart index 2d57344..bbf5cff 100644 --- a/lib/widgets/home_screen.dart +++ b/lib/widgets/home_screen.dart @@ -1,8 +1,8 @@ import 'dart:async'; +import 'package:contacts_plus/api_client.dart'; import 'package:contacts_plus/apis/friend_api.dart'; import 'package:contacts_plus/apis/user_api.dart'; -import 'package:contacts_plus/main.dart'; import 'package:contacts_plus/models/friend.dart'; import 'package:contacts_plus/models/user.dart'; import 'package:contacts_plus/widgets/expanding_input_fab.dart'; diff --git a/lib/widgets/login_screen.dart b/lib/widgets/login_screen.dart index f620a75..b04ea15 100644 --- a/lib/widgets/login_screen.dart +++ b/lib/widgets/login_screen.dart @@ -15,9 +15,9 @@ class LoginScreen extends StatefulWidget { class _LoginScreenState extends State { final TextEditingController _usernameController = TextEditingController(); final TextEditingController _passwordController = TextEditingController(); - late final Future _cachedLoginFuture = ApiClient.tryCachedLogin().then((value) { + late final Future _cachedLoginFuture = ApiClient.tryCachedLogin().then((value) async { if (value.isAuthenticated) { - widget.onLoginSuccessful?.call(value); + await widget.onLoginSuccessful?.call(value); } return value; }); @@ -68,8 +68,9 @@ class _LoginScreenState extends State { _error = ""; _isLoading = false; }); - widget.onLoginSuccessful?.call(authData); - } catch (e) { + await widget.onLoginSuccessful?.call(authData); + } catch (e, s) { + FlutterError.reportError(FlutterErrorDetails(exception: e, stack: s)); setState(() { _error = "Login unsuccessful: $e."; _isLoading = false; diff --git a/lib/widgets/messages.dart b/lib/widgets/messages.dart index b597876..90ccef7 100644 --- a/lib/widgets/messages.dart +++ b/lib/widgets/messages.dart @@ -1,12 +1,11 @@ -import 'package:cached_network_image/cached_network_image.dart'; +import 'package:contacts_plus/api_client.dart'; import 'package:contacts_plus/apis/message_api.dart'; import 'package:contacts_plus/aux.dart'; -import 'package:contacts_plus/main.dart'; import 'package:contacts_plus/models/friend.dart'; import 'package:contacts_plus/models/message.dart'; +import 'package:contacts_plus/neos_hub.dart'; import 'package:contacts_plus/widgets/generic_avatar.dart'; import 'package:flutter/material.dart'; -import 'package:http/http.dart'; import 'package:intl/intl.dart'; class Messages extends StatefulWidget { @@ -19,11 +18,11 @@ class Messages extends StatefulWidget { } class _MessagesState extends State { - static const double headerItemSize = 300.0; + static const double headerItemSize = 120.0; Future>? _messagesFuture; final TextEditingController _messageTextController = TextEditingController(); ClientHolder? _clientHolder; - MessageCacheHolder? _cacheHolder; + HubHolder? _cacheHolder; bool _headerExpanded = false; bool _isSendable = false; @@ -31,15 +30,17 @@ class _MessagesState extends State { double get _headerHeight => _headerExpanded ? headerItemSize : 0; double get _chevronTurns => _headerExpanded ? -1/4 : 1/4; - void _refreshMessages() { - final cache = _cacheHolder?.getCache(widget.friend.id); - if (cache?.isValid ?? false) { - _messagesFuture = Future(() => cache!.messages); + void _loadMessages() { + final cache = _cacheHolder?.hub.getCache(widget.friend.id); + if (cache != null) { + _messagesFuture = Future(() => cache.messages); } else { _messagesFuture = MessageApi.getUserMessages(_clientHolder!.client, userId: widget.friend.id) ..then((value) { final list = value.toList(); - _cacheHolder?.setCache(widget.friend.id, list); + list.sort(); + _cacheHolder?.hub.setCache(widget.friend.id, list); + _cacheHolder?.hub.registerListener(widget.friend.id, () => setState(() {})); return list; }); } @@ -54,12 +55,18 @@ class _MessagesState extends State { _clientHolder = clientHolder; dirty = true; } - final cacheHolder = MessageCacheHolder.of(context); + final cacheHolder = HubHolder.of(context); if (_cacheHolder != cacheHolder) { _cacheHolder = cacheHolder; dirty = true; } - if (dirty) _refreshMessages(); + if (dirty) _loadMessages(); + } + + @override + void dispose() { + _cacheHolder?.hub.unregisterListener(widget.friend.id); + super.dispose(); } @override @@ -69,73 +76,57 @@ class _MessagesState extends State { return Scaffold( appBar: AppBar( title: Text(widget.friend.username), - actions: [ - if(sessions.isNotEmpty) AnimatedRotation( - turns: _chevronTurns, - curve: Curves.easeOutCirc, - duration: const Duration(milliseconds: 250), - child: IconButton( - onPressed: () { - setState(() { - _headerExpanded = !_headerExpanded; - }); - }, - icon: const Icon(Icons.chevron_right), - ), - ) - ], scrolledUnderElevation: 0.0, backgroundColor: Theme.of(context).colorScheme.surfaceVariant, - bottom: sessions.isEmpty ? null : PreferredSize( + /*bottom: sessions.isEmpty ? null : PreferredSize( preferredSize: Size.fromHeight(_headerHeight), - child: AnimatedContainer( - height: _headerHeight, - duration: const Duration(milliseconds: 400), - child: Column( - children: sessions.getRange(0, _headerExpanded ? sessions.length : 1).map((e) => Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: GenericAvatar(imageUri: Aux.neosDbToHttp(e.thumbnail),), + child: Column( + children: sessions.getRange(0, _headerExpanded ? sessions.length : 1).map((e) => Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: GenericAvatar(imageUri: Aux.neosDbToHttp(e.thumbnail),), + ), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(e.name), + Text("${e.sessionUsers.length} users active"), + ], ), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(e.name), - Text("${e.sessionUsers.length} users active"), - ], - ), - ), - const Spacer(), - if (sessions.length > 1) TextButton(onPressed: (){ - setState(() { - _headerExpanded = !_headerExpanded; - }); - }, child: Text("+${sessions.length-1}"),) - ], - )).toList(), - ), + ), + const Spacer(), + if (sessions.length > 1) TextButton(onPressed: (){ + setState(() { + _headerExpanded = !_headerExpanded; + }); + }, child: Text("+${sessions.length-1}"),) + ], + )).toList(), ), - ), + ),*/ ), body: FutureBuilder( future: _messagesFuture, builder: (context, snapshot) { if (snapshot.hasData) { - final data = snapshot.data as Iterable; - return ListView.builder( - reverse: true, - itemCount: data.length, - itemBuilder: (context, index) { - final entry = data.elementAt(index); - return entry.senderId == apiClient.userId - ? MyMessageBubble(message: entry) - : OtherMessageBubble(message: entry); - }, + final data = _cacheHolder?.hub.getCache(widget.friend.id)?.messages ?? []; + return Padding( + padding: const EdgeInsets.only(top: 12), + child: ListView.builder( + reverse: true, + itemCount: data.length, + itemBuilder: (context, index) { + final entry = data.elementAt(index); + return entry.senderId == apiClient.userId + ? MyMessageBubble(message: entry) + : OtherMessageBubble(message: entry); + }, + ), ); } else if (snapshot.hasError) { return Padding( @@ -153,7 +144,7 @@ class _MessagesState extends State { TextButton.icon( onPressed: () { setState(() { - _refreshMessages(); + _loadMessages(); }); }, style: TextButton.styleFrom( @@ -221,7 +212,10 @@ class _MessagesState extends State { sendTime: DateTime.now().toUtc(), ); try { - await apiClient.hub.sendMessage(message); + if (_cacheHolder == null) { + throw "Hub not connected."; + } + _cacheHolder!.hub.sendMessage(message); _messageTextController.clear(); } catch (e) { ScaffoldMessenger.of(context).showSnackBar( @@ -273,7 +267,7 @@ class MyMessageBubble extends StatelessWidget { borderRadius: BorderRadius.circular(16), ), color: Theme.of(context).colorScheme.primaryContainer, - margin: const EdgeInsets.only(left: 32, bottom: 16, right: 8), + margin: const EdgeInsets.only(left: 32, bottom: 16, right: 12), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Column( @@ -286,9 +280,17 @@ class MyMessageBubble extends StatelessWidget { style: Theme.of(context).textTheme.bodyLarge, ), const SizedBox(height: 6,), - Text( - _dateFormat.format(message.sendTime), - style: Theme.of(context).textTheme.labelMedium?.copyWith(color: Colors.white54), + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + _dateFormat.format(message.sendTime), + style: Theme.of(context).textTheme.labelMedium?.copyWith(color: Colors.white54), + ), + const SizedBox(width: 4,), + MessageStateIndicator(messageState: message.state), + ], ), ], ), @@ -330,7 +332,7 @@ class OtherMessageBubble extends StatelessWidget { .of(context) .colorScheme .secondaryContainer, - margin: const EdgeInsets.only(right: 32, bottom: 16, left: 8), + margin: const EdgeInsets.only(right: 32, bottom: 16, left: 12), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Column( @@ -357,13 +359,30 @@ class OtherMessageBubble extends StatelessWidget { } } -class MessageStatusIndicator extends StatelessWidget { - const MessageStatusIndicator({super.key}); +class MessageStateIndicator extends StatelessWidget { + const MessageStateIndicator({required this.messageState, super.key}); + + final MessageState messageState; @override Widget build(BuildContext context) { - // TODO: implement build - throw UnimplementedError(); + late final IconData icon; + switch (messageState) { + case MessageState.local: + icon = Icons.alarm; + break; + case MessageState.sent: + icon = Icons.done; + break; + case MessageState.read: + icon = Icons.done_all; + break; + } + return Icon( + icon, + size: 12, + color: messageState == MessageState.read ? Theme.of(context).colorScheme.primary : null, + ); } } \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 9ec4c0a..d074894 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -502,7 +502,7 @@ packages: source: hosted version: "2.1.4" web_socket_channel: - dependency: transitive + dependency: "direct main" description: name: web_socket_channel sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b diff --git a/pubspec.yaml b/pubspec.yaml index 5d2e90f..cb9ab33 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -43,6 +43,7 @@ dependencies: signalr_netcore: ^1.3.3 logging: ^1.1.1 cached_network_image: ^3.2.3 + web_socket_channel: ^2.4.0 dev_dependencies: flutter_test: