Implement message sending and streamed receiving

This commit is contained in:
Nutcake 2023-05-01 17:34:34 +02:00 committed by Nils Rother
parent 37ad7b7438
commit 630cd2fde7
10 changed files with 351 additions and 205 deletions

View file

@ -1,17 +1,11 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:developer';
import 'package:contacts_plus/models/message.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:contacts_plus/models/authentication_data.dart'; 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:uuid/uuid.dart';
import 'package:logging/logging.dart';
import 'config.dart'; import 'config.dart';
@ -21,13 +15,8 @@ class ApiClient {
static const String tokenKey = "token"; static const String tokenKey = "token";
static const String passwordKey = "password"; static const String passwordKey = "password";
ApiClient({required AuthenticationData authenticationData}) : _authenticationData = authenticationData { ApiClient({required AuthenticationData authenticationData}) : _authenticationData = authenticationData;
if (_authenticationData.isAuthenticated) {
//hub.start();
}
}
late final NeosHub hub = NeosHub(token: authorizationHeader.values.first);
final AuthenticationData _authenticationData; final AuthenticationData _authenticationData;
String get userId => _authenticationData.userId; String get userId => _authenticationData.userId;
@ -103,9 +92,7 @@ class ApiClient {
} }
} }
Map<String, String> get authorizationHeader => { Map<String, String> get authorizationHeader => _authenticationData.authorizationHeader;
"Authorization": "neos ${_authenticationData.userId}:${_authenticationData.token}"
};
static Uri buildFullUri(String path) => Uri.parse("${Config.apiBaseUrl}/api$path"); static Uri buildFullUri(String path) => Uri.parse("${Config.apiBaseUrl}/api$path");
@ -135,48 +122,22 @@ class ApiClient {
} }
} }
class NeosHub { class ClientHolder extends InheritedWidget {
late final HubConnection hubConnection; final ApiClient client;
final Logger _logger = Logger("NeosHub");
NeosHub({required String token}) { ClientHolder({super.key, required AuthenticationData authenticationData, required super.child})
hubConnection = HubConnectionBuilder() : client = ApiClient(authenticationData: authenticationData);
.withUrl(
Config.neosHubUrl, static ClientHolder? maybeOf(BuildContext context) {
options: HttpConnectionOptions( return context.dependOnInheritedWidgetOfExactType<ClientHolder>();
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);
} }
void start() { static ClientHolder of(BuildContext context) {
hubConnection.start()?.onError((error, stackTrace) => log(error.toString())).whenComplete(() { final ClientHolder? result = maybeOf(context);
log("Hub connection established"); assert(result != null, 'No AuthenticatedClient found in context');
}); return result!;
} }
Future<void> sendMessage(Message message) async { @override
await hubConnection.send("SendMessage", args: [message.toMap()]); bool updateShouldNotify(covariant ClientHolder oldWidget) => oldWidget.client != client;
}
void _handleReceiveMessage(List<Object?>? params) {
log("Message received.");
if (params == null) return;
for(var obj in params) {
log("$obj");
}
}
} }

View file

@ -1,4 +1,5 @@
import 'package:contacts_plus/models/message.dart'; 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/home_screen.dart';
import 'package:contacts_plus/widgets/login_screen.dart'; import 'package:contacts_plus/widgets/login_screen.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -23,10 +24,11 @@ class _ContactsPlusState extends State<ContactsPlus> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ClientHolder( return HubHolder(
messageCache: _messageCache,
authenticationData: _authData, authenticationData: _authData,
child: MessageCacheHolder( child: ClientHolder(
messageCache: _messageCache, authenticationData: _authData,
child: MaterialApp( child: MaterialApp(
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
title: 'Contacts+', title: 'Contacts+',
@ -38,7 +40,7 @@ class _ContactsPlusState extends State<ContactsPlus> {
home: _authData.isAuthenticated ? home: _authData.isAuthenticated ?
const HomeScreen() : const HomeScreen() :
LoginScreen( LoginScreen(
onLoginSuccessful: (AuthenticationData authData) { onLoginSuccessful: (AuthenticationData authData) async {
if (authData.isAuthenticated) { if (authData.isAuthenticated) {
setState(() { setState(() {
_authData = authData; _authData = authData;
@ -51,52 +53,3 @@ class _ContactsPlusState extends State<ContactsPlus> {
); );
} }
} }
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<ClientHolder>();
}
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<String, MessageCache> messageCache, required super.child})
: _messageCache = messageCache;
final Map<String, MessageCache> _messageCache;
MessageCache? getCache(String index) => _messageCache[index];
void setCache(String index, List<Message> messages) {
_messageCache[index]?.invalidate();
_messageCache[index] = MessageCache(messages: messages);
}
static MessageCacheHolder? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<MessageCacheHolder>();
}
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;
}

View file

@ -20,4 +20,8 @@ class AuthenticationData {
} }
factory AuthenticationData.unauthenticated() => _unauthenticated; factory AuthenticationData.unauthenticated() => _unauthenticated;
Map<String, String> get authorizationHeader => {
"Authorization": "neos $userId:$token"
};
} }

View file

@ -29,18 +29,25 @@ enum MessageType {
} }
} }
class Message { enum MessageState {
local,
sent,
read,
}
class Message extends Comparable {
final String id; final String id;
final String recipientId; final String recipientId;
final String senderId; final String senderId;
final MessageType type; final MessageType type;
final String content; final String content;
final DateTime sendTime; final DateTime sendTime;
final MessageState state;
Message({required this.id, required this.recipientId, required this.senderId, required this.type, 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 typeString = (map["messageType"] as String?) ?? "";
final type = MessageType.fromName(typeString); final type = MessageType.fromName(typeString);
if (type == MessageType.unknown && typeString.isNotEmpty) { if (type == MessageType.unknown && typeString.isNotEmpty) {
@ -53,6 +60,17 @@ class Message {
type: type, type: type,
content: map["content"], content: map["content"],
sendTime: DateTime.parse(map["sendTime"]), 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() { static String generateId() {
return "MSG-${const Uuid().v4()}"; return "MSG-${const Uuid().v4()}";
} }
@override
int compareTo(other) {
return other.sendTime.compareTo(sendTime);
}
} }
class MessageCache { class MessageCache {
late final Timer _timer;
final List<Message> _messages; final List<Message> _messages;
bool get isValid => _timer.isActive;
List<Message> get messages => _messages; List<Message> get messages => _messages;
MessageCache({required List<Message> messages}) MessageCache({required List<Message> messages})
: _messages = messages, _timer = Timer(const Duration(seconds: Config.messageCacheValiditySeconds),() {}); : _messages = messages;
void invalidate() {
_timer.cancel();
}
} }

190
lib/neos_hub.dart Normal file
View file

@ -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<String, MessageCache> _messageCache;
final Map<String, Function> _updateListeners = {};
WebSocketChannel? _wsChannel;
NeosHub({required AuthenticationData authenticationData, required Map<String, MessageCache> messageCache})
: _authenticationData = authenticationData, _messageCache = messageCache {
start();
}
MessageCache? getCache(String index) => _messageCache[index];
void setCache(String index, List<Message> messages) {
_messageCache[index] = MessageCache(messages: messages);
}
Future<void> 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<String, MessageCache> messageCache, required super.child})
: hub = NeosHub(authenticationData: authenticationData, messageCache: messageCache);
final NeosHub hub;
static HubHolder? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<HubHolder>();
}
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;
}

View file

@ -1,8 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'package:contacts_plus/api_client.dart';
import 'package:contacts_plus/apis/friend_api.dart'; import 'package:contacts_plus/apis/friend_api.dart';
import 'package:contacts_plus/apis/user_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/friend.dart';
import 'package:contacts_plus/models/user.dart'; import 'package:contacts_plus/models/user.dart';
import 'package:contacts_plus/widgets/expanding_input_fab.dart'; import 'package:contacts_plus/widgets/expanding_input_fab.dart';

View file

@ -15,9 +15,9 @@ class LoginScreen extends StatefulWidget {
class _LoginScreenState extends State<LoginScreen> { class _LoginScreenState extends State<LoginScreen> {
final TextEditingController _usernameController = TextEditingController(); final TextEditingController _usernameController = TextEditingController();
final TextEditingController _passwordController = TextEditingController(); final TextEditingController _passwordController = TextEditingController();
late final Future<AuthenticationData> _cachedLoginFuture = ApiClient.tryCachedLogin().then((value) { late final Future<AuthenticationData> _cachedLoginFuture = ApiClient.tryCachedLogin().then((value) async {
if (value.isAuthenticated) { if (value.isAuthenticated) {
widget.onLoginSuccessful?.call(value); await widget.onLoginSuccessful?.call(value);
} }
return value; return value;
}); });
@ -68,8 +68,9 @@ class _LoginScreenState extends State<LoginScreen> {
_error = ""; _error = "";
_isLoading = false; _isLoading = false;
}); });
widget.onLoginSuccessful?.call(authData); await widget.onLoginSuccessful?.call(authData);
} catch (e) { } catch (e, s) {
FlutterError.reportError(FlutterErrorDetails(exception: e, stack: s));
setState(() { setState(() {
_error = "Login unsuccessful: $e."; _error = "Login unsuccessful: $e.";
_isLoading = false; _isLoading = false;

View file

@ -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/apis/message_api.dart';
import 'package:contacts_plus/aux.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/friend.dart';
import 'package:contacts_plus/models/message.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:contacts_plus/widgets/generic_avatar.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:http/http.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
class Messages extends StatefulWidget { class Messages extends StatefulWidget {
@ -19,11 +18,11 @@ class Messages extends StatefulWidget {
} }
class _MessagesState extends State<Messages> { class _MessagesState extends State<Messages> {
static const double headerItemSize = 300.0; static const double headerItemSize = 120.0;
Future<Iterable<Message>>? _messagesFuture; Future<Iterable<Message>>? _messagesFuture;
final TextEditingController _messageTextController = TextEditingController(); final TextEditingController _messageTextController = TextEditingController();
ClientHolder? _clientHolder; ClientHolder? _clientHolder;
MessageCacheHolder? _cacheHolder; HubHolder? _cacheHolder;
bool _headerExpanded = false; bool _headerExpanded = false;
bool _isSendable = false; bool _isSendable = false;
@ -31,15 +30,17 @@ class _MessagesState extends State<Messages> {
double get _headerHeight => _headerExpanded ? headerItemSize : 0; double get _headerHeight => _headerExpanded ? headerItemSize : 0;
double get _chevronTurns => _headerExpanded ? -1/4 : 1/4; double get _chevronTurns => _headerExpanded ? -1/4 : 1/4;
void _refreshMessages() { void _loadMessages() {
final cache = _cacheHolder?.getCache(widget.friend.id); final cache = _cacheHolder?.hub.getCache(widget.friend.id);
if (cache?.isValid ?? false) { if (cache != null) {
_messagesFuture = Future(() => cache!.messages); _messagesFuture = Future(() => cache.messages);
} else { } else {
_messagesFuture = MessageApi.getUserMessages(_clientHolder!.client, userId: widget.friend.id) _messagesFuture = MessageApi.getUserMessages(_clientHolder!.client, userId: widget.friend.id)
..then((value) { ..then((value) {
final list = value.toList(); 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; return list;
}); });
} }
@ -54,12 +55,18 @@ class _MessagesState extends State<Messages> {
_clientHolder = clientHolder; _clientHolder = clientHolder;
dirty = true; dirty = true;
} }
final cacheHolder = MessageCacheHolder.of(context); final cacheHolder = HubHolder.of(context);
if (_cacheHolder != cacheHolder) { if (_cacheHolder != cacheHolder) {
_cacheHolder = cacheHolder; _cacheHolder = cacheHolder;
dirty = true; dirty = true;
} }
if (dirty) _refreshMessages(); if (dirty) _loadMessages();
}
@override
void dispose() {
_cacheHolder?.hub.unregisterListener(widget.friend.id);
super.dispose();
} }
@override @override
@ -69,73 +76,57 @@ class _MessagesState extends State<Messages> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(widget.friend.username), 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, scrolledUnderElevation: 0.0,
backgroundColor: Theme.of(context).colorScheme.surfaceVariant, backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
bottom: sessions.isEmpty ? null : PreferredSize( /*bottom: sessions.isEmpty ? null : PreferredSize(
preferredSize: Size.fromHeight(_headerHeight), preferredSize: Size.fromHeight(_headerHeight),
child: AnimatedContainer( child: Column(
height: _headerHeight, children: sessions.getRange(0, _headerExpanded ? sessions.length : 1).map((e) => Row(
duration: const Duration(milliseconds: 400), mainAxisSize: MainAxisSize.max,
child: Column( mainAxisAlignment: MainAxisAlignment.start,
children: sessions.getRange(0, _headerExpanded ? sessions.length : 1).map((e) => Row( children: [
mainAxisSize: MainAxisSize.max, Padding(
mainAxisAlignment: MainAxisAlignment.start, padding: const EdgeInsets.all(8.0),
children: [ child: GenericAvatar(imageUri: Aux.neosDbToHttp(e.thumbnail),),
Padding( ),
padding: const EdgeInsets.all(8.0), Expanded(
child: GenericAvatar(imageUri: Aux.neosDbToHttp(e.thumbnail),), child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(e.name),
Text("${e.sessionUsers.length} users active"),
],
), ),
Expanded( ),
child: Column( const Spacer(),
mainAxisAlignment: MainAxisAlignment.spaceEvenly, if (sessions.length > 1) TextButton(onPressed: (){
crossAxisAlignment: CrossAxisAlignment.start, setState(() {
children: [ _headerExpanded = !_headerExpanded;
Text(e.name), });
Text("${e.sessionUsers.length} users active"), }, 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( body: FutureBuilder(
future: _messagesFuture, future: _messagesFuture,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasData) { if (snapshot.hasData) {
final data = snapshot.data as Iterable<Message>; final data = _cacheHolder?.hub.getCache(widget.friend.id)?.messages ?? [];
return ListView.builder( return Padding(
reverse: true, padding: const EdgeInsets.only(top: 12),
itemCount: data.length, child: ListView.builder(
itemBuilder: (context, index) { reverse: true,
final entry = data.elementAt(index); itemCount: data.length,
return entry.senderId == apiClient.userId itemBuilder: (context, index) {
? MyMessageBubble(message: entry) final entry = data.elementAt(index);
: OtherMessageBubble(message: entry); return entry.senderId == apiClient.userId
}, ? MyMessageBubble(message: entry)
: OtherMessageBubble(message: entry);
},
),
); );
} else if (snapshot.hasError) { } else if (snapshot.hasError) {
return Padding( return Padding(
@ -153,7 +144,7 @@ class _MessagesState extends State<Messages> {
TextButton.icon( TextButton.icon(
onPressed: () { onPressed: () {
setState(() { setState(() {
_refreshMessages(); _loadMessages();
}); });
}, },
style: TextButton.styleFrom( style: TextButton.styleFrom(
@ -221,7 +212,10 @@ class _MessagesState extends State<Messages> {
sendTime: DateTime.now().toUtc(), sendTime: DateTime.now().toUtc(),
); );
try { try {
await apiClient.hub.sendMessage(message); if (_cacheHolder == null) {
throw "Hub not connected.";
}
_cacheHolder!.hub.sendMessage(message);
_messageTextController.clear(); _messageTextController.clear();
} catch (e) { } catch (e) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@ -273,7 +267,7 @@ class MyMessageBubble extends StatelessWidget {
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
color: Theme.of(context).colorScheme.primaryContainer, 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( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column( child: Column(
@ -286,9 +280,17 @@ class MyMessageBubble extends StatelessWidget {
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
), ),
const SizedBox(height: 6,), const SizedBox(height: 6,),
Text( Row(
_dateFormat.format(message.sendTime), mainAxisSize: MainAxisSize.min,
style: Theme.of(context).textTheme.labelMedium?.copyWith(color: Colors.white54), 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) .of(context)
.colorScheme .colorScheme
.secondaryContainer, .secondaryContainer,
margin: const EdgeInsets.only(right: 32, bottom: 16, left: 8), margin: const EdgeInsets.only(right: 32, bottom: 16, left: 12),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Column( child: Column(
@ -357,13 +359,30 @@ class OtherMessageBubble extends StatelessWidget {
} }
} }
class MessageStatusIndicator extends StatelessWidget { class MessageStateIndicator extends StatelessWidget {
const MessageStatusIndicator({super.key}); const MessageStateIndicator({required this.messageState, super.key});
final MessageState messageState;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// TODO: implement build late final IconData icon;
throw UnimplementedError(); 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,
);
} }
} }

View file

@ -502,7 +502,7 @@ packages:
source: hosted source: hosted
version: "2.1.4" version: "2.1.4"
web_socket_channel: web_socket_channel:
dependency: transitive dependency: "direct main"
description: description:
name: web_socket_channel name: web_socket_channel
sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b

View file

@ -43,6 +43,7 @@ dependencies:
signalr_netcore: ^1.3.3 signalr_netcore: ^1.3.3
logging: ^1.1.1 logging: ^1.1.1
cached_network_image: ^3.2.3 cached_network_image: ^3.2.3
web_socket_channel: ^2.4.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: