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: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<String, String> get authorizationHeader => {
"Authorization": "neos ${_authenticationData.userId}:${_authenticationData.token}"
};
Map<String, String> 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<ClientHolder>();
}
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<void> sendMessage(Message message) async {
await hubConnection.send("SendMessage", args: [message.toMap()]);
}
void _handleReceiveMessage(List<Object?>? params) {
log("Message received.");
if (params == null) return;
for(var obj in params) {
log("$obj");
}
}
@override
bool updateShouldNotify(covariant ClientHolder oldWidget) => oldWidget.client != client;
}

View file

@ -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<ContactsPlus> {
@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<ContactsPlus> {
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<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;
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 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<Message> _messages;
bool get isValid => _timer.isActive;
List<Message> get messages => _messages;
MessageCache({required List<Message> messages})
: _messages = messages, _timer = Timer(const Duration(seconds: Config.messageCacheValiditySeconds),() {});
void invalidate() {
_timer.cancel();
}
: _messages = messages;
}

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 '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';

View file

@ -15,9 +15,9 @@ class LoginScreen extends StatefulWidget {
class _LoginScreenState extends State<LoginScreen> {
final TextEditingController _usernameController = 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) {
widget.onLoginSuccessful?.call(value);
await widget.onLoginSuccessful?.call(value);
}
return value;
});
@ -68,8 +68,9 @@ class _LoginScreenState extends State<LoginScreen> {
_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;

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/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<Messages> {
static const double headerItemSize = 300.0;
static const double headerItemSize = 120.0;
Future<Iterable<Message>>? _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<Messages> {
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<Messages> {
_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<Messages> {
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<Message>;
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<Messages> {
TextButton.icon(
onPressed: () {
setState(() {
_refreshMessages();
_loadMessages();
});
},
style: TextButton.styleFrom(
@ -221,7 +212,10 @@ class _MessagesState extends State<Messages> {
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,
);
}
}

View file

@ -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

View file

@ -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: