Skip to content

Commit c0f7552

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 55f3e52 commit c0f7552

File tree

5 files changed

+604
-4
lines changed

5 files changed

+604
-4
lines changed

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

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

Lines changed: 261 additions & 0 deletions
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 _TextStyles {
14+
static const primaryFieldText = 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.primaryFieldText.merge(const TextStyle(fontWeight: FontWeight.bold))),
44+
// TODO(#291) render email field
45+
Text(roleToLabel(user.role),
46+
textAlign: TextAlign.center,
47+
style: _TextStyles.primaryFieldText),
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.symmetric(horizontal: 16, vertical: 32),
86+
child: Row(
87+
mainAxisAlignment: MainAxisAlignment.center,
88+
children: [
89+
Icon(Icons.error),
90+
SizedBox(width: 4),
91+
Text('Could not show user profile.'),
92+
]))));
93+
}
94+
}
95+
96+
String roleToLabel(UserRole role) {
97+
return 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+
}
106+
107+
class _ProfileDataTable extends StatelessWidget {
108+
const _ProfileDataTable({required this.profileData});
109+
110+
final Map<int, ProfileFieldUserData>? profileData;
111+
112+
static T? _tryDecode<T, U>(T Function(U) fromJson, String data) {
113+
try {
114+
return fromJson(jsonDecode(data));
115+
} on FormatException {
116+
return null;
117+
} on TypeError {
118+
return null;
119+
}
120+
}
121+
122+
Widget? _buildCustomProfileFieldValue(BuildContext context, String value, CustomProfileField realmField) {
123+
final store = PerAccountStoreWidget.of(context);
124+
125+
switch (realmField.type) {
126+
case CustomProfileFieldType.link:
127+
return _LinkWidget(url: value, text: value);
128+
129+
case CustomProfileFieldType.choice:
130+
final choiceFieldData = _tryDecode(CustomProfileFieldChoiceDataItem.parseFieldDataChoices, realmField.fieldData);
131+
if (choiceFieldData == null) return null;
132+
final choiceItem = choiceFieldData[value];
133+
return (choiceItem == null) ? null : _TextWidget(text: choiceItem.text);
134+
135+
case CustomProfileFieldType.externalAccount:
136+
final externalAccountFieldData = _tryDecode(CustomProfileFieldExternalAccountData.fromJson, realmField.fieldData);
137+
if (externalAccountFieldData == null) return null;
138+
final urlPattern = externalAccountFieldData.urlPattern ??
139+
store.realmDefaultExternalAccounts[externalAccountFieldData.subtype]?.urlPattern;
140+
if (urlPattern == null) return null;
141+
final url = urlPattern.replaceFirst('%(username)s', value);
142+
return _LinkWidget(url: url, text: value);
143+
144+
case CustomProfileFieldType.user:
145+
// TODO(server): This is completely undocumented. The key to
146+
// reverse-engineering it was:
147+
// https://github.com/zulip/zulip/blob/18230fcd9/static/js/settings_account.js#L247
148+
final userIds = _tryDecode((List<dynamic> json) {
149+
return json.map((e) => e as int).toList();
150+
}, value);
151+
if (userIds == null) return null;
152+
return Column(
153+
children: userIds.map((userId) => _UserWidget(userId: userId)).toList());
154+
155+
case CustomProfileFieldType.date:
156+
// TODO(server): The value's format is undocumented, but empirically
157+
// it's a date in ISO format, like 2000-01-01.
158+
// That's readable as is, but:
159+
// TODO format this date using user's locale.
160+
return _TextWidget(text: value);
161+
162+
case CustomProfileFieldType.shortText:
163+
case CustomProfileFieldType.longText:
164+
case CustomProfileFieldType.pronouns:
165+
// The web client appears to treat `longText` identically to `shortText`;
166+
// `pronouns` is explicitly meant to display the same as `shortText`.
167+
return _TextWidget(text: value);
168+
169+
case CustomProfileFieldType.unknown:
170+
return null;
171+
}
172+
}
173+
174+
@override
175+
Widget build(BuildContext context) {
176+
final store = PerAccountStoreWidget.of(context);
177+
if (profileData == null) return const SizedBox.shrink();
178+
179+
List<Widget> items = [];
180+
181+
for (final realmField in store.customProfileFields) {
182+
final profileField = profileData![realmField.id];
183+
if (profileField == null) continue;
184+
final widget = _buildCustomProfileFieldValue(context, profileField.value, realmField);
185+
if (widget == null) continue; // TODO(log)
186+
187+
items.add(Row(
188+
crossAxisAlignment: CrossAxisAlignment.baseline,
189+
textBaseline: TextBaseline.alphabetic,
190+
children: [
191+
SizedBox(width: 96,
192+
child: Text(realmField.name, style: _TextStyles.customProfileFieldLabel)),
193+
const SizedBox(width: 8),
194+
Flexible(child: widget),
195+
]));
196+
items.add(const SizedBox(height: 8));
197+
}
198+
199+
if (items.isEmpty) return const SizedBox.shrink();
200+
201+
return Column(children: [
202+
const SizedBox(height: 16),
203+
...items
204+
]);
205+
}
206+
}
207+
208+
class _LinkWidget extends StatelessWidget {
209+
const _LinkWidget({required this.url, required this.text});
210+
211+
final String url;
212+
final String text;
213+
214+
@override
215+
Widget build(BuildContext context) {
216+
final linkNode = LinkNode(url: url, nodes: [TextNode(text)]);
217+
final paragraph = Paragraph(node: ParagraphNode(nodes: [linkNode], links: [linkNode]));
218+
return Padding(
219+
padding: const EdgeInsets.symmetric(horizontal: 8),
220+
child: MouseRegion(
221+
cursor: SystemMouseCursors.click,
222+
child: paragraph));
223+
}
224+
}
225+
226+
class _TextWidget extends StatelessWidget {
227+
const _TextWidget({required this.text});
228+
229+
final String text;
230+
231+
@override
232+
Widget build(BuildContext context) {
233+
return Padding(
234+
padding: const EdgeInsets.symmetric(horizontal: 8),
235+
child: Text(text, style: _TextStyles.customProfileFieldText));
236+
}
237+
}
238+
239+
class _UserWidget extends StatelessWidget {
240+
const _UserWidget({required this.userId});
241+
242+
final int userId;
243+
244+
@override
245+
Widget build(BuildContext context) {
246+
final store = PerAccountStoreWidget.of(context);
247+
final user = store.users[userId];
248+
final fullName = user?.fullName ?? '(unknown user)';
249+
return InkWell(
250+
onTap: () => Navigator.push(context,
251+
ProfilePage.buildRoute(context: context,
252+
userId: userId)),
253+
child: Padding(
254+
padding: const EdgeInsets.all(8),
255+
child: Row(children: [
256+
Avatar(userId: userId, size: 32, borderRadius: 32 / 8),
257+
const SizedBox(width: 8),
258+
Expanded(child: Text(fullName, style: _TextStyles.customProfileFieldText)), // TODO(#196) render active status
259+
])));
260+
}
261+
}

test/example_data.dart

Lines changed: 2 additions & 1 deletion
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

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

0 commit comments

Comments
 (0)