Isovel/contacts sorting (#26)

* feat: update contacts list sorting + correctly handle headless hosts
---------

Co-authored-by: Garrett Watson <toast@isota.ch>
This commit is contained in:
Nutcake 2024-01-27 15:55:55 +01:00 committed by GitHub
parent 8b285203aa
commit 76e32887e4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 183 additions and 78 deletions

View file

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

View file

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

View file

@ -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,
);
}

View file

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

View file

@ -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,
);
}

View file

@ -62,7 +62,7 @@ class _MessagesListState extends State<MessagesList> with SingleTickerProviderSt
title: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
FriendOnlineStatusIndicator(userStatus: friend.userStatus),
FriendOnlineStatusIndicator(friend: friend),
const SizedBox(
width: 8,
),

View file

@ -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<MyProfileDialog> {
Future<PersonalProfile>? _personalProfileFuture;
Future<StorageQuota>? _storageQuotaFuture;
@override
void didChangeDependencies() async {
super.didChangeDependencies();
@ -34,9 +33,7 @@ class _MyProfileDialogState extends State<MyProfileDialog> {
@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<MyProfileDialog> {
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<MyProfileDialog> {
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 {
),
);
}
}
}