Skip to content

Commit 7cbb4fb

Browse files
committed
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. User presence (zulip#196) and user status (zulip#197) are not yet displayed or tracked here. Fixes: zulip#195
1 parent 5800317 commit 7cbb4fb

File tree

5 files changed

+545
-4
lines changed

5 files changed

+545
-4
lines changed

Diff for: lib/widgets/message_list.dart

+12-3
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

@@ -580,14 +581,22 @@ class MessageWithSender extends StatelessWidget {
580581
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
581582
Padding(
582583
padding: const EdgeInsets.fromLTRB(3, 6, 11, 0),
583-
child: Avatar(userId: message.senderId, size: 35, borderRadius: 4)),
584+
child: GestureDetector(
585+
onTap: () => Navigator.push(context,
586+
ProfilePage.buildRoute(context: context,
587+
userId: message.senderId)),
588+
child: Avatar(userId: message.senderId, size: 35, borderRadius: 4))),
584589
Expanded(
585590
child: Column(
586591
crossAxisAlignment: CrossAxisAlignment.stretch,
587592
children: [
588593
const SizedBox(height: 3),
589-
Text(message.senderFullName, // TODO get from user data
590-
style: const TextStyle(fontWeight: FontWeight.bold)),
594+
GestureDetector(
595+
onTap: () => Navigator.push(context,
596+
ProfilePage.buildRoute(context: context,
597+
userId: message.senderId)),
598+
child: Text(message.senderFullName, // TODO get from user data
599+
style: const TextStyle(fontWeight: FontWeight.bold))),
591600
const SizedBox(height: 4),
592601
MessageContent(message: message, content: content),
593602
])),

Diff for: lib/widgets/profile.dart

+261
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
import 'dart:convert';
2+
3+
import 'package:flutter/material.dart';
4+
5+
import '../api/model/model.dart';
6+
import '../model/content.dart';
7+
import '../model/narrow.dart';
8+
import 'content.dart';
9+
import 'message_list.dart';
10+
import 'page.dart';
11+
import 'store.dart';
12+
13+
class ProfilePage extends StatelessWidget {
14+
final int userId;
15+
16+
const ProfilePage({super.key, required this.userId});
17+
18+
static Route<void> buildRoute({required BuildContext context, required int userId}) {
19+
return MaterialAccountWidgetRoute(context: context,
20+
page: ProfilePage(userId: userId));
21+
}
22+
23+
void _createDmNavigation (context) {
24+
final store = PerAccountStoreWidget.of(context);
25+
final narrow = DmNarrow.withUser(userId, selfUserId: store.account.userId);
26+
Navigator.push(context,
27+
MessageListPage.buildRoute(context: context, narrow: narrow));
28+
}
29+
30+
@override
31+
Widget build(BuildContext context) {
32+
final store = PerAccountStoreWidget.of(context);
33+
final user = store.users[userId];
34+
if (user == null) {
35+
return const _ProfileErrorPage();
36+
}
37+
38+
return Scaffold(
39+
appBar: AppBar(title: Text(user.fullName)),
40+
body: SingleChildScrollView(
41+
child: Padding(
42+
padding: const EdgeInsets.all(16),
43+
child: DefaultTextStyle.merge(
44+
softWrap: true,
45+
textAlign: TextAlign.center,
46+
style: const TextStyle(fontSize: 20, height: 1.5),
47+
child: Column(
48+
crossAxisAlignment: CrossAxisAlignment.stretch,
49+
children: [
50+
Center(
51+
child: Avatar(userId: userId, size: 200, borderRadius: 200 / 8)),
52+
const SizedBox(height: 16),
53+
Text(user.fullName,
54+
style: const TextStyle(fontWeight: FontWeight.bold)),
55+
// TODO(#291) render email field
56+
Text(roleToLabel(user.role)),
57+
// TODO(#197) render user status
58+
// TODO(#196) render active status
59+
// TODO(#292) render user local time
60+
_ProfileDataTable(user: user),
61+
const SizedBox(height: 16),
62+
FilledButton.icon(
63+
onPressed: () => _createDmNavigation(context),
64+
icon: const Icon(Icons.email),
65+
label: const Text("Send direct message")),
66+
])))));
67+
}
68+
}
69+
70+
class _ProfileErrorPage extends StatelessWidget {
71+
const _ProfileErrorPage({super.key});
72+
73+
@override
74+
Widget build(BuildContext context) {
75+
return Scaffold(
76+
appBar: AppBar(title: const Text("Error")),
77+
body: const SingleChildScrollView(
78+
child: Padding(
79+
padding: EdgeInsets.all(16),
80+
child: Row(
81+
mainAxisAlignment: MainAxisAlignment.center,
82+
children: [
83+
Icon(Icons.error),
84+
Text("Could not show user profile."),
85+
]))));
86+
}
87+
}
88+
89+
String roleToLabel(UserRole role) {
90+
return switch (role) {
91+
UserRole.owner => "Owner",
92+
UserRole.administrator => "Administrator",
93+
UserRole.moderator => "Moderator",
94+
UserRole.member => "Member",
95+
UserRole.guest => "Guest",
96+
UserRole.unknown => "Unknown",
97+
};
98+
}
99+
100+
class _ProfileDataTable extends StatelessWidget {
101+
const _ProfileDataTable({super.key, required this.user});
102+
103+
final User user;
104+
105+
@override
106+
Widget build(BuildContext context) {
107+
final store = PerAccountStoreWidget.of(context);
108+
final profileData = user.profileData;
109+
if (profileData == null) {
110+
return const SizedBox.shrink();
111+
}
112+
final List<({String label, Widget widget})> entries = [];
113+
for (final realmField in store.customProfileFields) {
114+
final profileField = profileData[realmField.id];
115+
if (profileField == null) continue;
116+
final widget = _renderCustomProfileFieldValue(context, profileField.value, realmField);
117+
if (widget == null) continue;
118+
entries.add((label: realmField.name, widget: widget));
119+
}
120+
121+
return DefaultTextStyle.merge(
122+
style: const TextStyle(fontSize: 15, height: 1),
123+
textAlign: TextAlign.left,
124+
child: Column(
125+
children: [
126+
const SizedBox(height: 16),
127+
Table(
128+
border: TableBorder.all(style: BorderStyle.none),
129+
columnWidths: const {
130+
0: FixedColumnWidth(96 + 8),
131+
1: FlexColumnWidth()},
132+
defaultVerticalAlignment: TableCellVerticalAlignment.baseline,
133+
textBaseline: TextBaseline.alphabetic,
134+
children: entries.map((entry) => TableRow(
135+
children: [
136+
Padding(
137+
padding: const EdgeInsets.fromLTRB(0, 4, 8, 4),
138+
child: Text(entry.label, style: const TextStyle(fontWeight: FontWeight.bold))),
139+
entry.widget,
140+
])).toList()),
141+
]));
142+
}
143+
144+
Widget? _renderCustomProfileFieldValue(BuildContext context, String value, CustomProfileField realmField) {
145+
switch (realmField.type) {
146+
case CustomProfileFieldType.link:
147+
return _buildLinkWidget(context, value, value);
148+
case CustomProfileFieldType.choice:
149+
return _buildChoiceField(context, value, realmField);
150+
case CustomProfileFieldType.externalAccount:
151+
return _buildExternalAccountField(context, value, realmField);
152+
case CustomProfileFieldType.user:
153+
return _buildUserField(context, value, realmField);
154+
case CustomProfileFieldType.date:
155+
// TODO(server): The value's format is undocumented, but empirically
156+
// it's a date in ISO format, like 2000-01-01.
157+
// That's readable as is, but:
158+
// TODO format this date using user's locale.
159+
return _buildTextWidget(value);
160+
case CustomProfileFieldType.shortText:
161+
case CustomProfileFieldType.longText:
162+
case CustomProfileFieldType.pronouns:
163+
return _buildTextWidget(value);
164+
case CustomProfileFieldType.unknown:
165+
return null;
166+
}
167+
}
168+
169+
Widget? _buildChoiceField(BuildContext context, String value, CustomProfileField realmField) {
170+
// TODO(server): This isn't really documented. But see chat thread:
171+
// https://chat.zulip.org/#narrow/stream/378-api-design/topic/custom.20profile.20fields/near/1383005
172+
final CustomProfileFieldChoices choiceFieldData;
173+
try {
174+
final realmFieldData = jsonDecode(realmField.fieldData);
175+
choiceFieldData = CustomProfileFieldChoices.fromJson(realmFieldData);
176+
} on FormatException {
177+
return null;
178+
} on TypeError {
179+
return null;
180+
}
181+
final choiceItem = choiceFieldData.choices[value];
182+
if (choiceItem == null) return null;
183+
return _buildTextWidget(choiceItem.text);
184+
}
185+
186+
Widget? _buildExternalAccountField(BuildContext context, String value, CustomProfileField realmField) {
187+
// TODO(server): This is undocumented. See chat thread:
188+
// https://chat.zulip.org/#narrow/stream/378-api-design/topic/external.20account.20custom.20profile.20fields/near/1387213
189+
final store = PerAccountStoreWidget.of(context);
190+
final CustomProfileFieldExternalAccount externalAccountFieldData;
191+
try {
192+
final realmFieldData = jsonDecode(realmField.fieldData);
193+
externalAccountFieldData = CustomProfileFieldExternalAccount.fromJson(realmFieldData);
194+
} on FormatException {
195+
return null;
196+
} on TypeError {
197+
return null;
198+
}
199+
final urlPattern = externalAccountFieldData.urlPattern ??
200+
store.realmDefaultExternalAccounts[externalAccountFieldData.subtype]?.urlPattern;
201+
if (urlPattern == null) return null;
202+
final url = urlPattern.replaceFirst("%(username)s", value);
203+
return _buildLinkWidget(context, url, value);
204+
}
205+
206+
Widget? _buildUserField(BuildContext context, String value, CustomProfileField realmField) {
207+
// TODO(server): This is completely undocumented. The key to
208+
// reverse-engineering it was:
209+
// https://github.com/zulip/zulip/blob/18230fcd9/static/js/settings_account.js#L247
210+
final List<int> userIds;
211+
try {
212+
userIds = (jsonDecode(value) as List<dynamic>).map((e) => e as int).toList();
213+
} on FormatException {
214+
return null;
215+
} on TypeError {
216+
return null;
217+
}
218+
if (userIds.isEmpty) {
219+
return null;
220+
}
221+
return Column(
222+
crossAxisAlignment: CrossAxisAlignment.start,
223+
children: userIds.map((userId) => _buildUserWidget(context, userId)).toList());
224+
}
225+
226+
Widget _buildTextWidget(String text) {
227+
return Padding(
228+
padding: const EdgeInsets.symmetric(vertical: 4),
229+
child: Text(text));
230+
}
231+
232+
Widget _buildLinkWidget(BuildContext context, String text, String url) {
233+
final linkNode = LinkNode(url: url, nodes: [TextNode(text)]);
234+
final paragraph = Paragraph(node: ParagraphNode(nodes: [linkNode], links: [linkNode]));
235+
return MouseRegion(
236+
cursor: SystemMouseCursors.click,
237+
child: GestureDetector(
238+
onTap: () => launchContentUrl(context, url),
239+
child: paragraph));
240+
}
241+
242+
Widget _buildUserWidget(BuildContext context, int userId) {
243+
final store = PerAccountStoreWidget.of(context);
244+
final User? user = store.users[userId];
245+
if (user == null) {
246+
return _buildTextWidget("Unknown user");
247+
}
248+
return InkWell(
249+
onTap: () => Navigator.push(context,
250+
ProfilePage.buildRoute(context: context,
251+
userId: userId)),
252+
child: Padding(
253+
padding: const EdgeInsets.symmetric(vertical: 4),
254+
child: Row(
255+
children: [
256+
Avatar(userId: userId, size: 32, borderRadius: 32 / 8),
257+
const SizedBox(width: 8),
258+
Expanded(child: Text(user.fullName)), // TODO(#196) render active status
259+
])));
260+
}
261+
}

Diff for: test/example_data.dart

+4-1
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
@@ -32,7 +33,7 @@ User user({
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,
@@ -261,6 +263,7 @@ InitialSnapshot initialSnapshot({
261263
zulipMergeBase: zulipMergeBase ?? recentZulipVersion,
262264
alertWords: alertWords ?? ['klaxon'],
263265
customProfileFields: customProfileFields ?? [],
266+
realmDefaultExternalAccounts: realmDefaultExternalAccounts ?? {},
264267
recentPrivateConversations: recentPrivateConversations ?? [],
265268
subscriptions: subscriptions ?? [], // TODO add subscriptions to default
266269
unreadMsgs: unreadMsgs ?? _unreadMsgs(),

Diff for: test/widgets/profile_page_checks.dart

+6
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+
}

0 commit comments

Comments
 (0)