Skip to content

Commit aa905d7

Browse files
committedAug 10, 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: #119
1 parent df46e57 commit aa905d7

File tree

5 files changed

+467
-0
lines changed

5 files changed

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

0 commit comments

Comments
 (0)
Please sign in to comment.