diff --git a/lib/clients/messaging_client.dart b/lib/clients/messaging_client.dart index fc54cdb..37de60f 100644 --- a/lib/clients/messaging_client.dart +++ b/lib/clients/messaging_client.dart @@ -1,5 +1,10 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:logging/logging.dart'; +import 'package:package_info_plus/package_info_plus.dart'; import 'package:recon/apis/contact_api.dart'; import 'package:recon/apis/message_api.dart'; import 'package:recon/apis/session_api.dart'; @@ -15,12 +20,6 @@ import 'package:recon/models/session.dart'; import 'package:recon/models/users/friend.dart'; import 'package:recon/models/users/online_status.dart'; import 'package:recon/models/users/user_status.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; -import 'package:hive_flutter/hive_flutter.dart'; -import 'package:logging/logging.dart'; -import 'package:package_info_plus/package_info_plus.dart'; - class MessagingClient extends ChangeNotifier { static const Duration _autoRefreshDuration = Duration(seconds: 10); @@ -49,11 +48,13 @@ class MessagingClient extends ChangeNotifier { UserStatus get userStatus => _userStatus; - MessagingClient({required ApiClient apiClient, required NotificationClient notificationClient, required SettingsClient settingsClient}) + MessagingClient( + {required ApiClient apiClient, + required NotificationClient notificationClient, + required SettingsClient settingsClient}) : _apiClient = apiClient, _notificationClient = notificationClient, - _settingsClient = settingsClient - { + _settingsClient = settingsClient { debugPrint("mClient created: $hashCode"); Hive.openBox(_messageBoxKey).then((box) async { await box.delete(_lastUpdateKey); @@ -222,14 +223,44 @@ class MessagingClient extends ChangeNotifier { } catch (_) {} } + // Calculate online status value, with 'headless' between 'busy' and 'offline' + double getOnlineStatusValue(Friend friend) { + // Adjusting values to ensure correct placement of 'headless' + if (friend.isHeadless) return 2.5; + switch (friend.userStatus.onlineStatus) { + case OnlineStatus.online: + return 0; + case OnlineStatus.away: + return 1; + case OnlineStatus.busy: + return 2; + case OnlineStatus.invisible: + return 2.5; + case OnlineStatus.offline: + default: + return 3; + } + } + void _sortFriendsCache() { _sortedFriendsCache.sort((a, b) { - var aVal = friendHasUnreads(a) ? -3 : 0; - var bVal = friendHasUnreads(b) ? -3 : 0; + // Check for unreads and sort by latest message time if either has unreads + bool aHasUnreads = friendHasUnreads(a); + bool bHasUnreads = friendHasUnreads(b); + if (aHasUnreads || bHasUnreads) { + if (aHasUnreads && bHasUnreads) { + return -a.latestMessageTime.compareTo(b.latestMessageTime); + } - aVal -= a.latestMessageTime.compareTo(b.latestMessageTime); - aVal += a.userStatus.onlineStatus.compareTo(b.userStatus.onlineStatus) * 2; - return aVal.compareTo(bVal); + return aHasUnreads ? -1 : 1; + } + + int onlineStatusComparison = getOnlineStatusValue(a).compareTo(getOnlineStatusValue(b)); + if (onlineStatusComparison != 0) { + return onlineStatusComparison; + } + + return -a.latestMessageTime.compareTo(b.latestMessageTime); }); } @@ -280,7 +311,8 @@ class MessagingClient extends ChangeNotifier { await _refreshUnreads(); _unreadSafeguard = Timer.periodic(_unreadSafeguardDuration, (timer) => _refreshUnreads()); _hubManager.send("RequestStatus", arguments: [null, false]); - final lastOnline = OnlineStatus.values.elementAtOrNull(_settingsClient.currentSettings.lastOnlineStatus.valueOrDefault); + final lastOnline = + OnlineStatus.values.elementAtOrNull(_settingsClient.currentSettings.lastOnlineStatus.valueOrDefault); await setOnlineStatus(lastOnline ?? OnlineStatus.online); _statusHeartbeat = Timer.periodic(_statusHeartbeatDuration, (timer) { setOnlineStatus(_userStatus.onlineStatus); @@ -332,7 +364,9 @@ class MessagingClient extends ChangeNotifier { var status = UserStatus.fromMap(statusUpdate); final sessionMap = createSessionMap(status.hashSalt); status = status.copyWith( - decodedSessions: status.sessions.map((e) => sessionMap[e.sessionHash] ?? Session.none().copyWith(accessLevel: e.accessLevel)).toList()); + decodedSessions: status.sessions + .map((e) => sessionMap[e.sessionHash] ?? Session.none().copyWith(accessLevel: e.accessLevel)) + .toList()); final friend = getAsFriend(statusUpdate["userId"])?.copyWith(userStatus: status); if (friend != null) { _updateContact(friend); diff --git a/lib/models/users/friend.dart b/lib/models/users/friend.dart index 35a92e8..461937b 100644 --- a/lib/models/users/friend.dart +++ b/lib/models/users/friend.dart @@ -1,7 +1,7 @@ import 'package:recon/auxiliary.dart'; -import 'package:recon/models/users/user_profile.dart'; import 'package:recon/models/users/friend_status.dart'; import 'package:recon/models/users/online_status.dart'; +import 'package:recon/models/users/user_profile.dart'; import 'package:recon/models/users/user_status.dart'; class Friend implements Comparable { @@ -15,11 +15,26 @@ class Friend implements Comparable { final FriendStatus contactStatus; final DateTime latestMessageTime; - const Friend({required this.id, required this.username, required this.ownerId, required this.userStatus, required this.userProfile, - required this.contactStatus, required this.latestMessageTime, + const Friend({ + required this.id, + required this.username, + required this.ownerId, + required this.userStatus, + required this.userProfile, + required this.contactStatus, + required this.latestMessageTime, }); - bool get isHeadless => userStatus.outputDevice == "Headless"; + bool get isHeadless => userStatus.sessionType == UserSessionType.headless; + + bool get isBot => userStatus.sessionType == UserSessionType.bot || id == _resoniteBotId; + + bool get isOffline => + (userStatus.onlineStatus == OnlineStatus.offline || userStatus.onlineStatus == OnlineStatus.invisible) && + !isBot && + !isHeadless; + + bool get isOnline => !isOffline; factory Friend.fromMap(Map map) { var userStatus = map["userStatus"] == null ? UserStatus.empty() : UserStatus.fromMap(map["userStatus"]); @@ -27,12 +42,13 @@ class Friend implements Comparable { id: map["id"], username: map["contactUsername"], ownerId: map["ownerId"] ?? map["id"], - // Neos bot status is always offline but should be displayed as online - userStatus: map["id"] == _resoniteBotId ? userStatus.copyWith(onlineStatus: OnlineStatus.online) : userStatus, + // Resonite bot status is always offline but should be displayed as online + userStatus: map["id"] == _resoniteBotId ? userStatus.copyWith(onlineStatus: OnlineStatus.online) : userStatus, userProfile: UserProfile.fromMap(map["profile"] ?? {}), contactStatus: FriendStatus.fromString(map["contactStatus"]), latestMessageTime: map["latestMessageTime"] == null - ? DateTime.fromMillisecondsSinceEpoch(0) : DateTime.parse(map["latestMessageTime"]), + ? DateTime.fromMillisecondsSinceEpoch(0) + : DateTime.parse(map["latestMessageTime"]), ); } @@ -43,21 +59,26 @@ class Friend implements Comparable { factory Friend.empty() { return Friend( - id: _emptyId, - username: "", - ownerId: "", - userStatus: UserStatus.empty(), - userProfile: UserProfile.empty(), - contactStatus: FriendStatus.none, - latestMessageTime: DateTimeX.epoch + id: _emptyId, + username: "", + ownerId: "", + userStatus: UserStatus.empty(), + userProfile: UserProfile.empty(), + contactStatus: FriendStatus.none, + latestMessageTime: DateTimeX.epoch, ); } bool get isEmpty => id == _emptyId; - Friend copyWith({ - String? id, String? username, String? ownerId, UserStatus? userStatus, UserProfile? userProfile, - FriendStatus? contactStatus, DateTime? latestMessageTime}) { + Friend copyWith( + {String? id, + String? username, + String? ownerId, + UserStatus? userStatus, + UserProfile? userProfile, + FriendStatus? contactStatus, + DateTime? latestMessageTime}) { return Friend( id: id ?? this.id, username: username ?? this.username, @@ -69,7 +90,7 @@ class Friend implements Comparable { ); } - Map toMap({bool shallow=false}) { + Map toMap({bool shallow = false}) { return { "id": id, "contactUsername": username, diff --git a/lib/models/users/online_status.dart b/lib/models/users/online_status.dart index 0e6de0b..2d8d8bf 100644 --- a/lib/models/users/online_status.dart +++ b/lib/models/users/online_status.dart @@ -15,10 +15,13 @@ enum OnlineStatus { Colors.green, ]; - Color color(BuildContext context) => this == OnlineStatus.offline || this == OnlineStatus.invisible ? Theme.of(context).colorScheme.onSurface : _colors[index]; + Color color(BuildContext context) => this == OnlineStatus.offline || this == OnlineStatus.invisible + ? Theme.of(context).colorScheme.onSecondaryContainer.withAlpha(150) + : _colors[index]; factory OnlineStatus.fromString(String? text) { - return OnlineStatus.values.firstWhere((element) => element.name.toLowerCase() == text?.toLowerCase(), + return OnlineStatus.values.firstWhere( + (element) => element.name.toLowerCase() == text?.toLowerCase(), orElse: () => OnlineStatus.online, ); } diff --git a/lib/widgets/friends/friend_list_tile.dart b/lib/widgets/friends/friend_list_tile.dart index 87a748d..36dc4e4 100644 --- a/lib/widgets/friends/friend_list_tile.dart +++ b/lib/widgets/friends/friend_list_tile.dart @@ -54,13 +54,12 @@ class FriendListTile extends StatelessWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, children: [ - FriendOnlineStatusIndicator(userStatus: friend.userStatus), + FriendOnlineStatusIndicator(friend: friend), const SizedBox( width: 4, ), - Text(toBeginningOfSentenceCase(friend.userStatus.onlineStatus.name) ?? "Unknown"), - if (!(friend.userStatus.onlineStatus == OnlineStatus.offline || - friend.userStatus.onlineStatus == OnlineStatus.invisible)) + if (!(friend.isOffline || friend.isHeadless)) ...[ + Text(toBeginningOfSentenceCase(friend.userStatus.onlineStatus.name) ?? "Unknown"), if (currentSession != null) ...[ const Text(" in "), if (currentSession.name.isNotEmpty) @@ -87,6 +86,24 @@ class FriendListTile extends StatelessWidget { maxLines: 1, ), ), + ] else if (friend.isOffline) + Text( + "Offline", + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: theme.textTheme.bodyMedium?.copyWith( + color: OnlineStatus.offline.color(context), + ), + ) + else + Text( + "Headless Host", + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: theme.textTheme.bodyMedium?.copyWith( + color: const Color.fromARGB(255, 41, 77, 92), + ), + ) ], ), onTap: () async { diff --git a/lib/widgets/friends/friend_online_status_indicator.dart b/lib/widgets/friends/friend_online_status_indicator.dart index 405e805..65e4d58 100644 --- a/lib/widgets/friends/friend_online_status_indicator.dart +++ b/lib/widgets/friends/friend_online_status_indicator.dart @@ -1,27 +1,30 @@ import 'package:flutter/material.dart'; +import 'package:recon/models/users/friend.dart'; import 'package:recon/models/users/online_status.dart'; import 'package:recon/models/users/user_status.dart'; class FriendOnlineStatusIndicator extends StatelessWidget { - const FriendOnlineStatusIndicator({required this.userStatus, super.key}); + const FriendOnlineStatusIndicator({required this.friend, super.key}); - final UserStatus userStatus; + final Friend friend; @override Widget build(BuildContext context) { - return userStatus.appVersion.contains("ReCon") && userStatus.onlineStatus != OnlineStatus.offline + final UserStatus userStatus = friend.userStatus; + final OnlineStatus onlineStatus = userStatus.onlineStatus; + return userStatus.appVersion.contains("ReCon") && friend.isOnline ? SizedBox.square( dimension: 10, child: Image.asset( "assets/images/logo-white.png", - color: userStatus.onlineStatus.color(context), + color: onlineStatus.color(context), filterQuality: FilterQuality.medium, isAntiAlias: true, ), ) : Icon( - userStatus.onlineStatus == OnlineStatus.offline ? Icons.circle_outlined : Icons.circle, - color: userStatus.onlineStatus.color(context), + friend.isOffline ? Icons.circle_outlined : Icons.circle, + color: friend.isHeadless ? const Color.fromARGB(255, 41, 77, 92) : onlineStatus.color(context), size: 10, ); } diff --git a/lib/widgets/messages/messages_list.dart b/lib/widgets/messages/messages_list.dart index a3311c8..2c2539e 100644 --- a/lib/widgets/messages/messages_list.dart +++ b/lib/widgets/messages/messages_list.dart @@ -62,7 +62,7 @@ class _MessagesListState extends State with SingleTickerProviderSt title: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - FriendOnlineStatusIndicator(userStatus: friend.userStatus), + FriendOnlineStatusIndicator(friend: friend), const SizedBox( width: 8, ), diff --git a/lib/widgets/my_profile_dialog.dart b/lib/widgets/my_profile_dialog.dart index b3f4efc..7e5b49b 100644 --- a/lib/widgets/my_profile_dialog.dart +++ b/lib/widgets/my_profile_dialog.dart @@ -1,11 +1,11 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; import 'package:recon/apis/user_api.dart'; import 'package:recon/auxiliary.dart'; import 'package:recon/client_holder.dart'; import 'package:recon/models/personal_profile.dart'; import 'package:recon/widgets/default_error_widget.dart'; import 'package:recon/widgets/generic_avatar.dart'; -import 'package:flutter/material.dart'; -import 'package:intl/intl.dart'; class MyProfileDialog extends StatefulWidget { const MyProfileDialog({super.key}); @@ -19,7 +19,6 @@ class _MyProfileDialogState extends State { Future? _personalProfileFuture; Future? _storageQuotaFuture; - @override void didChangeDependencies() async { super.didChangeDependencies(); @@ -34,9 +33,7 @@ class _MyProfileDialogState extends State { @override Widget build(BuildContext context) { - final tt = Theme - .of(context) - .textTheme; + final tt = Theme.of(context).textTheme; DateFormat dateFormat = DateFormat.yMd(); return Dialog( child: FutureBuilder( @@ -58,50 +55,79 @@ class _MyProfileDialogState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(profile.username, style: tt.titleLarge), - Text(profile.email, style: tt.labelMedium?.copyWith(color: Theme.of(context).colorScheme.onSurface.withAlpha(150)),) + Text( + profile.email, + style: + tt.labelMedium?.copyWith(color: Theme.of(context).colorScheme.onSurface.withAlpha(150)), + ) ], ), - GenericAvatar(imageUri: Aux.resdbToHttp(profile.userProfile.iconUrl), radius: 24,) + GenericAvatar( + imageUri: Aux.resdbToHttp(profile.userProfile.iconUrl), + radius: 24, + ) ], ), - const SizedBox(height: 16,), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [Text("User ID: ", style: tt.labelLarge,), Text(profile.id)], + const SizedBox( + height: 16, ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text("2FA: ", style: tt.labelLarge,), + Text( + "User ID: ", + style: tt.labelLarge, + ), + Text(profile.id) + ], + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "2FA: ", + style: tt.labelLarge, + ), Text(profile.twoFactor ? "Enabled" : "Disabled") ], ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text("Patreon Supporter: ", style: tt.labelLarge,), + Text( + "Patreon Supporter: ", + style: tt.labelLarge, + ), Text(profile.isPatreonSupporter ? "Yes" : "No") ], ), if (profile.publicBanExpiration?.isAfter(DateTime.now()) ?? false) Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [Text("Ban Expiration: ", style: tt.labelLarge,), - Text(dateFormat.format(profile.publicBanExpiration!))], + children: [ + Text( + "Ban Expiration: ", + style: tt.labelLarge, + ), + Text(dateFormat.format(profile.publicBanExpiration!)) + ], ), FutureBuilder( - future: _storageQuotaFuture, - builder: (context, snapshot) { - final storage = snapshot.data; - return StorageIndicator(usedBytes: storage?.usedBytes ?? 0, maxBytes: storage?.fullQuotaBytes ?? 1,); - } + future: _storageQuotaFuture, + builder: (context, snapshot) { + final storage = snapshot.data; + return StorageIndicator( + usedBytes: storage?.usedBytes ?? 0, + maxBytes: storage?.fullQuotaBytes ?? 1, + ); + }), + const SizedBox( + height: 12, ), - const SizedBox(height: 12,), ], ), ); - } - else if (snapshot.hasError) { + } else if (snapshot.hasError) { return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -109,9 +135,7 @@ class _MyProfileDialogState extends State { message: snapshot.error.toString(), onRetry: () { setState(() { - _personalProfileFuture = UserApi.getPersonalProfile(ClientHolder - .of(context) - .apiClient); + _personalProfileFuture = UserApi.getPersonalProfile(ClientHolder.of(context).apiClient); }); }, ), @@ -142,7 +166,7 @@ class StorageIndicator extends StatelessWidget { @override Widget build(BuildContext context) { - final value = usedBytes/maxBytes; + final value = usedBytes / maxBytes; return Padding( padding: const EdgeInsets.only(top: 16.0), child: Column( @@ -152,10 +176,13 @@ class StorageIndicator extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text("Storage:", style: Theme.of(context).textTheme.titleMedium), - Text("${(usedBytes * 1e-9).toStringAsFixed(2)}/${(maxBytes * 1e-9).toStringAsFixed(2)} GiB"), + Text(// Displayed in GiB instead of GB for consistency with Resonite + "${(usedBytes * 9.3132257461548e-10).toStringAsFixed(2)}/${(maxBytes * 9.3132257461548e-10).toStringAsFixed(2)} GB"), ], ), - const SizedBox(height: 8,), + const SizedBox( + height: 8, + ), ClipRRect( borderRadius: BorderRadius.circular(8), child: LinearProgressIndicator( @@ -168,4 +195,4 @@ class StorageIndicator extends StatelessWidget { ), ); } -} \ No newline at end of file +}