Implement message sending and streamed receiving
This commit is contained in:
parent
37ad7b7438
commit
630cd2fde7
10 changed files with 351 additions and 205 deletions
|
@ -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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
||||||
|
|
||||||
}
|
|
|
@ -20,4 +20,8 @@ class AuthenticationData {
|
||||||
}
|
}
|
||||||
|
|
||||||
factory AuthenticationData.unauthenticated() => _unauthenticated;
|
factory AuthenticationData.unauthenticated() => _unauthenticated;
|
||||||
|
|
||||||
|
Map<String, String> get authorizationHeader => {
|
||||||
|
"Authorization": "neos $userId:$token"
|
||||||
|
};
|
||||||
}
|
}
|
|
@ -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
190
lib/neos_hub.dart
Normal 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;
|
||||||
|
}
|
|
@ -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';
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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:
|
||||||
|
|
Loading…
Reference in a new issue