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 c79cd7c

Browse files
committedAug 8, 2023
RecentDmConversationsPage: Add
The screen's content area (so, the list of conversations, but not the app bar at the top) is built against Vlad's design: https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/design.3A.20DM-conversation.20list/near/1594654 except that some features that appear in that design are left unimplemented for now, since we don't have data structures for them yet: - unread counts - user presence Fixes: zulip#119
1 parent 55f9c4b commit c79cd7c

File tree

5 files changed

+494
-0
lines changed

5 files changed

+494
-0
lines changed
 

‎lib/widgets/app.dart

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import '../model/narrow.dart';
44
import 'about_zulip.dart';
55
import 'login.dart';
66
import 'message_list.dart';
7+
import 'recent_dm_conversations.dart';
78
import 'store.dart';
89

910
class ZulipApp extends StatelessWidget {
@@ -152,6 +153,11 @@ class HomePage extends StatelessWidget {
152153
MessageListPage.buildRoute(context: context,
153154
narrow: const AllMessagesNarrow())),
154155
child: const Text("All messages")),
156+
const SizedBox(height: 16),
157+
ElevatedButton(
158+
onPressed: () => Navigator.push(context,
159+
RecentDmConversationsPage.buildRoute(context: context)),
160+
child: const Text("Direct messages")),
155161
if (testStreamId != null) ...[
156162
const SizedBox(height: 16),
157163
ElevatedButton(
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import 'package:flutter/material.dart';
2+
3+
import '../model/narrow.dart';
4+
import '../model/recent_dm_conversations.dart';
5+
import 'content.dart';
6+
import 'icons.dart';
7+
import 'message_list.dart';
8+
import 'page.dart';
9+
import 'store.dart';
10+
import 'text.dart';
11+
12+
class RecentDmConversationsPage extends StatefulWidget {
13+
const RecentDmConversationsPage({super.key});
14+
15+
static Route<void> buildRoute({required BuildContext context}) {
16+
return MaterialAccountPageRoute(context: context,
17+
builder: (context) => const RecentDmConversationsPage());
18+
}
19+
20+
@override
21+
State<RecentDmConversationsPage> createState() => _RecentDmConversationsPageState();
22+
}
23+
24+
class _RecentDmConversationsPageState extends State<RecentDmConversationsPage> with PerAccountStoreAwareStateMixin<RecentDmConversationsPage> {
25+
RecentDmConversationsView? model;
26+
27+
@override
28+
void onNewStore() {
29+
model?.removeListener(_modelChanged);
30+
model = PerAccountStoreWidget.of(context).recentDmConversationsView
31+
..addListener(_modelChanged);
32+
}
33+
34+
void _modelChanged() {
35+
setState(() {
36+
// The actual state lives in [model].
37+
// This method was called because that just changed.
38+
});
39+
}
40+
41+
@override
42+
Widget build(BuildContext context) {
43+
final sorted = model!.sorted;
44+
return Scaffold(
45+
appBar: AppBar(title: const Text('Direct messages')),
46+
body: ListView.builder(
47+
itemCount: sorted.length,
48+
itemBuilder: (context, index) => RecentDmConversationsItem(narrow: sorted[index])));
49+
}
50+
}
51+
52+
class RecentDmConversationsItem extends StatelessWidget {
53+
const RecentDmConversationsItem({super.key, required this.narrow});
54+
55+
final DmNarrow narrow;
56+
57+
@override
58+
Widget build(BuildContext context) {
59+
final store = PerAccountStoreWidget.of(context);
60+
final selfUser = store.users[store.account.userId]!;
61+
62+
final String title;
63+
final Widget avatar;
64+
switch (narrow.otherRecipientIds) {
65+
case []:
66+
title = selfUser.fullName;
67+
avatar = AvatarImage(userId: selfUser.userId);
68+
case [var otherUserId]:
69+
final otherUser = store.users[otherUserId];
70+
title = otherUser?.fullName ?? '(unknown user)';
71+
avatar = AvatarImage(userId: otherUserId);
72+
default:
73+
// TODO(i18n): List formatting, like you can do in JavaScript:
74+
// new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya'])
75+
// // 'Chris、Greg、Alya'
76+
title = narrow.otherRecipientIds.map((id) => store.users[id]?.fullName ?? '(unknown user)').join(', ');
77+
avatar = ColoredBox(color: const Color(0x33808080),
78+
child: Center(
79+
child: Icon(ZulipIcons.group_dm, color: Colors.black.withOpacity(0.5))));
80+
}
81+
82+
return InkWell(
83+
onTap: () {
84+
Navigator.push(context,
85+
MessageListPage.buildRoute(context: context, narrow: narrow));
86+
},
87+
child: ConstrainedBox(constraints: const BoxConstraints(minHeight: 48),
88+
child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
89+
Padding(padding: const EdgeInsets.fromLTRB(12, 8, 0, 8),
90+
child: AvatarShape(size: 32, borderRadius: 3, child: avatar)),
91+
const SizedBox(width: 8),
92+
Expanded(child: Padding(
93+
padding: const EdgeInsets.symmetric(vertical: 4),
94+
child: Text(
95+
style: const TextStyle(
96+
fontFamily: 'Source Sans 3',
97+
fontSize: 17,
98+
height: (20 / 17),
99+
color: Color(0xFF222222),
100+
).merge(weightVariableTextStyle(context)),
101+
maxLines: 2,
102+
overflow: TextOverflow.ellipsis,
103+
title))),
104+
const SizedBox(width: 8),
105+
// TODO(#253): Unread count
106+
])));
107+
}
108+
}

‎test/test_navigation.dart

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import 'package:flutter/widgets.dart';
2+
3+
// Inspired by test code in the Flutter tree:
4+
// https://github.com/flutter/flutter/blob/53082f65b/packages/flutter/test/widgets/observer_tester.dart
5+
// https://github.com/flutter/flutter/blob/53082f65b/packages/flutter/test/widgets/navigator_test.dart
6+
7+
/// A trivial observer for testing the navigator.
8+
class TestNavigatorObserver extends NavigatorObserver {
9+
void Function(Route<dynamic> route, Route<dynamic>? previousRoute)? onPushed;
10+
void Function(Route<dynamic> route, Route<dynamic>? previousRoute)? onPopped;
11+
void Function(Route<dynamic> route, Route<dynamic>? previousRoute)? onRemoved;
12+
void Function(Route<dynamic>? route, Route<dynamic>? previousRoute)? onReplaced;
13+
void Function(Route<dynamic> route, Route<dynamic>? previousRoute)? onStartUserGesture;
14+
15+
@override
16+
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
17+
onPushed?.call(route, previousRoute);
18+
}
19+
20+
@override
21+
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
22+
onPopped?.call(route, previousRoute);
23+
}
24+
25+
@override
26+
void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) {
27+
onRemoved?.call(route, previousRoute);
28+
}
29+
30+
@override
31+
void didReplace({ Route<dynamic>? oldRoute, Route<dynamic>? newRoute }) {
32+
onReplaced?.call(newRoute, oldRoute);
33+
}
34+
35+
@override
36+
void didStartUserGesture(Route<dynamic> route, Route<dynamic>? previousRoute) {
37+
onStartUserGesture?.call(route, previousRoute);
38+
}
39+
}

‎test/widgets/content_checks.dart

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
11
import 'package:checks/checks.dart';
2+
import 'package:flutter/widgets.dart';
3+
24
import 'package:zulip/widgets/content.dart';
35

46
extension RealmContentNetworkImageChecks on Subject<RealmContentNetworkImage> {
57
Subject<Uri> get src => has((i) => i.src, 'src');
68
// TODO others
79
}
10+
11+
extension AvatarImageChecks on Subject<AvatarImage> {
12+
Subject<int> get userId => has((i) => i.userId, 'userId');
13+
}
14+
15+
extension AvatarShapeChecks on Subject<AvatarShape> {
16+
Subject<double> get size => has((i) => i.size, 'size');
17+
Subject<double> get borderRadius => has((i) => i.borderRadius, 'borderRadius');
18+
Subject<Widget> get child => has((i) => i.child, 'child');
19+
}
Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
import 'package:checks/checks.dart';
2+
import 'package:flutter/material.dart';
3+
import 'package:flutter/rendering.dart';
4+
import 'package:flutter_test/flutter_test.dart';
5+
import 'package:zulip/api/model/events.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/icons.dart';
10+
import 'package:zulip/widgets/recent_dm_conversations.dart';
11+
import 'package:zulip/widgets/store.dart';
12+
13+
import '../example_data.dart' as eg;
14+
import '../flutter_checks.dart';
15+
import '../model/binding.dart';
16+
import '../model/test_store.dart';
17+
import '../test_navigation.dart';
18+
import 'content_checks.dart';
19+
20+
21+
Future<void> setupPage(WidgetTester tester, {
22+
required List<DmMessage> dmMessages,
23+
required List<User> users,
24+
NavigatorObserver? navigatorObserver,
25+
String? newNameForSelfUser,
26+
}) async {
27+
addTearDown(TestZulipBinding.instance.reset);
28+
addTearDown(tester.view.resetPhysicalSize);
29+
30+
// N.B. this doesn't seem to work when running the tests with `flutter run`
31+
// instead of `flutter test`, resulting in test failures. Upstream issue:
32+
// https://github.com/flutter/flutter/issues/124071
33+
tester.view.physicalSize = const Size(390, 844) * 3; // iPhone 13 Pro
34+
35+
await TestZulipBinding.instance.globalStore.add(eg.selfAccount, eg.initialSnapshot());
36+
final store = await TestZulipBinding.instance.globalStore.perAccount(eg.selfAccount.id);
37+
38+
store.addUser(eg.selfUser);
39+
for (final user in users) {
40+
store.addUser(user);
41+
}
42+
43+
for (final dmMessage in dmMessages) {
44+
store.handleEvent(MessageEvent(id: 1, message: dmMessage));
45+
}
46+
47+
if (newNameForSelfUser != null) {
48+
store.handleEvent(RealmUserUpdateEvent(id: 1, userId: eg.selfUser.userId,
49+
fullName: newNameForSelfUser));
50+
}
51+
52+
await tester.pumpWidget(
53+
GlobalStoreWidget(
54+
child: MaterialApp(
55+
navigatorObservers: navigatorObserver != null ? [navigatorObserver] : [],
56+
home: PerAccountStoreWidget(
57+
accountId: eg.selfAccount.id,
58+
child: const RecentDmConversationsPage()))));
59+
60+
// global store, per-account store, and page get loaded
61+
await tester.pumpAndSettle();
62+
}
63+
64+
void main() {
65+
TestZulipBinding.ensureInitialized();
66+
67+
group('RecentDmConversationsPage', () {
68+
Finder findConversationItem(Narrow narrow) {
69+
return find.byWidgetPredicate(
70+
(widget) =>
71+
widget is RecentDmConversationsItem && widget.narrow == narrow,
72+
);
73+
}
74+
75+
testWidgets('page builds; conversations appear in order', (WidgetTester tester) async {
76+
final user1 = eg.user(userId: 1);
77+
final user2 = eg.user(userId: 2);
78+
79+
final message1 = eg.dmMessage(id: 1, from: eg.selfUser, to: [user1]); // 1:1
80+
final message2 = eg.dmMessage(id: 2, from: eg.selfUser, to: []); // self-1:1
81+
final message3 = eg.dmMessage(id: 3, from: eg.selfUser, to: [user1, user2]); // group
82+
83+
await setupPage(tester, users: [user1, user2],
84+
dmMessages: [message1, message2, message3]);
85+
86+
final items = tester.widgetList<RecentDmConversationsItem>(find.byType(RecentDmConversationsItem)).toList();
87+
check(items).length.equals(3);
88+
check(items[0].narrow).equals(DmNarrow.ofMessage(message3, selfUserId: eg.selfUser.userId));
89+
check(items[1].narrow).equals(DmNarrow.ofMessage(message2, selfUserId: eg.selfUser.userId));
90+
check(items[2].narrow).equals(DmNarrow.ofMessage(message1, selfUserId: eg.selfUser.userId));
91+
});
92+
93+
testWidgets('fling to scroll down', (WidgetTester tester) async {
94+
final List<User> users = [];
95+
final List<DmMessage> messages = [];
96+
for (int i = 0; i < 30; i++) {
97+
final user = eg.user(userId: i, fullName: 'User ${i.toString()}');
98+
users.add(user);
99+
messages.add(eg.dmMessage(id: i, from: eg.selfUser, to: [user]));
100+
}
101+
102+
await setupPage(tester, users: users, dmMessages: messages);
103+
104+
final oldestConversationFinder = findConversationItem(
105+
DmNarrow.ofMessage(messages.first, selfUserId: eg.selfUser.userId));
106+
107+
check(tester.any(oldestConversationFinder)).isFalse(); // not onscreen
108+
await tester.fling(find.byType(RecentDmConversationsPage),
109+
const Offset(0, -200), 4000);
110+
await tester.pumpAndSettle();
111+
check(tester.any(oldestConversationFinder)).isTrue(); // onscreen
112+
});
113+
});
114+
115+
group('RecentDmConversationsItem', () {
116+
group('appearance', () {
117+
void checkAvatar(WidgetTester tester, DmNarrow narrow) {
118+
final shape = tester.widget<AvatarShape>(
119+
find.descendant(
120+
of: find.byType(RecentDmConversationsItem),
121+
matching: find.byType(AvatarShape),
122+
));
123+
check(shape)
124+
..size.equals(32)
125+
..borderRadius.equals(3);
126+
127+
switch (narrow.otherRecipientIds) {
128+
case []: // self-1:1
129+
check(shape).child.isA<AvatarImage>().userId.equals(eg.selfUser.userId);
130+
case [var otherUserId]: // 1:1
131+
check(shape).child.isA<AvatarImage>().userId.equals(otherUserId);
132+
default: // group
133+
// TODO(#232): syntax like `check(find(…), findsOneWidget)`
134+
tester.widget(find.descendant(
135+
of: find.byWidget(shape.child),
136+
matching: find.byIcon(ZulipIcons.group_dm),
137+
));
138+
}
139+
}
140+
141+
Text getTitle(WidgetTester tester) {
142+
return tester.widget<Text>(
143+
find.descendant(
144+
of: find.byType(RecentDmConversationsItem),
145+
matching: find.byType(Text),
146+
));
147+
}
148+
149+
void checkTitleHeight(WidgetTester tester, int expectedLines) {
150+
final renderObject = tester.renderObject<RenderParagraph>(find.byWidget(getTitle(tester)));
151+
check(renderObject.size.height).equals(
152+
20.0 // line height
153+
* expectedLines);
154+
}
155+
156+
group('self-1:1', () {
157+
testWidgets('has right content', (WidgetTester tester) async {
158+
final message = eg.dmMessage(from: eg.selfUser, to: []);
159+
await setupPage(tester, users: [], dmMessages: [message]);
160+
161+
checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId));
162+
check(getTitle(tester).data).equals(eg.selfUser.fullName);
163+
});
164+
165+
testWidgets('short name takes one line', (WidgetTester tester) async {
166+
final message = eg.dmMessage(from: eg.selfUser, to: []);
167+
await setupPage(tester, users: [], dmMessages: [message],
168+
newNameForSelfUser: 'Short name');
169+
checkTitleHeight(tester, 1);
170+
});
171+
172+
testWidgets('long name takes two lines', (WidgetTester tester) async {
173+
final message = eg.dmMessage(from: eg.selfUser, to: []);
174+
await setupPage(tester, users: [], dmMessages: [message],
175+
newNameForSelfUser: 'Long name long name long name');
176+
checkTitleHeight(tester, 2);
177+
});
178+
179+
testWidgets('very long name takes two lines (must be ellipsized)', (WidgetTester tester) async {
180+
final message = eg.dmMessage(from: eg.selfUser, to: []);
181+
await setupPage(tester, users: [], dmMessages: [message],
182+
newNameForSelfUser: 'Long name long name long name long name long name long name long name');
183+
checkTitleHeight(tester, 2);
184+
});
185+
});
186+
187+
group('1:1', () {
188+
testWidgets('has right content', (WidgetTester tester) async {
189+
final user = eg.user(userId: 1);
190+
final message = eg.dmMessage(from: eg.selfUser, to: [user]);
191+
await setupPage(tester, users: [user], dmMessages: [message]);
192+
193+
checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId));
194+
check(getTitle(tester).data).equals(user.fullName);
195+
});
196+
197+
testWidgets('no error when user somehow missing from store.users', (WidgetTester tester) async {
198+
final user = eg.user(userId: 1);
199+
final message = eg.dmMessage(from: eg.selfUser, to: [user]);
200+
await setupPage(tester,
201+
users: [], // exclude user
202+
dmMessages: [message],
203+
);
204+
205+
checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId));
206+
check(getTitle(tester).data).equals('(unknown user)');
207+
});
208+
209+
testWidgets('short name takes one line', (WidgetTester tester) async {
210+
final user = eg.user(userId: 1, fullName: 'Short name');
211+
final message = eg.dmMessage(from: eg.selfUser, to: [user]);
212+
await setupPage(tester, users: [user], dmMessages: [message]);
213+
checkTitleHeight(tester, 1);
214+
});
215+
216+
testWidgets('long name takes two lines', (WidgetTester tester) async {
217+
final user = eg.user(userId: 1, fullName: 'Long name long name long name');
218+
final message = eg.dmMessage(from: eg.selfUser, to: [user]);
219+
await setupPage(tester, users: [user], dmMessages: [message]);
220+
checkTitleHeight(tester, 2);
221+
});
222+
223+
testWidgets('very long name takes two lines (must be ellipsized)', (WidgetTester tester) async {
224+
final user = eg.user(userId: 1, fullName: 'Long name long name long name long name long name long name long name');
225+
final message = eg.dmMessage(from: eg.selfUser, to: [user]);
226+
await setupPage(tester, users: [user], dmMessages: [message]);
227+
checkTitleHeight(tester, 2);
228+
});
229+
});
230+
231+
group('group', () {
232+
List<User> usersList(int count) {
233+
final result = <User>[];
234+
for (int i = 0; i < count; i++) {
235+
result.add(eg.user(userId: i, fullName: 'User ${i.toString()}'));
236+
}
237+
return result;
238+
}
239+
240+
testWidgets('has right content', (WidgetTester tester) async {
241+
final users = usersList(2);
242+
final user0 = users[0];
243+
final user1 = users[1];
244+
final message = eg.dmMessage(from: eg.selfUser, to: [user0, user1]);
245+
await setupPage(tester, users: users, dmMessages: [message]);
246+
247+
checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId));
248+
check(getTitle(tester).data).equals('${user0.fullName}, ${user1.fullName}');
249+
});
250+
251+
testWidgets('no error when one user somehow missing from store.users', (WidgetTester tester) async {
252+
final users = usersList(2);
253+
final user0 = users[0];
254+
final user1 = users[1];
255+
final message = eg.dmMessage(from: eg.selfUser, to: [user0, user1]);
256+
await setupPage(tester,
257+
users: [user0], // exclude user1
258+
dmMessages: [message],
259+
);
260+
261+
checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId));
262+
check(getTitle(tester).data).equals('${user0.fullName}, (unknown user)');
263+
});
264+
265+
testWidgets('few names takes one line', (WidgetTester tester) async {
266+
final users = usersList(2);
267+
final message = eg.dmMessage(from: eg.selfUser, to: users);
268+
await setupPage(tester, users: users, dmMessages: [message]);
269+
checkTitleHeight(tester, 1);
270+
});
271+
272+
testWidgets('more names takes two lines', (WidgetTester tester) async {
273+
final users = usersList(4);
274+
final message = eg.dmMessage(from: eg.selfUser, to: users);
275+
await setupPage(tester, users: users, dmMessages: [message]);
276+
checkTitleHeight(tester, 2);
277+
});
278+
279+
testWidgets('many names takes two lines (must be ellipsized)', (WidgetTester tester) async {
280+
final users = usersList(9);
281+
final message = eg.dmMessage(from: eg.selfUser, to: users);
282+
await setupPage(tester, users: users, dmMessages: [message]);
283+
checkTitleHeight(tester, 2);
284+
});
285+
});
286+
});
287+
288+
group('on tap, navigates to message list', () {
289+
Future<void> runAndCheck(WidgetTester tester, {
290+
required DmMessage message,
291+
required List<User> users
292+
}) async {
293+
final expectedNarrow = DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId);
294+
final pushedRoutes = <Route<dynamic>>[];
295+
final testNavObserver = TestNavigatorObserver()
296+
..onPushed = (route, prevRoute) => pushedRoutes.add(route);
297+
298+
await setupPage(tester, users: users,
299+
dmMessages: [message],
300+
navigatorObserver: testNavObserver);
301+
302+
await tester.tap(find.byType(RecentDmConversationsItem));
303+
// no `tester.pump`, to avoid having to mock API response for [MessageListPage]
304+
305+
check(pushedRoutes).last.settings
306+
..name.equals('message_list')
307+
..arguments.equals(expectedNarrow);
308+
}
309+
310+
testWidgets('1:1', (WidgetTester tester) async {
311+
final user = eg.user(userId: 1, fullName: 'User 1');
312+
await runAndCheck(tester, users: [user],
313+
message: eg.dmMessage(id: 1, from: eg.selfUser, to: [user]));
314+
});
315+
316+
testWidgets('self-1:1', (WidgetTester tester) async {
317+
await runAndCheck(tester, users: [],
318+
message: eg.dmMessage(id: 1, from: eg.selfUser, to: []));
319+
});
320+
321+
testWidgets('group', (WidgetTester tester) async {
322+
final user1 = eg.user(userId: 1, fullName: 'User 1');
323+
final user2 = eg.user(userId: 2, fullName: 'User 2');
324+
await runAndCheck(tester, users: [user1, user2],
325+
message: eg.dmMessage(id: 1, from: eg.selfUser, to: [user1, user2]));
326+
});
327+
});
328+
});
329+
}

0 commit comments

Comments
 (0)
Please sign in to comment.