Skip to content

Commit f4648d4

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 (#196) and user status (#197) are not yet displayed or tracked here. Fixes: #195
1 parent 8553310 commit f4648d4

File tree

5 files changed

+603
-4
lines changed

5 files changed

+603
-4
lines changed

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

@@ -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

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

0 commit comments

Comments
 (0)