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:
parent
8b285203aa
commit
76e32887e4
7 changed files with 183 additions and 78 deletions
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
),
|
||||
|
|
|
@ -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 {
|
|||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue