diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index 9b808a5a25..0c26afff0e 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -359,6 +359,13 @@ "num": {"type": "int", "example": "4"} } }, + "browseMoreNChannels": "Browse {num, plural, =1{1 more channel} other{{num} more channels}}", + "@browseMoreNChannels": { + "description": "Label showing the number of other channels that user can subscribe to", + "placeholders": { + "num": {"type": "int", "example": "4"} + } + }, "errorInvalidResponse": "The server sent an invalid response", "@errorInvalidResponse": { "description": "Error message when an API call returned an invalid response." @@ -472,6 +479,10 @@ "@combinedFeedPageTitle": { "description": "Title for the page of combined feed." }, + "channelListPageTitle": "All Channels", + "@channelListPageTitle": { + "description": "Title for the page of all channels." + }, "notifGroupDmConversationLabel": "{senderFullName} to you and {numOthers, plural, =1{1 other} other{{numOthers} others}}", "@notifGroupDmConversationLabel": { "description": "Label for a group DM conversation notification.", diff --git a/lib/widgets/channel_list.dart b/lib/widgets/channel_list.dart new file mode 100644 index 0000000000..4d06cab788 --- /dev/null +++ b/lib/widgets/channel_list.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; + +import '../api/model/model.dart'; +import '../model/narrow.dart'; +import 'icons.dart'; +import 'message_list.dart'; +import 'page.dart'; +import 'store.dart'; + +class ChannelListPage extends StatefulWidget { + const ChannelListPage({super.key}); + + static Route buildRoute({int? accountId, BuildContext? context}) { + return MaterialAccountWidgetRoute(accountId: accountId, context: context, + page: const ChannelListPage()); + } + + @override + State createState() => _ChannelListPageState(); +} + +class _ChannelListPageState extends State with PerAccountStoreAwareStateMixin { + @override + void onNewStore() => setState(() { + // Just rebuild whenever an update occur. + }); + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + final streams = store.streams.values.toList(); + return Scaffold( + appBar: AppBar(title: Text(zulipLocalizations.channelListPageTitle)), + body: SafeArea( + child: ListView.builder( + itemCount: streams.length, + itemBuilder: (context, index) => ChannelItem(stream: streams[index])))); + } +} + +@visibleForTesting +class ChannelItem extends StatelessWidget { + const ChannelItem({super.key, required this.stream}); + + final ZulipStream stream; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.white, + child: InkWell( + onTap: () => Navigator.push(context, MessageListPage.buildRoute(context: context, + narrow: ChannelNarrow(stream.streamId))), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + child: Row(children: [ + Icon(size: 16, iconDataForStream(stream)), + const SizedBox(width: 8), + Expanded(child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(stream.name, + style: const TextStyle( + fontSize: 18, + height: (20 / 18), + // TODO(#95) need dark-theme color + color: Color(0xFF262626)), + maxLines: 1, + overflow: TextOverflow.ellipsis), + if (stream.description.isNotEmpty) Text( + stream.description, + style: const TextStyle( + fontSize: 12, + // TODO(#95) need dark-theme color + color: Color(0xCC262626)), + maxLines: 1, + overflow: TextOverflow.ellipsis), + ])), + ])))); + } +} diff --git a/lib/widgets/subscription_list.dart b/lib/widgets/subscription_list.dart index 25c54ad8e8..4fe0ba30e5 100644 --- a/lib/widgets/subscription_list.dart +++ b/lib/widgets/subscription_list.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; import '../api/model/model.dart'; import '../model/narrow.dart'; @@ -7,6 +8,7 @@ import 'icons.dart'; import 'message_list.dart'; import 'page.dart'; import 'store.dart'; +import 'channel_list.dart'; import 'text.dart'; import 'theme.dart'; import 'unread_count_badge.dart'; @@ -106,7 +108,7 @@ class _SubscriptionListPageState extends State with PerAcc _SubscriptionList(unreadsModel: unreadsModel, subscriptions: unpinned), ], - // TODO(#188): add button leading to "All Streams" page with ability to subscribe + const _ChannelListLinkItem(), // This ensures last item in scrollable can settle in an unobstructed area. const SliverSafeArea(sliver: SliverToBoxAdapter(child: SizedBox.shrink())), @@ -194,6 +196,40 @@ class _SubscriptionList extends StatelessWidget { } } +class _ChannelListLinkItem extends StatelessWidget { + const _ChannelListLinkItem(); + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final notShownStreams = store.streams.length - store.subscriptions.length; + final zulipLocalizations = ZulipLocalizations.of(context); + return SliverToBoxAdapter( + child: Material( + // TODO(#95) need dark-theme color + color: Colors.white, + child: InkWell( + onTap: () => Navigator.push(context, + ChannelListPage.buildRoute(context: context)), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + style: const TextStyle( + fontSize: 18, + height: (20 / 18), + // TODO(#95) need dark-theme color + color: Color(0xFF262626), + ).merge(weightVariableTextStyle(context, wght: 600)), + zulipLocalizations.browseMoreNChannels(notShownStreams)), + const Icon(Icons.arrow_forward_ios, size: 18), + ]))))); + } +} + @visibleForTesting class SubscriptionItem extends StatelessWidget { const SubscriptionItem({ diff --git a/test/widgets/channel_list_test.dart b/test/widgets/channel_list_test.dart new file mode 100644 index 0000000000..3a1a84d267 --- /dev/null +++ b/test/widgets/channel_list_test.dart @@ -0,0 +1,42 @@ +import 'package:checks/checks.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/widgets/channel_list.dart'; + +import '../model/binding.dart'; +import '../example_data.dart' as eg; +import 'test_app.dart'; + +void main() { + TestZulipBinding.ensureInitialized(); + + Future setupChannelListPage(WidgetTester tester, { + required List streams, required List subscriptions}) async { + addTearDown(testBinding.reset); + final initialSnapshot = eg.initialSnapshot( + subscriptions: subscriptions, + streams: streams.toList(), + ); + await testBinding.globalStore.add(eg.selfAccount, initialSnapshot); + + await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, child: const ChannelListPage())); + + // global store, per-account store + await tester.pumpAndSettle(); + } + + int getItemCount() { + return find.byType(ChannelItem).evaluate().length; + } + + testWidgets('smoke', (tester) async { + await setupChannelListPage(tester, streams: [], subscriptions: []); + check(getItemCount()).equals(0); + }); + + testWidgets('basic list', (tester) async { + final streams = List.generate(3, (index) => eg.stream()); + await setupChannelListPage(tester, streams: streams, subscriptions: []); + check(getItemCount()).equals(3); + }); +} diff --git a/test/widgets/subscription_list_test.dart b/test/widgets/subscription_list_test.dart index 4966625e1c..c53e39f694 100644 --- a/test/widgets/subscription_list_test.dart +++ b/test/widgets/subscription_list_test.dart @@ -3,6 +3,7 @@ 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/model/localizations.dart'; import 'package:zulip/widgets/icons.dart'; import 'package:zulip/widgets/stream_colors.dart'; import 'package:zulip/widgets/subscription_list.dart'; @@ -20,11 +21,12 @@ void main() { required List subscriptions, List userTopics = const [], UnreadMessagesSnapshot? unreadMsgs, + List? streams, }) async { addTearDown(testBinding.reset); final initialSnapshot = eg.initialSnapshot( subscriptions: subscriptions, - streams: subscriptions, + streams: streams ?? subscriptions, userTopics: userTopics, unreadMsgs: unreadMsgs, ); @@ -56,6 +58,16 @@ void main() { check(isUnpinnedHeaderInTree()).isFalse(); }); + testWidgets('link to other channels is shown', (tester) async { + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + final streams = List.generate(5, (index) => eg.stream()); + await setupStreamListPage(tester, + streams: streams, + subscriptions: [eg.subscription(streams[1])]); + + check(find.text(zulipLocalizations.browseMoreNChannels(4)).evaluate()).isNotEmpty(); + }); + testWidgets('basic subscriptions', (tester) async { await setupStreamListPage(tester, subscriptions: [ eg.subscription(eg.stream(streamId: 1), pinToTop: true),