Add user-profile popup

This commit is contained in:
Nutcake 2023-05-05 15:05:06 +02:00
parent cb87c08be6
commit 1e336688b7
7 changed files with 282 additions and 42 deletions

View file

@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:contacts_plus_plus/clients/api_client.dart'; import 'package:contacts_plus_plus/clients/api_client.dart';
import 'package:contacts_plus_plus/models/friend.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.dart';
import 'package:contacts_plus_plus/models/user_profile.dart'; import 'package:contacts_plus_plus/models/user_profile.dart';
@ -20,6 +21,13 @@ class UserApi {
return User.fromMap(data); return User.fromMap(data);
} }
static Future<PersonalProfile> 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<void> addUserAsFriend(ApiClient client, {required User user}) async { static Future<void> addUserAsFriend(ApiClient client, {required User user}) async {
final friend = Friend( final friend = Friend(
id: user.id, id: user.id,

View file

@ -15,6 +15,12 @@ import 'package:workmanager/workmanager.dart';
enum EventType { enum EventType {
unknown, unknown,
message, message,
unknown1,
unknown2,
unknown3,
unknown4,
keepAlive,
error;
} }
enum EventTarget { enum EventTarget {
@ -44,6 +50,7 @@ class MessagingClient {
final Logger _logger = Logger("NeosHub"); final Logger _logger = Logger("NeosHub");
final Workmanager _workmanager = Workmanager(); final Workmanager _workmanager = Workmanager();
final NotificationClient _notificationClient; final NotificationClient _notificationClient;
int _attempts = 0;
Function? _unreadsUpdateListener; Function? _unreadsUpdateListener;
WebSocket? _wsChannel; WebSocket? _wsChannel;
bool _isConnecting = false; 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..."); _logger.warning("Neos Hub connection died with error '$error', reconnecting...");
start(); await start();
} }
Future<void> start() async { Future<void> start() async {
@ -161,7 +168,6 @@ class MessagingClient {
} }
Future<WebSocket> _tryConnect() async { Future<WebSocket> _tryConnect() async {
int attempts = 0;
while (true) { while (true) {
try { try {
final http.Response response; final http.Response response;
@ -181,13 +187,15 @@ class MessagingClient {
if (url == null || wsToken == null) { if (url == null || wsToken == null) {
throw "Invalid response from server."; 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) { } 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(e);
_logger.severe("Retrying in $timeout seconds"); _logger.severe("Retrying in $timeout seconds");
await Future.delayed(Duration(seconds: timeout)); await Future.delayed(Duration(seconds: timeout));
attempts++; _attempts++;
} }
} }
} }
@ -208,12 +216,24 @@ class MessagingClient {
return; return;
} }
switch (EventType.values[rawType]) { switch (EventType.values[rawType]) {
case EventType.unknown1:
case EventType.unknown2:
case EventType.unknown3:
case EventType.unknown4:
case EventType.unknown: case EventType.unknown:
_logger.info("[Hub]: Unknown event received: $rawType: $body"); _logger.info("Received unknown event: $rawType: $body");
break; break;
case EventType.message: case EventType.message:
_logger.info("Received message-event.");
_handleMessageEvent(body); _handleMessageEvent(body);
break; 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;
} }
} }

View file

@ -10,7 +10,6 @@ import 'package:flutter/material.dart';
import 'package:flutter_phoenix/flutter_phoenix.dart'; import 'package:flutter_phoenix/flutter_phoenix.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:workmanager/workmanager.dart'; import 'package:workmanager/workmanager.dart';
import 'clients/api_client.dart';
import 'models/authentication_data.dart'; import 'models/authentication_data.dart';
void main() async { 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(); final settingsClient = SettingsClient();
await settingsClient.loadSettings(); await settingsClient.loadSettings();
runApp(Phoenix(child: ContactsPlusPlus(settingsClient: settingsClient,))); runApp(Phoenix(child: ContactsPlusPlus(settingsClient: settingsClient,)));

View file

@ -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> storageQuotas;
final Map<String, int> 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"] ?? "",
);
}
}

View file

@ -1,13 +1,16 @@
import 'dart:async'; 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/client_holder.dart';
import 'package:contacts_plus_plus/apis/friend_api.dart'; import 'package:contacts_plus_plus/apis/friend_api.dart';
import 'package:contacts_plus_plus/apis/message_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/friend.dart';
import 'package:contacts_plus_plus/models/message.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/default_error_widget.dart';
import 'package:contacts_plus_plus/widgets/expanding_input_fab.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/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/settings_page.dart';
import 'package:contacts_plus_plus/widgets/user_search.dart'; import 'package:contacts_plus_plus/widgets/user_search.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -32,6 +35,7 @@ class _FriendsListState extends State<FriendsList> {
static const Duration _autoRefreshDuration = Duration(seconds: 90); static const Duration _autoRefreshDuration = Duration(seconds: 90);
static const Duration _refreshTimeoutDuration = Duration(seconds: 30); static const Duration _refreshTimeoutDuration = Duration(seconds: 30);
Future<List<Friend>>? _friendsFuture; Future<List<Friend>>? _friendsFuture;
Future<PersonalProfile>? _userProfileFuture;
ClientHolder? _clientHolder; ClientHolder? _clientHolder;
Timer? _autoRefresh; Timer? _autoRefresh;
Timer? _refreshTimeout; Timer? _refreshTimeout;
@ -59,6 +63,7 @@ class _FriendsListState extends State<FriendsList> {
} }
}); });
_refreshFriendsList(); _refreshFriendsList();
_userProfileFuture = UserApi.getPersonalProfile(_clientHolder!.apiClient);
} }
} }
@ -101,13 +106,21 @@ class _FriendsListState extends State<FriendsList> {
onSelected: (MenuItemDefinition itemDef) async { onSelected: (MenuItemDefinition itemDef) async {
await itemDef.onTap(); await itemDef.onTap();
}, },
itemBuilder: (BuildContext context) => [ itemBuilder: (BuildContext context) =>
MenuItemDefinition(name: "Settings", icon: Icons.settings, onTap: () async { [
MenuItemDefinition(
name: "Settings",
icon: Icons.settings,
onTap: () async {
_autoRefresh?.cancel(); _autoRefresh?.cancel();
await Navigator.of(context).push(MaterialPageRoute(builder: (context) => const SettingsPage())); await Navigator.of(context).push(MaterialPageRoute(builder: (context) => const SettingsPage()));
_autoRefresh = Timer(_autoRefreshDuration, () => setState(() => _refreshFriendsList())); _autoRefresh = Timer(_autoRefreshDuration, () => setState(() => _refreshFriendsList()));
}), },
MenuItemDefinition(name: "Find Users", icon: Icons.person_add, onTap: () async { ),
MenuItemDefinition(
name: "Find Users",
icon: Icons.person_add,
onTap: () async {
bool changed = false; bool changed = false;
_autoRefresh?.cancel(); _autoRefresh?.cancel();
await Navigator.of(context).push( await Navigator.of(context).push(
@ -126,7 +139,39 @@ class _FriendsListState extends State<FriendsList> {
} else { } else {
_autoRefresh = Timer(_autoRefreshDuration, () => setState(() => _refreshFriendsList())); _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) => ].map((item) =>
PopupMenuItem<MenuItemDefinition>( PopupMenuItem<MenuItemDefinition>(
value: item, value: item,
@ -156,7 +201,8 @@ class _FriendsListState extends State<FriendsList> {
if (snapshot.hasData) { if (snapshot.hasData) {
var friends = (snapshot.data as List<Friend>); var friends = (snapshot.data as List<Friend>);
if (_searchFilter.isNotEmpty) { 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)); friends.sort((a, b) => a.username.length.compareTo(b.username.length));
} }
return ListView.builder( return ListView.builder(

View file

@ -2,14 +2,16 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class GenericAvatar extends StatelessWidget { 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 String imageUri;
final IconData placeholderIcon; final IconData placeholderIcon;
final double? radius;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return imageUri.isEmpty ? CircleAvatar( return imageUri.isEmpty ? CircleAvatar(
radius: radius,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
child: Icon(placeholderIcon), child: Icon(placeholderIcon),
) : CachedNetworkImage( ) : CachedNetworkImage(
@ -17,18 +19,22 @@ class GenericAvatar extends StatelessWidget {
return CircleAvatar( return CircleAvatar(
foregroundImage: imageProvider, foregroundImage: imageProvider,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
radius: radius,
); );
}, },
imageUrl: imageUri, imageUrl: imageUri,
placeholder: (context, url) { placeholder: (context, url) {
return const CircleAvatar( return CircleAvatar(
backgroundColor: Colors.white54, backgroundColor: Colors.white54,
child: Padding( radius: radius,
child: const Padding(
padding: EdgeInsets.all(8.0), padding: EdgeInsets.all(8.0),
child: CircularProgressIndicator(color: Colors.black38, strokeWidth: 2), child: CircularProgressIndicator(color: Colors.black38, strokeWidth: 2),
)); ),
);
}, },
errorWidget: (context, error, what) => CircleAvatar( errorWidget: (context, error, what) => CircleAvatar(
radius: radius,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
child: Icon(placeholderIcon), child: Icon(placeholderIcon),
), ),

View file

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