Skip to content

Commit 66182bd

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 51aee6a commit 66182bd

File tree

5 files changed

+549
-5
lines changed

5 files changed

+549
-5
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

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

lib/widgets/profile.dart

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

test/example_data.dart

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

@@ -265,8 +266,8 @@ InitialSnapshot initialSnapshot({
265266
subscriptions: subscriptions ?? [], // TODO add subscriptions to default
266267
unreadMsgs: unreadMsgs ?? _unreadMsgs(),
267268
streams: streams ?? [], // TODO add streams to default
268-
realmDefaultExternalAccounts: realmDefaultExternalAccounts ?? {},
269269
userSettings: userSettings, // TODO add userSettings to default
270+
realmDefaultExternalAccounts: realmDefaultExternalAccounts ?? {},
270271
maxFileUploadSizeMib: maxFileUploadSizeMib ?? 25,
271272
realmUsers: realmUsers ?? [],
272273
realmNonActiveUsers: realmNonActiveUsers ?? [],

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)