From 1676e6d19daf953282113fbea3f808505f1205a8 Mon Sep 17 00:00:00 2001 From: Nutcake Date: Fri, 5 May 2023 16:39:40 +0200 Subject: [PATCH] Add user status switcher --- lib/apis/user_api.dart | 13 ++++ lib/clients/settings_client.dart | 2 +- lib/models/friend.dart | 27 ++++++-- lib/models/settings.dart | 28 ++++++-- lib/widgets/friends_list.dart | 106 +++++++++++++++++++++++++++++-- 5 files changed, 158 insertions(+), 18 deletions(-) diff --git a/lib/apis/user_api.dart b/lib/apis/user_api.dart index de5cd4c..30bf57b 100644 --- a/lib/apis/user_api.dart +++ b/lib/apis/user_api.dart @@ -21,6 +21,19 @@ class UserApi { return User.fromMap(data); } + static Future getUserStatus(ApiClient client, {required String userId}) async { + final response = await client.get("/users/$userId/status"); + ApiClient.checkResponse(response); + final data = jsonDecode(response.body); + return UserStatus.fromMap(data); + } + + static Future setStatus(ApiClient client, {required UserStatus status}) async { + final body = jsonEncode(status.toMap(shallow: true)); + final response = await client.put("/users/${client.userId}/status", body: body); + ApiClient.checkResponse(response); + } + static Future getPersonalProfile(ApiClient client) async { final response = await client.get("/users/${client.userId}"); ApiClient.checkResponse(response); diff --git a/lib/clients/settings_client.dart b/lib/clients/settings_client.dart index 8ba4277..9b8a3d4 100644 --- a/lib/clients/settings_client.dart +++ b/lib/clients/settings_client.dart @@ -7,7 +7,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; class SettingsClient { static const String _settingsKey = "settings"; static const _storage = FlutterSecureStorage(); - Settings _currentSettings = const Settings(); + Settings _currentSettings = Settings(); Settings get currentSettings => _currentSettings; diff --git a/lib/models/friend.dart b/lib/models/friend.dart index 2f0416a..59e7a01 100644 --- a/lib/models/friend.dart +++ b/lib/models/friend.dart @@ -2,6 +2,7 @@ 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'; class Friend extends Comparable { final String id; @@ -59,15 +60,25 @@ enum FriendStatus { } enum OnlineStatus { - unknown, offline, + invisible, away, busy, online; + static final List _colors = [ + Colors.black54, + Colors.white54, + Colors.yellow, + Colors.red, + Colors.green, + ]; + + Color get color => _colors[index]; + factory OnlineStatus.fromString(String? text) { return OnlineStatus.values.firstWhere((element) => element.name.toLowerCase() == text?.toLowerCase(), - orElse: () => OnlineStatus.unknown, + orElse: () => OnlineStatus.offline, ); } @@ -91,7 +102,7 @@ class UserStatus { UserStatus({required this.onlineStatus, required this.lastStatusChange, required this.activeSessions}); factory UserStatus.empty() => UserStatus( - onlineStatus: OnlineStatus.unknown, + onlineStatus: OnlineStatus.offline, lastStatusChange: DateTime.now(), activeSessions: [], ); @@ -99,9 +110,6 @@ class UserStatus { factory UserStatus.fromMap(Map map) { final statusString = map["onlineStatus"] as String?; final status = OnlineStatus.fromString(statusString); - if (status == OnlineStatus.unknown && statusString != null) { - log("Unknown OnlineStatus '$statusString' in response"); - } return UserStatus( onlineStatus: status, lastStatusChange: DateTime.parse(map["lastStatusChange"]), @@ -116,4 +124,11 @@ class UserStatus { "activeSessions": shallow ? [] : activeSessions.map((e) => e.toMap(),) }; } + + UserStatus copyWith({OnlineStatus? onlineStatus, DateTime? lastStatusChange, List? activeSessions}) + => UserStatus( + onlineStatus: onlineStatus ?? this.onlineStatus, + lastStatusChange: lastStatusChange ?? this.lastStatusChange, + activeSessions: activeSessions ?? this.activeSessions, + ); } \ No newline at end of file diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 979497a..b407b51 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -1,5 +1,7 @@ import 'dart:convert'; +import 'package:contacts_plus_plus/models/friend.dart'; + class SettingsEntry { final T? value; final T deflt; @@ -32,32 +34,44 @@ class SettingsEntry { class Settings { final SettingsEntry notificationsDenied; final SettingsEntry unreadCheckIntervalMinutes; + final SettingsEntry lastOnlineStatus; - const Settings({ - this.notificationsDenied = const SettingsEntry(deflt: false), - this.unreadCheckIntervalMinutes = const SettingsEntry(deflt: 60), - }); + Settings({ + SettingsEntry? notificationsDenied, + SettingsEntry? unreadCheckIntervalMinutes, + SettingsEntry? lastOnlineStatus, + }) : notificationsDenied = notificationsDenied ?? const SettingsEntry(deflt: false), + unreadCheckIntervalMinutes = unreadCheckIntervalMinutes ?? const SettingsEntry(deflt: 60), + lastOnlineStatus = lastOnlineStatus ?? SettingsEntry(deflt: OnlineStatus.online.index); factory Settings.fromMap(Map map) { return Settings( - notificationsDenied: SettingsEntry.fromMap(map["notificationsDenied"]), - unreadCheckIntervalMinutes: SettingsEntry.fromMap(map["unreadCheckIntervalMinutes"]), + notificationsDenied: retrieveEntryOrNull(map["notificationsDenied"]), + unreadCheckIntervalMinutes: retrieveEntryOrNull(map["unreadCheckIntervalMinutes"]), + lastOnlineStatus: retrieveEntryOrNull(map["lastOnlineStatus"]), ); } + static SettingsEntry? retrieveEntryOrNull(Map? map) { + if (map == null) return null; + return SettingsEntry.fromMap(map); + } + Map toMap() { return { "notificationsDenied": notificationsDenied.toMap(), "unreadCheckIntervalMinutes": unreadCheckIntervalMinutes.toMap(), + "lastOnlineStatus": lastOnlineStatus.toMap(), }; } Settings copy() => copyWith(); - Settings copyWith({bool? notificationsDenied, int? unreadCheckIntervalMinutes}) { + Settings copyWith({bool? notificationsDenied, int? unreadCheckIntervalMinutes, int? lastOnlineStatus}) { return Settings( notificationsDenied: this.notificationsDenied.passThrough(notificationsDenied), unreadCheckIntervalMinutes: this.unreadCheckIntervalMinutes.passThrough(unreadCheckIntervalMinutes), + lastOnlineStatus: this.lastOnlineStatus.passThrough(lastOnlineStatus), ); } diff --git a/lib/widgets/friends_list.dart b/lib/widgets/friends_list.dart index aa043bf..2372e61 100644 --- a/lib/widgets/friends_list.dart +++ b/lib/widgets/friends_list.dart @@ -14,6 +14,7 @@ import 'package:contacts_plus_plus/widgets/my_profile_dialog.dart'; 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'; class MenuItemDefinition { @@ -36,6 +37,7 @@ class _FriendsListState extends State { static const Duration _refreshTimeoutDuration = Duration(seconds: 30); Future>? _friendsFuture; Future? _userProfileFuture; + Future? _userStatusFuture; ClientHolder? _clientHolder; Timer? _autoRefresh; Timer? _refreshTimeout; @@ -63,14 +65,16 @@ class _FriendsListState extends State { } }); _refreshFriendsList(); - _userProfileFuture = UserApi.getPersonalProfile(_clientHolder!.apiClient); + final apiClient = _clientHolder!.apiClient; + _userProfileFuture = UserApi.getPersonalProfile(apiClient); } } void _refreshFriendsList() { if (_refreshTimeout?.isActive == true) return; - _friendsFuture = FriendApi.getFriendsList(_clientHolder!.apiClient).then((Iterable value) async { - final unreadMessages = await MessageApi.getUserMessages(_clientHolder!.apiClient, unreadOnly: true); + 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()); @@ -91,16 +95,110 @@ class _FriendsListState extends State { _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] + ); + await UserApi.setStatus(apiClient, status: newStatus); + return newStatus; + } + return value; + }); } @override Widget build(BuildContext context) { + final apiClient = ClientHolder.of(context).apiClient; 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)!), + ], + ), + ), + ).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, + ), + ), + label: const Text("Loading"), + ); + } + } + ), Padding( - padding: const EdgeInsets.only(right: 4), + padding: const EdgeInsets.only(left: 4, right: 4), child: PopupMenuButton( icon: const Icon(Icons.more_vert), onSelected: (MenuItemDefinition itemDef) async {