Add user-profile popup
This commit is contained in:
parent
cb87c08be6
commit
1e336688b7
7 changed files with 282 additions and 42 deletions
|
@ -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<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 {
|
||||
final friend = Friend(
|
||||
id: user.id,
|
||||
|
|
|
@ -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<void> start() async {
|
||||
|
@ -161,7 +168,6 @@ class MessagingClient {
|
|||
}
|
||||
|
||||
Future<WebSocket> _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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,)));
|
||||
|
|
63
lib/models/personal_profile.dart
Normal file
63
lib/models/personal_profile.dart
Normal 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"] ?? "",
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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<FriendsList> {
|
|||
static const Duration _autoRefreshDuration = Duration(seconds: 90);
|
||||
static const Duration _refreshTimeoutDuration = Duration(seconds: 30);
|
||||
Future<List<Friend>>? _friendsFuture;
|
||||
Future<PersonalProfile>? _userProfileFuture;
|
||||
ClientHolder? _clientHolder;
|
||||
Timer? _autoRefresh;
|
||||
Timer? _refreshTimeout;
|
||||
|
@ -59,6 +63,7 @@ class _FriendsListState extends State<FriendsList> {
|
|||
}
|
||||
});
|
||||
_refreshFriendsList();
|
||||
_userProfileFuture = UserApi.getPersonalProfile(_clientHolder!.apiClient);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -101,32 +106,72 @@ class _FriendsListState extends State<FriendsList> {
|
|||
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<MenuItemDefinition>(
|
||||
value: item,
|
||||
|
@ -156,7 +201,8 @@ class _FriendsListState extends State<FriendsList> {
|
|||
if (snapshot.hasData) {
|
||||
var friends = (snapshot.data as List<Friend>);
|
||||
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(
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
|
|
98
lib/widgets/my_profile_dialog.dart
Normal file
98
lib/widgets/my_profile_dialog.dart
Normal 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,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue