From 5fdf082a8ddc007e259705da16dd05bcb97294f1 Mon Sep 17 00:00:00 2001 From: Shu Chen Date: Fri, 10 Nov 2023 12:54:33 +0000 Subject: [PATCH] subscription_list: Add new SubscriptionListPage Fixes: #187 --- lib/widgets/app.dart | 6 + lib/widgets/subscription_list.dart | 239 +++++++++++++++++++++++ test/widgets/subscription_list_test.dart | 146 ++++++++++++++ 3 files changed, 391 insertions(+) create mode 100644 lib/widgets/subscription_list.dart create mode 100644 test/widgets/subscription_list_test.dart diff --git a/lib/widgets/app.dart b/lib/widgets/app.dart index 351108b80ba..4067d1bfa65 100644 --- a/lib/widgets/app.dart +++ b/lib/widgets/app.dart @@ -14,6 +14,7 @@ import 'message_list.dart'; import 'page.dart'; import 'recent_dm_conversations.dart'; import 'store.dart'; +import 'subscription_list.dart'; class ZulipApp extends StatelessWidget { const ZulipApp({super.key, this.navigatorObservers}); @@ -260,6 +261,11 @@ class HomePage extends StatelessWidget { InboxPage.buildRoute(context: context)), child: const Text("Inbox")), // TODO(i18n) const SizedBox(height: 16), + ElevatedButton( + onPressed: () => Navigator.push(context, + SubscriptionListPage.buildRoute(context: context)), + child: const Text("Subscribed streams")), + const SizedBox(height: 16), ElevatedButton( onPressed: () => Navigator.push(context, RecentDmConversationsPage.buildRoute(context: context)), diff --git a/lib/widgets/subscription_list.dart b/lib/widgets/subscription_list.dart new file mode 100644 index 00000000000..3431aeb3511 --- /dev/null +++ b/lib/widgets/subscription_list.dart @@ -0,0 +1,239 @@ + +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +import '../api/model/model.dart'; +import '../model/narrow.dart'; +import '../model/unreads.dart'; +import 'icons.dart'; +import 'message_list.dart'; +import 'page.dart'; +import 'store.dart'; +import 'text.dart'; +import 'unread_count_badge.dart'; + +/// Scrollable listing of subscribed streams. +class SubscriptionListPage extends StatefulWidget { + const SubscriptionListPage({super.key}); + + static Route buildRoute({required BuildContext context}) { + return MaterialAccountWidgetRoute(context: context, + page: const SubscriptionListPage()); + } + + @override + State createState() => _SubscriptionListPageState(); +} + +class _SubscriptionListPageState extends State with PerAccountStoreAwareStateMixin { + Map? subscriptions; + Unreads? unreadsModel; + + @override + void onNewStore() { + final store = PerAccountStoreWidget.of(context); + subscriptions = store.subscriptions; + + unreadsModel?.removeListener(_modelChanged); + unreadsModel = store.unreads + ..addListener(_modelChanged); + } + + @override + void dispose() { + unreadsModel?.removeListener(_modelChanged); + super.dispose(); + } + + void _modelChanged() { + setState(() { + // The actual state lives in [subscriptions] and [unreadsModel]. + // This method was called because one of those just changed. + }); + } + + @override + Widget build(BuildContext context) { + // Design referenced from: + // https://www.figma.com/file/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?type=design&node-id=171-12359&mode=design&t=4d0vykoYQ0KGpFuu-0 + + // This is an initial version with "Pinned" and "Unpinned" + // sections following behavior in mobile. Recalculating + // groups and sorting on every `build` here: it performs well + // enough and not worth optimizing as it will be replaced + // with a different behavior: + // TODO: Implement new grouping behavior and design, see discussion at: + // https://chat.zulip.org/#narrow/stream/101-design/topic/UI.20redesign.3A.20left.20sidebar/near/1540147 + + // TODO: Implement collapsible topics + + // TODO(i18n): localize strings on page + // Strings here left unlocalized as they likely will not + // exist in the settled design. + final List pinned = []; + final List unpinned = []; + for (final subscription in subscriptions!.values) { + if (subscription.pinToTop) { + pinned.add(subscription); + } else { + unpinned.add(subscription); + } + } + // TODO(i18n): add locale-aware sorting + pinned.sortBy((subscription) => subscription.name); + unpinned.sortBy((subscription) => subscription.name); + + return Scaffold( + appBar: AppBar(title: const Text("Streams")), + body: Builder( + builder: (BuildContext context) => Center( + child: CustomScrollView( + slivers: [ + if (pinned.isEmpty && unpinned.isEmpty) + const _NoSubscriptionsItem(), + if (pinned.isNotEmpty) ...[ + _SubscriptionListHeader(context: context, label: "Pinned"), + _SubscriptionList(unreadsModel: unreadsModel, subscriptions: pinned), + ], + if (unpinned.isNotEmpty) ...[ + _SubscriptionListHeader(context: context, label: "Unpinned"), + _SubscriptionList(unreadsModel: unreadsModel, subscriptions: unpinned), + ], + + // TODO(#188): add button to "All Streams" page with ability to subscribe + + // This ensures last item in scrollable can settle in an unobstructed area. + const SliverSafeArea(sliver: SliverToBoxAdapter(child: SizedBox.shrink())), + ])))); + } +} + +class _NoSubscriptionsItem extends StatelessWidget { + const _NoSubscriptionsItem(); + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(10), + child: Text("No streams found", + textAlign: TextAlign.center, + style: TextStyle( + color: const HSLColor.fromAHSL(1.0, 240, 0.1, 0.5).toColor(), + fontFamily: 'Source Sans 3', + fontSize: 18, + height: (20 / 18), + ).merge(weightVariableTextStyle(context))))); + } +} + +class _SubscriptionListHeader extends StatelessWidget { + const _SubscriptionListHeader({ + required this.context, + required this.label, + }); + + final BuildContext context; + final String label; + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: ColoredBox( + color: Colors.white, + child: SizedBox( + height: 30, + child: Row( + children: [ + const SizedBox(width: 16), + Expanded(child: Divider( + color: const HSLColor.fromAHSL(0.2, 240, 0.1, 0.5).toColor())), + const SizedBox(width: 8), + Text(label, + textAlign: TextAlign.center, + style: TextStyle( + color: const HSLColor.fromAHSL(1.0, 240, 0.1, 0.5).toColor(), + fontFamily: 'Source Sans 3', + fontSize: 14, + letterSpacing: 0.56, + height: (16 / 14), + ).merge(weightVariableTextStyle(context))), + const SizedBox(width: 8), + Expanded(child: Divider( + color: const HSLColor.fromAHSL(0.2, 240, 0.1, 0.5).toColor())), + const SizedBox(width: 16), + ])))); + } +} + +class _SubscriptionList extends StatelessWidget { + const _SubscriptionList({ + required this.unreadsModel, + required this.subscriptions, + }); + + final Unreads? unreadsModel; + final List subscriptions; + + @override + Widget build(BuildContext context) { + return SliverList.builder( + itemCount: subscriptions.length, + itemBuilder: (BuildContext context, int index) { + final subscription = subscriptions[index]; + final unreadCount = unreadsModel!.countInStreamNarrow(subscription.streamId); + return SubscriptionItem(subscription: subscription, unreadCount: unreadCount); + }); + } +} + +@visibleForTesting +class SubscriptionItem extends StatelessWidget { + const SubscriptionItem({ + super.key, + required this.subscription, + required this.unreadCount, + }); + + final Subscription subscription; + final int unreadCount; + + @override + Widget build(BuildContext context) { + final swatch = subscription.colorSwatch(); + final hasUnreads = (unreadCount > 0); + return Material( + color: Colors.white, + child: InkWell( + onTap: () { + Navigator.push(context, + MessageListPage.buildRoute(context: context, narrow: StreamNarrow(subscription.streamId))); + }, + child: SizedBox(height: 40, + child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ + const SizedBox(width: 16), + Icon(size: 18, color: swatch.iconOnPlainBackground, + iconDataForStream(subscription)), + const SizedBox(width: 5), + Expanded( + child: Text( + style: const TextStyle( + fontFamily: 'Source Sans 3', + fontSize: 18, + height: (20 / 18), + color: Color(0xFF262626), + ).merge(hasUnreads + ? weightVariableTextStyle(context, wght: 600, wghtIfPlatformRequestsBold: 900) + : weightVariableTextStyle(context)), + maxLines: 1, + overflow: TextOverflow.ellipsis, + subscription.name)), + if (unreadCount > 0) ...[ + const SizedBox(width: 12), + // TODO(#384) show @-mention indicator when it applies + UnreadCountBadge(count: unreadCount, backgroundColor: swatch, bold: true), + ], + const SizedBox(width: 16), + ])))); + } +} diff --git a/test/widgets/subscription_list_test.dart b/test/widgets/subscription_list_test.dart new file mode 100644 index 00000000000..1a23677047f --- /dev/null +++ b/test/widgets/subscription_list_test.dart @@ -0,0 +1,146 @@ +import 'package:checks/checks.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/model/initial_snapshot.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/widgets/store.dart'; +import 'package:zulip/widgets/subscription_list.dart'; +import 'package:zulip/widgets/unread_count_badge.dart'; + +import '../model/binding.dart'; +import '../example_data.dart' as eg; + +void main() { + TestZulipBinding.ensureInitialized(); + + Future setupStreamListPage(WidgetTester tester, { + required List subscriptions, + UnreadMessagesSnapshot? unreadMsgs, + }) async { + addTearDown(testBinding.reset); + // create simple versions from subscriptions + final List streams = subscriptions.map( + (e) => eg.stream(streamId: e.streamId, name: e.name)).toList(); + final initialSnapshot = eg.initialSnapshot( + subscriptions: subscriptions, + streams: streams, + unreadMsgs: unreadMsgs, + ); + await testBinding.globalStore.add(eg.selfAccount, initialSnapshot); + + await tester.pumpWidget( + MaterialApp( + home: GlobalStoreWidget( + child: PerAccountStoreWidget( + accountId: eg.selfAccount.id, + child: const SubscriptionListPage())))); + + // global store, per-account store + await tester.pumpAndSettle(); + } + + bool isPinnedHeaderInTree() { + return find.text('Pinned').evaluate().isNotEmpty; + } + + bool isUnpinnedHeaderInTree() { + return find.text('Unpinned').evaluate().isNotEmpty; + } + + int getItemCount() { + return find.byType(SubscriptionItem).evaluate().length; + } + + testWidgets('smoke', (tester) async { + await setupStreamListPage(tester, subscriptions: []); + check(getItemCount()).equals(0); + check(isPinnedHeaderInTree()).isFalse(); + check(isUnpinnedHeaderInTree()).isFalse(); + }); + + testWidgets('basic subscriptions', (tester) async { + await setupStreamListPage(tester, subscriptions: [ + eg.subscription(eg.stream(streamId: 1), pinToTop: true), + eg.subscription(eg.stream(streamId: 2), pinToTop: true), + eg.subscription(eg.stream(streamId: 3), pinToTop: false), + ]); + check(getItemCount()).equals(3); + check(isPinnedHeaderInTree()).isTrue(); + check(isUnpinnedHeaderInTree()).isTrue(); + }); + + testWidgets('only pinned subscriptions', (tester) async { + await setupStreamListPage(tester, subscriptions: [ + eg.subscription(eg.stream(streamId: 1), pinToTop: true), + eg.subscription(eg.stream(streamId: 2), pinToTop: true), + ]); + check(getItemCount()).equals(2); + check(isPinnedHeaderInTree()).isTrue(); + check(isUnpinnedHeaderInTree()).isFalse(); + }); + + testWidgets('only unpinned subscriptions', (tester) async { + await setupStreamListPage(tester, subscriptions: [ + eg.subscription(eg.stream(streamId: 1), pinToTop: false), + eg.subscription(eg.stream(streamId: 2), pinToTop: false), + ]); + check(getItemCount()).equals(2); + check(isPinnedHeaderInTree()).isFalse(); + check(isUnpinnedHeaderInTree()).isTrue(); + }); + + testWidgets('subscription sort', (tester) async { + await setupStreamListPage(tester, subscriptions: [ + eg.subscription(eg.stream(streamId: 1, name: 'd'), pinToTop: true), + eg.subscription(eg.stream(streamId: 2, name: 'c'), pinToTop: false), + eg.subscription(eg.stream(streamId: 3, name: 'b'), pinToTop: true), + eg.subscription(eg.stream(streamId: 4, name: 'a'), pinToTop: false), + ]); + check(isPinnedHeaderInTree()).isTrue(); + check(isUnpinnedHeaderInTree()).isTrue(); + + final streamListItems = tester.widgetList(find.byType(SubscriptionItem)).toList(); + check(streamListItems.length).equals(4); + check(streamListItems[0].subscription.streamId).equals(3); + check(streamListItems[1].subscription.streamId).equals(1); + check(streamListItems[2].subscription.streamId).equals(4); + check(streamListItems[3].subscription.streamId).equals(2); + }); + + testWidgets('unread badge shows with unreads', (tester) async { + final stream = eg.stream(); + final unreadMsgs = eg.unreadMsgs(streams: [ + UnreadStreamSnapshot(streamId: stream.streamId, topic: 'a', unreadMessageIds: [1, 2]), + ]); + await setupStreamListPage(tester, subscriptions: [ + eg.subscription(stream), + ], unreadMsgs: unreadMsgs); + check(find.byType(UnreadCountBadge).evaluate()).length.equals(1); + }); + + testWidgets('unread badge does not show with no unreads', (tester) async { + final stream = eg.stream(); + final unreadMsgs = eg.unreadMsgs(streams: []); + await setupStreamListPage(tester, subscriptions: [ + eg.subscription(stream), + ], unreadMsgs: unreadMsgs); + check(find.byType(UnreadCountBadge).evaluate()).length.equals(0); + }); + + testWidgets('color propagates to icon and badge', (tester) async { + final stream = eg.stream(); + final unreadMsgs = eg.unreadMsgs(streams: [ + UnreadStreamSnapshot(streamId: stream.streamId, topic: 'a', unreadMessageIds: [1, 2]), + ]); + final subscription = eg.subscription(stream, color: Colors.red.value); + final swatch = subscription.colorSwatch(); + await setupStreamListPage(tester, subscriptions: [ + subscription, + ], unreadMsgs: unreadMsgs); + check(getItemCount()).equals(1); + check(tester.widget(find.byType(Icon)).color) + .equals(swatch.iconOnPlainBackground); + check(tester.widget(find.byType(UnreadCountBadge)).backgroundColor) + .equals(swatch); + }); +}