From 1e336688b7198237dc8c86a4c8c343aebd31b7de Mon Sep 17 00:00:00 2001 From: Nutcake Date: Fri, 5 May 2023 15:05:06 +0200 Subject: [PATCH] Add user-profile popup --- lib/apis/user_api.dart | 8 +++ lib/clients/messaging_client.dart | 34 ++++++++--- lib/main.dart | 3 +- lib/models/personal_profile.dart | 63 +++++++++++++++++++ lib/widgets/friends_list.dart | 98 ++++++++++++++++++++++-------- lib/widgets/generic_avatar.dart | 20 +++--- lib/widgets/my_profile_dialog.dart | 98 ++++++++++++++++++++++++++++++ 7 files changed, 282 insertions(+), 42 deletions(-) create mode 100644 lib/models/personal_profile.dart create mode 100644 lib/widgets/my_profile_dialog.dart diff --git a/lib/apis/user_api.dart b/lib/apis/user_api.dart index 8dec605..de5cd4c 100644 --- a/lib/apis/user_api.dart +++ b/lib/apis/user_api.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:contacts_plus_plus/clients/api_client.dart'; import 'package:contacts_plus_plus/models/friend.dart'; +import 'package:contacts_plus_plus/models/personal_profile.dart'; import 'package:contacts_plus_plus/models/user.dart'; import 'package:contacts_plus_plus/models/user_profile.dart'; @@ -20,6 +21,13 @@ class UserApi { return User.fromMap(data); } + static Future getPersonalProfile(ApiClient client) async { + final response = await client.get("/users/${client.userId}"); + ApiClient.checkResponse(response); + final data = jsonDecode(response.body); + return PersonalProfile.fromMap(data); + } + static Future addUserAsFriend(ApiClient client, {required User user}) async { final friend = Friend( id: user.id, diff --git a/lib/clients/messaging_client.dart b/lib/clients/messaging_client.dart index 2770d68..83d501e 100644 --- a/lib/clients/messaging_client.dart +++ b/lib/clients/messaging_client.dart @@ -15,6 +15,12 @@ import 'package:workmanager/workmanager.dart'; enum EventType { unknown, message, + unknown1, + unknown2, + unknown3, + unknown4, + keepAlive, + error; } enum EventTarget { @@ -44,6 +50,7 @@ class MessagingClient { final Logger _logger = Logger("NeosHub"); final Workmanager _workmanager = Workmanager(); final NotificationClient _notificationClient; + int _attempts = 0; Function? _unreadsUpdateListener; WebSocket? _wsChannel; bool _isConnecting = false; @@ -138,9 +145,9 @@ class MessagingClient { ); } - void _onDisconnected(error) { + void _onDisconnected(error) async { _logger.warning("Neos Hub connection died with error '$error', reconnecting..."); - start(); + await start(); } Future start() async { @@ -161,7 +168,6 @@ class MessagingClient { } Future _tryConnect() async { - int attempts = 0; while (true) { try { final http.Response response; @@ -181,13 +187,15 @@ class MessagingClient { if (url == null || wsToken == null) { throw "Invalid response from server."; } - return await WebSocket.connect("$url&access_token=$wsToken"); + final ws = await WebSocket.connect("$url&access_token=$wsToken"); + _attempts = 0; + return ws; } catch (e) { - final timeout = _reconnectTimeoutsSeconds[attempts.clamp(0, _reconnectTimeoutsSeconds.length - 1)]; + final timeout = _reconnectTimeoutsSeconds[_attempts.clamp(0, _reconnectTimeoutsSeconds.length - 1)]; _logger.severe(e); _logger.severe("Retrying in $timeout seconds"); await Future.delayed(Duration(seconds: timeout)); - attempts++; + _attempts++; } } } @@ -208,12 +216,24 @@ class MessagingClient { return; } switch (EventType.values[rawType]) { + case EventType.unknown1: + case EventType.unknown2: + case EventType.unknown3: + case EventType.unknown4: case EventType.unknown: - _logger.info("[Hub]: Unknown event received: $rawType: $body"); + _logger.info("Received unknown event: $rawType: $body"); break; case EventType.message: + _logger.info("Received message-event."); _handleMessageEvent(body); break; + case EventType.keepAlive: + _logger.info("Received keep-alive."); + break; + case EventType.error: + _logger.severe("Received error-event: ${body["error"]}"); + // Should we trigger a manual reconnect here or just let the remote service close the connection? + break; } } diff --git a/lib/main.dart b/lib/main.dart index 0dc85fc..b338351 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,7 +10,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_phoenix/flutter_phoenix.dart'; import 'package:logging/logging.dart'; import 'package:workmanager/workmanager.dart'; -import 'clients/api_client.dart'; import 'models/authentication_data.dart'; void main() async { @@ -22,7 +21,7 @@ void main() async { ); } - Logger.root.onRecord.listen((event) => log(event.message, name: event.loggerName)); + Logger.root.onRecord.listen((event) => log(event.message, name: event.loggerName, time: event.time)); final settingsClient = SettingsClient(); await settingsClient.loadSettings(); runApp(Phoenix(child: ContactsPlusPlus(settingsClient: settingsClient,))); diff --git a/lib/models/personal_profile.dart b/lib/models/personal_profile.dart new file mode 100644 index 0000000..443a661 --- /dev/null +++ b/lib/models/personal_profile.dart @@ -0,0 +1,63 @@ + +import 'package:collection/collection.dart'; +import 'package:contacts_plus_plus/models/user_profile.dart'; + +class PersonalProfile { + final String id; + final String username; + final String email; + final DateTime? publicBanExpiration; + final String? publicBanType; + final List storageQuotas; + final Map quotaBytesSource; + final int usedBytes; + final bool twoFactor; + final bool isPatreonSupporter; + final UserProfile userProfile; + + PersonalProfile({ + required this.id, required this.username, required this.email, required this.publicBanExpiration, + required this.publicBanType, required this.storageQuotas, required this.quotaBytesSource, required this.usedBytes, + required this.twoFactor, required this.isPatreonSupporter, required this.userProfile, + }); + + factory PersonalProfile.fromMap(Map map) { + final banExp = map["publicBanExpiration"]; + return PersonalProfile( + id: map["id"], + username: map["username"], + email: map["email"], + publicBanExpiration: banExp == null ? null : DateTime.parse(banExp), + publicBanType: map["publicBanType"], + storageQuotas: (map["storageQuotas"] as List).map((e) => StorageQuotas.fromMap(e)).toList(), + quotaBytesSource: (map["quotaBytesSources"] as Map).map((key, value) => MapEntry(key, value as int)), + usedBytes: map["usedBytes"], + twoFactor: map["2fa_login"] ?? false, + isPatreonSupporter: map["patreonData"]?["isPatreonSupporter"] ?? false, + userProfile: UserProfile.fromMap(map["profile"]), + ); + } + + int get maxBytes => (quotaBytesSource.values.maxOrNull ?? 0) + storageQuotas.map((e) => e.bytes).reduce((value, element) => value + element); +} + +class StorageQuotas { + final String id; + final int bytes; + final DateTime addedOn; + final DateTime expiresOn; + final String giftedByUserId; + + StorageQuotas({required this.id, required this.bytes, required this.addedOn, required this.expiresOn, + required this.giftedByUserId}); + + factory StorageQuotas.fromMap(Map map) { + return StorageQuotas( + id: map["id"], + bytes: map["bytes"], + addedOn: DateTime.parse(map["addedOn"]), + expiresOn: DateTime.parse(map["expiresOn"]), + giftedByUserId: map["giftedByUserId"] ?? "", + ); + } +} \ No newline at end of file diff --git a/lib/widgets/friends_list.dart b/lib/widgets/friends_list.dart index 426b2d4..aa043bf 100644 --- a/lib/widgets/friends_list.dart +++ b/lib/widgets/friends_list.dart @@ -1,13 +1,16 @@ 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/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'; import 'package:contacts_plus_plus/widgets/friend_list_tile.dart'; +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'; @@ -32,6 +35,7 @@ class _FriendsListState extends State { static const Duration _autoRefreshDuration = Duration(seconds: 90); static const Duration _refreshTimeoutDuration = Duration(seconds: 30); Future>? _friendsFuture; + Future? _userProfileFuture; ClientHolder? _clientHolder; Timer? _autoRefresh; Timer? _refreshTimeout; @@ -59,6 +63,7 @@ class _FriendsListState extends State { } }); _refreshFriendsList(); + _userProfileFuture = UserApi.getPersonalProfile(_clientHolder!.apiClient); } } @@ -101,32 +106,72 @@ class _FriendsListState extends State { onSelected: (MenuItemDefinition itemDef) async { await itemDef.onTap(); }, - itemBuilder: (BuildContext context) => [ - MenuItemDefinition(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(); - await Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => - UserSearch( - onFriendsChanged: () => changed = true, - ), - ), - ); - if (changed) { - _refreshTimeout?.cancel(); - setState(() { - _refreshFriendsList(); - }); - } else { + itemBuilder: (BuildContext context) => + [ + MenuItemDefinition( + 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(); + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => + UserSearch( + onFriendsChanged: () => changed = true, + ), + ), + ); + if (changed) { + _refreshTimeout?.cancel(); + setState(() { + _refreshFriendsList(); + }); + } else { + _autoRefresh = Timer(_autoRefreshDuration, () => setState(() => _refreshFriendsList())); + } + }, + ), + MenuItemDefinition( + name: "My Profile", + icon: Icons.person, + onTap: () async { + await showDialog( + 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(),); + } + } + ); + }, + ); + }, + ), ].map((item) => PopupMenuItem( value: item, @@ -156,7 +201,8 @@ class _FriendsListState extends State { if (snapshot.hasData) { var friends = (snapshot.data as List); if (_searchFilter.isNotEmpty) { - friends = friends.where((element) => element.username.toLowerCase().contains(_searchFilter.toLowerCase())).toList(); + 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( diff --git a/lib/widgets/generic_avatar.dart b/lib/widgets/generic_avatar.dart index bf385c6..21a29fc 100644 --- a/lib/widgets/generic_avatar.dart +++ b/lib/widgets/generic_avatar.dart @@ -2,14 +2,16 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; class GenericAvatar extends StatelessWidget { - const GenericAvatar({this.imageUri="", super.key, this.placeholderIcon=Icons.person}); + const GenericAvatar({this.imageUri="", super.key, this.placeholderIcon=Icons.person, this.radius}); final String imageUri; final IconData placeholderIcon; + final double? radius; @override Widget build(BuildContext context) { return imageUri.isEmpty ? CircleAvatar( + radius: radius, backgroundColor: Colors.transparent, child: Icon(placeholderIcon), ) : CachedNetworkImage( @@ -17,18 +19,22 @@ class GenericAvatar extends StatelessWidget { return CircleAvatar( foregroundImage: imageProvider, backgroundColor: Colors.transparent, + radius: radius, ); }, imageUrl: imageUri, placeholder: (context, url) { - return const CircleAvatar( - backgroundColor: Colors.white54, - child: Padding( - padding: EdgeInsets.all(8.0), - child: CircularProgressIndicator(color: Colors.black38, strokeWidth: 2), - )); + return CircleAvatar( + backgroundColor: Colors.white54, + radius: radius, + child: const Padding( + padding: EdgeInsets.all(8.0), + child: CircularProgressIndicator(color: Colors.black38, strokeWidth: 2), + ), + ); }, errorWidget: (context, error, what) => CircleAvatar( + radius: radius, backgroundColor: Colors.transparent, child: Icon(placeholderIcon), ), diff --git a/lib/widgets/my_profile_dialog.dart b/lib/widgets/my_profile_dialog.dart new file mode 100644 index 0000000..08d7ecc --- /dev/null +++ b/lib/widgets/my_profile_dialog.dart @@ -0,0 +1,98 @@ +import 'package:contacts_plus_plus/auxiliary.dart'; +import 'package:contacts_plus_plus/models/personal_profile.dart'; +import 'package:contacts_plus_plus/widgets/generic_avatar.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +class MyProfileDialog extends StatelessWidget { + const MyProfileDialog({required this.profile, super.key}); + + final PersonalProfile profile; + + @override + Widget build(BuildContext context) { + final tt = Theme.of(context).textTheme; + DateFormat dateFormat = DateFormat.yMd(); + return Dialog( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(profile.username, style: tt.titleLarge), + Text(profile.email, style: tt.labelMedium?.copyWith(color: Colors.white54),) + ], + ), + GenericAvatar(imageUri: Aux.neosDbToHttp(profile.userProfile.iconUrl), radius: 24,) + ], + ), + const SizedBox(height: 16,), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [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(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!))], + ), + StorageIndicator(usedBytes: profile.usedBytes, maxBytes: profile.maxBytes,), + ], + ), + ), + ); + } +} + +class StorageIndicator extends StatelessWidget { + const StorageIndicator({required this.usedBytes, required this.maxBytes, super.key}); + + final int usedBytes; + final int maxBytes; + + @override + Widget build(BuildContext context) { + final value = usedBytes/maxBytes; + return Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text("Storage:", style: Theme.of(context).textTheme.titleMedium), + Text("${(usedBytes * 1e-9).toStringAsFixed(2)}/${(maxBytes * 1e-9).toStringAsFixed(2)} GiB"), + ], + ), + const SizedBox(height: 8,), + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: LinearProgressIndicator( + minHeight: 12, + color: value > 0.95 ? Theme.of(context).colorScheme.error : null, + value: value, + ), + ) + ], + ), + ); + } +} \ No newline at end of file