From 9ffafdd61424eca219ea66363e4087d3b0c0837f Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Mon, 30 Oct 2023 13:26:59 -0400 Subject: [PATCH] inbox: Add "Inbox" page Fixes: #117 --- lib/widgets/app.dart | 6 + lib/widgets/inbox.dart | 513 +++++++++++++++++++++++ lib/widgets/recent_dm_conversations.dart | 2 +- test/flutter_checks.dart | 7 + test/model/test_store.dart | 8 + test/widgets/inbox_test.dart | 297 +++++++++++++ 6 files changed, 832 insertions(+), 1 deletion(-) create mode 100644 lib/widgets/inbox.dart create mode 100644 test/widgets/inbox_test.dart diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index a7fe91bee9..351108b80b 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -8,6 +8,7 @@ import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; import '../model/localizations.dart'; import '../model/narrow.dart'; import 'about_zulip.dart'; +import 'inbox.dart'; import 'login.dart'; import 'message_list.dart'; import 'page.dart'; @@ -254,6 +255,11 @@ class HomePage extends StatelessWidget { narrow: const AllMessagesNarrow())), child: const Text("All messages")), const SizedBox(height: 16), + ElevatedButton( + onPressed: () => Navigator.push(context, + InboxPage.buildRoute(context: context)), + child: const Text("Inbox")), // TODO(i18n) + const SizedBox(height: 16), ElevatedButton( onPressed: () => Navigator.push(context, RecentDmConversationsPage.buildRoute(context: context)), diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart new file mode 100644 index 0000000000..858d732f11 --- /dev/null +++ b/lib/widgets/inbox.dart @@ -0,0 +1,513 @@ +import 'package:flutter/material.dart'; + +import '../api/model/model.dart'; +import '../model/narrow.dart'; +import '../model/recent_dm_conversations.dart'; +import '../model/unreads.dart'; +import 'icons.dart'; +import 'message_list.dart'; +import 'page.dart'; +import 'sticky_header.dart'; +import 'store.dart'; +import 'text.dart'; +import 'unread_count_badge.dart'; + +class InboxPage extends StatefulWidget { + const InboxPage({super.key}); + + static Route buildRoute({required BuildContext context}) { + return MaterialAccountWidgetRoute(context: context, + page: const InboxPage()); + } + + @override + State createState() => _InboxPageState(); +} + +class _InboxPageState extends State with PerAccountStoreAwareStateMixin { + Unreads? unreadsModel; + RecentDmConversationsView? recentDmConversationsModel; + + get allDmsCollapsed => _allDmsCollapsed; + bool _allDmsCollapsed = false; + set allDmsCollapsed(value) { + setState(() { + _allDmsCollapsed = value; + }); + } + + get collapsedStreamIds => _collapsedStreamIds; + final Set _collapsedStreamIds = {}; + void collapseStream(int streamId) { + setState(() { + _collapsedStreamIds.add(streamId); + }); + } + void uncollapseStream(int streamId) { + setState(() { + _collapsedStreamIds.remove(streamId); + }); + } + + @override + void onNewStore() { + final newStore = PerAccountStoreWidget.of(context); + unreadsModel?.removeListener(_modelChanged); + unreadsModel = newStore.unreads..addListener(_modelChanged); + recentDmConversationsModel?.removeListener(_modelChanged); + recentDmConversationsModel = newStore.recentDmConversationsView + ..addListener(_modelChanged); + } + + @override + void dispose() { + unreadsModel?.removeListener(_modelChanged); + recentDmConversationsModel?.removeListener(_modelChanged); + super.dispose(); + } + + void _modelChanged() { + setState(() { + // Much of the state lives in [unreadsModel] and + // [recentDmConversationsModel]. + // This method was called because one of those just changed. + // + // We also update some state that lives locally: we reset a collapsible + // row's collapsed state when it's cleared of unreads. + // TODO(perf) handle those updates efficiently + collapsedStreamIds.removeWhere((streamId) => + !unreadsModel!.streams.containsKey(streamId)); + if (unreadsModel!.dms.isEmpty) { + allDmsCollapsed = false; + } + }); + } + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + + final streams = store.streams; + final subscriptions = store.subscriptions; + + // TODO(perf) make an incrementally-updated view-model for InboxPage + final sections = <_InboxSectionData>[]; + + // TODO efficiently include DM conversations that aren't recent enough + // to appear in recentDmConversationsView, but still have unreads in + // unreadsModel. + final dmItems = <(DmNarrow, int)>[]; + int allDmsCount = 0; + for (final dmNarrow in recentDmConversationsModel!.sorted) { + final countInNarrow = unreadsModel!.countInDmNarrow(dmNarrow); + if (countInNarrow == 0) { + continue; + } + dmItems.add((dmNarrow, countInNarrow)); + allDmsCount += countInNarrow; + } + if (allDmsCount > 0) { + sections.add(_AllDmsSectionData(allDmsCount, dmItems)); + } + + final sortedUnreadStreams = unreadsModel!.streams.entries + .where((entry) => + streams.containsKey(entry.key) // TODO(log) a bug if missing in streams + // Filter out any straggling unreads in unsubscribed streams. + // There won't normally be any, but it happens with certain infrequent + // state changes, typically for less than a few hundred milliseconds. + // See [Unreads]. + // + // Also, we want to depend on the subscription data for things like + // choosing the stream icon. + && subscriptions.containsKey(entry.key)) + .toList() + ..sort((a, b) { + final streamA = streams[a.key]!; + final streamB = streams[b.key]!; + + // TODO(i18n) something like JS's String.prototype.localeCompare + return streamA.name.toLowerCase().compareTo(streamB.name.toLowerCase()); + }) + ..sort((a, b) { + // TODO "pin" icon on the stream row? dividers in the list? + final aPinned = subscriptions[a.key]!.pinToTop; + final bPinned = subscriptions[b.key]!.pinToTop; + return aPinned == bPinned ? 0 : (aPinned ? -1 : 1); + }); + + for (final MapEntry(key: streamId, value: topics) in sortedUnreadStreams) { + final topicItems = <(String, int, int)>[]; + int countInStream = 0; + for (final MapEntry(key: topic, value: messageIds) in topics.entries) { + final countInTopic = messageIds.length; + topicItems.add((topic, countInTopic, messageIds.last)); + countInStream += countInTopic; + } + if (countInStream == 0) { + continue; + } + topicItems.sort((a, b) { + final (_, _, aLastUnreadId) = a; + final (_, _, bLastUnreadId) = b; + return bLastUnreadId - aLastUnreadId; + }); + sections.add(_StreamSectionData(streamId, countInStream, topicItems)); + } + + // TODO(#346) Filter out muted messages. + // (Eventually let the user toggle that filtering?) + + return Scaffold( + appBar: AppBar(title: const Text('Inbox')), + body: StickyHeaderListView.builder( + itemCount: sections.length, + itemBuilder: (context, index) { + final section = sections[index]; + switch (section) { + case _AllDmsSectionData(): + return _AllDmsSection( + data: section, + collapsed: allDmsCollapsed, + pageState: this, + ); + case _StreamSectionData(:var streamId): + final collapsed = collapsedStreamIds.contains(streamId); + return _StreamSection(data: section, collapsed: collapsed, pageState: this); + } + })); + } +} + +sealed class _InboxSectionData { + const _InboxSectionData(); +} + +class _AllDmsSectionData extends _InboxSectionData { + final int count; + final List<(DmNarrow, int)> items; + + const _AllDmsSectionData(this.count, this.items); +} + +class _StreamSectionData extends _InboxSectionData { + final int streamId; + final int count; + final List<(String, int, int)> items; + + const _StreamSectionData(this.streamId, this.count, this.items); +} + +abstract class _HeaderItem extends StatelessWidget { + final bool collapsed; + final _InboxPageState pageState; + final int count; + + const _HeaderItem({ + required this.collapsed, + required this.pageState, + required this.count, + }); + + String get title; + IconData get icon; + Color get collapsedIconColor; + Color get uncollapsedIconColor; + Color get uncollapsedBackgroundColor; + Color? get unreadCountBadgeBackgroundColor; + + void Function() get onCollapseButtonTap; + void Function() get onRowTap; + + @override + Widget build(BuildContext context) { + return Material( + color: collapsed ? Colors.white : uncollapsedBackgroundColor, + child: InkWell( + // TODO use onRowTap to handle taps that are not on the collapse button. + // Probably we should give the collapse button a 44px or 48px square + // touch target: + // + // But that's in tension with the Figma, which gives these header rows + // 40px min height. + onTap: onCollapseButtonTap, + child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ + Padding(padding: const EdgeInsets.all(10), + child: Icon(size: 20, color: const Color(0x7F1D2E48), + collapsed ? ZulipIcons.arrow_right : ZulipIcons.arrow_down)), + Icon(size: 18, color: collapsed ? collapsedIconColor : uncollapsedIconColor, + icon), + const SizedBox(width: 5), + 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, wght: 600, wghtIfPlatformRequestsBold: 900)), + maxLines: 1, + overflow: TextOverflow.ellipsis, + title))), + const SizedBox(width: 12), + // TODO(#384) for streams, show @-mention indicator when it applies + Padding(padding: const EdgeInsetsDirectional.only(end: 16), + child: UnreadCountBadge(backgroundColor: unreadCountBadgeBackgroundColor, bold: true, + count: count)), + ]))); + } +} + +class _AllDmsHeaderItem extends _HeaderItem { + const _AllDmsHeaderItem({ + required super.collapsed, + required super.pageState, + required super.count, + }); + + @override get title => 'Direct messages'; // TODO(i18n) + @override get icon => ZulipIcons.user; + @override get collapsedIconColor => const Color(0xFF222222); + @override get uncollapsedIconColor => const Color(0xFF222222); + @override get uncollapsedBackgroundColor => const Color(0xFFF3F0E7); + @override get unreadCountBadgeBackgroundColor => null; + + @override get onCollapseButtonTap => () { + pageState.allDmsCollapsed = !collapsed; + }; + @override get onRowTap => onCollapseButtonTap; // TODO open all-DMs narrow? +} + +class _AllDmsSection extends StatelessWidget { + const _AllDmsSection({ + required this.data, + required this.collapsed, + required this.pageState, + }); + + final _AllDmsSectionData data; + final bool collapsed; + final _InboxPageState pageState; + + @override + Widget build(BuildContext context) { + final header = _AllDmsHeaderItem( + count: data.count, + collapsed: collapsed, + pageState: pageState, + ); + return StickyHeaderItem( + header: header, + child: Column(children: [ + header, + if (!collapsed) ...data.items.map((item) { + final (narrow, count) = item; + return _DmItem( + narrow: narrow, + count: count, + allDmsCount: data.count, + pageState: pageState, + ); + }), + ])); + } +} + +class _DmItem extends StatelessWidget { + const _DmItem({ + required this.narrow, + required this.count, + required this.allDmsCount, + required this.pageState + }); + + final DmNarrow narrow; + final int count; + final int allDmsCount; + final _InboxPageState pageState; + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final selfUser = store.users[store.account.userId]!; + + final title = switch (narrow.otherRecipientIds) { // TODO dedupe with [RecentDmConversationsItem] + [] => selfUser.fullName, + [var otherUserId] => store.users[otherUserId]?.fullName ?? '(unknown user)', + + // TODO(i18n): List formatting, like you can do in JavaScript: + // new Intl.ListFormat('ja').format(['Chris', 'Greg', 'Alya', 'Shu']) + // // 'Chris、Greg、Alya、Shu' + _ => narrow.otherRecipientIds.map((id) => store.users[id]?.fullName ?? '(unknown user)').join(', '), + }; + + return StickyHeaderItem( + header: _AllDmsHeaderItem( + count: allDmsCount, + collapsed: false, + pageState: pageState, + ), + allowOverflow: true, + child: Material( + color: Colors.white, + child: InkWell( + onTap: () { + Navigator.push(context, + MessageListPage.buildRoute(context: context, narrow: narrow)); + }, + child: ConstrainedBox(constraints: const BoxConstraints(minHeight: 34), + child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ + const SizedBox(width: 63), + 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: 12), + Padding(padding: const EdgeInsetsDirectional.only(end: 16), + child: UnreadCountBadge(backgroundColor: null, + count: count)), + ]))))); + } +} + +class _StreamHeaderItem extends _HeaderItem { + final Subscription subscription; + + const _StreamHeaderItem({ + required this.subscription, + required super.collapsed, + required super.pageState, + required super.count, + }); + + @override get title => subscription.name; + @override get icon => switch (subscription) { + // TODO these icons aren't quite right yet; see this message and the following: + // https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/design.3A.20.23F117.20.22Inbox.22.20screen/near/1680637 + Subscription(isWebPublic: true) => ZulipIcons.globe, + Subscription(inviteOnly: true) => ZulipIcons.lock, + Subscription() => ZulipIcons.hash_sign, + }; + @override get collapsedIconColor => subscription.colorSwatch().iconOnPlainBackground; + @override get uncollapsedIconColor => subscription.colorSwatch().iconOnBarBackground; + @override get uncollapsedBackgroundColor => + subscription.colorSwatch().barBackground; + @override get unreadCountBadgeBackgroundColor => + subscription.colorSwatch().unreadCountBadgeBackground; + + @override get onCollapseButtonTap => () { + if (collapsed) { + pageState.uncollapseStream(subscription.streamId); + } else { + pageState.collapseStream(subscription.streamId); + } + }; + @override get onRowTap => onCollapseButtonTap; // TODO open stream narrow +} + +class _StreamSection extends StatelessWidget { + const _StreamSection({ + required this.data, + required this.collapsed, + required this.pageState, + }); + + final _StreamSectionData data; + final bool collapsed; + final _InboxPageState pageState; + + @override + Widget build(BuildContext context) { + final subscription = PerAccountStoreWidget.of(context).subscriptions[data.streamId]!; + final header = _StreamHeaderItem( + subscription: subscription, + count: data.count, + collapsed: collapsed, + pageState: pageState, + ); + return StickyHeaderItem( + header: header, + child: Column(children: [ + header, + if (!collapsed) ...data.items.map((item) { + final (topic, count, _) = item; + return _TopicItem( + streamId: data.streamId, + topic: topic, + count: count, + streamCount: data.count, + pageState: pageState, + ); + }), + ])); + } +} + +class _TopicItem extends StatelessWidget { + const _TopicItem({ + required this.streamId, + required this.topic, + required this.count, + required this.streamCount, + required this.pageState, + }); + + final int streamId; + final String topic; + final int count; + final int streamCount; + final _InboxPageState pageState; + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final subscription = store.subscriptions[streamId]!; + + return StickyHeaderItem( + header: _StreamHeaderItem( + subscription: subscription, + count: streamCount, + collapsed: false, + pageState: pageState, + ), + allowOverflow: true, + child: Material( + color: Colors.white, + child: InkWell( + onTap: () { + final narrow = TopicNarrow(streamId, topic); + Navigator.push(context, + MessageListPage.buildRoute(context: context, narrow: narrow)); + }, + child: ConstrainedBox(constraints: const BoxConstraints(minHeight: 34), + child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ + const SizedBox(width: 63), + 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, + topic))), + const SizedBox(width: 12), + // TODO(#384) show @-mention indicator when it applies + Padding(padding: const EdgeInsetsDirectional.only(end: 16), + child: UnreadCountBadge(backgroundColor: subscription.colorSwatch(), + count: count)), + ]))))); + } +} diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index 81df4ee472..9ecbb9b0f8 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -86,7 +86,7 @@ class RecentDmConversationsItem extends StatelessWidget { final String title; final Widget avatar; - switch (narrow.otherRecipientIds) { + switch (narrow.otherRecipientIds) { // TODO dedupe with DM items in [InboxPage] case []: title = selfUser.fullName; avatar = AvatarImage(userId: selfUser.userId); diff --git a/test/flutter_checks.dart b/test/flutter_checks.dart index 6d3dfab777..7e24ed4782 100644 --- a/test/flutter_checks.dart +++ b/test/flutter_checks.dart @@ -22,6 +22,13 @@ extension GlobalKeyChecks> on Subject get currentState => has((k) => k.currentState, 'currentState'); } +extension IconChecks on Subject { + Subject get icon => has((i) => i.icon, 'icon'); + Subject get color => has((i) => i.color, 'color'); + + // TODO others +} + extension RouteChecks on Subject> { Subject get settings => has((r) => r.settings, 'settings'); } diff --git a/test/model/test_store.dart b/test/model/test_store.dart index 740a3b0788..6b1d293b11 100644 --- a/test/model/test_store.dart +++ b/test/model/test_store.dart @@ -76,4 +76,12 @@ extension PerAccountStoreTestExtension on PerAccountStore { void addStreams(List streams) { handleEvent(StreamCreateEvent(id: 1, streams: streams)); } + + void addSubscription(Subscription subscription) { + addSubscriptions([subscription]); + } + + void addSubscriptions(List subscriptions) { + handleEvent(SubscriptionAddEvent(id: 1, subscriptions: subscriptions)); + } } diff --git a/test/widgets/inbox_test.dart b/test/widgets/inbox_test.dart new file mode 100644 index 0000000000..61678527dd --- /dev/null +++ b/test/widgets/inbox_test.dart @@ -0,0 +1,297 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/material.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/store.dart'; +import 'package:zulip/widgets/icons.dart'; +import 'package:zulip/widgets/inbox.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'; + +void main() { + TestZulipBinding.ensureInitialized(); + + late PerAccountStore store; + + Future setupPage(WidgetTester tester, { + List? streams, + List? subscriptions, + List? users, + required List unreadMessages, + NavigatorObserver? navigatorObserver, + }) async { + addTearDown(testBinding.reset); + await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot()); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + + store + ..addStreams(streams ?? []) + ..addSubscriptions(subscriptions ?? []) + ..addUsers(users ?? [eg.selfUser]); + + for (final message in unreadMessages) { + assert(!message.flags.contains(MessageFlag.read)); + store.handleEvent(MessageEvent(id: 1, message: message)); + } + + await tester.pumpWidget( + GlobalStoreWidget( + child: MaterialApp( + navigatorObservers: [if (navigatorObserver != null) navigatorObserver], + home: PerAccountStoreWidget( + accountId: eg.selfAccount.id, + child: const InboxPage())))); + + // global store and per-account store get loaded + await tester.pumpAndSettle(); + } + + /// Set up an inbox view with lots of interesting content. + Future setupVarious(WidgetTester tester) async { + final stream1 = eg.stream(streamId: 1); + final sub1 = eg.subscription(stream1); + final stream2 = eg.stream(streamId: 2); + final sub2 = eg.subscription(stream2); + + await setupPage(tester, + streams: [stream1, stream2], + subscriptions: [sub1, sub2], + users: [eg.selfUser, eg.otherUser, eg.thirdUser], + unreadMessages: [ + eg.streamMessage(stream: stream1, topic: 'specific topic', flags: []), + eg.streamMessage(stream: stream2, flags: []), + eg.dmMessage(from: eg.otherUser, to: [eg.selfUser], flags: []), + eg.dmMessage(from: eg.otherUser, to: [eg.selfUser, eg.thirdUser], flags: []), + ]); + } + + /// Find an all-DMs header element. + // Why "an" all-DMs header element? Because there might be two: one that + // floats at the top of the screen to give the "sticky header" effect, and one + // that scrolls normally, the way it would in a regular [ListView]. + // TODO we'll need to find both and run checks on them, knowing which is which. + Widget? findAllDmsHeaderRow(WidgetTester tester) { + final rowLabel = tester.widgetList( + find.text('Direct messages'), + ).firstOrNull; + if (rowLabel == null) { + return null; + } + + return tester.widget( + find.ancestor( + of: find.byWidget(rowLabel), + matching: find.byType(Row))); + } + + /// For the given stream ID, find a stream header element. + // Why "an" all-DMs header element? Because there might be two: one that + // floats at the top of the screen to give the "sticky header" effect, and one + // that scrolls normally, the way it would in a regular [ListView]. + // TODO we'll need to find both and run checks on them, knowing which is which. + Widget? findStreamHeaderRow(WidgetTester tester, int streamId) { + final stream = store.streams[streamId]!; + final rowLabel = tester.widgetList(find.text(stream.name)).firstOrNull; + if (rowLabel == null) { + return null; + } + + return tester.widget( + find.ancestor( + of: find.byWidget(rowLabel), + matching: find.byType(Row))); + } + + IconData expectedStreamHeaderIcon(int streamId) { + final subscription = store.subscriptions[streamId]!; + return switch (subscription) { + Subscription(isWebPublic: true) => ZulipIcons.globe, + Subscription(inviteOnly: true) => ZulipIcons.lock, + Subscription() => ZulipIcons.hash_sign, + }; + } + + Icon findStreamHeaderIcon(WidgetTester tester, int streamId) { + final expectedIcon = expectedStreamHeaderIcon(streamId); + final headerRow = findStreamHeaderRow(tester, streamId); + check(headerRow).isNotNull(); + + return tester.widget(find.descendant( + of: find.byWidget(headerRow!), + matching: find.byIcon(expectedIcon), + )); + } + + group('InboxPage', () { + testWidgets('page builds; empty', (tester) async { + await setupPage(tester, unreadMessages: []); + }); + + // TODO more checks: ordering, etc. + testWidgets('page builds; not empty', (tester) async { + await setupVarious(tester); + }); + + // TODO test that tapping a conversation row opens the message list + // for the conversation + + group('collapsing', () { + Icon findHeaderCollapseIcon(WidgetTester tester, Widget headerRow) { + return tester.widget( + find.descendant( + of: find.byWidget(headerRow), + matching: find.byWidgetPredicate( + (widget) => widget is Icon + && (widget.icon == ZulipIcons.arrow_down + || widget.icon == ZulipIcons.arrow_right)))); + } + + group('all-DMs section', () { + Future tapCollapseIcon(WidgetTester tester) async { + final headerRow = findAllDmsHeaderRow(tester); + check(headerRow).isNotNull(); + final icon = findHeaderCollapseIcon(tester, headerRow!); + await tester.tap(find.byWidget(icon)); + await tester.pump(); + } + + /// Check that the section appears uncollapsed. + /// + /// For [findSectionContent], pass a [Finder] that will find some of + /// the section's content if it is uncollapsed. The function will + /// check that it finds something. + void checkAppearsUncollapsed( + WidgetTester tester, + Finder findSectionContent, + ) { + final headerRow = findAllDmsHeaderRow(tester); + check(headerRow).isNotNull(); + final icon = findHeaderCollapseIcon(tester, headerRow!); + check(icon).icon.equals(ZulipIcons.arrow_down); + // TODO check bar background color + check(tester.widgetList(findSectionContent)).isNotEmpty(); + } + + /// Check that the section appears collapsed. + /// + /// For [findSectionContent], pass a [Finder] that would find some of + /// the section's content if it were uncollapsed. The function will + /// check that the finder comes up empty. + void checkAppearsCollapsed( + WidgetTester tester, + Finder findSectionContent, + ) { + final headerRow = findAllDmsHeaderRow(tester); + check(headerRow).isNotNull(); + final icon = findHeaderCollapseIcon(tester, headerRow!); + check(icon).icon.equals(ZulipIcons.arrow_right); + // TODO check bar background color + check(tester.widgetList(findSectionContent)).isEmpty(); + } + + testWidgets('appearance', (tester) async { + await setupVarious(tester); + + final headerRow = findAllDmsHeaderRow(tester); + check(headerRow).isNotNull(); + + final findSectionContent = find.text(eg.otherUser.fullName); + + checkAppearsUncollapsed(tester, findSectionContent); + await tapCollapseIcon(tester); + checkAppearsCollapsed(tester, findSectionContent); + await tapCollapseIcon(tester); + checkAppearsUncollapsed(tester, findSectionContent); + }); + + // TODO check it remains collapsed even if you scroll far away and back + + // TODO check that it's always uncollapsed when it appears after being + // absent, even if it was collapsed the last time it was present. + // (Could test multiple triggers for its reappearance: it could + // reappear because a new unread arrived, but with #296 it could also + // reappear because of a change in muted-users state.) + }); + + group('stream section', () { + Future tapCollapseIcon(WidgetTester tester, int streamId) async { + final headerRow = findStreamHeaderRow(tester, streamId); + check(headerRow).isNotNull(); + final icon = findHeaderCollapseIcon(tester, headerRow!); + await tester.tap(find.byWidget(icon)); + await tester.pump(); + } + + /// Check that the section appears uncollapsed. + /// + /// For [findSectionContent], pass a [Finder] that will find some of + /// the section's content if it is uncollapsed. The function will + /// check that it finds something. + void checkAppearsUncollapsed( + WidgetTester tester, + int streamId, + Finder findSectionContent, + ) { + final subscription = store.subscriptions[streamId]!; + final headerRow = findStreamHeaderRow(tester, streamId); + check(headerRow).isNotNull(); + final collapseIcon = findHeaderCollapseIcon(tester, headerRow!); + check(collapseIcon).icon.equals(ZulipIcons.arrow_down); + final streamIcon = findStreamHeaderIcon(tester, streamId); + check(streamIcon).color.equals(subscription.colorSwatch().iconOnBarBackground); + // TODO check bar background color + check(tester.widgetList(findSectionContent)).isNotEmpty(); + } + + /// Check that the section appears collapsed. + /// + /// For [findSectionContent], pass a [Finder] that would find some of + /// the section's content if it were uncollapsed. The function will + /// check that the finder comes up empty. + void checkAppearsCollapsed( + WidgetTester tester, + int streamId, + Finder findSectionContent, + ) { + final subscription = store.subscriptions[streamId]!; + final headerRow = findStreamHeaderRow(tester, streamId); + check(headerRow).isNotNull(); + final collapseIcon = findHeaderCollapseIcon(tester, headerRow!); + check(collapseIcon).icon.equals(ZulipIcons.arrow_right); + final streamIcon = findStreamHeaderIcon(tester, streamId); + check(streamIcon).color.equals(subscription.colorSwatch().iconOnPlainBackground); + // TODO check bar background color + check(tester.widgetList(findSectionContent)).isEmpty(); + } + + testWidgets('appearance', (tester) async { + await setupVarious(tester); + + final headerRow = findStreamHeaderRow(tester, 1); + check(headerRow).isNotNull(); + + final findSectionContent = find.text('specific topic'); + + checkAppearsUncollapsed(tester, 1, findSectionContent); + await tapCollapseIcon(tester, 1); + checkAppearsCollapsed(tester, 1, findSectionContent); + await tapCollapseIcon(tester, 1); + checkAppearsUncollapsed(tester, 1, findSectionContent); + }); + + // TODO check it remains collapsed even if you scroll far away and back + + // TODO check that it's always uncollapsed when it appears after being + // absent, even if it was collapsed the last time it was present. + // (Could test multiple triggers for its reappearance: it could + // reappear because a new unread arrived, but with #346 it could also + // reappear because you unmuted a conversation.) + }); + }); + }); +}