Confirm before unsubscribing (always, if from action sheet; only sometimes, if from "All channels")

Stacked atop #1887.

-----

Fixes #1827.
Fixes #1878.

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.

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 !!

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

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),

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(


action_sheet: Check can_subscribe_group for "Subscribe" confirmation dialog

Fixes #1786.

(Also fill in some test coverage for the change in cf83c9ec6 / #1822.)

Check group-based permission to subscribe to a channel, as needed
We should check the relevant permission:

- ~~before deciding to show a "Subscribe"  button in the channel action sheet (#1224)~~ (this was done in #1822)
- to give a specific warning "you won't have permission to resubscribe" in the confirmation dialog when unsubscribing from a channel (#1224)

Help-center doc: https://zulip.com/help/configure-who-can-subscribe

### Prerequisites

- #814

flutter test test/widgets/action_sheet_test.dart --reporter expanded 2>&1

diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb
index 73bcb373b0..89160f5bad 100644
--- a/assets/l10n/app_en.arb
+++ b/assets/l10n/app_en.arb
@@ -143,9 +143,9 @@
       "channelName": {"type": "String", "example": "mobile"}
     }
   },
-  "unsubscribeConfirmationDialogMessageMaybeCannotResubscribe": "Once you leave this channel, you might not be able to rejoin.",
-  "@unsubscribeConfirmationDialogMessageMaybeCannotResubscribe": {
-    "description": "Message for a confirmation dialog for unsubscribing from a channel when you might not have permission to resubscribe."
+  "unsubscribeConfirmationDialogMessageCannotResubscribe": "Once you leave this channel, you will not be able to rejoin.",
+  "@unsubscribeConfirmationDialogMessageCannotResubscribe": {
+    "description": "Message for a confirmation dialog for unsubscribing from a channel when you will not have permission to resubscribe."
   },
   "unsubscribeConfirmationDialogConfirmButton": "Unsubscribe",
   "@unsubscribeConfirmationDialogConfirmButton": {
diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart
index 44fadaa2ec..a3bf9c2cc7 100644
--- a/lib/generated/l10n/zulip_localizations.dart
+++ b/lib/generated/l10n/zulip_localizations.dart
@@ -341,11 +341,11 @@ abstract class ZulipLocalizations {
   /// **'Unsubscribe from {channelName}?'**
   String unsubscribeConfirmationDialogTitle(String channelName);

-  /// Message for a confirmation dialog for unsubscribing from a channel when you might not have permission to resubscribe.
+  /// Message for a confirmation dialog for unsubscribing from a channel when you will not have permission to resubscribe.
   ///
   /// In en, this message translates to:
-  /// **'Once you leave this channel, you might not be able to rejoin.'**
-  String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe;
+  /// **'Once you leave this channel, you will not be able to rejoin.'**
+  String get unsubscribeConfirmationDialogMessageCannotResubscribe;

   /// Label for the 'Unsubscribe' button on a confirmation dialog for unsubscribing from a channel.
   ///
diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart
index 6c9ef2fcc5..9c67f11fc6 100644
--- a/lib/generated/l10n/zulip_localizations_ar.dart
+++ b/lib/generated/l10n/zulip_localizations_ar.dart
@@ -124,8 +124,8 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
   }

   @override
-  String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe =>
-      'Once you leave this channel, you might not be able to rejoin.';
+  String get unsubscribeConfirmationDialogMessageCannotResubscribe =>
+      'Once you leave this channel, you will not be able to rejoin.';

   @override
   String get unsubscribeConfirmationDialogConfirmButton => 'Unsubscribe';
diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart
index 9768e579e5..d693740f43 100644
--- a/lib/generated/l10n/zulip_localizations_de.dart
+++ b/lib/generated/l10n/zulip_localizations_de.dart
@@ -126,8 +126,8 @@ class ZulipLocalizationsDe extends ZulipLocalizations {
   }

   @override
-  String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe =>
-      'Wenn du diesen Kanal verlässt, kannst du sich vielleicht nicht wieder beitreten.';
+  String get unsubscribeConfirmationDialogMessageCannotResubscribe =>
+      'Once you leave this channel, you will not be able to rejoin.';

   @override
   String get unsubscribeConfirmationDialogConfirmButton => 'Deabonnieren';
diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart
index 81ceadcb9c..18e76a9331 100644
--- a/lib/generated/l10n/zulip_localizations_en.dart
+++ b/lib/generated/l10n/zulip_localizations_en.dart
@@ -124,8 +124,8 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
   }

   @override
-  String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe =>
-      'Once you leave this channel, you might not be able to rejoin.';
+  String get unsubscribeConfirmationDialogMessageCannotResubscribe =>
+      'Once you leave this channel, you will not be able to rejoin.';

   @override
   String get unsubscribeConfirmationDialogConfirmButton => 'Unsubscribe';
diff --git a/lib/generated/l10n/zulip_localizations_fr.dart b/lib/generated/l10n/zulip_localizations_fr.dart
index 94c4848a9f..2268056038 100644
--- a/lib/generated/l10n/zulip_localizations_fr.dart
+++ b/lib/generated/l10n/zulip_localizations_fr.dart
@@ -126,8 +126,8 @@ class ZulipLocalizationsFr extends ZulipLocalizations {
   }

   @override
-  String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe =>
-      'Once you leave this channel, you might not be able to rejoin.';
+  String get unsubscribeConfirmationDialogMessageCannotResubscribe =>
+      'Once you leave this channel, you will not be able to rejoin.';

   @override
   String get unsubscribeConfirmationDialogConfirmButton => 'Se désinscrire';
diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart
index a7b2cdd663..cccd1dc8b1 100644
--- a/lib/generated/l10n/zulip_localizations_it.dart
+++ b/lib/generated/l10n/zulip_localizations_it.dart
@@ -125,8 +125,8 @@ class ZulipLocalizationsIt extends ZulipLocalizations {
   }

   @override
-  String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe =>
-      'Once you leave this channel, you might not be able to rejoin.';
+  String get unsubscribeConfirmationDialogMessageCannotResubscribe =>
+      'Once you leave this channel, you will not be able to rejoin.';

   @override
   String get unsubscribeConfirmationDialogConfirmButton => 'Unsubscribe';
diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart
index 09a9b822b4..5f6fa404d4 100644
--- a/lib/generated/l10n/zulip_localizations_ja.dart
+++ b/lib/generated/l10n/zulip_localizations_ja.dart
@@ -123,8 +123,8 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
   }

   @override
-  String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe =>
-      'このチャンネルを退出すると、再び参加できない可能性があります。';
+  String get unsubscribeConfirmationDialogMessageCannotResubscribe =>
+      'Once you leave this channel, you will not be able to rejoin.';

   @override
   String get unsubscribeConfirmationDialogConfirmButton => 'チャンネルから退出';
diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart
index 8ee2e9c4da..8653de5c7b 100644
--- a/lib/generated/l10n/zulip_localizations_nb.dart
+++ b/lib/generated/l10n/zulip_localizations_nb.dart
@@ -124,8 +124,8 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
   }

   @override
-  String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe =>
-      'Once you leave this channel, you might not be able to rejoin.';
+  String get unsubscribeConfirmationDialogMessageCannotResubscribe =>
+      'Once you leave this channel, you will not be able to rejoin.';

   @override
   String get unsubscribeConfirmationDialogConfirmButton => 'Unsubscribe';
diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart
index fcc527a284..98d6b1de53 100644
--- a/lib/generated/l10n/zulip_localizations_pl.dart
+++ b/lib/generated/l10n/zulip_localizations_pl.dart
@@ -126,8 +126,8 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
   }

   @override
-  String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe =>
-      'Po opuszczeniu kanału możesz utracić możliwość powrotu.';
+  String get unsubscribeConfirmationDialogMessageCannotResubscribe =>
+      'Once you leave this channel, you will not be able to rejoin.';

   @override
   String get unsubscribeConfirmationDialogConfirmButton => 'Odsubskrybuj';
diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart
index fedbacf98f..7effd9fa73 100644
--- a/lib/generated/l10n/zulip_localizations_ru.dart
+++ b/lib/generated/l10n/zulip_localizations_ru.dart
@@ -126,8 +126,8 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
   }

   @override
-  String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe =>
-      'Покинув этот канал, возможно, вы не сможете присоединиться вновь.';
+  String get unsubscribeConfirmationDialogMessageCannotResubscribe =>
+      'Once you leave this channel, you will not be able to rejoin.';

   @override
   String get unsubscribeConfirmationDialogConfirmButton => 'Отписаться';
diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart
index 4201d37be2..14310986f6 100644
--- a/lib/generated/l10n/zulip_localizations_sk.dart
+++ b/lib/generated/l10n/zulip_localizations_sk.dart
@@ -124,8 +124,8 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
   }

   @override
-  String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe =>
-      'Once you leave this channel, you might not be able to rejoin.';
+  String get unsubscribeConfirmationDialogMessageCannotResubscribe =>
+      'Once you leave this channel, you will not be able to rejoin.';

   @override
   String get unsubscribeConfirmationDialogConfirmButton => 'Unsubscribe';
diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart
index ac70ca61da..8bfc8810cb 100644
--- a/lib/generated/l10n/zulip_localizations_sl.dart
+++ b/lib/generated/l10n/zulip_localizations_sl.dart
@@ -124,8 +124,8 @@ class ZulipLocalizationsSl extends ZulipLocalizations {
   }

   @override
-  String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe =>
-      'Ko zapustite ta kanal, se morda ne boste mogli znova pridružiti.';
+  String get unsubscribeConfirmationDialogMessageCannotResubscribe =>
+      'Once you leave this channel, you will not be able to rejoin.';

   @override
   String get unsubscribeConfirmationDialogConfirmButton => 'Prekliči naročnino';
diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart
index ddfb79ac57..f5b955fb55 100644
--- a/lib/generated/l10n/zulip_localizations_uk.dart
+++ b/lib/generated/l10n/zulip_localizations_uk.dart
@@ -127,8 +127,8 @@ class ZulipLocalizationsUk extends ZulipLocalizations {
   }

   @override
-  String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe =>
-      'Після того, як ви залишите цей канал, ви, можливо, не зможете приєднатися знову.';
+  String get unsubscribeConfirmationDialogMessageCannotResubscribe =>
+      'Once you leave this channel, you will not be able to rejoin.';

   @override
   String get unsubscribeConfirmationDialogConfirmButton => 'Скасувати підписку';
diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart
index f974ce7573..e3f483e560 100644
--- a/lib/generated/l10n/zulip_localizations_zh.dart
+++ b/lib/generated/l10n/zulip_localizations_zh.dart
@@ -124,8 +124,8 @@ class ZulipLocalizationsZh extends ZulipLocalizations {
   }

   @override
-  String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe =>
-      'Once you leave this channel, you might not be able to rejoin.';
+  String get unsubscribeConfirmationDialogMessageCannotResubscribe =>
+      'Once you leave this channel, you will not be able to rejoin.';

   @override
   String get unsubscribeConfirmationDialogConfirmButton => 'Unsubscribe';
@@ -1254,10 +1254,6 @@ class ZulipLocalizationsZhHansCn extends ZulipLocalizationsZh {
     return '确定取消订阅$channelName么?';
   }

-  @override
-  String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe =>
-      '一旦退出该频道，您可能无法重新加入。';
-
   @override
   String get unsubscribeConfirmationDialogConfirmButton => '取消订阅';

@@ -2342,10 +2338,6 @@ class ZulipLocalizationsZhHantTw extends ZulipLocalizationsZh {
     return '確定要取消訂閱 $channelName 嗎？';
   }

-  @override
-  String get unsubscribeConfirmationDialogMessageMaybeCannotResubscribe =>
-      '一旦您離開此頻道，可能無法重新加入。';
-
   @override
   String get unsubscribeConfirmationDialogConfirmButton => '取消訂閱';

diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart
index 8191f58069..5d233a527f 100644
--- a/lib/widgets/action_sheet.dart
+++ b/lib/widgets/action_sheet.dart
@@ -17,6 +17,7 @@ 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';
@@ -633,20 +634,21 @@ class UnsubscribeButton extends ActionSheetMenuItemButton {

   @override
   void onPressed() async {
-    final subscription = PerAccountStoreWidget.of(pageContext).subscriptions[channelId];
+    final store = PerAccountStoreWidget.of(pageContext);
+    final subscription = store.subscriptions[channelId];
     if (subscription == null) return; // TODO could give feedback

-    // TODO(#1786) check group-based permission to subscribe, then replace
-    //   error message with a new one saying "will not" instead of "might not"
     // TODO(future) check if the self-user is a guest and the channel is not web-public
-    final couldResubscribe = !subscription.inviteOnly;
+    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.unsubscribeConfirmationDialogMessageMaybeCannotResubscribe,
+        message: zulipLocalizations.unsubscribeConfirmationDialogMessageCannotResubscribe,
         destructiveActionButton: true,
         actionButtonText: zulipLocalizations.unsubscribeConfirmationDialogConfirmButton);
       if (await dialog.result != true) return;

diff --git a/test/model/test_store.dart b/test/model/test_store.dart
index e706f3139f..faf62412e4 100644
--- a/test/model/test_store.dart
+++ b/test/model/test_store.dart
@@ -319,6 +319,19 @@ extension PerAccountStoreTestExtension on PerAccountStore {
     await handleEvent(ChannelCreateEvent(id: 1, streams: streams));
   }

+  Future<void> updateChannel(
+    int channelId,
+    ChannelPropertyName property,
+    Object? value,
+  ) async {
+    await handleEvent(ChannelUpdateEvent(
+      id: 1,
+      streamId: channelId,
+      name: 'some channel name', // (not true, of course, but that's fine)
+      property: property,
+      value: value));
+  }
+
   Future<void> addSubscription(Subscription subscription) async {
     await addSubscriptions([subscription]);
   }
diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart
index 9866b9dc10..e03e102cf5 100644
--- a/test/widgets/action_sheet_test.dart
+++ b/test/widgets/action_sheet_test.dart
@@ -341,14 +341,30 @@ void main() {
         await tester.pump(); // [MenuItemButton.onPressed] called in a post-frame callback: flutter/flutter@e4a39fa2e
       }

-      testWidgets('channel not subscribed', (tester) async {
+      testWidgets('channel not subscribed, with content access', (tester) async {
         await prepare();
         final narrow = ChannelNarrow(someChannel.streamId);
         await store.removeSubscription(narrow.streamId);
+        check(store.selfHasContentAccess(someChannel)).isTrue();
         await showFromMsglistAppBar(tester, narrow: narrow);
         checkButton('Subscribe');
       });

+      testWidgets('channel not subscribed, without content access', (tester) async {
+        final privateChannel = eg.stream(inviteOnly: true);
+        await prepare();
+        await store.addStream(privateChannel);
+        await store.updateChannel(privateChannel.streamId,
+          ChannelPropertyName.canSubscribeGroup, eg.groupSetting(members: []));
+        await store.updateChannel(privateChannel.streamId,
+          ChannelPropertyName.canAddSubscribersGroup, eg.groupSetting(members: []));
+        final narrow = ChannelNarrow(privateChannel.streamId);
+        check(store.selfHasContentAccess(privateChannel)).isFalse();
+        await showFromMsglistAppBar(tester,
+          channel: privateChannel, narrow: narrow);
+        checkNoButton('Subscribe');
+      });
+
       testWidgets('channel subscribed', (tester) async {
         await prepare();
         final narrow = ChannelNarrow(someChannel.streamId);
@@ -584,7 +600,7 @@ void main() {

         final (unsubscribeButton, cancelButton) = checkSuggestedActionDialog(tester,
           expectedTitle: 'Unsubscribe from ${channel.name}?',
-          expectedMessage: 'Once you leave this channel, you might not be able to rejoin.',
+          expectedMessage: 'Once you leave this channel, you will not be able to rejoin.',
           expectDestructiveActionButton: true,
           expectedActionButtonText: 'Unsubscribe');
         await tester.tap(find.byWidget(unsubscribeButton));


home: Add icons label on main navigation bar.

**Key Changes:** (Screenshots Outdated)

| Dark Theme | Light Theme |
|---------|---------|
| ![1000051916](https://github.com/user-attachments/assets/a85fada6-7038-45a0-a595-13bcc386197e) | ![1000051917](https://github.com/user-attachments/assets/8e1cd84f-4641-42c1-b5d9-63ff21a84ebb) |

I have kept the height of the Navigation to be fixed because a fully dynamic navigation bar height that adjusts based on label content across languages is not feasibly after having done some feasibility study. The UI jitter when the bar changes its size suddenly when the user changes the app language is extremely unlikely to be solved and can negatively impact user experience hence even in day to day app these types of dynamic navigation bar are not used.

integration of the page bodies and semantics has been done

Discussion : [#mobile > Flutter feedback - Icons in app @ 💬](https://chat.zulip.org/#narrow/channel/48-mobile/topic/Flutter.20feedback.20-.20Icons.20in.20app/near/2219685)

- Language Consistency for all language for the added labels will be fixed after receiving feedback from the Moderators about the Implementation of the solution.

Thank you

[Figma Design Link](https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=11677-62351&t=z0Pq39n8h1rzALdc-4)

Fixes #1857
Fixes #1960

Label icons on main nav bar

Semantic labels on main nav bar

Label icons on main nav bar
In the bottom nav bar on the home screens of the app, we want to show a text label along with the icon for each button.

This should also resolve the lack of such labels for screen readers, which was one of the points noted at https://github.com/zulip/zulip-flutter/issues/535#issuecomment-2973276995 .

### References
* Chat thread with user request: [#mobile > Flutter feedback - Icons in app](https://chat.zulip.org/#narrow/channel/48-mobile/topic/Flutter.20feedback.20-.20Icons.20in.20app/with/2223128)
* Design: [#mobile > Flutter feedback - Icons in app @ 💬](https://chat.zulip.org/#narrow/channel/48-mobile/topic/Flutter.20feedback.20-.20Icons.20in.20app/near/2219685)

Semantic labels on main nav bar
This was found in https://github.com/zulip/zulip-flutter/issues/535#issuecomment-2973276995 when investigating the app's behavior with a screen reader:
> The home page bottom tabs do not have labels. The caption for each of them is just: "button, double tap to activate".

We should fix this.

This might get fixed as part of the same change as:
* #1857

@MritunjayTiwari14, would you check whether your #1879 resolves this issue, and if so then mark it as doing so?

Yes @gnprice , it can also fix it. In most app i checked out like Google and Whatsapp using TalkBack On. I noticed that both of them uses `<navbar_item_name> Tab` as a convention to announce the different bottom navbar items. So i am implementing the same in Zulip-flutter.

flutter test test/widgets/home_test.dart test/widgets/recent_dm_conversations_test.dart --reporter expanded 2>&1

diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb
index 45c7e6ca94..38b49c2edd 100644
--- a/assets/l10n/app_en.arb
+++ b/assets/l10n/app_en.arb
@@ -1261,6 +1261,10 @@
   "@wildcardMentionTopicDescription": {
     "description": "Description for \"@topic\" wildcard-mention autocomplete options when writing a channel message."
   },
+  "navBarMenuLabel": "Menu",
+  "@navBarMenuLabel": {
+    "description": "Label for the Menu button on the bottom navigation bar."
+  },
   "messageIsEditedLabel": "EDITED",
   "@messageIsEditedLabel": {
     "description": "Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)"
diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart
index ce46ae6e7d..fe79921c07 100644
--- a/lib/generated/l10n/zulip_localizations.dart
+++ b/lib/generated/l10n/zulip_localizations.dart
@@ -1833,6 +1833,12 @@ abstract class ZulipLocalizations {
   /// **'Notify topic'**
   String get wildcardMentionTopicDescription;

+  /// Label for the Menu button on the bottom navigation bar.
+  ///
+  /// In en, this message translates to:
+  /// **'Menu'**
+  String get navBarMenuLabel;
+
   /// Label for an edited message. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)
   ///
   /// In en, this message translates to:
diff --git a/lib/generated/l10n/zulip_localizations_ar.dart b/lib/generated/l10n/zulip_localizations_ar.dart
index d4c35968bf..5e69a181da 100644
--- a/lib/generated/l10n/zulip_localizations_ar.dart
+++ b/lib/generated/l10n/zulip_localizations_ar.dart
@@ -1054,6 +1054,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
   @override
   String get wildcardMentionTopicDescription => 'إخطار الموضوع';

+  @override
+  String get navBarMenuLabel => 'Menu';
+
   @override
   String get messageIsEditedLabel => 'EDITED';

diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart
index 0a43ac50b5..241e886e55 100644
--- a/lib/generated/l10n/zulip_localizations_de.dart
+++ b/lib/generated/l10n/zulip_localizations_de.dart
@@ -1073,6 +1073,9 @@ class ZulipLocalizationsDe extends ZulipLocalizations {
   @override
   String get wildcardMentionTopicDescription => 'Thema benachrichtigen';

+  @override
+  String get navBarMenuLabel => 'Menu';
+
   @override
   String get messageIsEditedLabel => 'BEARBEITET';

diff --git a/lib/generated/l10n/zulip_localizations_el.dart b/lib/generated/l10n/zulip_localizations_el.dart
index c7725a64e2..ce72dc77bf 100644
--- a/lib/generated/l10n/zulip_localizations_el.dart
+++ b/lib/generated/l10n/zulip_localizations_el.dart
@@ -1054,6 +1054,9 @@ class ZulipLocalizationsEl extends ZulipLocalizations {
   @override
   String get wildcardMentionTopicDescription => 'Notify topic';

+  @override
+  String get navBarMenuLabel => 'Menu';
+
   @override
   String get messageIsEditedLabel => 'EDITED';

diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart
index 334df16239..6d10362555 100644
--- a/lib/generated/l10n/zulip_localizations_en.dart
+++ b/lib/generated/l10n/zulip_localizations_en.dart
@@ -1054,6 +1054,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
   @override
   String get wildcardMentionTopicDescription => 'Notify topic';

+  @override
+  String get navBarMenuLabel => 'Menu';
+
   @override
   String get messageIsEditedLabel => 'EDITED';

diff --git a/lib/generated/l10n/zulip_localizations_es.dart b/lib/generated/l10n/zulip_localizations_es.dart
index f45d3db383..80098d557f 100644
--- a/lib/generated/l10n/zulip_localizations_es.dart
+++ b/lib/generated/l10n/zulip_localizations_es.dart
@@ -1054,6 +1054,9 @@ class ZulipLocalizationsEs extends ZulipLocalizations {
   @override
   String get wildcardMentionTopicDescription => 'Notify topic';

+  @override
+  String get navBarMenuLabel => 'Menu';
+
   @override
   String get messageIsEditedLabel => 'EDITED';

diff --git a/lib/generated/l10n/zulip_localizations_fr.dart b/lib/generated/l10n/zulip_localizations_fr.dart
index 0d64c3b679..adb41177b2 100644
--- a/lib/generated/l10n/zulip_localizations_fr.dart
+++ b/lib/generated/l10n/zulip_localizations_fr.dart
@@ -1070,6 +1070,9 @@ class ZulipLocalizationsFr extends ZulipLocalizations {
   @override
   String get wildcardMentionTopicDescription => 'Notify topic';

+  @override
+  String get navBarMenuLabel => 'Menu';
+
   @override
   String get messageIsEditedLabel => 'EDITED';

diff --git a/lib/generated/l10n/zulip_localizations_he.dart b/lib/generated/l10n/zulip_localizations_he.dart
index 5e1609ccba..e59309be3e 100644
--- a/lib/generated/l10n/zulip_localizations_he.dart
+++ b/lib/generated/l10n/zulip_localizations_he.dart
@@ -1054,6 +1054,9 @@ class ZulipLocalizationsHe extends ZulipLocalizations {
   @override
   String get wildcardMentionTopicDescription => 'Notify topic';

+  @override
+  String get navBarMenuLabel => 'Menu';
+
   @override
   String get messageIsEditedLabel => 'EDITED';

diff --git a/lib/generated/l10n/zulip_localizations_hu.dart b/lib/generated/l10n/zulip_localizations_hu.dart
index aacb5d9d8d..6a3c11465c 100644
--- a/lib/generated/l10n/zulip_localizations_hu.dart
+++ b/lib/generated/l10n/zulip_localizations_hu.dart
@@ -1054,6 +1054,9 @@ class ZulipLocalizationsHu extends ZulipLocalizations {
   @override
   String get wildcardMentionTopicDescription => 'Notify topic';

+  @override
+  String get navBarMenuLabel => 'Menu';
+
   @override
   String get messageIsEditedLabel => 'EDITED';

diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart
index 4fc1ba6e42..cd27a1daa7 100644
--- a/lib/generated/l10n/zulip_localizations_it.dart
+++ b/lib/generated/l10n/zulip_localizations_it.dart
@@ -1067,6 +1067,9 @@ class ZulipLocalizationsIt extends ZulipLocalizations {
   @override
   String get wildcardMentionTopicDescription => 'Notifica argomento';

+  @override
+  String get navBarMenuLabel => 'Menu';
+
   @override
   String get messageIsEditedLabel => 'MODIFICATO';

diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart
index e779208dcf..d11aedf19f 100644
--- a/lib/generated/l10n/zulip_localizations_ja.dart
+++ b/lib/generated/l10n/zulip_localizations_ja.dart
@@ -1029,6 +1029,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
   @override
   String get wildcardMentionTopicDescription => 'トピック参加者に通知';

+  @override
+  String get navBarMenuLabel => 'Menu';
+
   @override
   String get messageIsEditedLabel => '編集済み';

diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart
index 3d9b7990e7..162ca9e677 100644
--- a/lib/generated/l10n/zulip_localizations_nb.dart
+++ b/lib/generated/l10n/zulip_localizations_nb.dart
@@ -1054,6 +1054,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
   @override
   String get wildcardMentionTopicDescription => 'Notify topic';

+  @override
+  String get navBarMenuLabel => 'Menu';
+
   @override
   String get messageIsEditedLabel => 'EDITED';

diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart
index ea8d3b4fd8..40682171b2 100644
--- a/lib/generated/l10n/zulip_localizations_pl.dart
+++ b/lib/generated/l10n/zulip_localizations_pl.dart
@@ -1070,6 +1070,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
   @override
   String get wildcardMentionTopicDescription => 'Powiadom w wątku';

+  @override
+  String get navBarMenuLabel => 'Menu';
+
   @override
   String get messageIsEditedLabel => 'ZMIENIONO';

diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart
index 17f82a0d26..04f8903b91 100644
--- a/lib/generated/l10n/zulip_localizations_ru.dart
+++ b/lib/generated/l10n/zulip_localizations_ru.dart
@@ -1083,6 +1083,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
   @override
   String get wildcardMentionTopicDescription => 'Оповестить тему';

+  @override
+  String get navBarMenuLabel => 'Menu';
+
   @override
   String get messageIsEditedLabel => 'ИЗМЕНЕНО';

diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart
index 092070d0f3..e2dc542fc5 100644
--- a/lib/generated/l10n/zulip_localizations_sk.dart
+++ b/lib/generated/l10n/zulip_localizations_sk.dart
@@ -1056,6 +1056,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
   @override
   String get wildcardMentionTopicDescription => 'Notify topic';

+  @override
+  String get navBarMenuLabel => 'Menu';
+
   @override
   String get messageIsEditedLabel => 'UPRAVENÉ';

diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart
index f18f440748..6f9d8343ca 100644
--- a/lib/generated/l10n/zulip_localizations_sl.dart
+++ b/lib/generated/l10n/zulip_localizations_sl.dart
@@ -1091,6 +1091,9 @@ class ZulipLocalizationsSl extends ZulipLocalizations {
   @override
   String get wildcardMentionTopicDescription => 'Obvesti udeležence teme';

+  @override
+  String get navBarMenuLabel => 'Menu';
+
   @override
   String get messageIsEditedLabel => 'UREJENO';

diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart
index 3810960b63..84df26a42e 100644
--- a/lib/generated/l10n/zulip_localizations_uk.dart
+++ b/lib/generated/l10n/zulip_localizations_uk.dart
@@ -1071,6 +1071,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations {
   @override
   String get wildcardMentionTopicDescription => 'Повідомити канал';

+  @override
+  String get navBarMenuLabel => 'Menu';
+
   @override
   String get messageIsEditedLabel => 'РЕДАГОВАНО';

diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart
index 5db806ac0e..b08c25004e 100644
--- a/lib/generated/l10n/zulip_localizations_zh.dart
+++ b/lib/generated/l10n/zulip_localizations_zh.dart
@@ -1054,6 +1054,9 @@ class ZulipLocalizationsZh extends ZulipLocalizations {
   @override
   String get wildcardMentionTopicDescription => 'Notify topic';

+  @override
+  String get navBarMenuLabel => 'Menu';
+
   @override
   String get messageIsEditedLabel => 'EDITED';

diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart
index 62c09c0857..c74da27a86 100644
--- a/lib/widgets/home.dart
+++ b/lib/widgets/home.dart
@@ -1,6 +1,7 @@
 import 'dart:async';

 import 'package:flutter/material.dart';
+import 'package:flutter/rendering.dart';

 import '../generated/l10n/zulip_localizations.dart';
 import '../model/narrow.dart';
@@ -47,6 +48,9 @@ class HomePage extends StatefulWidget {
       HomePage.buildRoute(accountId: accountId)));
   }

+  static String contentSemanticsIdentifier = 'home-page-content';
+  static String titleSemanticsIdentifier = 'home-page-title';
+
   @override
   State<HomePage> createState() => _HomePageState();
 }
@@ -93,58 +97,97 @@ class _HomePageState extends State<HomePage> {
       (_HomePageTab.directMessages, RecentDmConversationsPageBody()),
     ];

-    _NavigationBarButton button(_HomePageTab tab, IconData icon) {
-      return _NavigationBarButton(icon: icon,
-        selected: _tab.value == tab,
-        onPressed: () {
-          _tab.value = tab;
-        });
-    }
+    return Scaffold(
+      appBar: ZulipAppBar(titleSpacing: 16,
+        title: Semantics(
+          identifier: HomePage.titleSemanticsIdentifier,
+          namesRoute: true,
+          child: Text(_currentTabTitle))),
+      body: Semantics(
+        role: SemanticsRole.tabPanel,
+        identifier: HomePage.contentSemanticsIdentifier,
+        container: true,
+        explicitChildNodes: true,
+        child: Stack(
+          children: [
+            for (final (tab, body) in pageBodies)
+              Offstage(offstage: tab != _tab.value, child: body),
+          ])),
+      bottomNavigationBar: _BottomNavBar(tabNotifier: _tab));
+  }
+}
+
+class _BottomNavBar extends StatelessWidget {
+  const _BottomNavBar({required this.tabNotifier});
+
+  final ValueNotifier<_HomePageTab> tabNotifier;
+
+  _NavigationBarButton _button({
+    required _HomePageTab tab,
+    required IconData icon,
+    required String label,
+  }) {
+    return _NavigationBarButton(icon: icon,
+      label: label,
+      selected: tabNotifier.value == tab,
+      onPressed: () {
+        tabNotifier.value = tab;
+      });
+  }
+
+  @override
+  Widget build(BuildContext context) {
+    final designVariables = DesignVariables.of(context);
+    final zulipLocalizations = ZulipLocalizations.of(context);

     // TODO(a11y): add tooltips for these buttons
     final navigationBarButtons = [
-      button(_HomePageTab.inbox,          ZulipIcons.inbox),
-      _NavigationBarButton(         icon: ZulipIcons.message_feed,
+      _button(tab: _HomePageTab.inbox,
+        icon: ZulipIcons.inbox,
+        label: zulipLocalizations.inboxPageTitle),
+      _NavigationBarButton(icon: ZulipIcons.message_feed,
+        label: zulipLocalizations.combinedFeedPageTitle,
         selected: false,
         onPressed: () => Navigator.push(context,
           MessageListPage.buildRoute(context: context,
             narrow: const CombinedFeedNarrow()))),
-      button(_HomePageTab.channels,       ZulipIcons.hash_italic),
+      _button(tab: _HomePageTab.channels,
+        icon: ZulipIcons.hash_italic,
+        label: zulipLocalizations.channelsPageTitle),
       // TODO(#1094): Users
-      button(_HomePageTab.directMessages, ZulipIcons.two_person),
-      _NavigationBarButton(         icon: ZulipIcons.menu,
+      _button(tab: _HomePageTab.directMessages,
+        icon: ZulipIcons.two_person,
+        label: zulipLocalizations.recentDmConversationsPageTitle),
+      _NavigationBarButton(icon: ZulipIcons.menu,
+        label: zulipLocalizations.navBarMenuLabel,
         selected: false,
-        onPressed: () => _showMainMenu(context, tabNotifier: _tab)),
+        onPressed: () => _showMainMenu(context, tabNotifier: tabNotifier)),
     ];

-    final designVariables = DesignVariables.of(context);
-    return Scaffold(
-      appBar: ZulipAppBar(titleSpacing: 16,
-        title: Text(_currentTabTitle)),
-      body: Stack(
-        children: [
-          for (final (tab, body) in pageBodies)
-            // TODO(#535): Decide if we find it helpful to use something like
-            //   [SemanticsProperties.namesRoute] to structure this UI better
-            //   for screen-reader software.
-            Offstage(offstage: tab != _tab.value, child: body),
-        ]),
-      bottomNavigationBar: DecoratedBox(
-        decoration: BoxDecoration(
-          border: Border(top: BorderSide(color: designVariables.borderBar)),
-          color: designVariables.bgBotBar),
-        child: SafeArea(
-          child: SizedBox(height: 48,
-            child: Center(
-              child: ConstrainedBox(
-                // TODO(design): determine a suitable max width for bottom nav bar
-                constraints: const BoxConstraints(maxWidth: 600),
-                child: Row(
-                  crossAxisAlignment: CrossAxisAlignment.stretch,
-                  children: [
-                    for (final navigationBarButton in navigationBarButtons)
-                      Expanded(child: navigationBarButton),
-                  ])))))));
+    Widget result = DecoratedBox(
+      decoration: BoxDecoration(
+        border: Border(top: BorderSide(color: designVariables.borderBar)),
+        color: designVariables.bgBotBar),
+      child: SafeArea(
+        child: Center(
+          heightFactor: 1,
+          child: ConstrainedBox(
+            // TODO(design): determine a suitable max width for bottom nav bar
+            constraints: const BoxConstraints(maxWidth: 600, minHeight: 48),
+            child: Row(
+              crossAxisAlignment: CrossAxisAlignment.start,
+              children: [
+                for (final navigationBarButton in navigationBarButtons)
+                  Expanded(child: navigationBarButton),
+              ])))));
+
+    result = Semantics(
+      container: true,
+      explicitChildNodes: true,
+      role: SemanticsRole.tabBar,
+      child: result);
+
+    return result;
   }
 }

@@ -229,37 +272,62 @@ class _LoadingPlaceholderPageState extends State<_LoadingPlaceholderPage> {
 class _NavigationBarButton extends StatelessWidget {
   const _NavigationBarButton({
     required this.icon,
+    required this.label,
     required this.selected,
     required this.onPressed,
   });

   final IconData icon;
+  final String label;
   final bool selected;
   final void Function() onPressed;

   @override
   Widget build(BuildContext context) {
     final designVariables = DesignVariables.of(context);
+    final color = selected ? designVariables.iconSelected : designVariables.icon;

-    final iconColor = WidgetStateColor.fromMap({
-      WidgetState.pressed:  designVariables.iconSelected,
-      ~WidgetState.pressed: selected ? designVariables.iconSelected
-                                     : designVariables.icon,
-    });
-
-    return AnimatedScaleOnTap(
+    Widget result = AnimatedScaleOnTap(
       scaleEnd: 0.875,
       duration: const Duration(milliseconds: 100),
-      child: IconButton(
-        icon: Icon(icon, size: 24),
-        onPressed: onPressed,
-        style: IconButton.styleFrom(
+      child: Material(
+        type: MaterialType.transparency,
+        child: InkWell(
+          borderRadius: BorderRadius.all(Radius.circular(4)),
           // TODO(#417): Disable splash effects for all buttons globally.
           splashFactory: NoSplash.splashFactory,
           highlightColor: designVariables.navigationButtonBg,
-          shape: const RoundedRectangleBorder(
-            borderRadius: BorderRadius.all(Radius.circular(4))),
-        ).copyWith(foregroundColor: iconColor)));
+          onTap: onPressed,
+          child: Padding(
+            // (Added 3px horizontal padding not present in Figma, to make the
+            // text wrap before getting too close to the button's edge, which is
+            // visible on tap-down.)
+            padding: const EdgeInsets.fromLTRB(3, 6, 3, 3),
+            child: Column(
+              spacing: 3,
+              mainAxisSize: MainAxisSize.min,
+              children: [
+                Icon(icon, size: 24, color: color),
+                Flexible(
+                  child: Text(
+                    label,
+                    style: TextStyle(fontSize: 12, color: color, height: 12 / 12),
+                    textAlign: TextAlign.center,
+                    textScaler: MediaQuery.textScalerOf(context).clamp(maxScaleFactor: 1.5))),
+              ])))));
+
+    result = MergeSemantics(
+      child: Semantics(
+        role: SemanticsRole.tab,
+        controlsNodes: {
+          HomePage.contentSemanticsIdentifier,
+          HomePage.titleSemanticsIdentifier,
+        },
+        selected: selected,
+        onTap: onPressed,
+        child: result));
+
+    return result;
   }
 }

diff --git a/test/widgets/home_test.dart b/test/widgets/home_test.dart
index 1ee0a0ae8e..239449c990 100644
--- a/test/widgets/home_test.dart
+++ b/test/widgets/home_test.dart
@@ -1,3 +1,5 @@
+import 'dart:ui';
+
 import 'package:checks/checks.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_checks/flutter_checks.dart';
@@ -82,6 +84,15 @@ void main () {
   }

   group('bottom nav navigation', () {
+    final findBottomNavSemantics = find.byWidgetPredicate((widget) {
+      if (widget is! Semantics) return false;
+      return widget.properties.role == SemanticsRole.tab;
+    });
+
+    // Finds a widget within the bottom navbar's semantics box subtree.
+    Finder findInBottomNav(Finder finder) =>
+      find.descendant(of: findBottomNavSemantics, matching: finder);
+
     testWidgets('preserve states when switching between views', (tester) async {
       await prepare(tester);
       await store.addUser(eg.otherUser);
@@ -131,6 +142,26 @@ void main () {
         matching: find.text('Direct messages'))).findsOne();
     });

+    testWidgets("view switches when labels are tapped", (tester) async {
+      await prepare(tester);
+
+      check(find.descendant(
+        of: find.byType(ZulipAppBar),
+        matching: find.text('Inbox'))).findsOne();
+
+      await tester.tap(findInBottomNav(find.text('Channels')));
+      await tester.pump();
+      check(find.descendant(
+        of: find.byType(ZulipAppBar),
+        matching: find.text('Channels'))).findsOne();
+
+      await tester.tap(findInBottomNav(find.text('Direct messages')));
+      await tester.pump();
+      check(find.descendant(
+        of: find.byType(ZulipAppBar),
+        matching: find.text('Direct messages'))).findsOne();
+    });
+
     testWidgets('combined feed', (tester) async {
       await prepare(tester);
       pushedRoutes.clear();
diff --git a/test/widgets/recent_dm_conversations_test.dart b/test/widgets/recent_dm_conversations_test.dart
index a558734119..b5fdd10217 100644
--- a/test/widgets/recent_dm_conversations_test.dart
+++ b/test/widgets/recent_dm_conversations_test.dart
@@ -59,7 +59,7 @@ Future<void> setupPage(WidgetTester tester, {

   // Switch to direct messages tab.
   await tester.tap(find.descendant(
-    of: find.byType(Center),
+    of: find.byType(DecoratedBox),
     matching: find.byIcon(ZulipIcons.two_person)));
   await tester.pump();
 }


Fixes #1533.

-----

Outdated screenshots; working on new ones…

| Channel | Topic |
| --- | --- |
|  |  |
|  |  |

| Light (general chat) | Dark (general chat) |
| --- | --- |
|  |  |

| Globe, short letter | Globe, tall letter |
| --- | --- |
|  |  |
|  |  |

| Hash, short letter | Hash, tall letter |
| --- | --- |
|  |  |
|  |  |

| Lock, short letter | Lock, tall letter |
| --- | --- |
|  |  |
|  |  |

| Long topic (light) | Long topic (dark) |
| --- | --- |
|  |  |

| Unknown channel | Unknown channel, with topic |
| --- | --- |
|  |  |

Show channel/topic at top of channel/topic action sheet
When the user opens the channel action sheet or topic action sheet (e.g. by long-pressing on the channel or topic name somewhere), the resulting bottom sheet should show at the top the name of the channel, or the channel and topic, respectively.

### References
* Chat thread: [#mobile > abbreviated headings @ 💬](https://chat.zulip.org/#narrow/channel/48-mobile/topic/abbreviated.20headings/near/2182042)
* Similar issue (but more complex to implement): #217

### Figma design

Channel action sheet:

https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6327-96692&m=dev
<img width="487" height="429" alt="Image" src="https://github.com/user-attachments/assets/d13b1d58-a890-4047-9468-29ef4081b737" />

Topic action sheet:

(I didn't find a Figma design for this in a quick look.)

diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart
index 59be92482f..30bbf433f5 100644
--- a/lib/widgets/action_sheet.dart
+++ b/lib/widgets/action_sheet.dart
@@ -35,11 +35,20 @@ import 'text.dart';
 import 'theme.dart';
 import 'topic_list.dart';

+/// Show an action sheet with scrollable menu buttons
+/// and an optional scrollable header.
+///
+/// [header] should not use vertical padding to position itself on the sheet.
+/// It will be wrapped in vertical padding
+/// and, if [headerScrollable], a scroll view and an [InsetShadowBox].
 void _showActionSheet(
   BuildContext pageContext, {
   Widget? header,
+  bool headerScrollable = true,
   required List<List<Widget>> buttonSections,
 }) {
+  assert(header is! BottomSheetHeader || !header.outerVerticalPadding);
+
   // Could omit this if we need _showActionSheet outside a per-account context.
   final accountId = PerAccountStoreWidget.accountIdOf(pageContext);

@@ -53,6 +62,52 @@ void _showActionSheet(
     isScrollControlled: true,
     builder: (BuildContext _) {
       final designVariables = DesignVariables.of(pageContext);
+
+      Widget? effectiveHeader;
+      if (header != null) {
+        effectiveHeader = headerScrollable
+          ? Flexible(
+              // TODO(upstream) Enforce a flex ratio (e.g. 1:3)
+              //   only when the header height plus the buttons' height
+              //   exceeds available space. Otherwise let one or the other
+              //   grow to fill available space even if it breaks the ratio.
+              //   Needs support for separate properties like `flex-grow`
+              //   and `flex-shrink`.
+              flex: 1,
+              child: InsetShadowBox(
+                top: 8, bottom: 8,
+                color: designVariables.bgContextMenu,
+                child: SingleChildScrollView(
+                  padding: EdgeInsets.symmetric(vertical: 8),
+                  child: header)))
+          : Padding(
+              padding: EdgeInsets.only(top: 16, bottom: 4),
+              child: header);
+      }
+
+      final body = Flexible(
+        flex: (effectiveHeader != null && headerScrollable)
+          ? 3
+          : 1,
+        child: Padding(
+          padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
+          child: Column(
+            crossAxisAlignment: CrossAxisAlignment.stretch,
+            mainAxisSize: MainAxisSize.min,
+            children: [
+              Flexible(child: InsetShadowBox(
+                top: 8, bottom: 8,
+                color: designVariables.bgContextMenu,
+                child: SingleChildScrollView(
+                  padding: const EdgeInsets.symmetric(vertical: 8),
+                  child: Column(
+                    mainAxisSize: MainAxisSize.min,
+                    spacing: 8,
+                    children: buttonSections.map((buttons) =>
+                      MenuButtonsShape(buttons: buttons)).toList())))),
+              const BottomSheetDismissButton(style: BottomSheetDismissButtonStyle.cancel),
+            ])));
+
       return PerAccountStoreWidget(
         accountId: accountId,
         child: Semantics(
@@ -62,43 +117,11 @@ void _showActionSheet(
             child: Column(
               mainAxisSize: MainAxisSize.min,
               children: [
-                if (header != null)
-                  Flexible(
-                    // TODO(upstream) Enforce a flex ratio (e.g. 1:3)
-                    //   only when the header height plus the buttons' height
-                    //   exceeds available space. Otherwise let one or the other
-                    //   grow to fill available space even if it breaks the ratio.
-                    //   Needs support for separate properties like `flex-grow`
-                    //   and `flex-shrink`.
-                    flex: 1,
-                    child: InsetShadowBox(
-                      top: 8, bottom: 8,
-                      color: designVariables.bgContextMenu,
-                      child: SingleChildScrollView(
-                        padding: EdgeInsets.symmetric(vertical: 8),
-                        child: header)))
+                if (effectiveHeader != null)
+                  effectiveHeader
                 else
                   SizedBox(height: 8),
-                Flexible(
-                  flex: 3,
-                  child: Padding(
-                    padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
-                    child: Column(
-                      crossAxisAlignment: CrossAxisAlignment.stretch,
-                      mainAxisSize: MainAxisSize.min,
-                      children: [
-                        Flexible(child: InsetShadowBox(
-                          top: 8, bottom: 8,
-                          color: designVariables.bgContextMenu,
-                          child: SingleChildScrollView(
-                            padding: const EdgeInsets.symmetric(vertical: 8),
-                            child: Column(
-                              mainAxisSize: MainAxisSize.min,
-                              spacing: 8,
-                              children: buttonSections.map((buttons) =>
-                                MenuButtonsShape(buttons: buttons)).toList())))),
-                        const BottomSheetDismissButton(style: BottomSheetDismissButtonStyle.cancel),
-                      ]))),
+                body,
               ]))));
     });
 }
@@ -114,7 +137,8 @@ typedef WidgetBuilderFromTextStyle = Widget Function(TextStyle);
 /// The "build" params support richer content, such as [TextWithLink],
 /// and the callback is passed a [TextStyle] which is the base style.
 ///
-/// Assumes 8px padding below the top of the bottom sheet.
+/// To add outer vertical padding to position the header on the sheet,
+/// pass true for [outerVerticalPadding].
 ///
 /// Figma; just message no title:
 ///   https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=3481-26993&m=dev
@@ -133,6 +157,7 @@ class BottomSheetHeader extends StatelessWidget {
     this.buildTitle,
     this.message,
     this.buildMessage,
+    this.outerVerticalPadding = false,
   }) : assert(message == null || buildMessage == null),
        assert(title == null || buildTitle == null),
        assert((message != null || buildMessage != null)
@@ -142,6 +167,7 @@ class BottomSheetHeader extends StatelessWidget {
   final Widget Function(TextStyle)? buildTitle;
   final String? message;
   final Widget Function(TextStyle)? buildMessage;
+  final bool outerVerticalPadding;

   @override
   Widget build(BuildContext context) {
@@ -149,16 +175,30 @@ class BottomSheetHeader extends StatelessWidget {

     final baseTitleStyle = TextStyle(
       fontSize: 20,
-      height: 20 / 20,
+      // More height than in Figma, but it was looking too tight:
+      //   https://github.com/zulip/zulip-flutter/pull/1877#issuecomment-3379664807
+      // (See use of TextHeightBehavior below.)
+      height: 24 / 20,
       color: designVariables.title,
     ).merge(weightVariableTextStyle(context, wght: 600));

-    final effectiveTitle = switch ((buildTitle, title)) {
+    Widget? effectiveTitle = switch ((buildTitle, title)) {
       (final build?, null) => build(baseTitleStyle),
       (null,  final data?) => Text(style: baseTitleStyle, data),
       _                    => null,
     };

+    if (effectiveTitle != null) {
+      effectiveTitle = DefaultTextHeightBehavior(
+        textHeightBehavior: TextHeightBehavior(
+          // We want some breathing room between lines,
+          // without adding margin above or below the title.
+          applyHeightToFirstAscent: false,
+          applyHeightToLastDescent: false,
+        ),
+        child: effectiveTitle);
+    }
+
     final baseMessageStyle = TextStyle(
       color: designVariables.labelTime,
       fontSize: 17,
@@ -170,12 +210,20 @@ class BottomSheetHeader extends StatelessWidget {
       _                    => null,
     };

-    return Padding(
-      padding: EdgeInsets.fromLTRB(16, 8, 16, 4),
+    Widget result = Padding(
+      padding: EdgeInsets.symmetric(horizontal: 16),
       child: Column(
         crossAxisAlignment: CrossAxisAlignment.stretch,
         spacing: 8,
         children: [?effectiveTitle, ?effectiveMessage]));
+
+    if (outerVerticalPadding) {
+      result = Padding(
+        padding: EdgeInsets.only(top: 16, bottom: 4),
+        child: result);
+    }
+
+    return result;
   }
 }

@@ -468,7 +516,21 @@ void showChannelActionSheet(BuildContext context, {
       [UnsubscribeButton(pageContext: pageContext, channelId: channelId)],
   ];

-  _showActionSheet(pageContext, buttonSections: buttonSections);
+  final header = BottomSheetHeader(
+    buildTitle: (baseStyle) => Text.rich(
+      style: baseStyle,
+      channelTopicLabelSpan(
+        context: context,
+        channelId: channelId,
+        fontSize: baseStyle.fontSize!,
+        color: baseStyle.color!)),
+    // TODO(#1896) show channel description
+  );
+
+  _showActionSheet(pageContext,
+    header: header,
+    headerScrollable: false,
+    buttonSections: buttonSections);
 }

 class SubscribeButton extends ActionSheetMenuItemButton {
@@ -723,7 +785,20 @@ void showTopicActionSheet(BuildContext context, {
     narrow: TopicNarrow(channelId, topic, with_: someMessageIdInTopic),
     pageContext: context));

-  _showActionSheet(pageContext, buttonSections: [optionButtons]);
+  final header = BottomSheetHeader(
+    buildTitle: (baseStyle) => Text.rich(
+      style: baseStyle,
+      channelTopicLabelSpan(
+        context: context,
+        channelId: channelId,
+        topic: topic,
+        fontSize: baseStyle.fontSize!,
+        color: baseStyle.color!)));
+
+  _showActionSheet(pageContext,
+    header: header,
+    headerScrollable: false,
+    buttonSections: [optionButtons]);
 }

 class UserTopicUpdateButton extends ActionSheetMenuItemButton {
diff --git a/lib/widgets/emoji_reaction.dart b/lib/widgets/emoji_reaction.dart
index 2c7c5d61ab..45131d7ad0 100644
--- a/lib/widgets/emoji_reaction.dart
+++ b/lib/widgets/emoji_reaction.dart
@@ -804,10 +804,9 @@ class ViewReactionsHeader extends StatelessWidget {
     final reactions = message?.reactions;

     if (reactions == null || reactions.aggregated.isEmpty) {
-      return Padding(
-        padding: const EdgeInsets.only(top: 8),
-        child: BottomSheetHeader(message: zulipLocalizations.seeWhoReactedSheetNoReactions),
-      );
+      return BottomSheetHeader(
+        outerVerticalPadding: true,
+        message: zulipLocalizations.seeWhoReactedSheetNoReactions);
     }

     return Padding(
diff --git a/lib/widgets/read_receipts.dart b/lib/widgets/read_receipts.dart
index 46c920372a..8416d78c85 100644
--- a/lib/widgets/read_receipts.dart
+++ b/lib/widgets/read_receipts.dart
@@ -128,11 +128,10 @@ class _ReadReceiptsHeader extends StatelessWidget {
         markup: zulipLocalizations.actionSheetReadReceiptsReadCount(receiptCount));
     }

-    return Padding(
-      padding: const EdgeInsets.only(top: 8),
-      child: BottomSheetHeader(
-        title: zulipLocalizations.actionSheetReadReceipts,
-        buildMessage: headerMessageBuilder));
+    return BottomSheetHeader(
+      outerVerticalPadding: true,
+      title: zulipLocalizations.actionSheetReadReceipts,
+      buildMessage: headerMessageBuilder);
   }
 }

diff --git a/lib/widgets/text.dart b/lib/widgets/text.dart
index 3fe48484e3..10a6485023 100644
--- a/lib/widgets/text.dart
+++ b/lib/widgets/text.dart
@@ -4,6 +4,10 @@ import 'package:flutter/foundation.dart';
 import 'package:flutter/gestures.dart';
 import 'package:flutter/material.dart';

+import '../api/model/model.dart';
+import '../generated/l10n/zulip_localizations.dart';
+import 'icons.dart';
+import 'store.dart';
 import 'theme.dart';

 /// An app-wide [Typography] for Zulip, customized from the Material default.
@@ -533,3 +537,161 @@ class _TextWithLinkState extends State<TextWithLink> {
       span);
   }
 }
+
+/// Data to size and position a square icon in a span of text.
+class InlineIconGeometryData {
+  /// What size the icon should be,
+  /// as a fraction of the surrounding text's font size.
+  final double sizeFactor;
+
+  /// Where to assign the icon's baseline, as a fraction of the icon's size,
+  /// when the span is rendered with [TextBaseline.alphabetic].
+  ///
+  /// This is ignored when the span is rendered with [TextBaseline.ideographic];
+  /// zero is used instead.
+  final double alphabeticBaselineFactor;
+
+  /// How much horizontal padding should separate the icon from surrounding text,
+  /// as a fraction of the icon's size.
+  final double paddingFactor;
+
+  const InlineIconGeometryData._({
+    required this.sizeFactor,
+    required this.alphabeticBaselineFactor,
+    required this.paddingFactor,
+  });
+
+  factory InlineIconGeometryData.forIcon(IconData icon) {
+    final result = _inlineIconGeometries[icon];
+    assert(result != null);
+    return result ?? _defaultGeometry;
+  }
+
+  // Values are ad hoc unless otherwise specified.
+  static final Map<IconData, InlineIconGeometryData> _inlineIconGeometries = {
+    ZulipIcons.globe: InlineIconGeometryData._(
+      sizeFactor: 0.8,
+      alphabeticBaselineFactor: 1 / 8,
+      paddingFactor: 1 / 4),
+
+    ZulipIcons.hash_sign: InlineIconGeometryData._(
+      sizeFactor: 0.8,
+      alphabeticBaselineFactor: 1 / 16,
+      paddingFactor: 1 / 4),
+
+    ZulipIcons.lock: InlineIconGeometryData._(
+      sizeFactor: 0.8,
+      alphabeticBaselineFactor: 1 / 16,
+      paddingFactor: 1 / 4),
+
+    ZulipIcons.chevron_right: InlineIconGeometryData._(
+      sizeFactor: 1,
+      alphabeticBaselineFactor: 5 / 24,
+      paddingFactor: 0),
+ };
+
+  static final _defaultGeometry = InlineIconGeometryData._(
+    sizeFactor: 0.8,
+    alphabeticBaselineFactor: 1 / 16,
+    paddingFactor: 1 / 4,
+  );
+}
+
+/// An icon, sized and aligned for use in a span of text.
+WidgetSpan iconWidgetSpan({
+  required IconData icon,
+  required double fontSize,
+  required TextBaseline baselineType,
+  required Color? color,
+  bool padBefore = false,
+  bool padAfter = false,
+}) {
+  final InlineIconGeometryData(
+    :sizeFactor,
+    :alphabeticBaselineFactor,
+    :paddingFactor,
+  ) = InlineIconGeometryData.forIcon(icon);
+
+  final size = sizeFactor * fontSize;
+
+  final effectiveBaselineOffset = switch (baselineType) {
+    TextBaseline.alphabetic => alphabeticBaselineFactor * size,
+    TextBaseline.ideographic => 0.0,
+  };
+
+  Widget child = Icon(size: size, color: color, icon);
+
+  if (effectiveBaselineOffset != 0) {
+    child = Transform.translate(
+      offset: Offset(0, effectiveBaselineOffset),
+      child: child);
+  }
+
+  if (padBefore || padAfter) {
+    final padding = paddingFactor * size;
+    child = Padding(
+      padding: EdgeInsetsDirectional.only(
+        start: padBefore ? padding : 0,
+        end: padAfter ? padding : 0,
+      ),
+      child: child);
+  }
+
+  return WidgetSpan(
+    alignment: PlaceholderAlignment.baseline,
+    baseline: baselineType,
+    child: child);
+}
+
+/// An [InlineSpan] with a channel privacy icon, channel name,
+/// and optionally a chevron-right icon plus topic.
+///
+/// Pass this to [Text.rich], which can be styled arbitrarily.
+/// Pass the [fontSize] and [color] of surrounding text
+/// so that the icons are sized and colored appropriately.
+InlineSpan channelTopicLabelSpan({
+  required BuildContext context,
+  required int channelId,
+  TopicName? topic,
+  required double fontSize,
+  required Color color,
+}) {
+  final zulipLocalizations = ZulipLocalizations.of(context);
+  final store = PerAccountStoreWidget.of(context);
+  final channel = store.streams[channelId];
+  final subscription = store.subscriptions[channelId];
+  final swatch = colorSwatchFor(context, subscription);
+  final channelIcon = channel != null ? iconDataForStream(channel) : null;
+  final baselineType = localizedTextBaseline(context);
+
+  return TextSpan(children: [
+    if (channelIcon != null)
+      iconWidgetSpan(
+        icon: channelIcon,
+        fontSize: fontSize,
+        baselineType: baselineType,
+        color: swatch.iconOnPlainBackground,
+        padAfter: true),
+    if (channel != null)
+      TextSpan(text: channel.name)
+    else
+      TextSpan(
+        style: TextStyle(fontStyle: FontStyle.italic),
+        text: zulipLocalizations.unknownChannelName),
+    if (topic != null) ...[
+      iconWidgetSpan(
+        icon: ZulipIcons.chevron_right,
+        fontSize: fontSize,
+        baselineType: baselineType,
+        color: color,
+        padBefore: true,
+        padAfter: true),
+      if (topic.displayName != null)
+        TextSpan(text: topic.displayName)
+      else
+        TextSpan(
+          style: TextStyle(fontStyle: FontStyle.italic),
+          text: store.realmEmptyTopicDisplayName),
+    ],
+  ]);
+}

diff --git a/test/api/model/model_checks.dart b/test/api/model/model_checks.dart
index a7db054b55..5cb6df7c24 100644
--- a/test/api/model/model_checks.dart
+++ b/test/api/model/model_checks.dart
@@ -54,6 +54,9 @@ extension SavedSnippetChecks on Subject<SavedSnippet> {

 extension ZulipStreamChecks on Subject<ZulipStream> {
   Subject<int> get streamId => has((x) => x.streamId, 'streamId');
+
+  Subject<bool> get inviteOnly => has((x) => x.inviteOnly, 'inviteOnly');
+  Subject<bool> get isWebPublic => has((x) => x.isWebPublic, 'isWebPublic');
 }

 extension ChannelFolderChecks on Subject<ChannelFolder> {
diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart
index 06e0eab5ca..621f759f28 100644
--- a/test/widgets/action_sheet_test.dart
+++ b/test/widgets/action_sheet_test.dart
@@ -38,6 +38,7 @@ import 'package:zulip/widgets/topic_list.dart';
 import 'package:zulip/widgets/user.dart';
 import '../api/fake_api.dart';

+import '../api/model/model_checks.dart';
 import '../example_data.dart' as eg;
 import '../flutter_checks.dart';
 import '../model/binding.dart';
@@ -255,19 +256,21 @@ void main() {
       await tester.pump(const Duration(milliseconds: 250));
     }

-    Future<void> showFromTopicListAppBar(WidgetTester tester) async {
+    Future<void> showFromTopicListAppBar(WidgetTester tester, {int? streamId}) async {
+      streamId ??= someChannel.streamId;
       final transitionDurationObserver = TransitionDurationObserver();

       connection.prepare(json: GetStreamTopicsResult(topics: []).toJson());
       await tester.pumpWidget(TestZulipApp(
         navigatorObservers: [transitionDurationObserver],
         accountId: eg.selfAccount.id,
-        child: TopicListPage(streamId: someChannel.streamId)));
+        child: TopicListPage(streamId: streamId)));
       await tester.pump();

+      final titleText = store.streams[streamId]?.name ?? '(unknown channel)';
       await tester.longPress(find.descendant(
         of: find.byType(ZulipAppBar),
-        matching: find.text(someChannel.name)));
+        matching: find.text(titleText)));
       await transitionDurationObserver.pumpPastTransition(tester);
     }

@@ -290,6 +293,61 @@ void main() {
         checkButton('Copy link to channel');
       }

+      group('header', () {
+        final findHeader = find.descendant(
+          of: actionSheetFinder,
+          matching: find.byType(BottomSheetHeader));
+
+        Finder findInHeader(Finder finder) =>
+          find.descendant(of: findHeader, matching: finder);
+
+        testWidgets('public channel', (tester) async {
+          await prepare();
+          check(store.streams[someChannel.streamId]).isNotNull()
+            ..inviteOnly.isFalse()..isWebPublic.isFalse();
+          await showFromInbox(tester);
+          check(findInHeader(find.byIcon(ZulipIcons.hash_sign))).findsOne();
+          check(findInHeader(find.textContaining(someChannel.name))).findsOne();
+        });
+
+        testWidgets('web-public channel', (tester) async {
+          await prepare();
+          await store.handleEvent(ChannelUpdateEvent(id: 1,
+            streamId: someChannel.streamId,
+            name: someChannel.name,
+            property: null, value: null,
+            // (Ideally we'd use `property` and `value` but I'm not sure if
+            // modern servers actually do that or if they still use this
+            // separate field.)
+            isWebPublic: true));
+          check(store.streams[someChannel.streamId]).isNotNull()
+            ..inviteOnly.isFalse()..isWebPublic.isTrue();
+          await showFromInbox(tester);
+          check(findInHeader(find.byIcon(ZulipIcons.globe))).findsOne();
+          check(findInHeader(find.textContaining(someChannel.name))).findsOne();
+        });
+
+        testWidgets('private channel', (tester) async {
+          await prepare();
+          await store.handleEvent(eg.channelUpdateEvent(someChannel,
+            property: ChannelPropertyName.inviteOnly, value: true));
+          check(store.streams[someChannel.streamId]).isNotNull()
+            ..inviteOnly.isTrue()..isWebPublic.isFalse();
+          await showFromInbox(tester);
+          check(findInHeader(find.byIcon(ZulipIcons.lock))).findsOne();
+          check(findInHeader(find.textContaining(someChannel.name))).findsOne();
+        });
+
+        testWidgets('unknown channel', (tester) async {
+          await prepare();
+          await store.handleEvent(ChannelDeleteEvent(id: 1, streams: [someChannel]));
+          check(store.streams[someChannel.streamId]).isNull();
+          await showFromTopicListAppBar(tester);
+          check(findInHeader(find.byType(Icon))).findsNothing();
+          check(findInHeader(find.textContaining('(unknown channel)'))).findsOne();
+        });
+      });
+
       testWidgets('show from inbox', (tester) async {
         await prepare();
         await showFromInbox(tester);
@@ -747,6 +805,38 @@ void main() {
         checkButton('Copy link to topic');
       }

+      group('header', () {
+        final findHeader = find.descendant(
+          of: actionSheetFinder,
+          matching: find.byType(BottomSheetHeader));
+
+        Finder findInHeader(Finder finder) =>
+          find.descendant(of: findHeader, matching: finder);
+
+        testWidgets('with topic', (tester) async {
+          await prepare();
+          check(store.streams[someChannel.streamId]).isNotNull()
+            ..inviteOnly.isFalse()..isWebPublic.isFalse();
+          await showFromAppBar(tester);
+          check(findInHeader(find.byIcon(ZulipIcons.hash_sign))).findsOne();
+          check(findInHeader(find.textContaining(someChannel.name))).findsOne();
+          check(findInHeader(find.textContaining(someTopic))).findsOne();
+        });
+
+        testWidgets('without topic (general chat)', (tester) async {
+          await prepare(topic: '');
+          check(store.streams[someChannel.streamId]).isNotNull()
+            ..inviteOnly.isFalse()..isWebPublic.isFalse();
+          final message = eg.streamMessage(
+            stream: someChannel, topic: '', sender: eg.otherUser);
+          await showFromAppBar(tester, messages: [message], topic: eg.t(''));
+          check(findInHeader(find.byIcon(ZulipIcons.hash_sign))).findsOne();
+          check(findInHeader(find.textContaining(someChannel.name))).findsOne();
+          check(findInHeader(find.textContaining(store.realmEmptyTopicDisplayName)))
+            .findsOne();
+        });
+      });
+
       testWidgets('show from inbox; message in Unreads but not in MessageStore', (tester) async {
         await prepare(unreadMsgs: eg.unreadMsgs(count: 1,
           channels: [eg.unreadChannelMsgs(