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 519b570

Browse files
committedAug 29, 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. User presence (#196) and user status (#197) are not yet displayed or tracked here. Fixes: #195
1 parent 8553310 commit 519b570

File tree

6 files changed

+605
-4
lines changed

6 files changed

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

@@ -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: 334 additions & 0 deletions
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+
}

‎test/api/model/model_test.dart

Lines changed: 2 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', () {

‎test/example_data.dart

Lines changed: 4 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

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