diff --git a/lib/apis/friend_api.dart b/lib/apis/friend_api.dart index 71bfb57..bf4dd2f 100644 --- a/lib/apis/friend_api.dart +++ b/lib/apis/friend_api.dart @@ -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> getFriendsList(ApiClient client) async { + static Future> 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(); } } \ No newline at end of file diff --git a/lib/apis/message_api.dart b/lib/apis/message_api.dart index ff17eaf..ab79e33 100644 --- a/lib/apis/message_api.dart +++ b/lib/apis/message_api.dart @@ -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> getUserMessages(ApiClient client, {String userId = "", DateTime? fromTime, + static Future> 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(); } } \ No newline at end of file diff --git a/lib/apis/user_api.dart b/lib/apis/user_api.dart index 3363b34..5424579 100644 --- a/lib/apis/user_api.dart +++ b/lib/apis/user_api.dart @@ -28,6 +28,11 @@ class UserApi { final data = jsonDecode(response.body); return UserStatus.fromMap(data); } + + static Future notifyOnlineInstance(ApiClient client) async { + final response = await client.post("/stats/instanceOnline/${client.authenticationData.secretMachineId.hashCode}"); + ApiClient.checkResponse(response); + } static Future setStatus(ApiClient client, {required UserStatus status}) async { final pkginfo = await PackageInfo.fromPlatform(); @@ -54,6 +59,7 @@ class UserApi { userStatus: UserStatus.empty(), userProfile: UserProfile.empty(), friendStatus: FriendStatus.accepted, + latestMessageTime: DateTime.now(), ); final body = jsonEncode(friend.toMap(shallow: true)); final response = await client.put("/users/${client.userId}/friends/${user.id}", body: body); diff --git a/lib/client_holder.dart b/lib/client_holder.dart index 7f0e469..988d578 100644 --- a/lib/client_holder.dart +++ b/lib/client_holder.dart @@ -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(); @@ -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; } diff --git a/lib/clients/api_client.dart b/lib/clients/api_client.dart index c1240f0..e9e54ab 100644 --- a/lib/clients/api_client.dart +++ b/lib/clients/api_client.dart @@ -1,8 +1,5 @@ import 'dart:async'; import 'dart:convert'; -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:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_phoenix/flutter_phoenix.dart'; diff --git a/lib/clients/messaging_client.dart b/lib/clients/messaging_client.dart index 83d501e..72347b9 100644 --- a/lib/clients/messaging_client.dart +++ b/lib/clients/messaging_client.dart @@ -1,9 +1,13 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; +import 'package:contacts_plus_plus/apis/friend_api.dart'; import 'package:contacts_plus_plus/apis/message_api.dart'; +import 'package:contacts_plus_plus/apis/user_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,27 +41,52 @@ enum EventTarget { } } -class MessagingClient { +class MessagingClient extends ChangeNotifier { static const String eofChar = ""; static const String _negotiationPacket = "{\"protocol\":\"json\", \"version\":1}$eofChar"; static const List _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 _friendsCache = {}; + final List _sortedFriendsCache = []; // Keep a sorted copy so as to not have to sort during build() final Map _messageCache = {}; - final Map _messageUpdateListeners = {}; final Map> _unreads = {}; final Logger _logger = Logger("NeosHub"); final Workmanager _workmanager = Workmanager(); final NotificationClient _notificationClient; + Friend? selectedFriend; + Timer? _notifyOnlineTimer; + Timer? _autoRefresh; + Timer? _refreshTimeout; int _attempts = 0; - Function? _unreadsUpdateListener; WebSocket? _wsChannel; bool _isConnecting = false; + String? _initStatus; + + String? get initStatus => _initStatus; + + bool get websocketConnected => _wsChannel != null; MessagingClient({required ApiClient apiClient, required NotificationClient notificationClient}) : _apiClient = apiClient, _notificationClient = notificationClient { - start(); + refreshFriendsListWithErrorHandler(); + startWebsocket(); + _notifyOnlineTimer = Timer.periodic(const Duration(seconds: 60), (timer) async { + // We should probably let the MessagingClient handle the entire state of USerStatus instead of mirroring like this + // but I don't feel like implementing that right now. + UserApi.setStatus(apiClient, status: await UserApi.getUserStatus(apiClient, userId: apiClient.userId)); + }); + } + + @override + void dispose() { + _autoRefresh?.cancel(); + _refreshTimeout?.cancel(); + _notifyOnlineTimer?.cancel(); + _wsChannel?.close(); + super.dispose(); } void _sendData(data) { @@ -65,11 +94,52 @@ class MessagingClient { _wsChannel!.add(jsonEncode(data)+eofChar); } - void updateFriendsCache(List friends) { + void resetStatus() { + _initStatus = null; + notifyListeners(); + } + + void refreshFriendsListWithErrorHandler () async { + try { + await refreshFriendsList(); + } catch (e) { + _initStatus = "$e"; + notifyListeners(); + } + } + + Future 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); + _sortFriendsCache(); + _initStatus = ""; + notifyListeners(); + } + + void _sortFriendsCache() { + _sortedFriendsCache.sort((a, b) { + var aVal = friendHasUnreads(a) ? -3 : 0; + var bVal = friendHasUnreads(b) ? -3 : 0; + + aVal -= a.latestMessageTime.compareTo(b.latestMessageTime); + aVal += a.userStatus.onlineStatus.compareTo(b.userStatus.onlineStatus) * 2; + return aVal.compareTo(bVal); + }); } void updateAllUnreads(List messages) { @@ -95,13 +165,14 @@ class MessagingClient { messages.add(message); } messages.sort(); + _sortFriendsCache(); _notificationClient.showUnreadMessagesNotification(messages.reversed); - notifyUnreadListener(); + notifyListeners(); } - void clearUnreadsForFriend(Friend friend) { - _unreads[friend.id]?.clear(); - notifyUnreadListener(); + void clearUnreadsForUser(String userId) { + _unreads[userId]?.clear(); + notifyListeners(); } List getUnreadsForFriend(Friend friend) => _unreads[friend.id] ?? []; @@ -114,16 +185,19 @@ class MessagingClient { Friend? getAsFriend(String userId) => _friendsCache[userId]; - Future getMessageCache(String userId) async { - var cache = _messageCache[userId]; - if (cache == null){ - cache = MessageCache(apiClient: _apiClient, userId: userId); - await cache.loadInitialMessages(); - _messageCache[userId] = cache; - } - return cache; + List get cachedFriends => _sortedFriendsCache; + + MessageCache _createUserMessageCache(String userId) => MessageCache(apiClient: _apiClient, userId: userId); + + Future loadUserMessageCache(String userId) async { + final cache = getUserMessageCache(userId) ?? _createUserMessageCache(userId); + await cache.loadMessages(); + _messageCache[userId] = cache; + notifyListeners(); } + MessageCache? getUserMessageCache(String userId) => _messageCache[userId]; + static Future backgroundCheckUnreads(Map? inputData) async { if (inputData == null) return; final auth = AuthenticationData.fromMap(inputData); @@ -146,11 +220,12 @@ class MessagingClient { } void _onDisconnected(error) async { + _wsChannel = null; _logger.warning("Neos Hub connection died with error '$error', reconnecting..."); - await start(); + await startWebsocket(); } - Future start() async { + Future startWebsocket() async { if (!_apiClient.isAuthenticated) { _logger.info("Tried to connect to Neos Hub without authentication, this is probably fine for now."); return; @@ -200,14 +275,6 @@ class MessagingClient { } } - void registerMessageListener(String userId, Function function) => _messageUpdateListeners[userId] = function; - 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; @@ -247,28 +314,30 @@ class MessagingClient { case EventTarget.messageSent: final msg = args[0]; final message = Message.fromMap(msg, withState: MessageState.sent); - final cache = await getMessageCache(message.recipientId); + final cache = getUserMessageCache(message.recipientId) ?? _createUserMessageCache(message.recipientId); cache.addMessage(message); - notifyMessageListener(message.recipientId); + notifyListeners(); break; case EventTarget.receiveMessage: final msg = args[0]; final message = Message.fromMap(msg); - final cache = await getMessageCache(message.senderId); + final cache = getUserMessageCache(message.senderId) ?? _createUserMessageCache(message.senderId); cache.addMessage(message); - if (!_messageUpdateListeners.containsKey(message.senderId)) { + if (message.senderId != selectedFriend?.id) { addUnread(message); } - notifyMessageListener(message.senderId); + notifyListeners(); break; case EventTarget.messagesRead: final messageIds = args[0]["ids"] as List; final recipientId = args[0]["recipientId"]; - final cache = await getMessageCache(recipientId ?? ""); + if (recipientId == null) break; + final cache = getUserMessageCache(recipientId); + if (cache == null) break; for (var id in messageIds) { cache.setMessageState(id, MessageState.read); } - notifyMessageListener(recipientId); + notifyListeners(); break; } } @@ -283,9 +352,9 @@ class MessagingClient { ], }; _sendData(data); - final cache = await getMessageCache(message.recipientId); + final cache = getUserMessageCache(message.recipientId) ?? _createUserMessageCache(message.recipientId); cache.messages.add(message); - notifyMessageListener(message.recipientId); + notifyListeners(); } void markMessagesRead(MarkReadBatch batch) { @@ -298,5 +367,6 @@ class MessagingClient { ], }; _sendData(data); + clearUnreadsForUser(batch.senderId); } -} +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index b338351..c995552 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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,25 +57,37 @@ class _ContactsPlusPlusState extends State { return ClientHolder( settingsClient: widget.settingsClient, authenticationData: _authData, - child: MaterialApp( - debugShowCheckedModeBanner: false, - title: 'Contacts++', - theme: ThemeData( - useMaterial3: true, - textTheme: _typography.white, - colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark) - ), - home: _authData.isAuthenticated ? - const FriendsList() : - LoginScreen( - onLoginSuccessful: (AuthenticationData authData) async { - if (authData.isAuthenticated) { - setState(() { - _authData = authData; - }); - } - }, - ), + child: Builder( + builder: (context) { + final clientHolder = ClientHolder.of(context); + return MaterialApp( + debugShowCheckedModeBanner: false, + title: 'Contacts++', + theme: ThemeData( + useMaterial3: true, + textTheme: _typography.white, + colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple, brightness: Brightness.dark) + ), + home: _authData.isAuthenticated ? + ChangeNotifierProvider( // This doesn't need to be a proxy provider since the arguments should never change during it's lifetime. + create: (context) => + MessagingClient( + apiClient: clientHolder.apiClient, + notificationClient: clientHolder.notificationClient, + ), + child: const FriendsList(), + ) : + LoginScreen( + onLoginSuccessful: (AuthenticationData authData) async { + if (authData.isAuthenticated) { + setState(() { + _authData = authData; + }); + } + }, + ) + ); + } ), ); } diff --git a/lib/models/friend.dart b/lib/models/friend.dart index fed15cc..37c290a 100644 --- a/lib/models/friend.dart +++ b/lib/models/friend.dart @@ -1,5 +1,3 @@ -import 'dart:developer'; - import 'package:contacts_plus_plus/models/session.dart'; import 'package:contacts_plus_plus/models/user_profile.dart'; import 'package:flutter/material.dart'; @@ -11,9 +9,10 @@ class Friend extends Comparable { final UserStatus userStatus; final UserProfile userProfile; final FriendStatus friendStatus; + final DateTime latestMessageTime; Friend({required this.id, required this.username, required this.ownerId, required this.userStatus, required this.userProfile, - required this.friendStatus, + required this.friendStatus, required this.latestMessageTime, }); factory Friend.fromMap(Map map) { @@ -24,6 +23,8 @@ class Friend extends Comparable { userStatus: UserStatus.fromMap(map["userStatus"]), userProfile: UserProfile.fromMap(map["profile"] ?? {}), friendStatus: FriendStatus.fromString(map["friendStatus"]), + latestMessageTime: map["latestMessageTime"] == null + ? DateTime.fromMillisecondsSinceEpoch(0) : DateTime.parse(map["latestMessageTime"]), ); } @@ -35,6 +36,7 @@ class Friend extends Comparable { "userStatus": userStatus.toMap(shallow: shallow), "profile": userProfile.toMap(), "friendStatus": friendStatus.name, + "latestMessageTime": latestMessageTime.toIso8601String(), }; } diff --git a/lib/models/message.dart b/lib/models/message.dart index 1695c65..10f5314 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -140,7 +140,7 @@ class MessageCache { return this; //lmao } - Future loadInitialMessages() async { + Future loadMessages() async { final messages = await MessageApi.getUserMessages(_apiClient, userId: _userId); _messages.addAll(messages); _ensureIntegrity(); diff --git a/lib/widgets/friend_list_tile.dart b/lib/widgets/friend_list_tile.dart index 17b19a1..80e37b9 100644 --- a/lib/widgets/friend_list_tile.dart +++ b/lib/widgets/friend_list_tile.dart @@ -1,9 +1,13 @@ import 'package:contacts_plus_plus/auxiliary.dart'; +import 'package:contacts_plus_plus/client_holder.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/widgets/generic_avatar.dart'; import 'package:contacts_plus_plus/widgets/messages_list.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; class FriendListTile extends StatelessWidget { const FriendListTile({required this.friend, this.unreads, this.onTap, super.key}); @@ -24,8 +28,32 @@ class FriendListTile extends StatelessWidget { title: Text(friend.username), subtitle: Text(toBeginningOfSentenceCase(friend.userStatus.onlineStatus.name) ?? "Unknown"), onTap: () async { - Navigator.of(context).push(MaterialPageRoute(builder: (context) => MessagesList(friend: friend))); - await onTap?.call(); + onTap?.call(); + final mClient = Provider.of(context, listen: false); + mClient.loadUserMessageCache(friend.id); + final apiClient = ClientHolder + .of(context) + .apiClient; + final unreads = mClient.getUnreadsForFriend(friend); + if (unreads.isNotEmpty) { + final readBatch = MarkReadBatch( + senderId: apiClient.userId, + ids: unreads.map((e) => e.id).toList(), + readTime: DateTime.now(), + ); + mClient.markMessagesRead(readBatch); + } + mClient.selectedFriend = friend; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => + ChangeNotifierProvider.value( + value: mClient, + child: MessagesList(friend: friend), + ), + ), + ); + mClient.selectedFriend = null; }, ); } diff --git a/lib/widgets/friends_list.dart b/lib/widgets/friends_list.dart index 2372e61..feeac68 100644 --- a/lib/widgets/friends_list.dart +++ b/lib/widgets/friends_list.dart @@ -2,10 +2,8 @@ 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'; import 'package:contacts_plus_plus/widgets/default_error_widget.dart'; import 'package:contacts_plus_plus/widgets/expanding_input_fab.dart'; @@ -15,6 +13,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 +32,30 @@ class FriendsList extends StatefulWidget { } class _FriendsListState extends State { - static const Duration _autoRefreshDuration = Duration(seconds: 90); - static const Duration _refreshTimeoutDuration = Duration(seconds: 30); - Future>? _friendsFuture; Future? _userProfileFuture; Future? _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 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,93 +66,109 @@ class _FriendsListState extends State { @override Widget build(BuildContext context) { - final apiClient = ClientHolder.of(context).apiClient; + final clientHolder = ClientHolder.of(context); return Scaffold( appBar: AppBar( title: const Text("Contacts++"), actions: [ FutureBuilder( - future: _userStatusFuture, - builder: (context, snapshot) { - if (snapshot.hasData) { - final userStatus = snapshot.data as UserStatus; - return PopupMenuButton( - child: Row( - children: [ - Padding( - padding: const EdgeInsets.only(right: 8.0), - child: Icon(Icons.circle, size: 16, color: userStatus.onlineStatus.color,), - ), - Text(toBeginningOfSentenceCase(userStatus.onlineStatus.name) ?? "Unknown"), - ], - ), - onSelected: (OnlineStatus onlineStatus) async { - try { - final newStatus = userStatus.copyWith(onlineStatus: onlineStatus); - 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)); - } catch (e, s) { - FlutterError.reportError(FlutterErrorDetails(exception: e, stack: s)); - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("Failed to set online-status."))); - setState(() { - _userStatusFuture = Future.value(userStatus); - }); - } - }, - itemBuilder: (BuildContext context) => - OnlineStatus.values.where((element) => element != OnlineStatus.offline).map((item) => - PopupMenuItem( - value: item, - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - Icon(Icons.circle, size: 16, color: item.color,), - const SizedBox(width: 8,), - Text(toBeginningOfSentenceCase(item.name)!), - ], + future: _userStatusFuture, + builder: (context, snapshot) { + if (snapshot.hasData) { + final userStatus = snapshot.data as UserStatus; + return PopupMenuButton( + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Icon(Icons.circle, size: 16, color: userStatus.onlineStatus.color,), + ), + Text(toBeginningOfSentenceCase(userStatus.onlineStatus.name) ?? "Unknown"), + ], + ), + onSelected: (OnlineStatus onlineStatus) async { + try { + final newStatus = userStatus.copyWith(onlineStatus: onlineStatus); + setState(() { + _userStatusFuture = Future.value(newStatus.copyWith(lastStatusChange: DateTime.now())); + }); + 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."))); + setState(() { + _userStatusFuture = Future.value(userStatus); + }); + } + }, + itemBuilder: (BuildContext context) => + OnlineStatus.values.where((element) => + element == OnlineStatus.online + || element == OnlineStatus.invisible).map((item) => + PopupMenuItem( + value: item, + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon(Icons.circle, size: 16, color: item.color,), + const SizedBox(width: 8,), + Text(toBeginningOfSentenceCase(item.name)!), + ], + ), ), - ), - ).toList()); - } else if (snapshot.hasError) { - return TextButton.icon( - style: TextButton.styleFrom( - foregroundColor: Theme.of(context).colorScheme.onSurface, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2) - ), - onPressed: () { - setState(() { - _userStatusFuture = null; - }); - setState(() { - _userStatusFuture = UserApi.getUserStatus(apiClient, userId: apiClient.userId); - }); - }, - icon: const Icon(Icons.warning), - label: const Text("Retry"), - ); - } else { - return TextButton.icon( - style: TextButton.styleFrom( - disabledForegroundColor: Theme.of(context).colorScheme.onSurface, - ), - onPressed: null, - icon: Container( - width: 16, - height: 16, - margin: const EdgeInsets.only(right: 4), - child: CircularProgressIndicator( - strokeWidth: 2, - color: Theme.of(context).colorScheme.onSurface, + ).toList()); + } else if (snapshot.hasError) { + return TextButton.icon( + style: TextButton.styleFrom( + foregroundColor: Theme + .of(context) + .colorScheme + .onSurface, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2) ), - ), - label: const Text("Loading"), - ); + onPressed: () { + setState(() { + _userStatusFuture = null; + }); + setState(() { + _userStatusFuture = UserApi.getUserStatus(clientHolder.apiClient, userId: clientHolder.apiClient + .userId); + }); + }, + icon: const Icon(Icons.warning), + label: const Text("Retry"), + ); + } else { + return TextButton.icon( + style: TextButton.styleFrom( + disabledForegroundColor: Theme + .of(context) + .colorScheme + .onSurface, + ), + onPressed: null, + icon: Container( + width: 16, + height: 16, + margin: const EdgeInsets.only(right: 4), + child: CircularProgressIndicator( + strokeWidth: 2, + color: Theme + .of(context) + .colorScheme + .onSurface, + ), + ), + label: const Text("Loading"), + ); + } } - } ), Padding( padding: const EdgeInsets.only(left: 4, right: 4), @@ -210,33 +183,23 @@ class _FriendsListState extends State { 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 { - bool changed = false; - _autoRefresh?.cancel(); + final mClient = Provider.of(context, listen: false); await Navigator.of(context).push( MaterialPageRoute( builder: (context) => - UserSearch( - onFriendsChanged: () => changed = true, + ChangeNotifierProvider.value( + value: mClient, + child: const UserSearch(), ), ), ); - if (changed) { - _refreshTimeout?.cancel(); - setState(() { - _refreshFriendsList(); - }); - } else { - _autoRefresh = Timer(_autoRefreshDuration, () => setState(() => _refreshFriendsList())); - } }, ), MenuItemDefinition( @@ -247,24 +210,26 @@ class _FriendsListState extends State { context: context, builder: (context) { return FutureBuilder( - future: _userProfileFuture, - builder: (context, snapshot) { - if (snapshot.hasData) { - final profile = snapshot.data as PersonalProfile; - return MyProfileDialog(profile: profile); - } else if (snapshot.hasError) { - return DefaultErrorWidget( - title: "Failed to load personal profile.", - onRetry: () { - setState(() { - _userProfileFuture = UserApi.getPersonalProfile(ClientHolder.of(context).apiClient); - }); - }, - ); - } else { - return const Center(child: CircularProgressIndicator(),); + future: _userProfileFuture, + builder: (context, snapshot) { + if (snapshot.hasData) { + final profile = snapshot.data as PersonalProfile; + return MyProfileDialog(profile: profile); + } else if (snapshot.hasError) { + return DefaultErrorWidget( + title: "Failed to load personal profile.", + onRetry: () { + setState(() { + _userProfileFuture = UserApi.getPersonalProfile(ClientHolder + .of(context) + .apiClient); + }); + }, + ); + } else { + return const Center(child: CircularProgressIndicator(),); + } } - } ); }, ); @@ -287,63 +252,46 @@ class _FriendsListState extends State { ], ), body: Stack( + alignment: Alignment.topCenter, children: [ - RefreshIndicator( - onRefresh: () async { - _refreshFriendsList(); - await _friendsFuture; // Keep the indicator running until everything's loaded - }, - child: FutureBuilder( - future: _friendsFuture, - builder: (context, snapshot) { - if (snapshot.hasData) { - var friends = (snapshot.data as List); - if (_searchFilter.isNotEmpty) { - friends = friends.where((element) => - element.username.toLowerCase().contains(_searchFilter.toLowerCase())).toList(); - friends.sort((a, b) => a.username.length.compareTo(b.username.length)); - } - return ListView.builder( - itemCount: friends.length, - itemBuilder: (context, index) { - final friend = friends[index]; - final unreads = _clientHolder?.messagingClient.getUnreadsForFriend(friend) ?? []; - return FriendListTile( - friend: friend, - unreads: unreads.length, - onTap: () async { - if (unreads.isNotEmpty) { - final readBatch = MarkReadBatch( - senderId: _clientHolder!.apiClient.userId, - ids: unreads.map((e) => e.id).toList(), - readTime: DateTime.now(), - ); - _clientHolder!.messagingClient.markMessagesRead(readBatch); - } - setState(() { - unreads.clear(); - }); - }, - ); - }, - ); - } 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(); + Consumer( + builder: (context, mClient, _) { + if (mClient.initStatus == null) { + return const LinearProgressIndicator(); + } else if (mClient.initStatus!.isNotEmpty) { + return Column( + children: [ + Expanded( + child: DefaultErrorWidget( + message: mClient.initStatus, + onRetry: () async { + mClient.resetStatus(); + mClient.refreshFriendsListWithErrorHandler(); + }, + ), + ), + ], + ); + } else { + var friends = List.from(mClient.cachedFriends); // Explicit copy. + if (_searchFilter.isNotEmpty) { + friends = friends.where((element) => + element.username.toLowerCase().contains(_searchFilter.toLowerCase())).toList(); + friends.sort((a, b) => a.username.length.compareTo(b.username.length)); } + return ListView.builder( + itemCount: friends.length, + itemBuilder: (context, index) { + final friend = friends[index]; + final unreads = mClient.getUnreadsForFriend(friend); + return FriendListTile( + friend: friend, + unreads: unreads.length, + ); + }, + ); } - ), + } ), Align( alignment: Alignment.bottomCenter, @@ -366,4 +314,4 @@ class _FriendsListState extends State { ), ); } -} +} \ No newline at end of file diff --git a/lib/widgets/messages_list.dart b/lib/widgets/messages_list.dart index 046eb46..2ab80a1 100644 --- a/lib/widgets/messages_list.dart +++ b/lib/widgets/messages_list.dart @@ -1,17 +1,16 @@ -import 'dart:async'; - import 'package:cached_network_image/cached_network_image.dart'; import 'package:contacts_plus_plus/client_holder.dart'; import 'package:contacts_plus_plus/auxiliary.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/session.dart'; -import 'package:contacts_plus_plus/widgets/default_error_widget.dart'; import 'package:contacts_plus_plus/widgets/message_audio_player.dart'; import 'package:contacts_plus_plus/widgets/generic_avatar.dart'; import 'package:contacts_plus_plus/widgets/message_session_invite.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; class MessagesList extends StatefulWidget { const MessagesList({required this.friend, super.key}); @@ -23,46 +22,18 @@ class MessagesList extends StatefulWidget { } class _MessagesListState extends State { - Future? _messageCacheFuture; final TextEditingController _messageTextController = TextEditingController(); final ScrollController _sessionListScrollController = ScrollController(); final ScrollController _messageScrollController = ScrollController(); - ClientHolder? _clientHolder; bool _isSendable = false; - bool _showSessionListChevron = false; - bool _messageCacheFutureComplete = false; + bool _showSessionListScrollChevron = false; - double get _shevronOpacity => _showSessionListChevron ? 1.0 : 0.0; + double get _shevronOpacity => _showSessionListScrollChevron ? 1.0 : 0.0; - @override - void didChangeDependencies() { - super.didChangeDependencies(); - final clientHolder = ClientHolder.of(context); - if (_clientHolder != clientHolder) { - _clientHolder = clientHolder; - } - _loadMessages(); - } - - void _loadMessages() { - _messageCacheFutureComplete = false; - _messageCacheFuture = _clientHolder?.messagingClient.getMessageCache(widget.friend.id) - .whenComplete(() => _messageCacheFutureComplete = true); - final mClient = _clientHolder?.messagingClient; - final id = widget.friend.id; - mClient?.registerMessageListener(id, () { - if (context.mounted) { - setState(() {}); - } else { - mClient.unregisterMessageListener(id); - } - }); - } @override void dispose() { - _clientHolder?.messagingClient.unregisterMessageListener(widget.friend.id); _messageTextController.dispose(); _sessionListScrollController.dispose(); super.dispose(); @@ -72,25 +43,15 @@ class _MessagesListState extends State { void initState() { super.initState(); _sessionListScrollController.addListener(() { - if (_sessionListScrollController.position.maxScrollExtent > 0 && !_showSessionListChevron) { + if (_sessionListScrollController.position.maxScrollExtent > 0 && !_showSessionListScrollChevron) { setState(() { - _showSessionListChevron = true; + _showSessionListScrollChevron = true; }); } if (_sessionListScrollController.position.atEdge && _sessionListScrollController.position.pixels > 0 - && _showSessionListChevron) { + && _showSessionListScrollChevron) { setState(() { - _showSessionListChevron = false; - }); - } - }); - _messageScrollController.addListener(() { - if (_messageScrollController.position.atEdge && _messageScrollController.position.pixels > 0 && - _messageScrollController.position.maxScrollExtent > 0 && _messageCacheFutureComplete) { - setState(() { - _messageCacheFutureComplete = false; - _messageCacheFuture = _clientHolder?.messagingClient.getMessageCache(widget.friend.id) - .then((value) => value.loadOlderMessages()).whenComplete(() => _messageCacheFutureComplete = true); + _showSessionListScrollChevron = false; }); } }); @@ -156,68 +117,56 @@ class _MessagesListState extends State { ), ), Expanded( - child: FutureBuilder( - future: _messageCacheFuture, - builder: (context, snapshot) { - if (snapshot.hasData) { - final cache = snapshot.data as MessageCache; - if (cache.messages.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.message_outlined), - Padding( - padding: const EdgeInsets.symmetric(vertical: 24), - child: Text( - "There are no messages here\nWhy not say hello?", - textAlign: TextAlign.center, - style: Theme - .of(context) - .textTheme - .titleMedium, - ), - ) - ], - ), - ); - } - return ListView.builder( - controller: _messageScrollController, - reverse: true, - itemCount: cache.messages.length, - itemBuilder: (context, index) { - final entry = cache.messages[index]; - final widget = entry.senderId == apiClient.userId - ? MyMessageBubble(message: entry) - : OtherMessageBubble(message: entry); - if (index == cache.messages.length-1) { - return Padding( - padding: const EdgeInsets.only(top: 12), - child: widget, - ); - } - return widget; - }, - ); - } else if (snapshot.hasError) { - return DefaultErrorWidget( - message: "${snapshot.error}", - onRetry: () { - setState(() { - _loadMessages(); - }); - }, - ); - } else { + child: Consumer( + builder: (context, mClient, _) { + final cache = mClient.getUserMessageCache(widget.friend.id); + if (cache == null) { return Column( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.start, children: const [ - LinearProgressIndicator(), + LinearProgressIndicator() ], ); } + if (cache.messages.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.message_outlined), + Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Text( + "There are no messages here\nWhy not say hello?", + textAlign: TextAlign.center, + style: Theme + .of(context) + .textTheme + .titleMedium, + ), + ) + ], + ), + ); + } + return ListView.builder( + controller: _messageScrollController, + reverse: true, + itemCount: cache.messages.length, + itemBuilder: (context, index) { + final entry = cache.messages[index]; + final widget = entry.senderId == apiClient.userId + ? MyMessageBubble(message: entry) + : OtherMessageBubble(message: entry); + if (index == cache.messages.length - 1) { + return Padding( + padding: const EdgeInsets.only(top: 12), + child: widget, + ); + } + return widget; + }, + ); }, ), ), @@ -265,39 +214,43 @@ class _MessagesListState extends State { ), Padding( padding: const EdgeInsets.only(left: 8, right: 4.0), - child: IconButton( - splashRadius: 24, - onPressed: _isSendable && _clientHolder != null ? () async { - setState(() { - _isSendable = false; - }); - final message = Message( - id: Message.generateId(), - recipientId: widget.friend.id, - senderId: apiClient.userId, - type: MessageType.text, - content: _messageTextController.text, - sendTime: DateTime.now().toUtc(), + child: Consumer( + builder: (context, mClient, _) { + return IconButton( + splashRadius: 24, + onPressed: _isSendable ? () async { + setState(() { + _isSendable = false; + }); + final message = Message( + id: Message.generateId(), + recipientId: widget.friend.id, + senderId: apiClient.userId, + type: MessageType.text, + content: _messageTextController.text, + sendTime: DateTime.now().toUtc(), + ); + try { + mClient.sendMessage(message); + _messageTextController.clear(); + setState(() {}); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text("Failed to send message\n$e", + maxLines: null, + ), + ), + ); + setState(() { + _isSendable = true; + }); + } + } : null, + iconSize: 28, + icon: const Icon(Icons.send), ); - try { - _clientHolder!.messagingClient.sendMessage(message); - _messageTextController.clear(); - setState(() {}); - } catch (e) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text("Failed to send message\n$e", - maxLines: null, - ), - ), - ); - setState(() { - _isSendable = true; - }); - } - } : null, - iconSize: 28, - icon: const Icon(Icons.send), + } ), ) ], diff --git a/lib/widgets/user_list_tile.dart b/lib/widgets/user_list_tile.dart index 53f59a0..f7614e3 100644 --- a/lib/widgets/user_list_tile.dart +++ b/lib/widgets/user_list_tile.dart @@ -7,11 +7,11 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; class UserListTile extends StatefulWidget { - const UserListTile({required this.user, required this.isFriend, required this.onChange, super.key}); + const UserListTile({required this.user, required this.isFriend, required this.onChanged, super.key}); final User user; final bool isFriend; - final Function()? onChange; + final Function()? onChanged; @override State createState() => _UserListTileState(); @@ -63,6 +63,11 @@ class _UserListTileState extends State { .of(context) .apiClient, user: widget.user); } + setState(() { + _loading = false; + _localAdded = !_localAdded; + }); + widget.onChanged?.call(); } catch (e, s) { FlutterError.reportError(FlutterErrorDetails(exception: e, stack: s)); ScaffoldMessenger.of(context).showSnackBar( @@ -80,11 +85,6 @@ class _UserListTileState extends State { }); return; } - setState(() { - _loading = false; - _localAdded = !_localAdded; - }); - widget.onChange?.call(); }, ), ); diff --git a/lib/widgets/user_search.dart b/lib/widgets/user_search.dart index 25ba988..e6e2446 100644 --- a/lib/widgets/user_search.dart +++ b/lib/widgets/user_search.dart @@ -2,10 +2,12 @@ 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/clients/messaging_client.dart'; import 'package:contacts_plus_plus/models/user.dart'; import 'package:contacts_plus_plus/widgets/default_error_widget.dart'; import 'package:contacts_plus_plus/widgets/user_list_tile.dart'; import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; class SearchError { final String message; @@ -15,9 +17,7 @@ class SearchError { } class UserSearch extends StatefulWidget { - const UserSearch({required this.onFriendsChanged, super.key}); - - final Function()? onFriendsChanged; + const UserSearch({super.key}); @override State createState() => _UserSearchState(); @@ -53,9 +53,7 @@ class _UserSearchState extends State { @override Widget build(BuildContext context) { - final mClient = ClientHolder - .of(context) - .messagingClient; + final mClient = Provider.of(context, listen: false); return Scaffold( appBar: AppBar( title: const Text("Find Users"), @@ -72,7 +70,9 @@ class _UserSearchState extends State { 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, onChanged: () { + mClient.refreshFriendsList(); + }, isFriend: mClient.getAsFriend(user.id) != null,); }, ); } else if (snapshot.hasError) { diff --git a/pubspec.lock b/pubspec.lock index 84aa285..d84fb34 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 7ab28f8..1e79b97 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.1+1 +version: 1.0.2+1 environment: sdk: '>=2.19.6 <3.0.0' @@ -52,6 +52,7 @@ dependencies: flutter_local_notifications: ^14.0.0+1 collection: ^1.17.0 package_info_plus: ^3.1.2 + provider: ^6.0.5 dev_dependencies: flutter_test: