diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb index ce6d8e7e68..f61b0457e8 100644 --- a/assets/l10n/app_en.arb +++ b/assets/l10n/app_en.arb @@ -396,6 +396,24 @@ "@topicValidationErrorMandatoryButEmpty": { "description": "Topic validation error when topic is required but was empty." }, + "subscribedToNChannels": "Subscribed to {num, plural, =0{no channels} =1{1 channel} other{{num} channels}}", + "@subscribedToNChannels": { + "description": "Test page label showing number of channels user is subscribed to.", + "placeholders": { + "num": {"type": "int", "example": "4"} + } + }, + "browseNMoreChannels": "Browse {num, plural, =1{1 more channel} other{{num} more channels}}", + "@browseNMoreChannels": { + "description": "Label showing the number of other channels that user can subscribe to", + "placeholders": { + "num": {"type": "int", "example": "4"} + } + }, + "browseAllChannels": "Browse all channels", + "@browseAllChannels": { + "description": "Label for the option to show all channels, this is only shown if user is already subscribed to all visible channels" + }, "errorInvalidResponse": "The server sent an invalid response", "@errorInvalidResponse": { "description": "Error message when an API call returned an invalid response." @@ -532,6 +550,14 @@ "@starredMessagesPageTitle": { "description": "Title for the page of starred messages." }, + "channelListPageTitle": "All channels", + "@channelListPageTitle": { + "description": "Title for the page of all channels." + }, + "noChannelsFound": "There are no channels you can view in this organization.", + "@noChannelsFound": { + "description": "Message when no channels are found" + }, "notifGroupDmConversationLabel": "{senderFullName} to you and {numOthers, plural, =1{1 other} other{{numOthers} others}}", "@notifGroupDmConversationLabel": { "description": "Label for a group DM conversation notification.", @@ -586,5 +612,25 @@ "errorNotificationOpenAccountMissing": "The account associated with this notification no longer exists.", "@errorNotificationOpenAccountMissing": { "description": "Error message when the account associated with the notification is not found" + }, + "messageSubscribedToChannel": "You've just subscribed to ", + "@messageSubscribedToChannel": { + "description": "A message shown to inform user that subscription is successful" + }, + "messageAlreadySubscribedToChannel": "You're already subscribed to ", + "@messageAlreadySubscribedToChannel": { + "description": "A message shown to inform user that subscription is already made" + }, + "errorFailedToSubscribeToChannel": "Failed to subscribe to ", + "@errorFailedToSubscribeToChannel": { + "description": "An error message when subscribe action fails" + }, + "messageUnsubscribedFromChannel": "You've unsubscribed from ", + "@messageUnsubscribedFromChannel": { + "description": "A message shown to inform user that unsubscribe action passes" + }, + "errorFailedToUnsubscribeFromChannel": "Failed to unsubscribe to ", + "@errorFailedToUnsubscribeFromChannel": { + "description": "An error message when unsubscribe action fails" } } diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index bd66976b86..ad63207b4d 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -495,6 +495,24 @@ class Subscription extends ZulipStream { @override Map toJson() => _$SubscriptionToJson(this); + + /// Returns a plain `ZulipStream` with same values as `this` + /// + /// This is helpful when unsubscribing from a stream. + ZulipStream toStream() => ZulipStream( + streamId: streamId, + name: name, + description: description, + renderedDescription: renderedDescription, + dateCreated: dateCreated, + firstMessageId: firstMessageId, + inviteOnly: inviteOnly, + isWebPublic: isWebPublic, + historyPublicToSubscribers: historyPublicToSubscribers, + messageRetentionDays: messageRetentionDays, + channelPostPolicy: channelPostPolicy, + canRemoveSubscribersGroup: canRemoveSubscribersGroup, + streamWeeklyTraffic: streamWeeklyTraffic); } @JsonEnum(fieldRename: FieldRename.snake, valueField: "apiValue") diff --git a/lib/api/route/channels.dart b/lib/api/route/channels.dart index d03ce501cf..7c52d9d1f5 100644 --- a/lib/api/route/channels.dart +++ b/lib/api/route/channels.dart @@ -1,6 +1,7 @@ import 'package:json_annotation/json_annotation.dart'; import '../core.dart'; +import '../model/model.dart'; part 'channels.g.dart'; /// https://zulip.com/api/get-stream-topics @@ -38,3 +39,49 @@ class GetStreamTopicsEntry { Map toJson() => _$GetStreamTopicsEntryToJson(this); } + +/// https://zulip.com/api/subscribe +Future subscribeToChannels(ApiConnection connection, List streams) { + return connection.post('subscribe', SubscribeToChannelsResult.fromJson, + 'users/me/subscriptions', {'subscriptions': streams.map((e) => {'name': e.name}).toList()}); +} + +@JsonSerializable(fieldRename: FieldRename.snake) +class SubscribeToChannelsResult { + final Map> subscribed; + final Map> alreadySubscribed; + final List? unauthorized; + + SubscribeToChannelsResult({ + required this.subscribed, + required this.alreadySubscribed, + this.unauthorized, + }); + + factory SubscribeToChannelsResult.fromJson(Map json) => + _$SubscribeToChannelsResultFromJson(json); + + Map toJson() => _$SubscribeToChannelsResultToJson(this); +} + +/// https://zulip.com/api/usubscribe +Future unsubscribeFromChannels(ApiConnection connection, List streams) { + return connection.delete('unsubscribe', UnsubscribeFromChannelsResult.fromJson, + 'users/me/subscriptions', {'subscriptions': streams.map((e) => e.name).toList()}); +} + +@JsonSerializable(fieldRename: FieldRename.snake) +class UnsubscribeFromChannelsResult { + final List removed; + final List notRemoved; + + UnsubscribeFromChannelsResult({ + required this.removed, + required this.notRemoved, + }); + + factory UnsubscribeFromChannelsResult.fromJson(Map json) => + _$UnsubscribeFromChannelsResultFromJson(json); + + Map toJson() => _$UnsubscribeFromChannelsResultToJson(this); +} diff --git a/lib/api/route/channels.g.dart b/lib/api/route/channels.g.dart index 561b43f005..b8a66e6e1f 100644 --- a/lib/api/route/channels.g.dart +++ b/lib/api/route/channels.g.dart @@ -35,3 +35,45 @@ Map _$GetStreamTopicsEntryToJson( 'max_id': instance.maxId, 'name': instance.name, }; + +SubscribeToChannelsResult _$SubscribeToChannelsResultFromJson( + Map json) => + SubscribeToChannelsResult( + subscribed: (json['subscribed'] as Map).map( + (k, e) => + MapEntry(k, (e as List).map((e) => e as String).toList()), + ), + alreadySubscribed: + (json['already_subscribed'] as Map).map( + (k, e) => + MapEntry(k, (e as List).map((e) => e as String).toList()), + ), + unauthorized: (json['unauthorized'] as List?) + ?.map((e) => e as String) + .toList(), + ); + +Map _$SubscribeToChannelsResultToJson( + SubscribeToChannelsResult instance) => + { + 'subscribed': instance.subscribed, + 'already_subscribed': instance.alreadySubscribed, + 'unauthorized': instance.unauthorized, + }; + +UnsubscribeFromChannelsResult _$UnsubscribeFromChannelsResultFromJson( + Map json) => + UnsubscribeFromChannelsResult( + removed: + (json['removed'] as List).map((e) => e as String).toList(), + notRemoved: (json['not_removed'] as List) + .map((e) => e as String) + .toList(), + ); + +Map _$UnsubscribeFromChannelsResultToJson( + UnsubscribeFromChannelsResult instance) => + { + 'removed': instance.removed, + 'not_removed': instance.notRemoved, + }; diff --git a/lib/model/channel.dart b/lib/model/channel.dart index 1b9bda66e0..2d18092e62 100644 --- a/lib/model/channel.dart +++ b/lib/model/channel.dart @@ -304,6 +304,15 @@ class ChannelStoreImpl with ChannelStore { case SubscriptionRemoveEvent(): for (final streamId in event.streamIds) { + final subscription = streams[streamId]; + if (subscription == null || subscription is! Subscription) { // TODO(log) + continue; + } + assert(streamsByName[subscription.name] is Subscription); + assert(subscriptions.containsKey(streamId)); + final unsubscribedStream = subscription.toStream(); + streams[streamId] = unsubscribedStream; + streamsByName[subscription.name] = unsubscribedStream; subscriptions.remove(streamId); } diff --git a/lib/widgets/channel_list.dart b/lib/widgets/channel_list.dart new file mode 100644 index 0000000000..fe052a80be --- /dev/null +++ b/lib/widgets/channel_list.dart @@ -0,0 +1,208 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/zulip_localizations.dart'; + +import '../api/model/model.dart'; +import '../api/route/channels.dart'; +import '../model/narrow.dart'; +import 'app_bar.dart'; +import '../model/store.dart'; +import 'icons.dart'; +import 'message_list.dart'; +import 'page.dart'; +import 'store.dart'; +import 'text.dart'; +import 'theme.dart'; + +class ChannelListPage extends StatelessWidget { + const ChannelListPage({super.key}); + + static Route buildRoute({int? accountId, BuildContext? context}) { + return MaterialAccountWidgetRoute(accountId: accountId, context: context, + page: const ChannelListPage()); + } + + @override + Widget build(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final zulipLocalizations = ZulipLocalizations.of(context); + final streams = store.streams.values.toList()..sort((a, b) { + return a.name.toLowerCase().compareTo(b.name.toLowerCase()); + }); + return Scaffold( + appBar: ZulipAppBar(title: Text(zulipLocalizations.channelListPageTitle)), + body: SafeArea( + // Don't pad the bottom here; we want the list content to do that. + bottom: false, + child: streams.isEmpty ? const _NoChannelsItem() : ListView.builder( + itemCount: streams.length, + itemBuilder: (context, index) => ChannelItem(stream: streams[index])))); + } +} + +class _NoChannelsItem extends StatelessWidget { + const _NoChannelsItem(); + + @override + Widget build(BuildContext context) { + final zulipLocalizations = ZulipLocalizations.of(context); + final designVariables = DesignVariables.of(context); + + return Center( + child: Padding( + padding: const EdgeInsets.all(10), + child: Text(zulipLocalizations.noChannelsFound, + textAlign: TextAlign.center, + style: TextStyle( + // TODO(design) check if this is the right variable + color: designVariables.subscriptionListHeaderText, + fontSize: 18, + height: (20 / 18), + )))); + } +} + +@visibleForTesting +class ChannelItem extends StatelessWidget { + const ChannelItem({super.key, required this.stream}); + + final ZulipStream stream; + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + return Material( + // TODO(design) check if this is the right variable + color: designVariables.background, + child: InkWell( + onTap: () => Navigator.push(context, MessageListPage.buildRoute(context: context, + narrow: ChannelNarrow(stream.streamId))), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4, 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: TextStyle( + fontSize: 18, + height: (20 / 18), + // TODO(design) check if this is the right variable + color: designVariables.labelMenuButton), + maxLines: 1, + overflow: TextOverflow.ellipsis), + // TODO(#488) parse and show `stream.renderedDescription` with content widget + if (stream.description.isNotEmpty) Text( + stream.description, + style: TextStyle( + fontSize: 12, + // TODO(design) check if this is the right variable + color: designVariables.labelMenuButton.withValues(alpha: 0xBF)), + maxLines: 1, + overflow: TextOverflow.ellipsis), + ])), + const SizedBox(width: 8), + _ChannelItemSubscriptionToggle(stream: stream, channelItemContext: context), + ])))); + } +} + +class _ChannelItemSubscriptionToggle extends StatefulWidget { + const _ChannelItemSubscriptionToggle({required this.stream, required this.channelItemContext}); + + final ZulipStream stream; + final BuildContext channelItemContext; + + @override + State<_ChannelItemSubscriptionToggle> createState() => _ChannelItemSubscriptionToggleState(); +} + +class _ChannelItemSubscriptionToggleState extends State<_ChannelItemSubscriptionToggle> { + bool _isLoading = false; + + void _setIsLoading(bool value) { + if (!mounted) return; + setState(() => _isLoading = value); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final (icon, color, onPressed) = widget.stream is Subscription + ? (Icons.check, colorScheme.primary, _unsubscribeFromChannel) + : (Icons.add, null, _subscribeToChannel); + + return IconButton( + color: color, + icon: Icon(icon), + onPressed: _isLoading ? null : () async { + _setIsLoading(true); + await onPressed(context, widget.stream); + _setIsLoading(false); + }); + } + + Future _unsubscribeFromChannel(BuildContext context, ZulipStream stream) async { + final store = PerAccountStoreWidget.of(context); + final connection = store.connection; + final zulipLocalizations = ZulipLocalizations.of(context); + try { + final res = await unsubscribeFromChannels(connection, [stream]); + if (!context.mounted) return; + if (res.removed.contains(stream.name)) { + _showSnackbarWithChannelTitle(context, zulipLocalizations.messageUnsubscribedFromChannel, stream); + } else if (res.notRemoved.contains(stream.name)) { + _showSnackbarWithChannelTitle(context, zulipLocalizations.errorFailedToUnsubscribeFromChannel, stream); + } + } catch (e) { + if (!context.mounted) return; + _showSnackbarWithChannelTitle(context, zulipLocalizations.errorFailedToUnsubscribeFromChannel, stream); + } + } + + Future _subscribeToChannel(BuildContext context, ZulipStream stream) async { + final store = PerAccountStoreWidget.of(context); + final connection = store.connection; + final zulipLocalizations = ZulipLocalizations.of(context); + try { + final res = await subscribeToChannels(connection, [stream]); + if (!context.mounted) return; + if (_subscriptionResultContains(store, res.subscribed, stream.name)) { + _showSnackbarWithChannelTitle(context, zulipLocalizations.messageSubscribedToChannel, stream); + } else if (_subscriptionResultContains(store, res.alreadySubscribed, stream.name)) { + _showSnackbarWithChannelTitle(context, zulipLocalizations.messageAlreadySubscribedToChannel, stream); + } else { + _showSnackbarWithChannelTitle(context, zulipLocalizations.errorFailedToSubscribeToChannel, stream); + } + } catch (e) { + if (!context.mounted) return; + final zulipLocalizations = ZulipLocalizations.of(context); + _showSnackbarWithChannelTitle(context, zulipLocalizations.errorFailedToSubscribeToChannel, stream); + } + } + + bool _subscriptionResultContains(PerAccountStore store, Map> emailOrIdSubs, String subscription) { + // TODO (server-10) Before the user keys were Zulip API email addresses, not user ID. + final expectedEmail = store.users[store.selfUserId]?.email; + final foundByEmail = emailOrIdSubs[expectedEmail]?.contains(subscription); + final foundById = emailOrIdSubs[store.selfUserId.toString()]?.contains(subscription); + return foundByEmail ?? foundById ?? false; + } + + void _showSnackbarWithChannelTitle(BuildContext context, String message, ZulipStream stream) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + behavior: SnackBarBehavior.floating, + content: Text.rich(TextSpan(text: message, children: [ + WidgetSpan(child: Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Icon( + size: 12, + iconDataForStream(stream), + color: Theme.of(context).scaffoldBackgroundColor))), + TextSpan(text: ' ${stream.name}.', style: weightVariableTextStyle(context, wght: 700)), + ])))); + } +} diff --git a/lib/widgets/subscription_list.dart b/lib/widgets/subscription_list.dart index 86a9caf4e4..61646d9125 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'; @@ -8,6 +9,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'; @@ -107,7 +109,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 + if (store.streams.isNotEmpty) const _ChannelListLinkItem(), // This ensures last item in scrollable can settle in an unobstructed area. const SliverSafeArea(sliver: SliverToBoxAdapter(child: SizedBox.shrink())), @@ -199,6 +201,47 @@ class _SubscriptionList extends StatelessWidget { } } +class _ChannelListLinkItem extends StatelessWidget { + const _ChannelListLinkItem(); + + @override + Widget build(BuildContext context) { + final designVariables = DesignVariables.of(context); + final store = PerAccountStoreWidget.of(context); + final notShownStreams = store.streams.length - store.subscriptions.length; + final zulipLocalizations = ZulipLocalizations.of(context); + final label = notShownStreams != 0 + ? zulipLocalizations.browseNMoreChannels(notShownStreams) + : zulipLocalizations.browseAllChannels; + return SliverToBoxAdapter( + child: Material( + color: designVariables.background, + 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: TextStyle( + fontSize: 18, + height: (20 / 18), + // TODO(design) check if this is the right variable + color: designVariables.labelMenuButton, + ).merge(weightVariableTextStyle(context, wght: 600)), + label), + Icon( + Icons.adaptive.arrow_forward, + size: 18, + // TODO(design) check if this is the right variable + color: designVariables.labelMenuButton), + ]))))); + } +} + @visibleForTesting class SubscriptionItem extends StatelessWidget { const SubscriptionItem({ diff --git a/test/model/channel_test.dart b/test/model/channel_test.dart index 14a9f69ea3..a3943f328a 100644 --- a/test/model/channel_test.dart +++ b/test/model/channel_test.dart @@ -114,6 +114,17 @@ void main() { value: false)); check(store.subscriptions[stream.streamId]!.isMuted).isTrue(); }); + + test('SubscriptionRemoveEvent updates streams and streamsByName', () async { + final store = eg.store(initialSnapshot: eg.initialSnapshot( + streams: [eg.subscription(stream)], + subscriptions: [eg.subscription(stream)], + )); + + check(store.streams[stream.streamId] is Subscription).isTrue(); + await store.handleEvent(SubscriptionRemoveEvent(id: 1, streamIds: [stream.streamId])); + check(store.streams[stream.streamId] is Subscription).isFalse(); + }); }); group('topic visibility', () { diff --git a/test/widgets/channel_list_test.dart b/test/widgets/channel_list_test.dart new file mode 100644 index 0000000000..cd7e052587 --- /dev/null +++ b/test/widgets/channel_list_test.dart @@ -0,0 +1,313 @@ +import 'dart:convert'; + +import 'package:checks/checks.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:zulip/api/model/events.dart'; +import 'package:zulip/api/model/model.dart'; +import 'package:zulip/api/route/channels.dart'; +import 'package:zulip/model/localizations.dart'; +import 'package:zulip/model/store.dart'; +import 'package:zulip/widgets/channel_list.dart'; +import 'package:zulip/widgets/icons.dart'; + +import '../api/fake_api.dart'; +import '../model/binding.dart'; +import '../example_data.dart' as eg; +import '../stdlib_checks.dart'; +import 'test_app.dart'; + +void main() { + TestZulipBinding.ensureInitialized(); + late FakeApiConnection connection; + late PerAccountStore store; + + Future setupChannelListPage(WidgetTester tester, { + required List streams, + required List subscriptions + }) async { + addTearDown(testBinding.reset); + final initialSnapshot = eg.initialSnapshot( + subscriptions: subscriptions, + streams: streams, + realmUsers: [eg.selfUser]); + await testBinding.globalStore.add(eg.selfAccount, initialSnapshot); + store = await testBinding.globalStore.perAccount(eg.selfAccount.id); + connection = store.connection as FakeApiConnection; + + await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id, child: const ChannelListPage())); + + // global store, per-account store + await tester.pumpAndSettle(); + } + + void checkItemCount(int expectedCount) { + check(find.byType(ChannelItem).evaluate()).length.equals(expectedCount); + } + + testWidgets('smoke', (tester) async { + await setupChannelListPage(tester, streams: [], subscriptions: []); + checkItemCount(0); + check(find.text('There are no channels you can view in this organization.').evaluate()).single; + }); + + testWidgets('basic list', (tester) async { + final streams = List.generate(3, (index) => eg.stream()); + await setupChannelListPage(tester, streams: streams, subscriptions: []); + checkItemCount(3); + }); + + group('list ordering', () { + Iterable listedStreamNames(WidgetTester tester) => tester + .widgetList(find.byType(ChannelItem)) + .map((e) => e.stream.name); + + List streamsFromNames(List names) { + return names.map((name) => eg.stream(name: name)).toList(); + } + + testWidgets('is alphabetically case-insensitive', (tester) async { + final streams = streamsFromNames(['b', 'C', 'A']); + await setupChannelListPage(tester, streams: streams, subscriptions: []); + + check(listedStreamNames(tester)).deepEquals(['A', 'b', 'C']); + }); + + testWidgets('is insensitive of user subscription', (tester) async { + final streams = streamsFromNames(['b', 'c', 'a']); + await setupChannelListPage(tester, streams: streams, + subscriptions: [eg.subscription(streams[0])]); + + check(listedStreamNames(tester)).deepEquals(['a', 'b', 'c']); + }); + }); + + group('subscription toggle', () { + final zulipLocalizations = GlobalLocalizations.zulipLocalizations; + + Future prepareSingleStream(WidgetTester tester) async { + final stream = eg.stream(); + await setupChannelListPage(tester, streams: [stream], subscriptions: []); + return stream; + } + + Future prepareSingleSubscription(WidgetTester tester) async { + final stream = eg.subscription(eg.stream()); + await setupChannelListPage(tester, streams: [stream], subscriptions: [stream]); + return stream; + } + + Future tapSubscribeButton(WidgetTester tester) async { + await tester.tap(find.byIcon(Icons.add)); + } + + Future tapUnsubscribeButton(WidgetTester tester) async { + await tester.tap(find.byIcon(Icons.check)); + } + + Future waitAndCheckSnackbarIsShown(WidgetTester tester, String message, String channelName) async { + await tester.pump(Duration.zero); + await tester.pumpAndSettle(); + final richTextFinder = find.byWidgetPredicate( + (widget) => widget is RichText && widget.text.toPlainText().contains(message)); + check(richTextFinder.evaluate()).single; + final richTextWidget = tester.widget(richTextFinder); + check(richTextWidget.text.toPlainText()).contains(message); + check(richTextWidget.text.toPlainText()).contains(channelName); + check(find.descendant( + of: richTextFinder, + matching: find.byIcon(ZulipIcons.hash_sign), + ).evaluate()).single; + } + + testWidgets('is affected by subscription events', (WidgetTester tester) async { + final stream = await prepareSingleStream(tester); + connection.prepare(json: SubscribeToChannelsResult( + subscribed: {eg.selfUser.email: [stream.name]}, + alreadySubscribed: {}).toJson()); + + check(find.byIcon(Icons.add).evaluate()).isNotEmpty(); + check(find.byIcon(Icons.check).evaluate()).isEmpty(); + + await store.handleEvent(SubscriptionAddEvent(id: 1, + subscriptions: [eg.subscription(stream)])); + await tester.pumpAndSettle(); + + check(find.byIcon(Icons.add).evaluate()).isEmpty(); + check(find.byIcon(Icons.check).evaluate()).isNotEmpty(); + + await store.handleEvent(SubscriptionRemoveEvent(id: 2, streamIds: [stream.streamId])); + await tester.pumpAndSettle(); + + check(find.byIcon(Icons.add).evaluate()).isNotEmpty(); + check(find.byIcon(Icons.check).evaluate()).isEmpty(); + }); + + testWidgets('is disabled while loading', (WidgetTester tester) async { + final stream = eg.stream(); + await setupChannelListPage(tester, streams: [stream], subscriptions: []); + connection.prepare(json: SubscribeToChannelsResult( + subscribed: {eg.selfUser.email: [stream.name]}, + alreadySubscribed: {}).toJson()); + await tapSubscribeButton(tester); + await tester.pump(); + + check(tester.widget( + find.byType(IconButton)).onPressed).isNull(); + + await tester.pump(const Duration(seconds: 2)); + + check(tester.widget( + find.byType(IconButton)).onPressed).isNotNull(); + }); + + testWidgets('is disabled while loading and enabled back when loading fails', (WidgetTester tester) async { + final stream = eg.stream(); + await setupChannelListPage(tester, streams: [stream], subscriptions: []); + connection.prepare(exception: http.ClientException('Oops'), delay: const Duration(seconds: 2)); + await tapSubscribeButton(tester); + await tester.pump(); + + check(tester.widget( + find.byType(IconButton)).onPressed).isNull(); + + await tester.pump(const Duration(seconds: 2)); + + check(tester.widget( + find.byType(IconButton)).onPressed).isNotNull(); + }); + + group('subscribe', () { + testWidgets('is shown only for streams that user is not subscribed to', (tester) async { + final streams = [eg.stream(), eg.stream(), eg.subscription(eg.stream())]; + final subscriptions = [streams[2] as Subscription]; + await setupChannelListPage(tester, streams: streams, subscriptions: subscriptions); + + check(find.byIcon(Icons.add).evaluate().length).equals(2); + }); + + testWidgets('smoke api', (tester) async { + final stream = await prepareSingleStream(tester); + connection.prepare(json: SubscribeToChannelsResult( + subscribed: {eg.selfUser.email: [stream.name]}, + alreadySubscribed: {}).toJson()); + await tapSubscribeButton(tester); + + await tester.pump(Duration.zero); + await tester.pumpAndSettle(); + check(connection.lastRequest).isA() + ..method.equals('POST') + ..url.path.equals('/api/v1/users/me/subscriptions') + ..bodyFields.deepEquals({ + 'subscriptions': jsonEncode([{'name': stream.name}]) + }); + }); + + testWidgets('shows a snackbar when subscription passes', (WidgetTester tester) async { + final stream = await prepareSingleStream(tester); + connection.prepare(json: SubscribeToChannelsResult( + subscribed: {eg.selfUser.email: [stream.name]}, + alreadySubscribed: {}).toJson()); + await tapSubscribeButton(tester); + + await waitAndCheckSnackbarIsShown(tester, + zulipLocalizations.messageSubscribedToChannel, stream.name); + }); + + testWidgets('shows a snackbar when already subscribed', (WidgetTester tester) async { + final stream = await prepareSingleStream(tester); + connection.prepare(json: SubscribeToChannelsResult( + subscribed: {}, + alreadySubscribed: {eg.selfUser.email: [stream.name]}).toJson()); + await tapSubscribeButton(tester); + + await waitAndCheckSnackbarIsShown(tester, + zulipLocalizations.messageAlreadySubscribedToChannel, stream.name); + }); + + testWidgets('shows a snackbar when subscription fails', (WidgetTester tester) async { + final stream = await prepareSingleStream(tester); + connection.prepare(json: SubscribeToChannelsResult( + subscribed: {}, + alreadySubscribed: {}, + unauthorized: [stream.name]).toJson()); + await tapSubscribeButton(tester); + + await waitAndCheckSnackbarIsShown(tester, + zulipLocalizations.errorFailedToSubscribeToChannel, stream.name); + }); + + testWidgets('catch-all api errors', (WidgetTester tester) async { + final stream = await prepareSingleStream(tester); + connection.prepare(exception: http.ClientException('Oops')); + await tapSubscribeButton(tester); + await tester.pump(Duration.zero); + await tester.pumpAndSettle(); + + await waitAndCheckSnackbarIsShown(tester, + zulipLocalizations.errorFailedToSubscribeToChannel, stream.name); + }); + }); + + group('unsubscribe', () { + testWidgets('is shown only for streams that user is subscribed to', (tester) async { + final streams = [eg.stream(), eg.stream(), eg.subscription(eg.stream())]; + final subscriptions = [streams[2] as Subscription]; + await setupChannelListPage(tester, streams: streams, subscriptions: subscriptions); + + check(find.byIcon(Icons.check).evaluate().length).equals(1); + }); + + testWidgets('smoke api', (tester) async { + final stream = await prepareSingleSubscription(tester); + connection.prepare(json: UnsubscribeFromChannelsResult( + removed: [stream.name], + notRemoved: []).toJson()); + await tapUnsubscribeButton(tester); + + await tester.pump(Duration.zero); + await tester.pumpAndSettle(); + check(connection.lastRequest).isA() + ..method.equals('DELETE') + ..url.path.equals('/api/v1/users/me/subscriptions') + ..bodyFields.deepEquals({ + 'subscriptions': jsonEncode([stream.name]) + }); + }); + + testWidgets('shows a snackbar when subscription passes', (WidgetTester tester) async { + final stream = await prepareSingleSubscription(tester); + connection.prepare(json: UnsubscribeFromChannelsResult( + removed: [stream.name], + notRemoved: []).toJson()); + await tapUnsubscribeButton(tester); + + await waitAndCheckSnackbarIsShown(tester, + zulipLocalizations.messageUnsubscribedFromChannel, stream.name); + }); + + testWidgets('shows a snackbar when subscription fails', (WidgetTester tester) async { + final stream = await prepareSingleSubscription(tester); + connection.prepare(json: UnsubscribeFromChannelsResult( + removed: [], + notRemoved: [stream.name]).toJson()); + await tapUnsubscribeButton(tester); + + await waitAndCheckSnackbarIsShown(tester, + zulipLocalizations.errorFailedToUnsubscribeFromChannel, stream.name); + }); + + testWidgets('catch-all api errors', (WidgetTester tester) async { + final stream = await prepareSingleSubscription(tester); + connection.prepare(exception: http.ClientException('Oops')); + await tapUnsubscribeButton(tester); + await tester.pump(Duration.zero); + await tester.pumpAndSettle(); + + await waitAndCheckSnackbarIsShown(tester, + zulipLocalizations.errorFailedToUnsubscribeFromChannel, stream.name); + }); + }); + }); +} diff --git a/test/widgets/subscription_list_test.dart b/test/widgets/subscription_list_test.dart index 130b6c802a..9f6af2009a 100644 --- a/test/widgets/subscription_list_test.dart +++ b/test/widgets/subscription_list_test.dart @@ -23,11 +23,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.toList(), + streams: streams ?? subscriptions, userTopics: userTopics, unreadMsgs: unreadMsgs, ); @@ -59,6 +60,37 @@ void main() { check(isUnpinnedHeaderInTree()).isFalse(); }); + testWidgets('link to channels is shown with 1 unsubscribed channel', (tester) async { + final streams = List.generate(2, (index) => eg.stream()); + await setupStreamListPage(tester, + streams: streams, + subscriptions: [eg.subscription(streams[1])]); + + check(find.text('Browse 1 more channel').evaluate()).isNotEmpty(); + }); + + testWidgets('link to channels is shown with n unsubscribed channels', (tester) async { + final streams = List.generate(5, (index) => eg.stream()); + await setupStreamListPage(tester, + streams: streams, + subscriptions: [eg.subscription(streams[1])]); + + check(find.text('Browse 4 more channels').evaluate()).isNotEmpty(); + }); + + testWidgets('link to channels is shown with 0 unsubscribed channels', (tester) async { + final subscriptions = List.generate(5, (index) => eg.subscription(eg.stream())); + await setupStreamListPage(tester, subscriptions: subscriptions); + + check(find.text('Browse all channels').evaluate()).isNotEmpty(); + }); + + testWidgets('link to channels is not shown if there are no channels', (tester) async { + await setupStreamListPage(tester, streams: [], subscriptions: []); + + check(find.text('Browse all channels').evaluate()).isEmpty(); + }); + testWidgets('basic subscriptions', (tester) async { await setupStreamListPage(tester, subscriptions: [ eg.subscription(eg.stream(streamId: 1), pinToTop: true),