Move all messaging state to MessagingClient and use Provider for state updates

This commit is contained in:
Nutcake 2023-05-06 16:45:26 +02:00
parent 43a451aad2
commit 766f5a94e2
9 changed files with 290 additions and 252 deletions

View file

@ -5,10 +5,10 @@ import 'package:contacts_plus_plus/clients/api_client.dart';
import 'package:contacts_plus_plus/models/friend.dart';
class FriendApi {
static Future<Iterable<Friend>> getFriendsList(ApiClient client) async {
static Future<List<Friend>> getFriendsList(ApiClient client) async {
final response = await client.get("/users/${client.userId}/friends");
ApiClient.checkResponse(response);
final data = jsonDecode(response.body) as List;
return data.map((e) => Friend.fromMap(e));
return data.map((e) => Friend.fromMap(e)).toList();
}
}

View file

@ -4,7 +4,7 @@ import 'package:contacts_plus_plus/clients/api_client.dart';
import 'package:contacts_plus_plus/models/message.dart';
class MessageApi {
static Future<Iterable<Message>> getUserMessages(ApiClient client, {String userId = "", DateTime? fromTime,
static Future<List<Message>> getUserMessages(ApiClient client, {String userId = "", DateTime? fromTime,
int maxItems = 50, bool unreadOnly = false}) async {
final response = await client.get("/users/${client.userId}/messages"
@ -15,6 +15,6 @@ class MessageApi {
);
ApiClient.checkResponse(response);
final data = jsonDecode(response.body) as List;
return data.map((e) => Message.fromMap(e));
return data.map((e) => Message.fromMap(e)).toList();
}
}

View file

@ -1,6 +1,5 @@
import 'package:contacts_plus_plus/clients/api_client.dart';
import 'package:contacts_plus_plus/clients/messaging_client.dart';
import 'package:contacts_plus_plus/clients/notification_client.dart';
import 'package:contacts_plus_plus/clients/settings_client.dart';
import 'package:contacts_plus_plus/models/authentication_data.dart';
@ -9,7 +8,6 @@ import 'package:flutter/material.dart';
class ClientHolder extends InheritedWidget {
final ApiClient apiClient;
final SettingsClient settingsClient;
late final MessagingClient messagingClient;
final NotificationClient notificationClient = NotificationClient();
ClientHolder({
@ -17,9 +15,7 @@ class ClientHolder extends InheritedWidget {
required AuthenticationData authenticationData,
required this.settingsClient,
required super.child
}) : apiClient = ApiClient(authenticationData: authenticationData) {
messagingClient = MessagingClient(apiClient: apiClient, notificationClient: notificationClient);
}
}) : apiClient = ApiClient(authenticationData: authenticationData);
static ClientHolder? maybeOf(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<ClientHolder>();
@ -34,6 +30,5 @@ class ClientHolder extends InheritedWidget {
@override
bool updateShouldNotify(covariant ClientHolder oldWidget) =>
oldWidget.apiClient != apiClient
|| oldWidget.settingsClient != settingsClient
|| oldWidget.messagingClient != messagingClient;
|| oldWidget.settingsClient != settingsClient;
}

View file

@ -1,9 +1,13 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:contacts_plus_plus/apis/friend_api.dart';
import 'package:contacts_plus_plus/apis/message_api.dart';
import 'package:contacts_plus_plus/clients/notification_client.dart';
import 'package:contacts_plus_plus/models/authentication_data.dart';
import 'package:contacts_plus_plus/models/friend.dart';
import 'package:flutter/widgets.dart';
import 'package:http/http.dart' as http;
import 'package:contacts_plus_plus/clients/api_client.dart';
@ -37,39 +41,88 @@ enum EventTarget {
}
}
class MessagingClient {
class MessagingClient extends ChangeNotifier {
static const String eofChar = "";
static const String _negotiationPacket = "{\"protocol\":\"json\", \"version\":1}$eofChar";
static const List<int> _reconnectTimeoutsSeconds = [0, 5, 10, 20, 60];
static const String taskName = "periodic-unread-check";
static const Duration _autoRefreshDuration = Duration(seconds: 90);
static const Duration _refreshTimeoutDuration = Duration(seconds: 30);
final ApiClient _apiClient;
final Map<String, Friend> _friendsCache = {};
final List<Friend> _sortedFriendsCache = []; // Keep a sorted copy so as to not have to sort during build()
final Map<String, MessageCache> _messageCache = {};
final Map<String, Function> _messageUpdateListeners = {};
final Map<String, List<Message>> _unreads = {};
final Logger _logger = Logger("NeosHub");
final Workmanager _workmanager = Workmanager();
final NotificationClient _notificationClient;
Timer? _autoRefresh;
Timer? _refreshTimeout;
int _attempts = 0;
Function? _unreadsUpdateListener;
WebSocket? _wsChannel;
bool _isConnecting = false;
String _initError = "";
bool _initDone = false;
String get initError => _initError;
MessagingClient({required ApiClient apiClient, required NotificationClient notificationClient})
: _apiClient = apiClient, _notificationClient = notificationClient {
refreshFriendsListWithErrorHandler();
start();
}
@override
void dispose() {
_autoRefresh?.cancel();
_refreshTimeout?.cancel();
_wsChannel?.close();
super.dispose();
}
void _sendData(data) {
if (_wsChannel == null) throw "Neos Hub is not connected";
_wsChannel!.add(jsonEncode(data)+eofChar);
}
void updateFriendsCache(List<Friend> friends) {
void refreshFriendsListWithErrorHandler () async {
try {
await refreshFriendsList();
_initDone = true;
} catch (e) {
_initError = "$e";
}
notifyListeners();
}
Future<void> refreshFriendsList() async {
if (_refreshTimeout?.isActive == true) return;
_autoRefresh?.cancel();
_autoRefresh = Timer(_autoRefreshDuration, () => refreshFriendsList());
_refreshTimeout?.cancel();
_refreshTimeout = Timer(_refreshTimeoutDuration, () {});
final unreadMessages = await MessageApi.getUserMessages(_apiClient, unreadOnly: true);
updateAllUnreads(unreadMessages.toList());
final friends = await FriendApi.getFriendsList(_apiClient);
_friendsCache.clear();
for (final friend in friends) {
_friendsCache[friend.id] = friend;
}
_sortedFriendsCache.clear();
_sortedFriendsCache.addAll(friends.sorted((a, b) {
var aVal = friendHasUnreads(a) ? -3 : 0;
var bVal = friendHasUnreads(b) ? -3 : 0;
aVal -= a.userStatus.lastStatusChange.compareTo(b.userStatus.lastStatusChange);
aVal += a.userStatus.onlineStatus.compareTo(b.userStatus.onlineStatus) * 2;
return aVal.compareTo(bVal);
}));
_initError = "";
notifyListeners();
}
void updateAllUnreads(List<Message> messages) {
@ -96,12 +149,12 @@ class MessagingClient {
}
messages.sort();
_notificationClient.showUnreadMessagesNotification(messages.reversed);
notifyUnreadListener();
notifyListeners();
}
void clearUnreadsForFriend(Friend friend) {
_unreads[friend.id]?.clear();
notifyUnreadListener();
notifyListeners();
}
List<Message> getUnreadsForFriend(Friend friend) => _unreads[friend.id] ?? [];
@ -114,6 +167,8 @@ class MessagingClient {
Friend? getAsFriend(String userId) => _friendsCache[userId];
List<Friend> get cachedFriends => _sortedFriendsCache;
Future<MessageCache> getMessageCache(String userId) async {
var cache = _messageCache[userId];
if (cache == null){
@ -204,10 +259,6 @@ class MessagingClient {
void unregisterMessageListener(String userId) => _messageUpdateListeners.remove(userId);
void notifyMessageListener(String userId) => _messageUpdateListeners[userId]?.call();
void registerUnreadListener(Function function) => _unreadsUpdateListener = function;
void unregisterUnreadListener() => _unreadsUpdateListener = null;
void notifyUnreadListener() => _unreadsUpdateListener?.call();
void _handleEvent(event) {
final body = jsonDecode((event.toString().replaceAll(eofChar, "")));
final int rawType = body["type"] ?? 0;

View file

@ -9,6 +9,7 @@ import 'package:contacts_plus_plus/widgets/login_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_phoenix/flutter_phoenix.dart';
import 'package:logging/logging.dart';
import 'package:provider/provider.dart';
import 'package:workmanager/workmanager.dart';
import 'models/authentication_data.dart';
@ -56,7 +57,10 @@ class _ContactsPlusPlusState extends State<ContactsPlusPlus> {
return ClientHolder(
settingsClient: widget.settingsClient,
authenticationData: _authData,
child: MaterialApp(
child: Builder(
builder: (context) {
final clientHolder = ClientHolder.of(context);
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Contacts++',
theme: ThemeData(
@ -65,7 +69,12 @@ class _ContactsPlusPlusState extends State<ContactsPlusPlus> {
colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark)
),
home: _authData.isAuthenticated ?
const FriendsList() :
ChangeNotifierProvider(
create: (context) =>
MessagingClient(
apiClient: clientHolder.apiClient, notificationClient: clientHolder.notificationClient),
child: const FriendsList(),
) :
LoginScreen(
onLoginSuccessful: (AuthenticationData authData) async {
if (authData.isAuthenticated) {
@ -74,7 +83,9 @@ class _ContactsPlusPlusState extends State<ContactsPlusPlus> {
});
}
},
),
)
);
}
),
);
}

View file

@ -2,8 +2,7 @@ import 'dart:async';
import 'package:contacts_plus_plus/apis/user_api.dart';
import 'package:contacts_plus_plus/client_holder.dart';
import 'package:contacts_plus_plus/apis/friend_api.dart';
import 'package:contacts_plus_plus/apis/message_api.dart';
import 'package:contacts_plus_plus/clients/messaging_client.dart';
import 'package:contacts_plus_plus/models/friend.dart';
import 'package:contacts_plus_plus/models/message.dart';
import 'package:contacts_plus_plus/models/personal_profile.dart';
@ -15,6 +14,7 @@ import 'package:contacts_plus_plus/widgets/settings_page.dart';
import 'package:contacts_plus_plus/widgets/user_search.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
class MenuItemDefinition {
@ -33,72 +33,30 @@ class FriendsList extends StatefulWidget {
}
class _FriendsListState extends State<FriendsList> {
static const Duration _autoRefreshDuration = Duration(seconds: 90);
static const Duration _refreshTimeoutDuration = Duration(seconds: 30);
Future<List<Friend>>? _friendsFuture;
Future<PersonalProfile>? _userProfileFuture;
Future<UserStatus>? _userStatusFuture;
ClientHolder? _clientHolder;
Timer? _autoRefresh;
Timer? _refreshTimeout;
String _searchFilter = "";
@override
void dispose() {
_autoRefresh?.cancel();
_refreshTimeout?.cancel();
super.dispose();
}
@override
void didChangeDependencies() async {
super.didChangeDependencies();
final clientHolder = ClientHolder.of(context);
if (_clientHolder != clientHolder) {
_clientHolder = clientHolder;
final mClient = _clientHolder!.messagingClient;
mClient.registerUnreadListener(() {
if (context.mounted) {
setState(() {});
} else {
mClient.unregisterUnreadListener();
}
});
_refreshFriendsList();
final apiClient = _clientHolder!.apiClient;
_userProfileFuture = UserApi.getPersonalProfile(apiClient);
_refreshUserStatus();
}
}
void _refreshFriendsList() {
if (_refreshTimeout?.isActive == true) return;
void _refreshUserStatus() {
final apiClient = _clientHolder!.apiClient;
_friendsFuture = FriendApi.getFriendsList(apiClient).then((Iterable<Friend> value) async {
final unreadMessages = await MessageApi.getUserMessages(apiClient, unreadOnly: true);
final mClient = _clientHolder?.messagingClient;
if (mClient == null) return [];
mClient.updateAllUnreads(unreadMessages.toList());
final friends = value.toList()
..sort((a, b) {
var aVal = mClient.friendHasUnreads(a) ? -3 : 0;
var bVal = mClient.friendHasUnreads(b) ? -3 : 0;
aVal -= a.userStatus.lastStatusChange.compareTo(b.userStatus.lastStatusChange);
aVal += a.userStatus.onlineStatus.compareTo(b.userStatus.onlineStatus) * 2;
return aVal.compareTo(bVal);
});
_autoRefresh?.cancel();
_autoRefresh = Timer(_autoRefreshDuration, () => setState(() => _refreshFriendsList()));
_refreshTimeout?.cancel();
_refreshTimeout = Timer(_refreshTimeoutDuration, () {});
_clientHolder?.messagingClient.updateFriendsCache(friends);
return friends;
});
_userStatusFuture = UserApi.getUserStatus(apiClient, userId: apiClient.userId).then((value) async {
if (value.onlineStatus == OnlineStatus.offline) {
final newStatus = value.copyWith(
onlineStatus: OnlineStatus.values[_clientHolder!.settingsClient.currentSettings.lastOnlineStatus.valueOrDefault]
onlineStatus: OnlineStatus.values[_clientHolder!.settingsClient.currentSettings.lastOnlineStatus
.valueOrDefault]
);
await UserApi.setStatus(apiClient, status: newStatus);
return newStatus;
@ -109,7 +67,7 @@ class _FriendsListState extends State<FriendsList> {
@override
Widget build(BuildContext context) {
final apiClient = ClientHolder.of(context).apiClient;
final clientHolder = ClientHolder.of(context);
return Scaffold(
appBar: AppBar(
title: const Text("Contacts++"),
@ -135,12 +93,16 @@ class _FriendsListState extends State<FriendsList> {
setState(() {
_userStatusFuture = Future.value(newStatus.copyWith(lastStatusChange: DateTime.now()));
});
final settingsClient = ClientHolder.of(context).settingsClient;
await UserApi.setStatus(apiClient, status: newStatus);
await settingsClient.changeSettings(settingsClient.currentSettings.copyWith(lastOnlineStatus: onlineStatus.index));
final settingsClient = ClientHolder
.of(context)
.settingsClient;
await UserApi.setStatus(clientHolder.apiClient, status: newStatus);
await settingsClient.changeSettings(
settingsClient.currentSettings.copyWith(lastOnlineStatus: onlineStatus.index));
} catch (e, s) {
FlutterError.reportError(FlutterErrorDetails(exception: e, stack: s));
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("Failed to set online-status.")));
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text(
"Failed to set online-status.")));
setState(() {
_userStatusFuture = Future.value(userStatus);
});
@ -163,7 +125,10 @@ class _FriendsListState extends State<FriendsList> {
} else if (snapshot.hasError) {
return TextButton.icon(
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.onSurface,
foregroundColor: Theme
.of(context)
.colorScheme
.onSurface,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2)
),
onPressed: () {
@ -171,7 +136,8 @@ class _FriendsListState extends State<FriendsList> {
_userStatusFuture = null;
});
setState(() {
_userStatusFuture = UserApi.getUserStatus(apiClient, userId: apiClient.userId);
_userStatusFuture = UserApi.getUserStatus(clientHolder.apiClient, userId: clientHolder.apiClient
.userId);
});
},
icon: const Icon(Icons.warning),
@ -180,7 +146,10 @@ class _FriendsListState extends State<FriendsList> {
} else {
return TextButton.icon(
style: TextButton.styleFrom(
disabledForegroundColor: Theme.of(context).colorScheme.onSurface,
disabledForegroundColor: Theme
.of(context)
.colorScheme
.onSurface,
),
onPressed: null,
icon: Container(
@ -189,7 +158,10 @@ class _FriendsListState extends State<FriendsList> {
margin: const EdgeInsets.only(right: 4),
child: CircularProgressIndicator(
strokeWidth: 2,
color: Theme.of(context).colorScheme.onSurface,
color: Theme
.of(context)
.colorScheme
.onSurface,
),
),
label: const Text("Loading"),
@ -210,17 +182,15 @@ class _FriendsListState extends State<FriendsList> {
name: "Settings",
icon: Icons.settings,
onTap: () async {
_autoRefresh?.cancel();
await Navigator.of(context).push(MaterialPageRoute(builder: (context) => const SettingsPage()));
_autoRefresh = Timer(_autoRefreshDuration, () => setState(() => _refreshFriendsList()));
},
),
MenuItemDefinition(
name: "Find Users",
icon: Icons.person_add,
onTap: () async {
final mClient = Provider.of<MessagingClient>(context, listen: false);
bool changed = false;
_autoRefresh?.cancel();
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) =>
@ -230,12 +200,7 @@ class _FriendsListState extends State<FriendsList> {
),
);
if (changed) {
_refreshTimeout?.cancel();
setState(() {
_refreshFriendsList();
});
} else {
_autoRefresh = Timer(_autoRefreshDuration, () => setState(() => _refreshFriendsList()));
mClient.refreshFriendsList();
}
},
),
@ -257,7 +222,9 @@ class _FriendsListState extends State<FriendsList> {
title: "Failed to load personal profile.",
onRetry: () {
setState(() {
_userProfileFuture = UserApi.getPersonalProfile(ClientHolder.of(context).apiClient);
_userProfileFuture = UserApi.getPersonalProfile(ClientHolder
.of(context)
.apiClient);
});
},
);
@ -288,16 +255,22 @@ class _FriendsListState extends State<FriendsList> {
),
body: Stack(
children: [
RefreshIndicator(
onRefresh: () async {
_refreshFriendsList();
await _friendsFuture; // Keep the indicator running until everything's loaded
Consumer<MessagingClient>(
builder: (context, mClient, _) {
if (mClient.initError.isNotEmpty) {
return Column(
children: [
Expanded(
child: DefaultErrorWidget(
message: mClient.initError,
onRetry: () async {
mClient.refreshFriendsListWithErrorHandler();
},
child: FutureBuilder(
future: _friendsFuture,
builder: (context, snapshot) {
if (snapshot.hasData) {
var friends = (snapshot.data as List<Friend>);
)),
],
);
} else {
var friends = List.from(mClient.cachedFriends); // Explicit copy.
if (_searchFilter.isNotEmpty) {
friends = friends.where((element) =>
element.username.toLowerCase().contains(_searchFilter.toLowerCase())).toList();
@ -307,7 +280,7 @@ class _FriendsListState extends State<FriendsList> {
itemCount: friends.length,
itemBuilder: (context, index) {
final friend = friends[index];
final unreads = _clientHolder?.messagingClient.getUnreadsForFriend(friend) ?? [];
final unreads = mClient.getUnreadsForFriend(friend);
return FriendListTile(
friend: friend,
unreads: unreads.length,
@ -318,7 +291,7 @@ class _FriendsListState extends State<FriendsList> {
ids: unreads.map((e) => e.id).toList(),
readTime: DateTime.now(),
);
_clientHolder!.messagingClient.markMessagesRead(readBatch);
mClient.markMessagesRead(readBatch);
}
setState(() {
unreads.clear();
@ -327,24 +300,9 @@ class _FriendsListState extends State<FriendsList> {
);
},
);
} else if (snapshot.hasError) {
FlutterError.reportError(
FlutterErrorDetails(exception: snapshot.error!, stack: snapshot.stackTrace));
return DefaultErrorWidget(
message: "${snapshot.error}",
onRetry: () {
_refreshTimeout?.cancel();
setState(() {
_refreshFriendsList();
});
},
);
} else {
return const LinearProgressIndicator();
}
}
),
),
Align(
alignment: Alignment.bottomCenter,
child: ExpandingInputFab(

View file

@ -47,6 +47,7 @@ class _MessagesListState extends State<MessagesList> {
void _loadMessages() {
_messageCacheFutureComplete = false;
/* TODO: Use provider
_messageCacheFuture = _clientHolder?.messagingClient.getMessageCache(widget.friend.id)
.whenComplete(() => _messageCacheFutureComplete = true);
final mClient = _clientHolder?.messagingClient;
@ -58,11 +59,13 @@ class _MessagesListState extends State<MessagesList> {
mClient.unregisterMessageListener(id);
}
});
*/
}
@override
void dispose() {
_clientHolder?.messagingClient.unregisterMessageListener(widget.friend.id);
// TODO user provider
//_clientHolder?.messagingClient.unregisterMessageListener(widget.friend.id);
_messageTextController.dispose();
_sessionListScrollController.dispose();
super.dispose();
@ -89,8 +92,9 @@ class _MessagesListState extends State<MessagesList> {
_messageScrollController.position.maxScrollExtent > 0 && _messageCacheFutureComplete) {
setState(() {
_messageCacheFutureComplete = false;
_messageCacheFuture = _clientHolder?.messagingClient.getMessageCache(widget.friend.id)
.then((value) => value.loadOlderMessages()).whenComplete(() => _messageCacheFutureComplete = true);
// TODO: Use provider
//_messageCacheFuture = _clientHolder?.messagingClient.getMessageCache(widget.friend.id)
// .then((value) => value.loadOlderMessages()).whenComplete(() => _messageCacheFutureComplete = true);
});
}
});
@ -280,7 +284,8 @@ class _MessagesListState extends State<MessagesList> {
sendTime: DateTime.now().toUtc(),
);
try {
_clientHolder!.messagingClient.sendMessage(message);
// TODO use provider
//_clientHolder!.messagingClient.sendMessage(message);
_messageTextController.clear();
setState(() {});
} catch (e) {

View file

@ -53,9 +53,11 @@ class _UserSearchState extends State<UserSearch> {
@override
Widget build(BuildContext context) {
/* TODO: Use provider
final mClient = ClientHolder
.of(context)
.messagingClient;
*/
return Scaffold(
appBar: AppBar(
title: const Text("Find Users"),
@ -72,7 +74,7 @@ class _UserSearchState extends State<UserSearch> {
itemCount: users.length,
itemBuilder: (context, index) {
final user = users[index];
return UserListTile(user: user, isFriend: mClient.getAsFriend(user.id) != null, onChange: widget.onFriendsChanged);
return UserListTile(user: user, onChange: widget.onFriendsChanged, isFriend: false,); // TODO: Use provider mClient.getAsFriend(user.id) != null,);
},
);
} else if (snapshot.hasError) {

View file

@ -368,6 +368,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.8.0"
nested:
dependency: transitive
description:
name: nested
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
octo_image:
dependency: transitive
description:
@ -488,6 +496,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.2.4"
provider:
dependency: "direct main"
description:
name: provider
sha256: cdbe7530b12ecd9eb455bdaa2fcb8d4dad22e80b8afb4798b41479d5ce26847f
url: "https://pub.dev"
source: hosted
version: "6.0.5"
rxdart:
dependency: transitive
description: