Repository: zulip/zulip-flutter (language: dart)

Issue:
Add warning when unsubscribing from private channel using toggle
When unsubscribing from a private channel using the unsubscribe option in the three dot menu, the app shows a confirmation dialog with a warning that you might not be able to rejoin.

However, when unsubscribing using the toggle switch next to the channel name, this warning is not shown. This makes it very easy to accidentally unsubscribe from a private channel by hitting this toggle.

I think the toggle switch should show the same warning that the unsubscribe option in the menu does. 

Alternatively, maybe private channels shouldn't have this toggle switch at all, since the convenience of fast subscribe-unsubscribe doesn't really benefit private channels where you can't subscribe yourself.



Confirmation dialog on channel-unsubscribe from action sheet, for any channel
We offer an "Unsubscribe" button in the channel action sheet, but it doesn't give a confirmation dialog unless it's a private channel (with refinements of that condition tracked in #1788 and #1786). That dialog exists to guard against leaving channels that you won't be able to rejoin.

It's helpful to guard against accidental unsubscribes for a few other reasons, which apply to any channel:
- If you opened the action sheet from a channel row in some views including the inbox and subscribed-channels screen, unsubscribing will make the channel's row disappear, and you'll have to find the channel again to undo the action.
- Unsubscribing can be a destructive action even if you're able to resubscribe right away, because its messages are automatically marked as read.

In the soon-to-be-build "All channels" page, the second item doesn't apply, and a confirmation dialog might be annoying if you want to unsubscribe from many channels quickly by switching each channel's switch to "off". We could skip a confirmation dialog on that page, or maybe offer a snackbar with an "undo" button instead.


Discussion hints:
Screenshot to clarify what I'm talking about:

<img src="https://github.com/user-attachments/assets/b1268a28-cda1-41d9-a411-b639b053c737" alt="Screenshot" width="350">
Warning here when we'd warn from the other UI makes sense to me.
Can I try this??

/attempt
@chrisbobbe  can I work on this???
 a snack bar like the one whatsapp shows to undo the deleted message action 

can i implement the undo option ???

for  undo option we can 
    - unsubscribe the user  show him the snackbar when "undo " is pressed the resubscribe him and restore settings like final previousSettings = {
  'isMuted': subscription.isMuted,
  'notificationSettings': subscription.notificationSettings,
.... other things here 
};
- or when unsubscribed we can just take that channel away from user view like cache it and then unsubscribe it if "undo " is pressed then take  it back to user view instantly as 'unsubscribe' window in short in time !!
 

Pull request: Confirm before unsubscribing (always, if from action sheet; only sometimes, if from "All channels")
Stacked atop #1887.

-----

Fixes #1827.
Fixes #1878.



Test command: flutter test test/widgets/action_sheet_test.dart test/widgets/all_channels_test.dart --reporter expanded 2>&1

Fix patch:
diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb
index 2570578b6f..05598dab42 100644
--- a/assets/l10n/app_en.arb
+++ b/assets/l10n/app_en.arb
@@ -140,7 +140,7 @@
   "@unsubscribeConfirmationDialogTitle": {
     "description": "Title for a confirmation dialog for unsubscribing from a channel.",
     "placeholders": {
-      "channelName": {"type": "String", "example": "mobile"}
+      "channelName": {"type": "String", "example": "#mobile"}
     }
   },
   "unsubscribeConfirmationDialogMessageCannotResubscribe": "Once you leave this channel, you will not be able to rejoin.",
diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart
index 5d233a527f..c01b8b9937 100644
--- a/lib/widgets/action_sheet.dart
+++ b/lib/widgets/action_sheet.dart
@@ -17,7 +17,6 @@ import '../model/content.dart';
 import '../model/emoji.dart';
 import '../model/internal_link.dart';
 import '../model/narrow.dart';
-import '../model/realm.dart';
 import 'actions.dart';
 import 'button.dart';
 import 'color.dart';
@@ -634,45 +633,7 @@ class UnsubscribeButton extends ActionSheetMenuItemButton {
 
   @override
   void onPressed() async {
-    final store = PerAccountStoreWidget.of(pageContext);
-    final subscription = store.subscriptions[channelId];
-    if (subscription == null) return; // TODO could give feedback
-
-    // TODO(future) check if the self-user is a guest and the channel is not web-public
-    final couldResubscribe = !subscription.inviteOnly
-      || store.selfHasPermissionForGroupSetting(subscription.canSubscribeGroup,
-           GroupSettingType.stream, 'can_subscribe_group');
-    if (!couldResubscribe) {
-      // TODO(#1788) warn if org would lose content access (nobody can subscribe)
-      final zulipLocalizations = ZulipLocalizations.of(pageContext);
-
-      final dialog = showSuggestedActionDialog(context: pageContext,
-        title: zulipLocalizations.unsubscribeConfirmationDialogTitle(subscription.name),
-        message: zulipLocalizations.unsubscribeConfirmationDialogMessageCannotResubscribe,
-        destructiveActionButton: true,
-        actionButtonText: zulipLocalizations.unsubscribeConfirmationDialogConfirmButton);
-      if (await dialog.result != true) return;
-      if (!pageContext.mounted) return;
-    }
-
-    try {
-      await unsubscribeFromChannel(PerAccountStoreWidget.of(pageContext).connection,
-        subscriptions: [subscription.name]);
-    } catch (e) {
-      if (!pageContext.mounted) return;
-
-      String? errorMessage;
-      switch (e) {
-        case ZulipApiException():
-          errorMessage = e.message;
-          // TODO(#741) specific messages for common errors, like network errors
-          //   (support with reusable code)
-        default:
-      }
-
-      final title = ZulipLocalizations.of(pageContext).unsubscribeFailedTitle;
-      showErrorDialog(context: pageContext, title: title, message: errorMessage);
-    }
+    await ZulipAction.unsubscribeFromChannel(pageContext, channelId: channelId);
   }
 }
 
diff --git a/lib/widgets/actions.dart b/lib/widgets/actions.dart
index 4c725f9878..dd34564768 100644
--- a/lib/widgets/actions.dart
+++ b/lib/widgets/actions.dart
@@ -7,9 +7,11 @@ import '../api/exception.dart';
 import '../api/model/model.dart';
 import '../api/model/narrow.dart';
 import '../api/route/messages.dart';
+import '../api/route/channels.dart' as channels_api;
 import '../generated/l10n/zulip_localizations.dart';
 import '../model/binding.dart';
 import '../model/narrow.dart';
+import '../model/realm.dart';
 import 'dialog.dart';
 import 'store.dart';
 
@@ -239,6 +241,64 @@ abstract final class ZulipAction {
 
     return fetchedMessage?.content;
   }
+
+  /// Unsubscribe from a channel, possibly after a confirmation dialog,
+  /// showing an error dialog on failure.
+  ///
+  /// A confirmation dialog is shown if the user would not have permission
+  /// to resubscribe.
+  /// If [alwaysAsk] is true (the default),
+  /// a confirmation dialog is shown unconditionally.
+  static Future<void> unsubscribeFromChannel(BuildContext context, {
+    required int channelId,
+    bool alwaysAsk = true,
+  }) async {
+    final store = PerAccountStoreWidget.of(context);
+    final subscription = store.subscriptions[channelId];
+    if (subscription == null) return; // TODO could give feedback
+
+    // TODO(future) check if the self-user is a guest and the channel is not web-public
+    final couldResubscribe = !subscription.inviteOnly
+      || store.selfHasPermissionForGroupSetting(subscription.canSubscribeGroup,
+           GroupSettingType.stream, 'can_subscribe_group');
+    final zulipLocalizations = ZulipLocalizations.of(context);
+    if (!couldResubscribe) {
+      // TODO(#1788) warn if org would lose content access (nobody can subscribe)
+
+      final dialog = showSuggestedActionDialog(context: context,
+        title: zulipLocalizations.unsubscribeConfirmationDialogTitle('#${subscription.name}'),
+        message: zulipLocalizations.unsubscribeConfirmationDialogMessageCannotResubscribe,
+        destructiveActionButton: true,
+        actionButtonText: zulipLocalizations.unsubscribeConfirmationDialogConfirmButton);
+      if (await dialog.result != true) return;
+      if (!context.mounted) return;
+    } else if (alwaysAsk) {
+      final dialog = showSuggestedActionDialog(context: context,
+        title: zulipLocalizations.unsubscribeConfirmationDialogTitle('#${subscription.name}'),
+        actionButtonText: zulipLocalizations.unsubscribeConfirmationDialogConfirmButton);
+      if (await dialog.result != true) return;
+      if (!context.mounted) return;
+    }
+
+    try {
+      await channels_api.unsubscribeFromChannel(PerAccountStoreWidget.of(context).connection,
+        subscriptions: [subscription.name]);
+    } catch (e) {
+      if (!context.mounted) return;
+
+      String? errorMessage;
+      switch (e) {
+        case ZulipApiException():
+          errorMessage = e.message;
+          // TODO(#741) specific messages for common errors, like network errors
+          //   (support with reusable code)
+        default:
+      }
+
+      final title = ZulipLocalizations.of(context).unsubscribeFailedTitle;
+      showErrorDialog(context: context, title: title, message: errorMessage);
+    }
+  }
 }
 
 /// Methods that act through platform APIs and show feedback in the UI.
diff --git a/lib/widgets/all_channels.dart b/lib/widgets/all_channels.dart
index 7acdbe8521..3aaf0d8baf 100644
--- a/lib/widgets/all_channels.dart
+++ b/lib/widgets/all_channels.dart
@@ -6,6 +6,7 @@ import '../generated/l10n/zulip_localizations.dart';
 import '../log.dart';
 import '../model/channel.dart';
 import 'action_sheet.dart';
+import 'actions.dart';
 import 'app_bar.dart';
 import 'button.dart';
 import 'icons.dart';
@@ -137,8 +138,9 @@ class _SubscribeToggle extends StatelessWidget {
           await subscribeToChannel(store.connection,
             subscriptions: [channel.name]);
         } else {
-          await unsubscribeFromChannel(store.connection,
-            subscriptions: [channel.name]);
+          await ZulipAction.unsubscribeFromChannel(context,
+            channelId: channel.streamId,
+            alwaysAsk: false);
         }
       },
       // TODO(#741) interpret API errors for user
diff --git a/lib/widgets/dialog.dart b/lib/widgets/dialog.dart
index fb93a3d6cc..8a0164d577 100644
--- a/lib/widgets/dialog.dart
+++ b/lib/widgets/dialog.dart
@@ -145,7 +145,7 @@ DialogStatus<void> showErrorDialog({
 DialogStatus<bool> showSuggestedActionDialog({
   required BuildContext context,
   required String title,
-  required String message,
+  String? message,
   required String? actionButtonText,
   bool destructiveActionButton = false,
 }) {
@@ -154,7 +154,7 @@ DialogStatus<bool> showSuggestedActionDialog({
     context: context,
     builder: (BuildContext context) => AlertDialog.adaptive(
       title: Text(title),
-      content: _adaptiveContent(Text(message)),
+      content: message != null ? _adaptiveContent(Text(message)) : null,
       actions: [
         _adaptiveAction(
           onPressed: () => Navigator.pop<bool>(context, null),


Test patch:
diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart
index e03e102cf5..22a4b06d8e 100644
--- a/test/widgets/action_sheet_test.dart
+++ b/test/widgets/action_sheet_test.dart
@@ -573,9 +573,15 @@ void main() {
 
         connection.prepare(json: {});
         await tapButton(tester);
-        await tester.pump(Duration.zero);
+        await tester.pump();
 
-        checkNoDialog(tester);
+        final (unsubscribeButton, cancelButton) = checkSuggestedActionDialog(tester,
+          expectedTitle: 'Unsubscribe from #${channel.name}?',
+          expectDestructiveActionButton: false,
+          expectedActionButtonText: 'Unsubscribe');
+        await tester.tap(find.byWidget(unsubscribeButton));
+        await tester.pump();
+        await tester.pump(Duration.zero);
 
         check(connection.lastRequest).isA<http.Request>()
           ..method.equals('DELETE')
@@ -599,7 +605,7 @@ void main() {
         await tester.pump();
 
         final (unsubscribeButton, cancelButton) = checkSuggestedActionDialog(tester,
-          expectedTitle: 'Unsubscribe from ${channel.name}?',
+          expectedTitle: 'Unsubscribe from #${channel.name}?',
           expectedMessage: 'Once you leave this channel, you will not be able to rejoin.',
           expectDestructiveActionButton: true,
           expectedActionButtonText: 'Unsubscribe');
diff --git a/test/widgets/all_channels_test.dart b/test/widgets/all_channels_test.dart
index c824e5fe9b..b47546be40 100644
--- a/test/widgets/all_channels_test.dart
+++ b/test/widgets/all_channels_test.dart
@@ -1,7 +1,10 @@
+import 'dart:convert';
+
 import 'package:checks/checks.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_checks/flutter_checks.dart';
 import 'package:flutter_test/flutter_test.dart';
+import 'package:http/http.dart' as http;
 import 'package:zulip/api/model/model.dart';
 import 'package:zulip/model/store.dart';
 import 'package:zulip/widgets/all_channels.dart';
@@ -10,20 +13,25 @@ import 'package:zulip/widgets/button.dart';
 import 'package:zulip/widgets/home.dart';
 import 'package:zulip/widgets/icons.dart';
 import 'package:zulip/widgets/page.dart';
+import 'package:zulip/widgets/remote_settings.dart';
 import 'package:zulip/widgets/theme.dart';
 
+import '../api/fake_api.dart';
 import '../api/model/model_checks.dart';
 import '../flutter_checks.dart';
 import '../model/binding.dart';
 import '../example_data.dart' as eg;
 import '../model/test_store.dart';
+import '../stdlib_checks.dart';
 import 'checks.dart';
+import 'dialog_checks.dart';
 import 'test_app.dart';
 
 void main() {
   TestZulipBinding.ensureInitialized();
 
   late PerAccountStore store;
+  late FakeApiConnection connection;
   late TransitionDurationObserver transitionDurationObserver;
 
   final groupSettingWithSelf = eg.groupSetting(members: [eg.selfUser.userId]);
@@ -40,6 +48,7 @@ void main() {
     );
     await testBinding.globalStore.add(eg.selfAccount, initialSnapshot);
     store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
+    connection = store.connection as FakeApiConnection;
 
     transitionDurationObserver = TransitionDurationObserver();
     await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
@@ -207,4 +216,79 @@ void main() {
 
     check(find.byType(BottomSheet)).findsOne();
   });
+
+  testWidgets('use toggle switch to subscribe/unsubscribe', (tester) async {
+    final channel = eg.stream();
+    await setupAllChannelsPage(tester, channels: [channel]);
+
+    await tester.tap(find.byType(Toggle));
+    check(connection.lastRequest).isA<http.Request>()
+      ..method.equals('POST')
+      ..url.path.equals('/api/v1/users/me/subscriptions')
+      ..bodyFields.deepEquals({
+        'subscriptions': jsonEncode([{'name': channel.name}]),
+      });
+
+    await store.addSubscription(eg.subscription(channel));
+    await tester.pump(); // Toggle changes state
+
+    await tester.tap(find.byType(Toggle));
+    check(connection.lastRequest).isA<http.Request>()
+      ..method.equals('DELETE')
+      ..url.path.equals('/api/v1/users/me/subscriptions')
+      ..bodyFields.deepEquals({
+        'subscriptions': jsonEncode([channel.name]),
+      });
+  });
+
+  testWidgets('Toggle "off" to unsubscribe, public channel', (tester) async {
+    final channel = eg.stream(inviteOnly: false);
+    final subscription = eg.subscription(channel);
+
+    await setupAllChannelsPage(tester, channels: [subscription]);
+
+    connection.prepare(json: {});
+    await tester.tap(find.byType(Toggle));
+    await tester.pump(Duration.zero);
+    checkNoDialog(tester);
+    check(connection.lastRequest).isA<http.Request>()
+      ..method.equals('DELETE')
+      ..url.path.equals('/api/v1/users/me/subscriptions')
+      ..bodyFields.deepEquals({
+        'subscriptions': jsonEncode([channel.name]),
+      });
+  });
+
+  testWidgets('Toggle "off" to unsubscribe, but without resubscribe permission', (tester) async {
+    final channel = eg.stream(
+      inviteOnly: true, canSubscribeGroup: eg.groupSetting(members: []));
+    final subscription = eg.subscription(channel);
+
+    (Widget, Widget) checkConfirmDialog() => checkSuggestedActionDialog(tester,
+      expectedTitle: 'Unsubscribe from #${channel.name}?',
+      expectedMessage: 'Once you leave this channel, you will not be able to rejoin.',
+      expectDestructiveActionButton: true,
+      expectedActionButtonText: 'Unsubscribe');
+
+    await setupAllChannelsPage(tester, channels: [subscription]);
+
+    await tester.tap(find.byType(Toggle));
+    await tester.pump();
+    final (_, cancelButton) = checkConfirmDialog();
+    await tester.tap(find.byWidget(cancelButton));
+    await tester.pumpAndSettle();
+    check(connection.lastRequest).isNull();
+    await tester.pump(RemoteSettingBuilder.localEchoIdleTimeout);
+
+    await tester.tap(find.byType(Toggle));
+    await tester.pump();
+    final (unsubscribeButton, _) = checkConfirmDialog();
+    await tester.tap(find.byWidget(unsubscribeButton));
+    check(connection.lastRequest).isA<http.Request>()
+      ..method.equals('DELETE')
+      ..url.path.equals('/api/v1/users/me/subscriptions')
+      ..bodyFields.deepEquals({
+        'subscriptions': jsonEncode([channel.name]),
+      });
+  });
 }
diff --git a/test/widgets/dialog_checks.dart b/test/widgets/dialog_checks.dart
index aca3eeffae..b41f6daf79 100644
--- a/test/widgets/dialog_checks.dart
+++ b/test/widgets/dialog_checks.dart
@@ -109,7 +109,7 @@ void checkNoDialog(WidgetTester tester) {
 /// Tap the action button by calling `tester.tap(find.byWidget(actionButton))`.
 (Widget, Widget) checkSuggestedActionDialog(WidgetTester tester, {
   required String expectedTitle,
-  required String expectedMessage,
+  String? expectedMessage,
   String? expectedActionButtonText,
   bool expectDestructiveActionButton = false,
 }) {
@@ -121,8 +121,10 @@ void checkNoDialog(WidgetTester tester) {
       final dialog = tester.widget<AlertDialog>(find.bySubtype<AlertDialog>());
       tester.widget(find.descendant(matchRoot: true,
         of: find.byWidget(dialog.title!), matching: find.text(expectedTitle)));
-      tester.widget(find.descendant(matchRoot: true,
-        of: find.byWidget(dialog.content!), matching: find.text(expectedMessage)));
+      if (expectedMessage != null) {
+        tester.widget(find.descendant(matchRoot: true,
+          of: find.byWidget(dialog.content!), matching: find.text(expectedMessage)));
+      }
 
       final actionButton = tester.widget(find.descendant(of: find.byWidget(dialog),
         matching: find.widgetWithText(TextButton, expectedActionButtonText ?? 'Continue')));
@@ -135,8 +137,10 @@ void checkNoDialog(WidgetTester tester) {
       final dialog = tester.widget<CupertinoAlertDialog>(find.byType(CupertinoAlertDialog));
       tester.widget(find.descendant(matchRoot: true,
         of: find.byWidget(dialog.title!), matching: find.text(expectedTitle)));
-      tester.widget(find.descendant(matchRoot: true,
-        of: find.byWidget(dialog.content!), matching: find.text(expectedMessage)));
+      if (expectedMessage != null) {
+        tester.widget(find.descendant(matchRoot: true,
+          of: find.byWidget(dialog.content!), matching: find.text(expectedMessage)));
+      }
 
       final actionButton = tester.widget<CupertinoDialogAction>(
         find.descendant(