Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 0469f02

Browse files
committedAug 25, 2023
profile: Implement profile screen for users
Added profile screen with user information and custom profile fields, linked from sender name and avatar in message list. Support in initial_snapshot and models for the related `RealmDefaultExternalAccounts` and `CustomUserProfileFields` also added, as event handling for `custom_profile_fields`. User presence (zulip#196) and user status (zulip#197) are not yet displayed or tracked here. Fixes: zulip#195
1 parent 8831c2d commit 0469f02

20 files changed

+895
-91
lines changed
 

‎lib/api/model/events.dart

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ sealed class Event {
2222
case 'update': return UserSettingsUpdateEvent.fromJson(json);
2323
default: return UnexpectedEvent.fromJson(json);
2424
}
25+
case 'custom_profile_fields': return CustomProfileFieldsEvent.fromJson(json);
2526
case 'realm_user':
2627
switch (json['op'] as String) {
2728
case 'add': return RealmUserAddEvent.fromJson(json);
@@ -130,6 +131,24 @@ class UserSettingsUpdateEvent extends Event {
130131
Map<String, dynamic> toJson() => _$UserSettingsUpdateEventToJson(this);
131132
}
132133

134+
/// A Zulip event of type `custom_profile_fields`: https://zulip.com/api/get-events#custom_profile_fields
135+
@JsonSerializable(fieldRename: FieldRename.snake)
136+
class CustomProfileFieldsEvent extends Event {
137+
@override
138+
@JsonKey(includeToJson: true)
139+
String get type => 'custom_profile_fields';
140+
141+
final List<CustomProfileField> fields;
142+
143+
CustomProfileFieldsEvent({required super.id, required this.fields});
144+
145+
factory CustomProfileFieldsEvent.fromJson(Map<String, dynamic> json) =>
146+
_$CustomProfileFieldsEventFromJson(json);
147+
148+
@override
149+
Map<String, dynamic> toJson() => _$CustomProfileFieldsEventToJson(this);
150+
}
151+
133152
/// A Zulip event of type `realm_user`.
134153
///
135154
/// The corresponding API docs are in several places for
@@ -211,7 +230,7 @@ class RealmUserUpdateEvent extends RealmUserEvent {
211230
@JsonKey(readValue: _readFromPerson) final int? avatarVersion;
212231
@JsonKey(readValue: _readFromPerson) final String? timezone;
213232
@JsonKey(readValue: _readFromPerson) final int? botOwnerId;
214-
@JsonKey(readValue: _readFromPerson) final int? role; // TODO enum
233+
@JsonKey(readValue: _readFromPerson) final UserRole? role;
215234
@JsonKey(readValue: _readFromPerson) final bool? isBillingAdmin;
216235
@JsonKey(readValue: _readFromPerson) final String? deliveryEmail; // TODO handle JSON `null`
217236
@JsonKey(readValue: _readFromPerson) final RealmUserUpdateCustomProfileField? customProfileField;

‎lib/api/model/events.g.dart

Lines changed: 28 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎lib/api/model/initial_snapshot.dart

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ class InitialSnapshot {
2323

2424
final List<CustomProfileField> customProfileFields;
2525

26+
final Map<String, RealmDefaultExternalAccount> realmDefaultExternalAccounts;
27+
2628
// TODO etc., etc.
2729

2830
final List<RecentDmConversation> recentPrivateConversations;
@@ -78,6 +80,7 @@ class InitialSnapshot {
7880
required this.zulipVersion,
7981
this.zulipMergeBase,
8082
required this.alertWords,
83+
required this.realmDefaultExternalAccounts,
8184
required this.customProfileFields,
8285
required this.recentPrivateConversations,
8386
required this.subscriptions,
@@ -96,6 +99,84 @@ class InitialSnapshot {
9699
Map<String, dynamic> toJson() => _$InitialSnapshotToJson(this);
97100
}
98101

102+
/// As in [InitialSnapshot.customProfileFields].
103+
///
104+
/// For docs, search for "custom_profile_fields:"
105+
/// in <https://zulip.com/api/register-queue>.
106+
@JsonSerializable(fieldRename: FieldRename.snake)
107+
class CustomProfileField {
108+
final int id;
109+
@JsonKey(unknownEnumValue: CustomProfileFieldType.unknown)
110+
final CustomProfileFieldType type; // TODO(server-6) a value added
111+
final int order;
112+
final String name;
113+
final String hint;
114+
final String fieldData;
115+
final bool? displayInProfileSummary; // TODO(server-6)
116+
117+
CustomProfileField({
118+
required this.id,
119+
required this.type,
120+
required this.order,
121+
required this.name,
122+
required this.hint,
123+
required this.fieldData,
124+
required this.displayInProfileSummary,
125+
});
126+
127+
factory CustomProfileField.fromJson(Map<String, dynamic> json) =>
128+
_$CustomProfileFieldFromJson(json);
129+
130+
Map<String, dynamic> toJson() => _$CustomProfileFieldToJson(this);
131+
}
132+
133+
/// As in [CustomProfileField.type].
134+
@JsonEnum(fieldRename: FieldRename.snake, valueField: "apiValue")
135+
enum CustomProfileFieldType {
136+
shortText(apiValue: 1),
137+
longText(apiValue: 2),
138+
choice(apiValue: 3),
139+
date(apiValue: 4),
140+
link(apiValue: 5),
141+
user(apiValue: 6),
142+
externalAccount(apiValue: 7),
143+
pronouns(apiValue: 8),
144+
unknown(apiValue: null);
145+
146+
const CustomProfileFieldType({
147+
required this.apiValue
148+
});
149+
150+
final int? apiValue;
151+
152+
int toJson() => _$CustomProfileFieldTypeEnumMap[this]!;
153+
}
154+
155+
/// An item in `realm_default_external_accounts`.
156+
///
157+
/// For docs, search for "realm_default_external_accounts:"
158+
/// in <https://zulip.com/api/register-queue>.
159+
@JsonSerializable(fieldRename: FieldRename.snake)
160+
class RealmDefaultExternalAccount {
161+
final String name;
162+
final String text;
163+
final String hint;
164+
final String urlPattern;
165+
166+
RealmDefaultExternalAccount({
167+
required this.name,
168+
required this.text,
169+
required this.hint,
170+
required this.urlPattern,
171+
});
172+
173+
factory RealmDefaultExternalAccount.fromJson(Map<String, dynamic> json) {
174+
return _$RealmDefaultExternalAccountFromJson(json);
175+
}
176+
177+
Map<String, dynamic> toJson() => _$RealmDefaultExternalAccountToJson(this);
178+
}
179+
99180
/// An item in `recent_private_conversations`.
100181
///
101182
/// For docs, search for "recent_private_conversations:"

‎lib/api/model/initial_snapshot.g.dart

Lines changed: 59 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎lib/api/model/model.dart

Lines changed: 37 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,49 +2,6 @@ import 'package:json_annotation/json_annotation.dart';
22

33
part 'model.g.dart';
44

5-
/// As in [InitialSnapshot.customProfileFields].
6-
///
7-
/// For docs, search for "custom_profile_fields:"
8-
/// in <https://zulip.com/api/register-queue>.
9-
@JsonSerializable(fieldRename: FieldRename.snake)
10-
class CustomProfileField {
11-
final int id;
12-
final int type; // TODO enum; also TODO(server-6) a value added
13-
final int order;
14-
final String name;
15-
final String hint;
16-
final String fieldData;
17-
final bool? displayInProfileSummary; // TODO(server-6)
18-
19-
CustomProfileField({
20-
required this.id,
21-
required this.type,
22-
required this.order,
23-
required this.name,
24-
required this.hint,
25-
required this.fieldData,
26-
required this.displayInProfileSummary,
27-
});
28-
29-
factory CustomProfileField.fromJson(Map<String, dynamic> json) =>
30-
_$CustomProfileFieldFromJson(json);
31-
32-
Map<String, dynamic> toJson() => _$CustomProfileFieldToJson(this);
33-
}
34-
35-
@JsonSerializable(fieldRename: FieldRename.snake)
36-
class ProfileFieldUserData {
37-
final String value;
38-
final String? renderedValue;
39-
40-
ProfileFieldUserData({required this.value, this.renderedValue});
41-
42-
factory ProfileFieldUserData.fromJson(Map<String, dynamic> json) =>
43-
_$ProfileFieldUserDataFromJson(json);
44-
45-
Map<String, dynamic> toJson() => _$ProfileFieldUserDataToJson(this);
46-
}
47-
485
/// As in [InitialSnapshot.realmUsers], [InitialSnapshot.realmNonActiveUsers], and [InitialSnapshot.crossRealmBots].
496
///
507
/// In the Zulip API, the items in realm_users, realm_non_active_users, and
@@ -69,7 +26,8 @@ class User {
6926
bool isBot;
7027
int? botType; // TODO enum
7128
int? botOwnerId;
72-
int role; // TODO enum
29+
@JsonKey(unknownEnumValue: UserRole.unknown)
30+
UserRole role;
7331
String timezone;
7432
String? avatarUrl; // TODO distinguish null from missing https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/flutter.3A.20omitted.20vs.2E.20null.20in.20JSON/near/1551759
7533
int avatarVersion;
@@ -120,6 +78,41 @@ class User {
12078
Map<String, dynamic> toJson() => _$UserToJson(this);
12179
}
12280

81+
/// As in [User.profileData].
82+
@JsonSerializable(fieldRename: FieldRename.snake)
83+
class ProfileFieldUserData {
84+
final String value;
85+
final String? renderedValue;
86+
87+
ProfileFieldUserData({required this.value, this.renderedValue});
88+
89+
factory ProfileFieldUserData.fromJson(Map<String, dynamic> json) =>
90+
_$ProfileFieldUserDataFromJson(json);
91+
92+
Map<String, dynamic> toJson() => _$ProfileFieldUserDataToJson(this);
93+
}
94+
95+
/// As in [User.role].
96+
@JsonEnum(fieldRename: FieldRename.snake, valueField: "apiValue")
97+
enum UserRole{
98+
owner(apiValue: 100, label: "Owner"),
99+
administrator(apiValue: 200, label: "Admin"),
100+
moderator(apiValue: 300, label: "Moderator"),
101+
member(apiValue: 400, label: "Member"),
102+
guest(apiValue: 600, label: "Guest"),
103+
unknown(apiValue: null, label: "Unknown");
104+
105+
const UserRole({
106+
required this.apiValue,
107+
required this.label,
108+
});
109+
110+
final int? apiValue;
111+
final String label;
112+
113+
int toJson() => _$UserRoleEnumMap[this]!;
114+
}
115+
123116
/// As in `streams` in the initial snapshot.
124117
///
125118
/// Not called `Stream` because dart:async uses that name.

‎lib/api/model/model.g.dart

Lines changed: 25 additions & 37 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎lib/main.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import 'package:flutter/foundation.dart';
22
import 'package:flutter/material.dart';
3+
import 'package:timezone/data/latest.dart' as tz;
34

45
import 'licenses.dart';
56
import 'log.dart';
@@ -13,5 +14,6 @@ void main() {
1314
}());
1415
LicenseRegistry.addLicense(additionalLicenses);
1516
LiveZulipBinding.ensureInitialized();
17+
tz.initializeTimeZones();
1618
runApp(const ZulipApp());
1719
}

‎lib/model/custom_profile_fields.dart

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import 'package:collection/collection.dart';
2+
import 'package:flutter/foundation.dart';
3+
4+
import '../api/model/initial_snapshot.dart';
5+
import '../api/model/events.dart';
6+
7+
8+
List<CustomProfileField> _sortByOrder(List<CustomProfileField> customProfileFields) {
9+
return customProfileFields.sorted((a, b) => a.order.compareTo(b.order));
10+
}
11+
12+
class CustomProfileFieldsView extends ChangeNotifier {
13+
factory CustomProfileFieldsView({
14+
required List<CustomProfileField> initial,
15+
}) {
16+
return CustomProfileFieldsView._(
17+
fields: QueueList.from(_sortByOrder(initial)));
18+
}
19+
20+
CustomProfileFieldsView._({
21+
required this.fields
22+
});
23+
24+
final QueueList<CustomProfileField> fields;
25+
26+
/// Handle [CustomProfileFieldsEvent]
27+
void handleCustomProfileFieldsEvent(CustomProfileFieldsEvent event) {
28+
fields.clear();
29+
fields.addAll(_sortByOrder(event.fields));
30+
notifyListeners();
31+
}
32+
}

‎lib/model/store.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import '../api/route/events.dart';
1414
import '../api/route/messages.dart';
1515
import '../log.dart';
1616
import 'autocomplete.dart';
17+
import 'custom_profile_fields.dart';
1718
import 'database.dart';
1819
import 'message_list.dart';
1920
import 'recent_dm_conversations.dart';
@@ -150,13 +151,16 @@ class PerAccountStore extends ChangeNotifier {
150151
required this.connection,
151152
required InitialSnapshot initialSnapshot,
152153
}) : zulipVersion = initialSnapshot.zulipVersion,
154+
customProfileFields = CustomProfileFieldsView(
155+
initial: initialSnapshot.customProfileFields),
153156
maxFileUploadSizeMib = initialSnapshot.maxFileUploadSizeMib,
154157
userSettings = initialSnapshot.userSettings,
155158
users = Map.fromEntries(
156159
initialSnapshot.realmUsers
157160
.followedBy(initialSnapshot.realmNonActiveUsers)
158161
.followedBy(initialSnapshot.crossRealmBots)
159162
.map((user) => MapEntry(user.userId, user))),
163+
realmDefaultExternalAccounts = initialSnapshot.realmDefaultExternalAccounts,
160164
streams = Map.fromEntries(initialSnapshot.streams.map(
161165
(stream) => MapEntry(stream.streamId, stream))),
162166
subscriptions = Map.fromEntries(initialSnapshot.subscriptions.map(
@@ -185,6 +189,11 @@ class PerAccountStore extends ChangeNotifier {
185189

186190
// TODO lots more data. When adding, be sure to update handleEvent too.
187191

192+
final Map<String, RealmDefaultExternalAccount> realmDefaultExternalAccounts;
193+
194+
// TODO call [CustomProfileFieldsView.dispose] in [dispose]
195+
final CustomProfileFieldsView customProfileFields;
196+
188197
// TODO call [RecentDmConversationsView.dispose] in [dispose]
189198
final RecentDmConversationsView recentDmConversationsView;
190199

@@ -234,6 +243,9 @@ class PerAccountStore extends ChangeNotifier {
234243
userSettings?.emojiset = event.value as Emojiset;
235244
}
236245
notifyListeners();
246+
} else if (event is CustomProfileFieldsEvent) {
247+
assert(debugLog("server event: custom_profile_fields"));
248+
customProfileFields.handleCustomProfileFieldsEvent(event);
237249
} else if (event is RealmUserAddEvent) {
238250
assert(debugLog("server event: realm_user/add"));
239251
users[event.person.userId] = event.person;

‎lib/utils/date.dart

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import 'package:timezone/timezone.dart' as tz;
2+
3+
DateTime? getNowInTimezone(String timezone) {
4+
if (timezone.isEmpty) {
5+
return null;
6+
}
7+
try {
8+
final location = tz.getLocation(timezone);
9+
return tz.TZDateTime.now(location);
10+
} on tz.LocationNotFoundException {
11+
return null;
12+
}
13+
}

‎lib/widgets/content.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -863,7 +863,7 @@ class AvatarImage extends StatelessWidget {
863863
};
864864
return (resolvedUrl == null)
865865
? const SizedBox.shrink()
866-
: RealmContentNetworkImage(resolvedUrl, filterQuality: FilterQuality.medium);
866+
: RealmContentNetworkImage(resolvedUrl, filterQuality: FilterQuality.medium, fit: BoxFit.cover);
867867
}
868868
}
869869

‎lib/widgets/message_list.dart

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import 'compose_box.dart';
1414
import 'content.dart';
1515
import 'icons.dart';
1616
import 'page.dart';
17+
import 'profile.dart';
1718
import 'sticky_header.dart';
1819
import 'store.dart';
1920

@@ -567,14 +568,22 @@ class MessageWithSender extends StatelessWidget {
567568
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
568569
Padding(
569570
padding: const EdgeInsets.fromLTRB(3, 6, 11, 0),
570-
child: Avatar(userId: message.senderId, size: 35, borderRadius: 4)),
571+
child: GestureDetector(
572+
onTap: () => Navigator.push(context,
573+
ProfilePage.buildRoute(context: context,
574+
userId: message.senderId)),
575+
child: Avatar(userId: message.senderId, size: 35, borderRadius: 4))),
571576
Expanded(
572577
child: Column(
573578
crossAxisAlignment: CrossAxisAlignment.stretch,
574579
children: [
575580
const SizedBox(height: 3),
576-
Text(message.senderFullName, // TODO get from user data
577-
style: const TextStyle(fontWeight: FontWeight.bold)),
581+
GestureDetector(
582+
onTap: () => Navigator.push(context,
583+
ProfilePage.buildRoute(context: context,
584+
userId: message.senderId)),
585+
child: Text(message.senderFullName, // TODO get from user data
586+
style: const TextStyle(fontWeight: FontWeight.bold))),
578587
const SizedBox(height: 4),
579588
MessageContent(message: message, content: content),
580589
])),

‎lib/widgets/profile.dart

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
import 'dart:convert';
2+
3+
import 'package:flutter/material.dart';
4+
import 'package:intl/intl.dart';
5+
6+
import '../api/model/initial_snapshot.dart';
7+
import '../api/model/model.dart';
8+
import '../model/binding.dart';
9+
import '../model/custom_profile_fields.dart';
10+
import '../model/narrow.dart';
11+
import '../model/store.dart';
12+
import '../utils/date.dart';
13+
import 'content.dart';
14+
import 'message_list.dart';
15+
import 'page.dart';
16+
import 'store.dart';
17+
18+
19+
class CustomProfileFieldDisplayEntry {
20+
final String label;
21+
final String text;
22+
final CustomProfileFieldType type;
23+
final String fieldData;
24+
25+
CustomProfileFieldDisplayEntry({required this.fieldData, required this.label, required this.text, required this.type});
26+
}
27+
28+
List<CustomProfileFieldDisplayEntry> sortAndAnnotateUserFields(Map<int, ProfileFieldUserData> profileData, CustomProfileFieldsView customProfileFieldsView) {
29+
// TODO(server): The realm-wide field objects have an `order` property,
30+
// but the actual API appears to be that the fields should be shown in
31+
// the order they appear in the array (`custom_profile_fields` in the
32+
// API; our `realmFields` array here.) See chat thread:
33+
// https://chat.zulip.org/#narrow/stream/378-api-design/topic/custom.20profile.20fields/near/1382982
34+
//
35+
// We go on to put at the start of the list any fields that are marked for
36+
// displaying in the "profile summary". (Possibly they should be at the
37+
// start of the list in the first place, but make sure just in case.)
38+
final displayFields = customProfileFieldsView.fields.where((e) => e.displayInProfileSummary ?? true);
39+
final nonDisplayFields = customProfileFieldsView.fields.where((e) => e.displayInProfileSummary == false);
40+
return displayFields.followedBy(nonDisplayFields)
41+
.where((e) => profileData.containsKey(e.id))
42+
.map((e) {
43+
final profileField = profileData[e.id]!;
44+
return CustomProfileFieldDisplayEntry(label: e.name, text: profileField.value, type: e.type, fieldData: e.fieldData);
45+
}).toList();
46+
}
47+
48+
49+
class ProfileDataTable extends StatelessWidget {
50+
final User user;
51+
52+
const ProfileDataTable({super.key, required this.user});
53+
54+
// In general this part of the API is not documented up to Zulip's
55+
// normal standards. Some discussion here:
56+
// https://chat.zulip.org/#narrow/stream/378-api-design/topic/custom.20profile.20fields/near/1387379
57+
Widget renderProfileDataField(BuildContext context, CustomProfileFieldDisplayEntry e, PerAccountStore store) {
58+
switch (e.type) {
59+
case CustomProfileFieldType.link:
60+
return Padding(
61+
padding: const EdgeInsets.all(4),
62+
child: renderHyperlink(url: e.text, label: e.text));
63+
case CustomProfileFieldType.choice:
64+
// TODO(server): This isn't really documented. But see chat thread:
65+
// https://chat.zulip.org/#narrow/stream/378-api-design/topic/custom.20profile.20fields/near/1383005
66+
final Map<String, dynamic> fieldData = jsonDecode(e.fieldData);
67+
return Padding(
68+
padding: const EdgeInsets.all(4),
69+
child: Text(fieldData[e.text]["text"]));
70+
case CustomProfileFieldType.externalAccount:
71+
// TODO(server): This is undocumented. See chat thread:
72+
// https://chat.zulip.org/#narrow/stream/378-api-design/topic/external.20account.20custom.20profile.20fields/near/1387213
73+
final Map<String, dynamic> fieldData = jsonDecode(e.fieldData);
74+
String? urlPattern = fieldData["url_pattern"];
75+
if (urlPattern == null) {
76+
final RealmDefaultExternalAccount? realmDefaultExternalAccount = store.realmDefaultExternalAccounts[fieldData["subtype"]];
77+
if (realmDefaultExternalAccount != null) {
78+
urlPattern = realmDefaultExternalAccount.urlPattern;
79+
}
80+
}
81+
if (urlPattern != null) {
82+
final url = urlPattern.replaceFirst("%(username)s", e.text);
83+
return Padding(
84+
padding: const EdgeInsets.all(4),
85+
child: renderHyperlink(url: url, label: e.text));
86+
} else {
87+
return Padding(
88+
padding: const EdgeInsets.all(4),
89+
child: Text(e.text));
90+
}
91+
case CustomProfileFieldType.user:
92+
// TODO(server): This is completely undocumented. The key to
93+
// reverse-engineering it was:
94+
// https://github.com/zulip/zulip/blob/18230fcd9/static/js/settings_account.js#L247
95+
final List<dynamic> userIds = jsonDecode(e.text);
96+
return renderUsers(context, userIds, store);
97+
case CustomProfileFieldType.date:
98+
// TODO(server): The value's format is undocumented, but empirically
99+
// it's a date in ISO format, like 2000-01-01.
100+
// That's readable as is, but:
101+
// TODO format this date using user's locale.
102+
return Padding(
103+
padding: const EdgeInsets.all(4),
104+
child: Text(e.text));
105+
case CustomProfileFieldType.shortText:
106+
case CustomProfileFieldType.longText:
107+
case CustomProfileFieldType.pronouns:
108+
case CustomProfileFieldType.unknown:
109+
return Padding(
110+
padding: const EdgeInsets.all(4),
111+
child: Text(e.text));
112+
}
113+
}
114+
115+
Widget renderUsers(BuildContext context, List<dynamic> userIds, PerAccountStore store) {
116+
return Column(
117+
crossAxisAlignment: CrossAxisAlignment.start,
118+
children: userIds.whereType<num>()
119+
.map((userId) => store.users[userId])
120+
.whereType<User>()
121+
.map((user) => InkWell(
122+
onTap: () => Navigator.push(context,
123+
ProfilePage.buildRoute(context: context,
124+
userId: user.userId)),
125+
child: Padding(
126+
padding: const EdgeInsets.all(4),
127+
child: Row(
128+
children: [
129+
Avatar(userId: user.userId, size: 32, borderRadius: 32 / 8),
130+
const SizedBox(width: 8),
131+
Text(user.fullName),
132+
])))).toList());
133+
}
134+
135+
Widget renderHyperlink({required String url, String? label}) {
136+
final text = label ?? url;
137+
final Uri? parsedUrl = Uri.tryParse(url);
138+
if (parsedUrl != null) {
139+
return MouseRegion(
140+
cursor: SystemMouseCursors.click,
141+
child: GestureDetector(
142+
onTap: () async {
143+
await ZulipBinding.instance.launchUrl(parsedUrl);
144+
},
145+
child: Text(text,
146+
style: TextStyle(color: const HSLColor.fromAHSL(1, 200, 1, 0.4).toColor()))));
147+
} else {
148+
return Text(text);
149+
}
150+
}
151+
152+
@override
153+
Widget build(BuildContext context) {
154+
final store = PerAccountStoreWidget.of(context);
155+
final userFields = sortAndAnnotateUserFields(user.profileData!, store.customProfileFields);
156+
if (userFields.isNotEmpty) {
157+
return DefaultTextStyle.merge(
158+
style: const TextStyle(fontSize: 16, height: 1),
159+
textAlign: TextAlign.left,
160+
child: Column(
161+
children: [
162+
const SizedBox(height: 16),
163+
Table(
164+
border: TableBorder.all(style: BorderStyle.none),
165+
columnWidths: const {
166+
0: FixedColumnWidth(116),
167+
1: FlexColumnWidth()},
168+
defaultVerticalAlignment: TableCellVerticalAlignment.top,
169+
children: userFields.map((e) => TableRow(
170+
children: [
171+
Padding(
172+
padding: const EdgeInsets.fromLTRB(0, 4, 8, 4),
173+
child: Text(e.label, style: const TextStyle(fontWeight: FontWeight.bold, ))),
174+
renderProfileDataField(context, e, store),
175+
])).toList()),
176+
]));
177+
} else {
178+
return const SizedBox.shrink();
179+
}
180+
}
181+
}
182+
183+
class ProfilePage extends StatelessWidget {
184+
final int userId;
185+
186+
const ProfilePage({super.key, required this.userId});
187+
188+
static Route<void> buildRoute({required BuildContext context, required int userId}) {
189+
return MaterialAccountWidgetRoute(context: context,
190+
page: ProfilePage(userId: userId));
191+
}
192+
193+
Widget buildErrorPage() {
194+
return Scaffold(
195+
appBar: AppBar(title: const Text("Error")),
196+
body: const SingleChildScrollView(
197+
child: Padding(
198+
padding: EdgeInsets.all(16),
199+
child: Row(
200+
mainAxisAlignment: MainAxisAlignment.center,
201+
children: [
202+
Icon(Icons.error),
203+
Text("Could not show user profile."),
204+
]))));
205+
}
206+
207+
void _createDmNavigation (context) {
208+
final store = PerAccountStoreWidget.of(context);
209+
final allRecipientIds = [store.account.userId, userId];
210+
allRecipientIds.sort();
211+
final narrow = DmNarrow(
212+
selfUserId: store.account.userId,
213+
allRecipientIds: allRecipientIds);
214+
Navigator.push(context,
215+
MessageListPage.buildRoute(context: context, narrow: narrow));
216+
}
217+
218+
@override
219+
Widget build(BuildContext context) {
220+
final store = PerAccountStoreWidget.of(context);
221+
final user = store.users[userId];
222+
if (user != null) {
223+
final avatar = Avatar(userId: userId, size: 200, borderRadius: 200 / 8);
224+
final Widget profileDataTable;
225+
if (user.profileData != null) {
226+
profileDataTable = ProfileDataTable(user:user);
227+
} else {
228+
profileDataTable = const SizedBox.shrink();
229+
}
230+
final Widget localTimeWidget;
231+
if (!user.isBot) {
232+
final userLocalNow = getNowInTimezone(user.timezone);
233+
if (userLocalNow != null) {
234+
localTimeWidget = Text("${DateFormat.jm().format(userLocalNow)} Local time",
235+
style: const TextStyle(fontWeight: FontWeight.normal));
236+
} else {
237+
localTimeWidget = const SizedBox.shrink();
238+
}
239+
} else {
240+
localTimeWidget = const SizedBox.shrink();
241+
}
242+
return Scaffold(
243+
appBar: AppBar(title: Text(user.fullName)),
244+
body: SingleChildScrollView(
245+
child: Padding(
246+
padding: const EdgeInsets.all(16),
247+
child: DefaultTextStyle.merge(
248+
textAlign: TextAlign.center,
249+
style: const TextStyle(fontSize: 20, height: 1.5),
250+
child: Column(
251+
crossAxisAlignment: CrossAxisAlignment.stretch,
252+
children: [
253+
Center(child: avatar),
254+
const SizedBox(height: 16),
255+
Text(user.fullName,
256+
style: const TextStyle(fontWeight: FontWeight.bold)),
257+
(user.isBot ? Text(user.email) : const SizedBox.shrink()),
258+
Text(user.role.label),
259+
// Text("Active about XXX"), // TODO render active status
260+
localTimeWidget,
261+
profileDataTable,
262+
const SizedBox(height: 16),
263+
FilledButton.icon(
264+
onPressed: () => _createDmNavigation(context),
265+
icon: const Icon(Icons.email),
266+
label: const Text("Send direct message")),
267+
])))));
268+
} else {
269+
return buildErrorPage();
270+
}
271+
}
272+
}

‎pubspec.lock

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -676,10 +676,10 @@ packages:
676676
dependency: transitive
677677
description:
678678
name: platform
679-
sha256: "4a451831508d7d6ca779f7ac6e212b4023dd5a7d08a27a63da33756410e32b76"
679+
sha256: "57c07bf82207aee366dfaa3867b3164e4f03a238a461a11b0e8a3a510d51203d"
680680
url: "https://pub.dev"
681681
source: hosted
682-
version: "3.1.0"
682+
version: "3.1.1"
683683
plugin_platform_interface:
684684
dependency: transitive
685685
description:
@@ -901,6 +901,14 @@ packages:
901901
url: "https://pub.dev"
902902
source: hosted
903903
version: "0.5.6"
904+
timezone:
905+
dependency: "direct main"
906+
description:
907+
name: timezone
908+
sha256: "1cfd8ddc2d1cfd836bc93e67b9be88c3adaeca6f40a00ca999104c30693cdca0"
909+
url: "https://pub.dev"
910+
source: hosted
911+
version: "0.9.2"
904912
timing:
905913
dependency: transitive
906914
description:

‎pubspec.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ dependencies:
5757
package_info_plus: ^4.0.1
5858
collection: ^1.17.2
5959
url_launcher: ^6.1.11
60+
timezone: ^0.9.2
6061

6162
dev_dependencies:
6263
flutter_test:

‎test/api/model/model_checks.dart

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import 'package:checks/checks.dart';
2+
import 'package:zulip/api/model/initial_snapshot.dart';
23
import 'package:zulip/api/model/model.dart';
34

5+
extension RealmDefaultExternalAccountChecks on Subject<RealmDefaultExternalAccount> {
6+
Subject<String> get name => has((e) => e.name, 'name');
7+
Subject<String> get hint => has((e) => e.hint, 'hint');
8+
Subject<String> get text => has((e) => e.text, 'text');
9+
Subject<String> get urlPattern => has((e) => e.urlPattern, 'urlPattern');
10+
}
11+
412
extension MessageChecks on Subject<Message> {
513
Subject<String> get content => has((e) => e.content, 'content');
614
Subject<bool> get isMeMessage => has((e) => e.isMeMessage, 'isMeMessage');

‎test/api/model/model_test.dart

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import 'package:checks/checks.dart';
22
import 'package:test/scaffolding.dart';
3+
import 'package:zulip/api/model/initial_snapshot.dart';
34
import 'package:zulip/api/model/model.dart';
45

56
import '../../example_data.dart' as eg;
7+
import './model_checks.dart';
68

79
void main() {
810
group('User', () {
@@ -108,4 +110,26 @@ void main() {
108110
.deepEquals([2, 3, 11]);
109111
});
110112
});
113+
114+
group('RealmDefaultExternalAccount', () {
115+
final Map<String, dynamic> baseJson = Map.unmodifiable({
116+
'name': 'site',
117+
'hint': 'hint for site',
118+
'text': 'description for site',
119+
'url_pattern': 'http://example/%{username}',
120+
});
121+
122+
RealmDefaultExternalAccount mkRealmDefaultExternalAccount(Map<String, dynamic> specialJson) {
123+
return RealmDefaultExternalAccount.fromJson({ ...baseJson, ...specialJson });
124+
}
125+
126+
test('realm_default_external_account', () {
127+
final realmDefaultExternalAccount = mkRealmDefaultExternalAccount({});
128+
check(realmDefaultExternalAccount)
129+
..name.equals('site')
130+
..hint.equals('hint for site')
131+
..text.equals('description for site')
132+
..urlPattern.equals('http://example/%{username}');
133+
});
134+
});
111135
}

‎test/example_data.dart

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ User user({
1515
String? email,
1616
String? fullName,
1717
String? avatarUrl,
18+
Map<int, ProfileFieldUserData>? profileData,
1819
}) {
1920
return User(
2021
userId: userId ?? 123, // TODO generate example IDs
@@ -28,11 +29,11 @@ User user({
2829
isGuest: false,
2930
isBillingAdmin: false,
3031
isBot: false,
31-
role: 400,
32+
role: UserRole.member,
3233
timezone: 'UTC',
3334
avatarUrl: avatarUrl,
3435
avatarVersion: 0,
35-
profileData: null,
36+
profileData: profileData,
3637
);
3738
}
3839

@@ -242,6 +243,7 @@ InitialSnapshot initialSnapshot({
242243
String? zulipMergeBase,
243244
List<String>? alertWords,
244245
List<CustomProfileField>? customProfileFields,
246+
Map<String, RealmDefaultExternalAccount>? realmDefaultExternalAccounts,
245247
List<RecentDmConversation>? recentPrivateConversations,
246248
List<Subscription>? subscriptions,
247249
UnreadMessagesSnapshot? unreadMsgs,
@@ -260,6 +262,7 @@ InitialSnapshot initialSnapshot({
260262
zulipMergeBase: zulipMergeBase ?? recentZulipVersion,
261263
alertWords: alertWords ?? ['klaxon'],
262264
customProfileFields: customProfileFields ?? [],
265+
realmDefaultExternalAccounts: realmDefaultExternalAccounts ?? {},
263266
recentPrivateConversations: recentPrivateConversations ?? [],
264267
subscriptions: subscriptions ?? [], // TODO add subscriptions to default
265268
unreadMsgs: unreadMsgs ?? _unreadMsgs(),

‎test/widgets/profile_page_checks.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import 'package:checks/checks.dart';
2+
import 'package:zulip/widgets/profile.dart';
3+
4+
extension ProfilePageChecks on Subject<ProfilePage> {
5+
Subject<int> get userId => has((x) => x.userId, 'userId');
6+
}

‎test/widgets/profile_test.dart

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
import 'package:checks/checks.dart';
2+
import 'package:flutter/material.dart';
3+
import 'package:flutter_test/flutter_test.dart';
4+
import 'package:url_launcher/url_launcher.dart';
5+
import 'package:zulip/api/model/initial_snapshot.dart';
6+
import 'package:zulip/api/model/model.dart';
7+
import 'package:zulip/model/narrow.dart';
8+
import 'package:zulip/widgets/content.dart';
9+
import 'package:zulip/widgets/message_list.dart';
10+
import 'package:zulip/widgets/page.dart';
11+
import 'package:zulip/widgets/profile.dart';
12+
import 'package:zulip/widgets/store.dart';
13+
14+
import '../example_data.dart' as eg;
15+
import '../model/binding.dart';
16+
import '../model/test_store.dart';
17+
import '../test_navigation.dart';
18+
import 'message_list_checks.dart';
19+
import 'page_checks.dart';
20+
import 'profile_page_checks.dart';
21+
22+
Future<void> setupPage(WidgetTester tester, {
23+
required int pageUserId,
24+
List<User>? users,
25+
List<CustomProfileField>? customProfileFields,
26+
Map<String, RealmDefaultExternalAccount>? realmDefaultExternalAccounts,
27+
NavigatorObserver? navigatorObserver
28+
}) async {
29+
addTearDown(testBinding.reset);
30+
31+
final initialSnapshot = eg.initialSnapshot(
32+
customProfileFields: customProfileFields,
33+
realmDefaultExternalAccounts: realmDefaultExternalAccounts);
34+
await testBinding.globalStore.add(eg.selfAccount, initialSnapshot);
35+
final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
36+
37+
store.addUser(eg.selfUser);
38+
if (users != null) {
39+
store.addUsers(users);
40+
}
41+
42+
await tester.pumpWidget(
43+
GlobalStoreWidget(
44+
child: MaterialApp(
45+
navigatorObservers: navigatorObserver != null ? [navigatorObserver] : [],
46+
home: PerAccountStoreWidget(
47+
accountId: eg.selfAccount.id,
48+
child: ProfilePage(userId: pageUserId)))));
49+
50+
// global store, per-account store, and page get loaded
51+
await tester.pumpAndSettle();
52+
}
53+
54+
CustomProfileField mkCustomProfileField(int id, CustomProfileFieldType type, {int? order, bool? displayInProfileSummary, String? fieldData}) {
55+
return CustomProfileField(
56+
id: id,
57+
order: order ?? id,
58+
name: "field$id",
59+
hint: "hint$id",
60+
displayInProfileSummary: displayInProfileSummary ?? true,
61+
fieldData: fieldData ?? "",
62+
type: type,
63+
);
64+
}
65+
66+
void main() {
67+
TestZulipBinding.ensureInitialized();
68+
69+
group('ProfilePage', () {
70+
testWidgets('page builds; profile page renders', (WidgetTester tester) async {
71+
final user = eg.user(userId: 1, fullName: "test user");
72+
73+
await setupPage(tester, users:[user], pageUserId: user.userId);
74+
75+
check(because: "find user avatar", find.byType(Avatar).evaluate()).length.equals(1);
76+
check(because: "find user name", find.text("test user").evaluate()).isNotEmpty();
77+
});
78+
79+
testWidgets('page builds; profile page renders with profileData', (WidgetTester tester) async {
80+
await setupPage(tester,
81+
users: [
82+
eg.user(userId: 1, profileData: {
83+
0: ProfileFieldUserData(value: "shortTextValue"),
84+
1: ProfileFieldUserData(value: "longTextValue"),
85+
2: ProfileFieldUserData(value: "x"),
86+
3: ProfileFieldUserData(value: "dateValue"),
87+
4: ProfileFieldUserData(value: "http://example/linkValue"),
88+
5: ProfileFieldUserData(value: "[2]"),
89+
6: ProfileFieldUserData(value: "externalValue"),
90+
7: ProfileFieldUserData(value: "pronounsValue"),
91+
}),
92+
eg.user(userId: 2),
93+
],
94+
pageUserId: 1,
95+
customProfileFields: [
96+
mkCustomProfileField(0, CustomProfileFieldType.shortText),
97+
mkCustomProfileField(1, CustomProfileFieldType.longText),
98+
mkCustomProfileField(2, CustomProfileFieldType.choice, fieldData: '{"x": {"text": "choiceValue", "order": "1"}}'),
99+
mkCustomProfileField(3, CustomProfileFieldType.date),
100+
mkCustomProfileField(4, CustomProfileFieldType.link),
101+
mkCustomProfileField(5, CustomProfileFieldType.user),
102+
mkCustomProfileField(6, CustomProfileFieldType.externalAccount, fieldData: '{"subtype": "external1"}'),
103+
mkCustomProfileField(7, CustomProfileFieldType.pronouns),
104+
], realmDefaultExternalAccounts: {
105+
"external1": RealmDefaultExternalAccount(
106+
name: "external1",
107+
text: "",
108+
hint: "",
109+
urlPattern: "https://example/%(username)s")});
110+
111+
final testCases = [
112+
(find.text("field0"), find.text("shortTextValue"), CustomProfileFieldType.shortText),
113+
(find.text("field1"), find.text("longTextValue"), CustomProfileFieldType.longText),
114+
(find.text("field2"), find.text("choiceValue"), CustomProfileFieldType.choice),
115+
(find.text("field3"), find.text("dateValue"), CustomProfileFieldType.date),
116+
(find.text("field4"), find.text("http://example/linkValue"), CustomProfileFieldType.link),
117+
(find.text("field5"), find.byType(Avatar), CustomProfileFieldType.user),
118+
(find.text("field6"), find.text("http://example/externalValue"), CustomProfileFieldType.externalAccount),
119+
(find.text("field7"), find.text("pronounsValue"), CustomProfileFieldType.pronouns),
120+
];
121+
final profileDataTable = find.byType(ProfileDataTable);
122+
testCases.map((testCase) {
123+
Finder labelFinder = testCase.$1;
124+
Finder fieldFinder = testCase.$2;
125+
CustomProfileFieldType testCaseType = testCase.$3;
126+
check(
127+
because: "find label for $testCaseType",
128+
find.descendant(of: profileDataTable, matching: labelFinder).evaluate().length
129+
).equals(1);
130+
check(
131+
because: "find field for $testCaseType",
132+
find.descendant(of: profileDataTable, matching: fieldFinder).evaluate().length
133+
).equals(1);
134+
});
135+
});
136+
137+
testWidgets('page builds; error page shows up if data is missing', (WidgetTester tester) async {
138+
await setupPage(tester, pageUserId: eg.selfUser.userId + 1989);
139+
check(because: "find no user avatar", find.byType(Avatar).evaluate()).isEmpty();
140+
check(because: "find error icon", find.byIcon(Icons.error).evaluate()).isNotEmpty();
141+
});
142+
143+
testWidgets('page builds; link type will navigate', (WidgetTester tester) async {
144+
const testUrl = "http://example/url";
145+
final user = eg.user(userId: 1, profileData: {
146+
0: ProfileFieldUserData(value: testUrl),
147+
});
148+
149+
await setupPage(tester,
150+
users: [user],
151+
pageUserId: user.userId,
152+
customProfileFields: [mkCustomProfileField(0, CustomProfileFieldType.link)],
153+
);
154+
155+
await tester.tap(find.text(testUrl));
156+
check(testBinding.takeLaunchUrlCalls())
157+
.single.equals((url: Uri.parse(testUrl), mode: LaunchMode.platformDefault));
158+
});
159+
160+
testWidgets('page builds; external link type navigates away', (WidgetTester tester) async {
161+
final user = eg.user(userId: 1, profileData: {
162+
0: ProfileFieldUserData(value: "externalValue"),
163+
});
164+
165+
await setupPage(tester,
166+
users: [user],
167+
pageUserId: user.userId,
168+
customProfileFields: [mkCustomProfileField(0, CustomProfileFieldType.externalAccount, fieldData:'{"subtype": "external1"}')],
169+
realmDefaultExternalAccounts: {
170+
"external1": RealmDefaultExternalAccount(
171+
name: "external1",
172+
text: "",
173+
hint: "",
174+
urlPattern: "http://example/%(username)s")},
175+
);
176+
177+
await tester.tap(find.text("externalValue"));
178+
check(testBinding.takeLaunchUrlCalls())
179+
.single.equals((url: Uri.parse("http://example/externalValue"), mode: LaunchMode.platformDefault));
180+
});
181+
182+
testWidgets('page builds; user links to profile', (WidgetTester tester) async {
183+
final users = [
184+
eg.user(userId: 1, profileData: {
185+
0: ProfileFieldUserData(value: "[2]"),
186+
}),
187+
eg.user(userId: 2, fullName: "test user"),
188+
];
189+
final pushedRoutes = <Route<dynamic>>[];
190+
final testNavObserver = TestNavigatorObserver()
191+
..onPushed = (route, prevRoute) => pushedRoutes.add(route);
192+
193+
await setupPage(tester,
194+
users: users,
195+
pageUserId: 1,
196+
customProfileFields: [mkCustomProfileField(0, CustomProfileFieldType.user)],
197+
navigatorObserver: testNavObserver,
198+
);
199+
200+
check(find.text("test user").evaluate()).length.equals(1);
201+
final targetWidget = find.descendant(of:find.byType(ProfileDataTable), matching:find.byType(Avatar));
202+
await tester.tap(targetWidget, warnIfMissed: false);
203+
check(pushedRoutes).last.isA<WidgetRoute>().page.isA<ProfilePage>().userId.equals(2);
204+
});
205+
206+
testWidgets('page builds; dm links to correct narrow', (WidgetTester tester) async {
207+
final pushedRoutes = <Route<dynamic>>[];
208+
final testNavObserver = TestNavigatorObserver()
209+
..onPushed = (route, prevRoute) => pushedRoutes.add(route);
210+
final expectedNarrow = DmNarrow(
211+
allRecipientIds: [eg.selfUser.userId, 1]..sort(),
212+
selfUserId: eg.selfUser.userId);
213+
214+
await setupPage(tester,
215+
users: [eg.user(userId: 1)],
216+
pageUserId: 1,
217+
navigatorObserver: testNavObserver,
218+
);
219+
220+
final targetWidget = find.byIcon(Icons.email);
221+
await tester.tap(targetWidget, warnIfMissed: false);
222+
check(pushedRoutes).last.isA<WidgetRoute>().page
223+
.isA<MessageListPage>()
224+
.narrow.equals(expectedNarrow);
225+
});
226+
227+
testWidgets('page builds; user links render multiple avatars', (WidgetTester tester) async {
228+
final users = [
229+
eg.user(userId: 1, profileData: {
230+
0: ProfileFieldUserData(value: "[2,3]"),
231+
}),
232+
eg.user(userId: 2, fullName: "test user"),
233+
eg.user(userId: 3, fullName: "test user"),
234+
];
235+
236+
await setupPage(tester,
237+
users: users,
238+
pageUserId: 1,
239+
customProfileFields: [mkCustomProfileField(0, CustomProfileFieldType.user)],
240+
);
241+
242+
final findAvatars = find.descendant(of:find.byType(ProfileDataTable), matching:find.byType(Avatar));
243+
check(findAvatars.evaluate()).length.equals(2);
244+
});
245+
246+
});
247+
}

0 commit comments

Comments
 (0)
Please sign in to comment.