-
Notifications
You must be signed in to change notification settings - Fork 309
RecentDmConversationsPage: Add #249
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
845987f
065cd4f
be47daa
95b29e5
6e1c1ad
a6692c7
caa9603
c75b6ae
dc1502d
df46e57
aa905d7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,93 @@ | ||
Copyright 2010-2022 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries. | ||
|
||
This Font Software is licensed under the SIL Open Font License, Version 1.1. | ||
|
||
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL | ||
|
||
|
||
----------------------------------------------------------- | ||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 | ||
----------------------------------------------------------- | ||
|
||
PREAMBLE | ||
The goals of the Open Font License (OFL) are to stimulate worldwide | ||
development of collaborative font projects, to support the font creation | ||
efforts of academic and linguistic communities, and to provide a free and | ||
open framework in which fonts may be shared and improved in partnership | ||
with others. | ||
|
||
The OFL allows the licensed fonts to be used, studied, modified and | ||
redistributed freely as long as they are not sold by themselves. The | ||
fonts, including any derivative works, can be bundled, embedded, | ||
redistributed and/or sold with any software provided that any reserved | ||
names are not used by derivative works. The fonts and derivatives, | ||
however, cannot be released under any other type of license. The | ||
requirement for fonts to remain under this license does not apply | ||
to any document created using the fonts or their derivatives. | ||
|
||
DEFINITIONS | ||
"Font Software" refers to the set of files released by the Copyright | ||
Holder(s) under this license and clearly marked as such. This may | ||
include source files, build scripts and documentation. | ||
|
||
"Reserved Font Name" refers to any names specified as such after the | ||
copyright statement(s). | ||
|
||
"Original Version" refers to the collection of Font Software components as | ||
distributed by the Copyright Holder(s). | ||
|
||
"Modified Version" refers to any derivative made by adding to, deleting, | ||
or substituting -- in part or in whole -- any of the components of the | ||
Original Version, by changing formats or by porting the Font Software to a | ||
new environment. | ||
|
||
"Author" refers to any designer, engineer, programmer, technical | ||
writer or other person who contributed to the Font Software. | ||
|
||
PERMISSION & CONDITIONS | ||
Permission is hereby granted, free of charge, to any person obtaining | ||
a copy of the Font Software, to use, study, copy, merge, embed, modify, | ||
redistribute, and sell modified and unmodified copies of the Font | ||
Software, subject to the following conditions: | ||
|
||
1) Neither the Font Software nor any of its individual components, | ||
in Original or Modified Versions, may be sold by itself. | ||
|
||
2) Original or Modified Versions of the Font Software may be bundled, | ||
redistributed and/or sold with any software, provided that each copy | ||
contains the above copyright notice and this license. These can be | ||
included either as stand-alone text files, human-readable headers or | ||
in the appropriate machine-readable metadata fields within text or | ||
binary files as long as those fields can be easily viewed by the user. | ||
|
||
3) No Modified Version of the Font Software may use the Reserved Font | ||
Name(s) unless explicit written permission is granted by the corresponding | ||
Copyright Holder. This restriction only applies to the primary font name as | ||
presented to the users. | ||
|
||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font | ||
Software shall not be used to promote, endorse or advertise any | ||
Modified Version, except to acknowledge the contribution(s) of the | ||
Copyright Holder(s) and the Author(s) or with their explicit written | ||
permission. | ||
|
||
5) The Font Software, modified or unmodified, in part or in whole, | ||
must be distributed entirely under this license, and must not be | ||
distributed under any other license. The requirement for fonts to | ||
remain under this license does not apply to any document created | ||
using the Font Software. | ||
|
||
TERMINATION | ||
This license becomes null and void if any of the above conditions are | ||
not met. | ||
|
||
DISCLAIMER | ||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, | ||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF | ||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT | ||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE | ||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, | ||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL | ||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING | ||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM | ||
OTHER DEALINGS IN THE FONT SOFTWARE. |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -21,6 +21,7 @@ class MessageListPage extends StatefulWidget { | |
|
||
static Route<void> buildRoute({required BuildContext context, required Narrow narrow}) { | ||
return MaterialAccountPageRoute(context: context, | ||
settings: RouteSettings(name: 'message_list', arguments: narrow), // for testing | ||
builder: (context) => MessageListPage(narrow: narrow)); | ||
Comment on lines
23
to
25
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, I see. This RouteSettings and the builder feel like they're expressing the same information twice. I'll try experimenting with ways to deduplicate that, and also to avoid us having to make up names like There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Interesting, yeah. Thanks! |
||
} | ||
|
||
|
@@ -474,16 +475,6 @@ class MessageWithSender extends StatelessWidget { | |
|
||
@override | ||
Widget build(BuildContext context) { | ||
final store = PerAccountStoreWidget.of(context); | ||
final author = store.users[message.senderId]!; | ||
|
||
final avatarUrl = author.avatarUrl == null | ||
? null // TODO handle computing gravatars | ||
: resolveUrl(author.avatarUrl!, store.account); | ||
final avatar = (avatarUrl == null) | ||
? const SizedBox.shrink() | ||
: RealmContentNetworkImage(avatarUrl, filterQuality: FilterQuality.medium); | ||
|
||
final time = _kMessageTimestampFormat | ||
.format(DateTime.fromMillisecondsSinceEpoch(1000 * message.timestamp)); | ||
|
||
|
@@ -496,13 +487,7 @@ class MessageWithSender extends StatelessWidget { | |
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ | ||
Padding( | ||
padding: const EdgeInsets.fromLTRB(3, 6, 11, 0), | ||
child: Container( | ||
clipBehavior: Clip.antiAlias, | ||
decoration: const BoxDecoration( | ||
borderRadius: BorderRadius.all(Radius.circular(4))), | ||
width: 35, | ||
height: 35, | ||
child: avatar)), | ||
child: Avatar(userId: message.senderId, size: 35, borderRadius: 4)), | ||
Expanded( | ||
child: Column( | ||
crossAxisAlignment: CrossAxisAlignment.stretch, | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,108 @@ | ||
import 'package:flutter/material.dart'; | ||
|
||
import '../model/narrow.dart'; | ||
import '../model/recent_dm_conversations.dart'; | ||
import 'content.dart'; | ||
import 'icons.dart'; | ||
import 'message_list.dart'; | ||
import 'page.dart'; | ||
import 'store.dart'; | ||
import 'text.dart'; | ||
|
||
class RecentDmConversationsPage extends StatefulWidget { | ||
const RecentDmConversationsPage({super.key}); | ||
|
||
static Route<void> buildRoute({required BuildContext context}) { | ||
return MaterialAccountPageRoute(context: context, | ||
builder: (context) => const RecentDmConversationsPage()); | ||
} | ||
|
||
@override | ||
State<RecentDmConversationsPage> createState() => _RecentDmConversationsPageState(); | ||
} | ||
|
||
class _RecentDmConversationsPageState extends State<RecentDmConversationsPage> with PerAccountStoreAwareStateMixin<RecentDmConversationsPage> { | ||
RecentDmConversationsView? model; | ||
|
||
@override | ||
void onNewStore() { | ||
model?.removeListener(_modelChanged); | ||
model = PerAccountStoreWidget.of(context).recentDmConversationsView | ||
..addListener(_modelChanged); | ||
} | ||
|
||
void _modelChanged() { | ||
setState(() { | ||
// The actual state lives in [model]. | ||
// This method was called because that just changed. | ||
}); | ||
} | ||
|
||
@override | ||
Widget build(BuildContext context) { | ||
final sorted = model!.sorted; | ||
return Scaffold( | ||
appBar: AppBar(title: const Text('Direct messages')), | ||
body: ListView.builder( | ||
itemCount: sorted.length, | ||
itemBuilder: (context, index) => RecentDmConversationsItem(narrow: sorted[index]))); | ||
} | ||
} | ||
|
||
class RecentDmConversationsItem extends StatelessWidget { | ||
const RecentDmConversationsItem({super.key, required this.narrow}); | ||
|
||
final DmNarrow narrow; | ||
|
||
@override | ||
Widget build(BuildContext context) { | ||
final store = PerAccountStoreWidget.of(context); | ||
final selfUser = store.users[store.account.userId]!; | ||
|
||
final String title; | ||
final Widget avatar; | ||
switch (narrow.otherRecipientIds) { | ||
case []: | ||
title = selfUser.fullName; | ||
avatar = AvatarImage(userId: selfUser.userId); | ||
case [var otherUserId]: | ||
final otherUser = store.users[otherUserId]; | ||
title = otherUser?.fullName ?? '(unknown user)'; | ||
avatar = AvatarImage(userId: otherUserId); | ||
default: | ||
// TODO(i18n): List formatting, like you can do in JavaScript: | ||
// new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya']) | ||
// // 'Chris、Greg、Alya' | ||
title = narrow.otherRecipientIds.map((id) => store.users[id]?.fullName ?? '(unknown user)').join(', '); | ||
avatar = ColoredBox(color: const Color(0x33808080), | ||
child: Center( | ||
child: Icon(ZulipIcons.group_dm, color: Colors.black.withOpacity(0.5)))); | ||
} | ||
|
||
return InkWell( | ||
onTap: () { | ||
Navigator.push(context, | ||
MessageListPage.buildRoute(context: context, narrow: narrow)); | ||
}, | ||
child: ConstrainedBox(constraints: const BoxConstraints(minHeight: 48), | ||
child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ | ||
Padding(padding: const EdgeInsets.fromLTRB(12, 8, 0, 8), | ||
child: AvatarShape(size: 32, borderRadius: 3, child: avatar)), | ||
const SizedBox(width: 8), | ||
Expanded(child: Padding( | ||
padding: const EdgeInsets.symmetric(vertical: 4), | ||
child: Text( | ||
style: const TextStyle( | ||
fontFamily: 'Source Sans 3', | ||
fontSize: 17, | ||
height: (20 / 17), | ||
color: Color(0xFF222222), | ||
).merge(weightVariableTextStyle(context)), | ||
maxLines: 2, | ||
overflow: TextOverflow.ellipsis, | ||
title))), | ||
const SizedBox(width: 8), | ||
// TODO(#253): Unread count | ||
]))); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
import 'package:flutter/widgets.dart'; | ||
|
||
// Inspired by test code in the Flutter tree: | ||
// https://github.com/flutter/flutter/blob/53082f65b/packages/flutter/test/widgets/observer_tester.dart | ||
// https://github.com/flutter/flutter/blob/53082f65b/packages/flutter/test/widgets/navigator_test.dart | ||
Comment on lines
+3
to
+5
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cool, good find. |
||
|
||
/// A trivial observer for testing the navigator. | ||
class TestNavigatorObserver extends NavigatorObserver { | ||
void Function(Route<dynamic> route, Route<dynamic>? previousRoute)? onPushed; | ||
void Function(Route<dynamic> route, Route<dynamic>? previousRoute)? onPopped; | ||
void Function(Route<dynamic> route, Route<dynamic>? previousRoute)? onRemoved; | ||
void Function(Route<dynamic>? route, Route<dynamic>? previousRoute)? onReplaced; | ||
void Function(Route<dynamic> route, Route<dynamic>? previousRoute)? onStartUserGesture; | ||
|
||
@override | ||
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) { | ||
onPushed?.call(route, previousRoute); | ||
} | ||
|
||
@override | ||
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) { | ||
onPopped?.call(route, previousRoute); | ||
} | ||
|
||
@override | ||
void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) { | ||
onRemoved?.call(route, previousRoute); | ||
} | ||
|
||
@override | ||
void didReplace({ Route<dynamic>? oldRoute, Route<dynamic>? newRoute }) { | ||
onReplaced?.call(newRoute, oldRoute); | ||
} | ||
|
||
@override | ||
void didStartUserGesture(Route<dynamic> route, Route<dynamic>? previousRoute) { | ||
onStartUserGesture?.call(route, previousRoute); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,19 @@ | ||
import 'package:checks/checks.dart'; | ||
import 'package:flutter/widgets.dart'; | ||
|
||
import 'package:zulip/widgets/content.dart'; | ||
|
||
extension RealmContentNetworkImageChecks on Subject<RealmContentNetworkImage> { | ||
Subject<Uri> get src => has((i) => i.src, 'src'); | ||
// TODO others | ||
} | ||
|
||
extension AvatarImageChecks on Subject<AvatarImage> { | ||
Subject<int> get userId => has((i) => i.userId, 'userId'); | ||
} | ||
|
||
extension AvatarShapeChecks on Subject<AvatarShape> { | ||
Subject<double> get size => has((i) => i.size, 'size'); | ||
Subject<double> get borderRadius => has((i) => i.borderRadius, 'borderRadius'); | ||
Subject<Widget> get child => has((i) => i.child, 'child'); | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,302 @@ | ||
import 'package:checks/checks.dart'; | ||
import 'package:flutter/material.dart'; | ||
import 'package:flutter/rendering.dart'; | ||
import 'package:flutter_test/flutter_test.dart'; | ||
import 'package:zulip/api/model/events.dart'; | ||
import 'package:zulip/api/model/model.dart'; | ||
import 'package:zulip/model/narrow.dart'; | ||
import 'package:zulip/widgets/content.dart'; | ||
import 'package:zulip/widgets/icons.dart'; | ||
import 'package:zulip/widgets/recent_dm_conversations.dart'; | ||
import 'package:zulip/widgets/store.dart'; | ||
|
||
import '../example_data.dart' as eg; | ||
import '../flutter_checks.dart'; | ||
import '../model/binding.dart'; | ||
import '../model/test_store.dart'; | ||
import '../test_navigation.dart'; | ||
import 'content_checks.dart'; | ||
|
||
Future<void> setupPage(WidgetTester tester, { | ||
required List<DmMessage> dmMessages, | ||
required List<User> users, | ||
NavigatorObserver? navigatorObserver, | ||
String? newNameForSelfUser, | ||
}) async { | ||
addTearDown(TestZulipBinding.instance.reset); | ||
|
||
await TestZulipBinding.instance.globalStore.add(eg.selfAccount, eg.initialSnapshot()); | ||
final store = await TestZulipBinding.instance.globalStore.perAccount(eg.selfAccount.id); | ||
|
||
store.addUser(eg.selfUser); | ||
for (final user in users) { | ||
store.addUser(user); | ||
} | ||
|
||
for (final dmMessage in dmMessages) { | ||
store.handleEvent(MessageEvent(id: 1, message: dmMessage)); | ||
} | ||
|
||
if (newNameForSelfUser != null) { | ||
store.handleEvent(RealmUserUpdateEvent(id: 1, userId: eg.selfUser.userId, | ||
fullName: newNameForSelfUser)); | ||
} | ||
|
||
await tester.pumpWidget( | ||
GlobalStoreWidget( | ||
child: MaterialApp( | ||
navigatorObservers: navigatorObserver != null ? [navigatorObserver] : [], | ||
home: PerAccountStoreWidget( | ||
accountId: eg.selfAccount.id, | ||
child: const RecentDmConversationsPage())))); | ||
|
||
// global store, per-account store, and page get loaded | ||
await tester.pumpAndSettle(); | ||
} | ||
|
||
void main() { | ||
TestZulipBinding.ensureInitialized(); | ||
|
||
group('RecentDmConversationsPage', () { | ||
Finder findConversationItem(Narrow narrow) { | ||
return find.byWidgetPredicate( | ||
(widget) => | ||
widget is RecentDmConversationsItem && widget.narrow == narrow, | ||
); | ||
} | ||
|
||
testWidgets('page builds; conversations appear in order', (WidgetTester tester) async { | ||
final user1 = eg.user(userId: 1); | ||
final user2 = eg.user(userId: 2); | ||
|
||
final message1 = eg.dmMessage(id: 1, from: eg.selfUser, to: [user1]); // 1:1 | ||
final message2 = eg.dmMessage(id: 2, from: eg.selfUser, to: []); // self-1:1 | ||
final message3 = eg.dmMessage(id: 3, from: eg.selfUser, to: [user1, user2]); // group | ||
|
||
await setupPage(tester, users: [user1, user2], | ||
dmMessages: [message1, message2, message3]); | ||
|
||
final items = tester.widgetList<RecentDmConversationsItem>(find.byType(RecentDmConversationsItem)).toList(); | ||
check(items).length.equals(3); | ||
check(items[0].narrow).equals(DmNarrow.ofMessage(message3, selfUserId: eg.selfUser.userId)); | ||
check(items[1].narrow).equals(DmNarrow.ofMessage(message2, selfUserId: eg.selfUser.userId)); | ||
check(items[2].narrow).equals(DmNarrow.ofMessage(message1, selfUserId: eg.selfUser.userId)); | ||
}); | ||
|
||
testWidgets('fling to scroll down', (WidgetTester tester) async { | ||
final List<User> users = []; | ||
final List<DmMessage> messages = []; | ||
for (int i = 0; i < 30; i++) { | ||
final user = eg.user(userId: i, fullName: 'User ${i.toString()}'); | ||
users.add(user); | ||
messages.add(eg.dmMessage(id: i, from: eg.selfUser, to: [user])); | ||
} | ||
|
||
await setupPage(tester, users: users, dmMessages: messages); | ||
|
||
final oldestConversationFinder = findConversationItem( | ||
DmNarrow.ofMessage(messages.first, selfUserId: eg.selfUser.userId)); | ||
|
||
check(tester.any(oldestConversationFinder)).isFalse(); // not onscreen | ||
await tester.fling(find.byType(RecentDmConversationsPage), | ||
const Offset(0, -200), 4000); | ||
await tester.pumpAndSettle(); | ||
check(tester.any(oldestConversationFinder)).isTrue(); // onscreen | ||
}); | ||
}); | ||
|
||
group('RecentDmConversationsItem', () { | ||
group('appearance', () { | ||
void checkAvatar(WidgetTester tester, DmNarrow narrow) { | ||
final shape = tester.widget<AvatarShape>( | ||
find.descendant( | ||
of: find.byType(RecentDmConversationsItem), | ||
matching: find.byType(AvatarShape), | ||
)); | ||
check(shape) | ||
..size.equals(32) | ||
..borderRadius.equals(3); | ||
Comment on lines
+116
to
+118
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm lukewarm on checking this level of detail of the layout. It's the sort of test that's liable to end up acting just as another place we have to update when we make changes. But because it's nicely centralized in this test helper, it is at least just one additional place to update, so it's a lot cheaper than the situation people often get into where tons of tests need to be updated for a small change in the code under test. So it's fine. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Makes sense. I'm not attached to it; I just saw it as an opportunity to be thorough. 🙂 I see what you mean about how this could get annoying if it weren't centralized in this helper. |
||
|
||
switch (narrow.otherRecipientIds) { | ||
case []: // self-1:1 | ||
check(shape).child.isA<AvatarImage>().userId.equals(eg.selfUser.userId); | ||
case [var otherUserId]: // 1:1 | ||
check(shape).child.isA<AvatarImage>().userId.equals(otherUserId); | ||
default: // group | ||
// TODO(#232): syntax like `check(find(…), findsOneWidget)` | ||
tester.widget(find.descendant( | ||
of: find.byWidget(shape.child), | ||
matching: find.byIcon(ZulipIcons.group_dm), | ||
)); | ||
} | ||
} | ||
|
||
void checkTitle(WidgetTester tester, String expectedText, [int? expectedLines]) { | ||
// TODO(#232): syntax like `check(find(…), findsOneWidget)` | ||
final widget = tester.widget(find.descendant( | ||
of: find.byType(RecentDmConversationsItem), | ||
matching: find.text(expectedText), | ||
)); | ||
if (expectedLines != null) { | ||
final renderObject = tester.renderObject<RenderParagraph>(find.byWidget(widget)); | ||
check(renderObject.size.height).equals( | ||
20.0 // line height | ||
* expectedLines); | ||
} | ||
} | ||
|
||
group('self-1:1', () { | ||
testWidgets('has right content', (WidgetTester tester) async { | ||
final message = eg.dmMessage(from: eg.selfUser, to: []); | ||
await setupPage(tester, users: [], dmMessages: [message]); | ||
|
||
checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); | ||
checkTitle(tester, eg.selfUser.fullName); | ||
}); | ||
|
||
testWidgets('short name takes one line', (WidgetTester tester) async { | ||
final message = eg.dmMessage(from: eg.selfUser, to: []); | ||
const name = 'Short name'; | ||
await setupPage(tester, users: [], dmMessages: [message], | ||
newNameForSelfUser: name); | ||
checkTitle(tester, name, 1); | ||
}); | ||
|
||
testWidgets('very long name takes two lines (must be ellipsized)', (WidgetTester tester) async { | ||
final message = eg.dmMessage(from: eg.selfUser, to: []); | ||
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'; | ||
await setupPage(tester, users: [], dmMessages: [message], | ||
newNameForSelfUser: name); | ||
checkTitle(tester, name, 2); | ||
}); | ||
}); | ||
|
||
group('1:1', () { | ||
testWidgets('has right content', (WidgetTester tester) async { | ||
final user = eg.user(userId: 1); | ||
final message = eg.dmMessage(from: eg.selfUser, to: [user]); | ||
await setupPage(tester, users: [user], dmMessages: [message]); | ||
|
||
checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); | ||
checkTitle(tester, user.fullName); | ||
}); | ||
|
||
testWidgets('no error when user somehow missing from store.users', (WidgetTester tester) async { | ||
final user = eg.user(userId: 1); | ||
final message = eg.dmMessage(from: eg.selfUser, to: [user]); | ||
await setupPage(tester, | ||
users: [], // exclude user | ||
dmMessages: [message], | ||
); | ||
|
||
checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); | ||
checkTitle(tester, '(unknown user)'); | ||
}); | ||
|
||
testWidgets('short name takes one line', (WidgetTester tester) async { | ||
final user = eg.user(userId: 1, fullName: 'Short name'); | ||
final message = eg.dmMessage(from: eg.selfUser, to: [user]); | ||
await setupPage(tester, users: [user], dmMessages: [message]); | ||
checkTitle(tester, user.fullName, 1); | ||
}); | ||
|
||
testWidgets('very long name takes two lines (must be ellipsized)', (WidgetTester tester) async { | ||
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'); | ||
final message = eg.dmMessage(from: eg.selfUser, to: [user]); | ||
await setupPage(tester, users: [user], dmMessages: [message]); | ||
checkTitle(tester, user.fullName, 2); | ||
}); | ||
}); | ||
|
||
group('group', () { | ||
List<User> usersList(int count) { | ||
final result = <User>[]; | ||
for (int i = 0; i < count; i++) { | ||
result.add(eg.user(userId: i, fullName: 'User ${i.toString()}')); | ||
} | ||
return result; | ||
} | ||
|
||
testWidgets('has right content', (WidgetTester tester) async { | ||
final users = usersList(2); | ||
final user0 = users[0]; | ||
final user1 = users[1]; | ||
final message = eg.dmMessage(from: eg.selfUser, to: [user0, user1]); | ||
await setupPage(tester, users: users, dmMessages: [message]); | ||
|
||
checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); | ||
checkTitle(tester, '${user0.fullName}, ${user1.fullName}'); | ||
}); | ||
|
||
testWidgets('no error when one user somehow missing from store.users', (WidgetTester tester) async { | ||
final users = usersList(2); | ||
final user0 = users[0]; | ||
final user1 = users[1]; | ||
final message = eg.dmMessage(from: eg.selfUser, to: [user0, user1]); | ||
await setupPage(tester, | ||
users: [user0], // exclude user1 | ||
dmMessages: [message], | ||
); | ||
|
||
checkAvatar(tester, DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId)); | ||
checkTitle(tester, '${user0.fullName}, (unknown user)'); | ||
}); | ||
|
||
testWidgets('few names takes one line', (WidgetTester tester) async { | ||
final users = usersList(2); | ||
final message = eg.dmMessage(from: eg.selfUser, to: users); | ||
await setupPage(tester, users: users, dmMessages: [message]); | ||
checkTitle(tester, users.map((u) => u.fullName).join(', '), 1); | ||
}); | ||
|
||
testWidgets('very many names takes two lines (must be ellipsized)', (WidgetTester tester) async { | ||
final users = usersList(40); | ||
final message = eg.dmMessage(from: eg.selfUser, to: users); | ||
await setupPage(tester, users: users, dmMessages: [message]); | ||
checkTitle(tester, users.map((u) => u.fullName).join(', '), 2); | ||
}); | ||
}); | ||
}); | ||
|
||
group('on tap, navigates to message list', () { | ||
Future<void> runAndCheck(WidgetTester tester, { | ||
required DmMessage message, | ||
required List<User> users | ||
}) async { | ||
final expectedNarrow = DmNarrow.ofMessage(message, selfUserId: eg.selfUser.userId); | ||
final pushedRoutes = <Route<dynamic>>[]; | ||
final testNavObserver = TestNavigatorObserver() | ||
..onPushed = (route, prevRoute) => pushedRoutes.add(route); | ||
Comment on lines
+267
to
+269
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Handy! |
||
|
||
await setupPage(tester, users: users, | ||
dmMessages: [message], | ||
navigatorObserver: testNavObserver); | ||
|
||
await tester.tap(find.byType(RecentDmConversationsItem)); | ||
// no `tester.pump`, to avoid having to mock API response for [MessageListPage] | ||
|
||
check(pushedRoutes).last.settings | ||
..name.equals('message_list') | ||
..arguments.equals(expectedNarrow); | ||
} | ||
|
||
testWidgets('1:1', (WidgetTester tester) async { | ||
final user = eg.user(userId: 1, fullName: 'User 1'); | ||
await runAndCheck(tester, users: [user], | ||
message: eg.dmMessage(id: 1, from: eg.selfUser, to: [user])); | ||
}); | ||
|
||
testWidgets('self-1:1', (WidgetTester tester) async { | ||
await runAndCheck(tester, users: [], | ||
message: eg.dmMessage(id: 1, from: eg.selfUser, to: [])); | ||
}); | ||
|
||
testWidgets('group', (WidgetTester tester) async { | ||
final user1 = eg.user(userId: 1, fullName: 'User 1'); | ||
final user2 = eg.user(userId: 2, fullName: 'User 2'); | ||
await runAndCheck(tester, users: [user1, user2], | ||
message: eg.dmMessage(id: 1, from: eg.selfUser, to: [user1, user2])); | ||
}); | ||
}); | ||
}); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The required borderRadius can be squashed into the "Pull out Avatar widget" commit, resolving the TODO in the latter.