diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf
index c753a15308..66d69be4b1 100644
Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ
diff --git a/assets/icons/more_horizontal.svg b/assets/icons/more_horizontal.svg
new file mode 100644
index 0000000000..646dbcc4fa
--- /dev/null
+++ b/assets/icons/more_horizontal.svg
@@ -0,0 +1,5 @@
+
diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb
index 776a866d3b..bd0d7efdd5 100644
--- a/assets/l10n/app_en.arb
+++ b/assets/l10n/app_en.arb
@@ -72,6 +72,18 @@
"@chooseAccountButtonAddAnAccount": {
"description": "Label for ChooseAccountPage button to add an account"
},
+ "navButtonAllChannels": "All channels",
+ "@navButtonAllChannels": {
+ "description": "Title for a nav button that opens the 'All channels' page."
+ },
+ "allChannelsPageTitle": "All channels",
+ "@allChannelsPageTitle": {
+ "description": "Title for the 'All channels' page."
+ },
+ "allChannelsEmptyPlaceholder": "There are no channels you can view in this organization.",
+ "@allChannelsEmptyPlaceholder": {
+ "description": "Centered text on the 'All channels' page saying that there is no content to show."
+ },
"profileButtonSendDirectMessage": "Send direct message",
"@profileButtonSendDirectMessage": {
"description": "Label for button in profile screen to navigate to DMs with the shown user."
@@ -1074,10 +1086,17 @@
"@channelsPageTitle": {
"description": "Title for the page with a list of subscribed channels."
},
- "channelsEmptyPlaceholder": "You are not subscribed to any channels yet.",
+ "channelsEmptyPlaceholder": "You’re not subscribed to any channels yet.",
"@channelsEmptyPlaceholder": {
"description": "Centered text on the 'Channels' page saying that there is no content to show."
},
+ "channelsEmptyPlaceholderWithAllChannelsLink": "You’re not subscribed to any channels yet. Try going to {allChannelsPageTitle} and joining some of them.",
+ "@channelsEmptyPlaceholderWithAllChannelsLink": {
+ "description": "Centered text on the 'Channels' page saying that there is no content to show, with a link to 'All channels'.",
+ "placeholders": {
+ "allChannelsPageTitle": {"type": "String", "example": "All channels"}
+ }
+ },
"sharePageTitle": "Share",
"@sharePageTitle": {
"description": "Title for the page about sharing content received from other apps."
diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart
index 2a6c9f6aa1..d0a80c2b4c 100644
--- a/lib/api/model/model.dart
+++ b/lib/api/model/model.dart
@@ -628,10 +628,15 @@ class ZulipStream {
final int streamId;
String name;
+ // We don't expect `true` for this until we declare the `archived_channels`
+ // client capability.
+ //
// Servers that don't send this property will only send non-archived channels;
// default to false for those servers.
+ // TODO(server-10) remove default and its comment
+ // TODO(#800) remove comment about `archived_channels` client capability.
@JsonKey(defaultValue: false)
- bool isArchived; // TODO(server-10) remove default and its comment
+ bool isArchived;
String description;
String renderedDescription;
diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart
index 238d336f39..276ae504f7 100644
--- a/lib/generated/l10n/zulip_localizations.dart
+++ b/lib/generated/l10n/zulip_localizations.dart
@@ -239,6 +239,24 @@ abstract class ZulipLocalizations {
/// **'Add an account'**
String get chooseAccountButtonAddAnAccount;
+ /// Title for a nav button that opens the 'All channels' page.
+ ///
+ /// In en, this message translates to:
+ /// **'All channels'**
+ String get navButtonAllChannels;
+
+ /// Title for the 'All channels' page.
+ ///
+ /// In en, this message translates to:
+ /// **'All channels'**
+ String get allChannelsPageTitle;
+
+ /// Centered text on the 'All channels' page saying that there is no content to show.
+ ///
+ /// In en, this message translates to:
+ /// **'There are no channels you can view in this organization.'**
+ String get allChannelsEmptyPlaceholder;
+
/// Label for button in profile screen to navigate to DMs with the shown user.
///
/// In en, this message translates to:
@@ -1588,9 +1606,17 @@ abstract class ZulipLocalizations {
/// Centered text on the 'Channels' page saying that there is no content to show.
///
/// In en, this message translates to:
- /// **'You are not subscribed to any channels yet.'**
+ /// **'You’re not subscribed to any channels yet.'**
String get channelsEmptyPlaceholder;
+ /// Centered text on the 'Channels' page saying that there is no content to show, with a link to 'All channels'.
+ ///
+ /// In en, this message translates to:
+ /// **'You’re not subscribed to any channels yet. Try going to {allChannelsPageTitle} and joining some of them.'**
+ String channelsEmptyPlaceholderWithAllChannelsLink(
+ String allChannelsPageTitle,
+ );
+
/// Title for the page about sharing content received from other apps.
///
/// 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 0ff35c4b5b..133476376f 100644
--- a/lib/generated/l10n/zulip_localizations_ar.dart
+++ b/lib/generated/l10n/zulip_localizations_ar.dart
@@ -67,6 +67,16 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
@override
String get chooseAccountButtonAddAnAccount => 'Add an account';
+ @override
+ String get navButtonAllChannels => 'All channels';
+
+ @override
+ String get allChannelsPageTitle => 'All channels';
+
+ @override
+ String get allChannelsEmptyPlaceholder =>
+ 'There are no channels you can view in this organization.';
+
@override
String get profileButtonSendDirectMessage => 'Send direct message';
@@ -898,7 +908,14 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
@override
String get channelsEmptyPlaceholder =>
- 'You are not subscribed to any channels yet.';
+ 'You’re not subscribed to any channels yet.';
+
+ @override
+ String channelsEmptyPlaceholderWithAllChannelsLink(
+ String allChannelsPageTitle,
+ ) {
+ return 'You’re not subscribed to any channels yet. Try going to $allChannelsPageTitle and joining some of them.';
+ }
@override
String get sharePageTitle => 'Share';
diff --git a/lib/generated/l10n/zulip_localizations_de.dart b/lib/generated/l10n/zulip_localizations_de.dart
index a22b46f863..4516ff21dd 100644
--- a/lib/generated/l10n/zulip_localizations_de.dart
+++ b/lib/generated/l10n/zulip_localizations_de.dart
@@ -67,6 +67,16 @@ class ZulipLocalizationsDe extends ZulipLocalizations {
@override
String get chooseAccountButtonAddAnAccount => 'Account hinzufügen';
+ @override
+ String get navButtonAllChannels => 'All channels';
+
+ @override
+ String get allChannelsPageTitle => 'All channels';
+
+ @override
+ String get allChannelsEmptyPlaceholder =>
+ 'There are no channels you can view in this organization.';
+
@override
String get profileButtonSendDirectMessage => 'Direktnachricht senden';
@@ -923,6 +933,13 @@ class ZulipLocalizationsDe extends ZulipLocalizations {
@override
String get channelsEmptyPlaceholder => 'Du hast noch keine Kanäle abonniert.';
+ @override
+ String channelsEmptyPlaceholderWithAllChannelsLink(
+ String allChannelsPageTitle,
+ ) {
+ return 'You’re not subscribed to any channels yet. Try going to $allChannelsPageTitle and joining some of them.';
+ }
+
@override
String get sharePageTitle => 'Teilen';
diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart
index 2e94965a87..348fc47890 100644
--- a/lib/generated/l10n/zulip_localizations_en.dart
+++ b/lib/generated/l10n/zulip_localizations_en.dart
@@ -67,6 +67,16 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
@override
String get chooseAccountButtonAddAnAccount => 'Add an account';
+ @override
+ String get navButtonAllChannels => 'All channels';
+
+ @override
+ String get allChannelsPageTitle => 'All channels';
+
+ @override
+ String get allChannelsEmptyPlaceholder =>
+ 'There are no channels you can view in this organization.';
+
@override
String get profileButtonSendDirectMessage => 'Send direct message';
@@ -898,7 +908,14 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
@override
String get channelsEmptyPlaceholder =>
- 'You are not subscribed to any channels yet.';
+ 'You’re not subscribed to any channels yet.';
+
+ @override
+ String channelsEmptyPlaceholderWithAllChannelsLink(
+ String allChannelsPageTitle,
+ ) {
+ return 'You’re not subscribed to any channels yet. Try going to $allChannelsPageTitle and joining some of them.';
+ }
@override
String get sharePageTitle => 'Share';
diff --git a/lib/generated/l10n/zulip_localizations_fr.dart b/lib/generated/l10n/zulip_localizations_fr.dart
index 3766c7051e..89f8ee317a 100644
--- a/lib/generated/l10n/zulip_localizations_fr.dart
+++ b/lib/generated/l10n/zulip_localizations_fr.dart
@@ -68,6 +68,16 @@ class ZulipLocalizationsFr extends ZulipLocalizations {
@override
String get chooseAccountButtonAddAnAccount => 'Ajouter un compte';
+ @override
+ String get navButtonAllChannels => 'All channels';
+
+ @override
+ String get allChannelsPageTitle => 'All channels';
+
+ @override
+ String get allChannelsEmptyPlaceholder =>
+ 'There are no channels you can view in this organization.';
+
@override
String get profileButtonSendDirectMessage => 'Envoyer un message direct';
@@ -912,7 +922,14 @@ class ZulipLocalizationsFr extends ZulipLocalizations {
@override
String get channelsEmptyPlaceholder =>
- 'You are not subscribed to any channels yet.';
+ 'You’re not subscribed to any channels yet.';
+
+ @override
+ String channelsEmptyPlaceholderWithAllChannelsLink(
+ String allChannelsPageTitle,
+ ) {
+ return 'You’re not subscribed to any channels yet. Try going to $allChannelsPageTitle and joining some of them.';
+ }
@override
String get sharePageTitle => 'Share';
diff --git a/lib/generated/l10n/zulip_localizations_it.dart b/lib/generated/l10n/zulip_localizations_it.dart
index 028f8680fb..01080f9809 100644
--- a/lib/generated/l10n/zulip_localizations_it.dart
+++ b/lib/generated/l10n/zulip_localizations_it.dart
@@ -67,6 +67,16 @@ class ZulipLocalizationsIt extends ZulipLocalizations {
@override
String get chooseAccountButtonAddAnAccount => 'Aggiungi un account';
+ @override
+ String get navButtonAllChannels => 'All channels';
+
+ @override
+ String get allChannelsPageTitle => 'All channels';
+
+ @override
+ String get allChannelsEmptyPlaceholder =>
+ 'There are no channels you can view in this organization.';
+
@override
String get profileButtonSendDirectMessage => 'Invia un messaggio diretto';
@@ -915,6 +925,13 @@ class ZulipLocalizationsIt extends ZulipLocalizations {
String get channelsEmptyPlaceholder =>
'Non sei ancora iscritto ad alcun canale.';
+ @override
+ String channelsEmptyPlaceholderWithAllChannelsLink(
+ String allChannelsPageTitle,
+ ) {
+ return 'You’re not subscribed to any channels yet. Try going to $allChannelsPageTitle and joining some of them.';
+ }
+
@override
String get sharePageTitle => 'Share';
diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart
index 249e42587b..e652d76c43 100644
--- a/lib/generated/l10n/zulip_localizations_ja.dart
+++ b/lib/generated/l10n/zulip_localizations_ja.dart
@@ -66,6 +66,16 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
@override
String get chooseAccountButtonAddAnAccount => '新しいアカウントを追加';
+ @override
+ String get navButtonAllChannels => 'All channels';
+
+ @override
+ String get allChannelsPageTitle => 'All channels';
+
+ @override
+ String get allChannelsEmptyPlaceholder =>
+ 'There are no channels you can view in this organization.';
+
@override
String get profileButtonSendDirectMessage => 'ダイレクトメッセージを送信';
@@ -879,6 +889,13 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
@override
String get channelsEmptyPlaceholder => 'まだ参加しているチャンネルはありません。';
+ @override
+ String channelsEmptyPlaceholderWithAllChannelsLink(
+ String allChannelsPageTitle,
+ ) {
+ return 'You’re not subscribed to any channels yet. Try going to $allChannelsPageTitle and joining some of them.';
+ }
+
@override
String get sharePageTitle => '共有';
diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart
index 36411b5274..7ae25c3d52 100644
--- a/lib/generated/l10n/zulip_localizations_nb.dart
+++ b/lib/generated/l10n/zulip_localizations_nb.dart
@@ -67,6 +67,16 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
@override
String get chooseAccountButtonAddAnAccount => 'Add an account';
+ @override
+ String get navButtonAllChannels => 'All channels';
+
+ @override
+ String get allChannelsPageTitle => 'All channels';
+
+ @override
+ String get allChannelsEmptyPlaceholder =>
+ 'There are no channels you can view in this organization.';
+
@override
String get profileButtonSendDirectMessage => 'Send direct message';
@@ -898,7 +908,14 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
@override
String get channelsEmptyPlaceholder =>
- 'You are not subscribed to any channels yet.';
+ 'You’re not subscribed to any channels yet.';
+
+ @override
+ String channelsEmptyPlaceholderWithAllChannelsLink(
+ String allChannelsPageTitle,
+ ) {
+ return 'You’re not subscribed to any channels yet. Try going to $allChannelsPageTitle and joining some of them.';
+ }
@override
String get sharePageTitle => 'Share';
diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart
index 2794614e81..6624146bc1 100644
--- a/lib/generated/l10n/zulip_localizations_pl.dart
+++ b/lib/generated/l10n/zulip_localizations_pl.dart
@@ -67,6 +67,16 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
@override
String get chooseAccountButtonAddAnAccount => 'Dodaj konto';
+ @override
+ String get navButtonAllChannels => 'All channels';
+
+ @override
+ String get allChannelsPageTitle => 'All channels';
+
+ @override
+ String get allChannelsEmptyPlaceholder =>
+ 'There are no channels you can view in this organization.';
+
@override
String get profileButtonSendDirectMessage => 'Wyślij wiadomość bezpośrednią';
@@ -914,6 +924,13 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
@override
String get channelsEmptyPlaceholder => 'Nie śledzisz żadnego z kanałów.';
+ @override
+ String channelsEmptyPlaceholderWithAllChannelsLink(
+ String allChannelsPageTitle,
+ ) {
+ return 'You’re not subscribed to any channels yet. Try going to $allChannelsPageTitle and joining some of them.';
+ }
+
@override
String get sharePageTitle => 'Udostępnij';
diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart
index b07752966c..27bf2a8ab3 100644
--- a/lib/generated/l10n/zulip_localizations_ru.dart
+++ b/lib/generated/l10n/zulip_localizations_ru.dart
@@ -67,6 +67,16 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
@override
String get chooseAccountButtonAddAnAccount => 'Добавить учетную запись';
+ @override
+ String get navButtonAllChannels => 'All channels';
+
+ @override
+ String get allChannelsPageTitle => 'All channels';
+
+ @override
+ String get allChannelsEmptyPlaceholder =>
+ 'There are no channels you can view in this organization.';
+
@override
String get profileButtonSendDirectMessage => 'Отправить личное сообщение';
@@ -925,6 +935,13 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
String get channelsEmptyPlaceholder =>
'Вы ещё не подписаны ни на один канал.';
+ @override
+ String channelsEmptyPlaceholderWithAllChannelsLink(
+ String allChannelsPageTitle,
+ ) {
+ return 'You’re not subscribed to any channels yet. Try going to $allChannelsPageTitle and joining some of them.';
+ }
+
@override
String get sharePageTitle => 'Поделиться';
diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart
index 26be66502e..fe2a658c52 100644
--- a/lib/generated/l10n/zulip_localizations_sk.dart
+++ b/lib/generated/l10n/zulip_localizations_sk.dart
@@ -67,6 +67,16 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
@override
String get chooseAccountButtonAddAnAccount => 'Pridať účet';
+ @override
+ String get navButtonAllChannels => 'All channels';
+
+ @override
+ String get allChannelsPageTitle => 'All channels';
+
+ @override
+ String get allChannelsEmptyPlaceholder =>
+ 'There are no channels you can view in this organization.';
+
@override
String get profileButtonSendDirectMessage => 'Poslať priamu správu';
@@ -900,7 +910,14 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
@override
String get channelsEmptyPlaceholder =>
- 'You are not subscribed to any channels yet.';
+ 'You’re not subscribed to any channels yet.';
+
+ @override
+ String channelsEmptyPlaceholderWithAllChannelsLink(
+ String allChannelsPageTitle,
+ ) {
+ return 'You’re not subscribed to any channels yet. Try going to $allChannelsPageTitle and joining some of them.';
+ }
@override
String get sharePageTitle => 'Share';
diff --git a/lib/generated/l10n/zulip_localizations_sl.dart b/lib/generated/l10n/zulip_localizations_sl.dart
index 782288eb49..9bb079a775 100644
--- a/lib/generated/l10n/zulip_localizations_sl.dart
+++ b/lib/generated/l10n/zulip_localizations_sl.dart
@@ -66,6 +66,16 @@ class ZulipLocalizationsSl extends ZulipLocalizations {
@override
String get chooseAccountButtonAddAnAccount => 'Dodaj račun';
+ @override
+ String get navButtonAllChannels => 'All channels';
+
+ @override
+ String get allChannelsPageTitle => 'All channels';
+
+ @override
+ String get allChannelsEmptyPlaceholder =>
+ 'There are no channels you can view in this organization.';
+
@override
String get profileButtonSendDirectMessage => 'Pošlji neposredno sporočilo';
@@ -925,6 +935,13 @@ class ZulipLocalizationsSl extends ZulipLocalizations {
@override
String get channelsEmptyPlaceholder => 'Niste še naročeni na noben kanal.';
+ @override
+ String channelsEmptyPlaceholderWithAllChannelsLink(
+ String allChannelsPageTitle,
+ ) {
+ return 'You’re not subscribed to any channels yet. Try going to $allChannelsPageTitle and joining some of them.';
+ }
+
@override
String get sharePageTitle => 'Share';
diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart
index 92bcdb05c8..91fbfc57c8 100644
--- a/lib/generated/l10n/zulip_localizations_uk.dart
+++ b/lib/generated/l10n/zulip_localizations_uk.dart
@@ -67,6 +67,16 @@ class ZulipLocalizationsUk extends ZulipLocalizations {
@override
String get chooseAccountButtonAddAnAccount => 'Додати обліковий запис';
+ @override
+ String get navButtonAllChannels => 'All channels';
+
+ @override
+ String get allChannelsPageTitle => 'All channels';
+
+ @override
+ String get allChannelsEmptyPlaceholder =>
+ 'There are no channels you can view in this organization.';
+
@override
String get profileButtonSendDirectMessage =>
'Надіслати особисте повідомлення';
@@ -915,6 +925,13 @@ class ZulipLocalizationsUk extends ZulipLocalizations {
@override
String get channelsEmptyPlaceholder => 'Ви ще не підписані на жодний канал.';
+ @override
+ String channelsEmptyPlaceholderWithAllChannelsLink(
+ String allChannelsPageTitle,
+ ) {
+ return 'You’re not subscribed to any channels yet. Try going to $allChannelsPageTitle and joining some of them.';
+ }
+
@override
String get sharePageTitle => 'Поділитися';
diff --git a/lib/generated/l10n/zulip_localizations_zh.dart b/lib/generated/l10n/zulip_localizations_zh.dart
index 00e7ed864e..46b38ca739 100644
--- a/lib/generated/l10n/zulip_localizations_zh.dart
+++ b/lib/generated/l10n/zulip_localizations_zh.dart
@@ -67,6 +67,16 @@ class ZulipLocalizationsZh extends ZulipLocalizations {
@override
String get chooseAccountButtonAddAnAccount => 'Add an account';
+ @override
+ String get navButtonAllChannels => 'All channels';
+
+ @override
+ String get allChannelsPageTitle => 'All channels';
+
+ @override
+ String get allChannelsEmptyPlaceholder =>
+ 'There are no channels you can view in this organization.';
+
@override
String get profileButtonSendDirectMessage => 'Send direct message';
@@ -898,7 +908,14 @@ class ZulipLocalizationsZh extends ZulipLocalizations {
@override
String get channelsEmptyPlaceholder =>
- 'You are not subscribed to any channels yet.';
+ 'You’re not subscribed to any channels yet.';
+
+ @override
+ String channelsEmptyPlaceholderWithAllChannelsLink(
+ String allChannelsPageTitle,
+ ) {
+ return 'You’re not subscribed to any channels yet. Try going to $allChannelsPageTitle and joining some of them.';
+ }
@override
String get sharePageTitle => 'Share';
diff --git a/lib/model/channel.dart b/lib/model/channel.dart
index ae864e7ed5..11d556b902 100644
--- a/lib/model/channel.dart
+++ b/lib/model/channel.dart
@@ -42,6 +42,24 @@ mixin ChannelStore on UserStore {
/// and [streamsByName].
Map get subscriptions;
+ static int compareChannelsByName(ZulipStream a, ZulipStream b) {
+ // A user gave feedback wanting zulip-flutter to match web in putting
+ // emoji-prefixed channels first; see #1202.
+ // TODO(#1165) for matching web's ordering completely, which
+ // (for the all-channels view) I think just means locale-aware sorting.
+ final aStartsWithEmoji = _startsWithEmojiRegex.hasMatch(a.name);
+ final bStartsWithEmoji = _startsWithEmojiRegex.hasMatch(b.name);
+ if (aStartsWithEmoji && !bStartsWithEmoji) return -1;
+ if (!aStartsWithEmoji && bStartsWithEmoji) return 1;
+
+ return a.name.toLowerCase().compareTo(b.name.toLowerCase());
+ }
+
+ // TODO(linter): The linter incorrectly flags the following regexp string
+ // as invalid. See: https://github.com/dart-lang/sdk/issues/61246
+ // ignore: valid_regexps
+ static final _startsWithEmojiRegex = RegExp(r'^\p{Emoji}', unicode: true);
+
/// The visibility policy that the self-user has for the given topic.
///
/// This does not incorporate the user's channel-level policy,
diff --git a/lib/widgets/all_channels.dart b/lib/widgets/all_channels.dart
new file mode 100644
index 0000000000..7acdbe8521
--- /dev/null
+++ b/lib/widgets/all_channels.dart
@@ -0,0 +1,153 @@
+import 'package:flutter/material.dart';
+
+import '../api/model/model.dart';
+import '../api/route/channels.dart';
+import '../generated/l10n/zulip_localizations.dart';
+import '../log.dart';
+import '../model/channel.dart';
+import 'action_sheet.dart';
+import 'app_bar.dart';
+import 'button.dart';
+import 'icons.dart';
+import 'page.dart';
+import 'remote_settings.dart';
+import 'store.dart';
+import 'text.dart';
+import 'theme.dart';
+
+/// The "All channels" page.
+///
+/// See Figma:
+/// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=7723-6411&m=dev
+// The Figma shows this page with both a back button and the bottom nav bar,
+// with "#" highlighted, as though it's in a stack with "Subscribed channels"
+// that lives in the home page "#" tab.
+// We skip making that sub-stack and just make this an ordinary page
+// that gets pushed onto the main stack, with no bottom nav bar.
+class AllChannelsPage extends StatelessWidget {
+ const AllChannelsPage({super.key});
+
+ static AccountRoute buildRoute({required BuildContext context}) {
+ return MaterialAccountWidgetRoute(
+ context: context, page: const AllChannelsPage());
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final zulipLocalizations = ZulipLocalizations.of(context);
+ return Scaffold(
+ appBar: ZulipAppBar(
+ title: Text(zulipLocalizations.allChannelsPageTitle)),
+ body: AllChannelsPageBody());
+ }
+}
+
+
+class AllChannelsPageBody extends StatelessWidget {
+ const AllChannelsPageBody({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ final zulipLocalizations = ZulipLocalizations.of(context);
+ final channels = PerAccountStoreWidget.of(context).streams;
+
+ if (channels.isEmpty) {
+ return PageBodyEmptyContentPlaceholder(
+ message: zulipLocalizations.allChannelsEmptyPlaceholder);
+ }
+
+ final items = channels.values.toList();
+ items.sort(ChannelStore.compareChannelsByName);
+
+ final sliverList = SliverPadding(
+ padding: EdgeInsets.symmetric(vertical: 8),
+ sliver: MediaQuery.removePadding(
+ context: context,
+ // the bottom inset will be consumed by a different sliver after this one
+ removeBottom: true,
+ child: SliverSafeArea(
+ minimum: EdgeInsetsDirectional.only(start: 8).resolve(Directionality.of(context)),
+ sliver: SliverList.builder(
+ itemCount: items.length,
+ itemBuilder: (context, i) =>
+ AllChannelsListEntry(channel: items[i])))));
+
+ return CustomScrollView(slivers: [
+ sliverList,
+ SliverSafeArea(
+ // TODO(#1572) "New channel" button
+ sliver: SliverPadding(padding: EdgeInsets.zero)),
+ ]);
+ }
+}
+
+@visibleForTesting
+class AllChannelsListEntry extends StatelessWidget {
+ const AllChannelsListEntry({super.key, required this.channel});
+
+ final ZulipStream channel;
+
+ @override
+ Widget build(BuildContext context) {
+ final store = PerAccountStoreWidget.of(context);
+ final designVariables = DesignVariables.of(context);
+ final channel = this.channel;
+ final Subscription? subscription = channel is Subscription ? channel : null;
+ final hasContentAccess = store.selfHasContentAccess(channel);
+
+ return Padding(
+ padding: EdgeInsetsDirectional.only(start: 8, end: 4),
+ child: Row(spacing: 6, children: [
+ Icon(
+ size: 20,
+ color: colorSwatchFor(context, subscription).iconOnPlainBackground,
+ iconDataForStream(channel)),
+ Expanded(
+ child: Text(
+ style: TextStyle(
+ color: designVariables.textMessage,
+ fontSize: 17,
+ height: 20 / 17,
+ ).merge(weightVariableTextStyle(context, wght: 600)),
+ channel.name)),
+ if (hasContentAccess) _SubscribeToggle(channel: channel),
+ ZulipIconButton(
+ icon: ZulipIcons.more_horizontal,
+ onPressed: () {
+ showChannelActionSheet(context, channelId: channel.streamId);
+ }),
+ ]));
+ }
+}
+
+class _SubscribeToggle extends StatelessWidget {
+ const _SubscribeToggle({required this.channel});
+
+ final ZulipStream channel;
+
+ @override
+ Widget build(BuildContext context) {
+ final zulipLocalizations = ZulipLocalizations.of(context);
+ final store = PerAccountStoreWidget.of(context);
+
+ return RemoteSettingBuilder(
+ findValueInStore: (store) => store.subscriptions.containsKey(channel.streamId),
+ sendValueToServer: (value) async {
+ if (value) {
+ await subscribeToChannel(store.connection,
+ subscriptions: [channel.name]);
+ } else {
+ await unsubscribeFromChannel(store.connection,
+ subscriptions: [channel.name]);
+ }
+ },
+ // TODO(#741) interpret API errors for user
+ onError: (e, requestedValue) => reportErrorToUserBriefly(
+ requestedValue
+ ? zulipLocalizations.subscribeFailedTitle
+ : zulipLocalizations.unsubscribeFailedTitle),
+ builder: (value, handleRequestNewValue) => Toggle(
+ value: value,
+ onChanged: handleRequestNewValue));
+ }
+}
diff --git a/lib/widgets/button.dart b/lib/widgets/button.dart
index d4f8c98486..86997c08f2 100644
--- a/lib/widgets/button.dart
+++ b/lib/widgets/button.dart
@@ -213,6 +213,45 @@ enum ZulipWebUiKitButtonSize {
normal,
}
+/// The "icon button" component in the Figma.
+///
+/// See Figma:
+/// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=7728-10468&m=dev
+class ZulipIconButton extends StatelessWidget {
+ const ZulipIconButton({
+ super.key,
+ required this.icon,
+ required this.onPressed,
+ });
+
+ final IconData icon;
+ final VoidCallback onPressed;
+
+ @override
+ Widget build(BuildContext context) {
+ final designVariables = DesignVariables.of(context);
+
+ // Really `fg-05` from the Zulip Web UI Kit palette,
+ // but this seems at least as good as that.
+ final touchFeedbackColor = designVariables.foreground.withFadedAlpha(0.05);
+
+ return IconButton(
+ color: designVariables.icon,
+ iconSize: 24,
+ icon: Icon(icon),
+ onPressed: onPressed,
+ style: IconButton.styleFrom(
+ tapTargetSize: MaterialTapTargetSize.shrinkWrap,
+ fixedSize: Size.square(40),
+
+ // TODO(#417): Disable splash effects for all buttons globally.
+ splashFactory: NoSplash.splashFactory,
+ highlightColor: touchFeedbackColor,
+ shape: const RoundedRectangleBorder(
+ borderRadius: BorderRadius.all(Radius.circular(4)))));
+ }
+}
+
/// Apply [Transform.scale] to the child widget when tapped, and reset its scale
/// when released, while animating the transitions.
class AnimatedScaleOnTap extends StatefulWidget {
@@ -291,7 +330,7 @@ class ZulipMenuItemButton extends StatelessWidget {
this.style = ZulipMenuItemButtonStyle.menu,
required this.label,
this.subLabel,
- this.onPressed,
+ required this.onPressed,
this.icon,
this.toggle,
});
@@ -299,7 +338,7 @@ class ZulipMenuItemButton extends StatelessWidget {
final ZulipMenuItemButtonStyle style;
final String label;
final TextSpan? subLabel;
- final VoidCallback? onPressed;
+ final VoidCallback onPressed;
final IconData? icon;
/// A [Toggle] to go before [icon], or in its place if it's null.
@@ -438,6 +477,9 @@ enum ZulipMenuItemButtonStyle {
/// The "toggle" component in Figma.
///
+/// If [onChanged] is null, the switch will be displayed as disabled.
+/// (Like in the Material [Switch] widget.)
+///
/// See Figma:
/// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6070-60682&m=dev
class Toggle extends StatelessWidget {
@@ -448,7 +490,7 @@ class Toggle extends StatelessWidget {
});
final bool value;
- final ValueChanged onChanged;
+ final ValueChanged? onChanged;
@override
Widget build(BuildContext context) {
@@ -456,10 +498,21 @@ class Toggle extends StatelessWidget {
// TODO(#831)
final activeColor = Color(0xff4370f0);
+ final activeColorDisabled = activeColor.withFadedAlpha(0.4);
+
// Figma has this (grey/400) in both light and dark mode.
// TODO(#831)
final inactiveColor = Color(0xff9194a3);
+ final inactiveColorDisabled = inactiveColor.withFadedAlpha(0.4);
+
+ final trackColor = WidgetStateColor.fromMap({
+ WidgetState.selected & ~WidgetState.disabled: activeColor,
+ WidgetState.selected & WidgetState.disabled: activeColorDisabled,
+ ~WidgetState.selected & ~WidgetState.disabled: inactiveColor,
+ ~WidgetState.selected & WidgetState.disabled: inactiveColorDisabled,
+ });
+
// TODO(#1636):
// All of these just need _SwitchConfig to be exposed,
// and there's an upstream issue for that:
@@ -485,12 +538,8 @@ class Toggle extends StatelessWidget {
// Figma has white for "on" and "off" in both light and dark mode.
thumbColor: WidgetStatePropertyAll(Colors.white),
- activeTrackColor: activeColor,
- inactiveTrackColor: inactiveColor,
- trackOutlineColor: WidgetStateColor.fromMap({
- WidgetState.selected: activeColor,
- ~WidgetState.selected: inactiveColor,
- }),
+ trackColor: trackColor,
+ trackOutlineColor: WidgetStatePropertyAll(Colors.transparent),
trackOutlineWidth: WidgetStateProperty.fromMap({
// The outline is effectively painted with strokeAlignCenter:
// https://api.flutter.dev/flutter/painting/BorderSide/strokeAlignCenter-constant.html
diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart
index ed352feeba..54ab74eed3 100644
--- a/lib/widgets/icons.dart
+++ b/lib/widgets/icons.dart
@@ -135,62 +135,65 @@ abstract final class ZulipIcons {
/// The Zulip custom icon "message_feed".
static const IconData message_feed = IconData(0xf125, fontFamily: "Zulip Icons");
+ /// The Zulip custom icon "more_horizontal".
+ static const IconData more_horizontal = IconData(0xf126, fontFamily: "Zulip Icons");
+
/// The Zulip custom icon "mute".
- static const IconData mute = IconData(0xf126, fontFamily: "Zulip Icons");
+ static const IconData mute = IconData(0xf127, fontFamily: "Zulip Icons");
/// The Zulip custom icon "person".
- static const IconData person = IconData(0xf127, fontFamily: "Zulip Icons");
+ static const IconData person = IconData(0xf128, fontFamily: "Zulip Icons");
/// The Zulip custom icon "plus".
- static const IconData plus = IconData(0xf128, fontFamily: "Zulip Icons");
+ static const IconData plus = IconData(0xf129, fontFamily: "Zulip Icons");
/// The Zulip custom icon "read_receipts".
- static const IconData read_receipts = IconData(0xf129, fontFamily: "Zulip Icons");
+ static const IconData read_receipts = IconData(0xf12a, fontFamily: "Zulip Icons");
/// The Zulip custom icon "remove".
- static const IconData remove = IconData(0xf12a, fontFamily: "Zulip Icons");
+ static const IconData remove = IconData(0xf12b, fontFamily: "Zulip Icons");
/// The Zulip custom icon "search".
- static const IconData search = IconData(0xf12b, fontFamily: "Zulip Icons");
+ static const IconData search = IconData(0xf12c, fontFamily: "Zulip Icons");
/// The Zulip custom icon "see_who_reacted".
- static const IconData see_who_reacted = IconData(0xf12c, fontFamily: "Zulip Icons");
+ static const IconData see_who_reacted = IconData(0xf12d, fontFamily: "Zulip Icons");
/// The Zulip custom icon "send".
- static const IconData send = IconData(0xf12d, fontFamily: "Zulip Icons");
+ static const IconData send = IconData(0xf12e, fontFamily: "Zulip Icons");
/// The Zulip custom icon "settings".
- static const IconData settings = IconData(0xf12e, fontFamily: "Zulip Icons");
+ static const IconData settings = IconData(0xf12f, fontFamily: "Zulip Icons");
/// The Zulip custom icon "share".
- static const IconData share = IconData(0xf12f, fontFamily: "Zulip Icons");
+ static const IconData share = IconData(0xf130, fontFamily: "Zulip Icons");
/// The Zulip custom icon "share_ios".
- static const IconData share_ios = IconData(0xf130, fontFamily: "Zulip Icons");
+ static const IconData share_ios = IconData(0xf131, fontFamily: "Zulip Icons");
/// The Zulip custom icon "smile".
- static const IconData smile = IconData(0xf131, fontFamily: "Zulip Icons");
+ static const IconData smile = IconData(0xf132, fontFamily: "Zulip Icons");
/// The Zulip custom icon "star".
- static const IconData star = IconData(0xf132, fontFamily: "Zulip Icons");
+ static const IconData star = IconData(0xf133, fontFamily: "Zulip Icons");
/// The Zulip custom icon "star_filled".
- static const IconData star_filled = IconData(0xf133, fontFamily: "Zulip Icons");
+ static const IconData star_filled = IconData(0xf134, fontFamily: "Zulip Icons");
/// The Zulip custom icon "three_person".
- static const IconData three_person = IconData(0xf134, fontFamily: "Zulip Icons");
+ static const IconData three_person = IconData(0xf135, fontFamily: "Zulip Icons");
/// The Zulip custom icon "topic".
- static const IconData topic = IconData(0xf135, fontFamily: "Zulip Icons");
+ static const IconData topic = IconData(0xf136, fontFamily: "Zulip Icons");
/// The Zulip custom icon "topics".
- static const IconData topics = IconData(0xf136, fontFamily: "Zulip Icons");
+ static const IconData topics = IconData(0xf137, fontFamily: "Zulip Icons");
/// The Zulip custom icon "two_person".
- static const IconData two_person = IconData(0xf137, fontFamily: "Zulip Icons");
+ static const IconData two_person = IconData(0xf138, fontFamily: "Zulip Icons");
/// The Zulip custom icon "unmute".
- static const IconData unmute = IconData(0xf138, fontFamily: "Zulip Icons");
+ static const IconData unmute = IconData(0xf139, fontFamily: "Zulip Icons");
// END GENERATED ICON DATA
}
diff --git a/lib/widgets/page.dart b/lib/widgets/page.dart
index 9aebca9dfd..63a78951f5 100644
--- a/lib/widgets/page.dart
+++ b/lib/widgets/page.dart
@@ -1,4 +1,3 @@
-
import 'package:flutter/material.dart';
import 'store.dart';
@@ -227,14 +226,49 @@ class LoadingPlaceholderPage extends StatelessWidget {
// TODO(#311) If the message list gets a bottom nav, the bottom inset will
// always be handled externally too; simplify implementation and dartdoc.
class PageBodyEmptyContentPlaceholder extends StatelessWidget {
- const PageBodyEmptyContentPlaceholder({super.key, required this.message});
+ const PageBodyEmptyContentPlaceholder({
+ super.key,
+ this.message,
+ this.messageWithLinkMarkup,
+ this.onTapLink,
+ }) : assert(
+ (message != null)
+ ^ (messageWithLinkMarkup != null && onTapLink != null));
- final String message;
+ final String? message;
+ final String? messageWithLinkMarkup;
+ final VoidCallback? onTapLink;
- @override
- Widget build(BuildContext context) {
+ TextStyle _messageStyle(BuildContext context) {
final designVariables = DesignVariables.of(context);
+ return TextStyle(
+ color: designVariables.labelSearchPrompt,
+ fontSize: 17,
+ height: 23 / 17,
+ ).merge(weightVariableTextStyle(context, wght: 500));
+ }
+
+ Widget? _buildMessage(BuildContext context) {
+ if (message != null) {
+ return Text(
+ textAlign: TextAlign.center,
+ style: _messageStyle(context),
+ message!);
+ }
+ if (messageWithLinkMarkup != null) {
+ return TextWithLink(
+ onTap: onTapLink!,
+ textAlign: TextAlign.center,
+ style: _messageStyle(context),
+ markup: messageWithLinkMarkup!);
+ }
+ assert(false);
+ return null;
+ }
+
+ @override
+ Widget build(BuildContext context) {
return SafeArea(
minimum: EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Padding(
@@ -243,13 +277,6 @@ class PageBodyEmptyContentPlaceholder extends StatelessWidget {
alignment: Alignment.topCenter,
// TODO leading and trailing elements, like in Figma (given as SVGs):
// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=5957-167736&m=dev
- child: Text(
- textAlign: TextAlign.center,
- style: TextStyle(
- color: designVariables.labelSearchPrompt,
- fontSize: 17,
- height: 23 / 17,
- ).merge(weightVariableTextStyle(context, wght: 500)),
- message))));
+ child: _buildMessage(context))));
}
}
diff --git a/lib/widgets/share.dart b/lib/widgets/share.dart
index e8762a7d53..083ed83bba 100644
--- a/lib/widgets/share.dart
+++ b/lib/widgets/share.dart
@@ -192,6 +192,7 @@ class SharePage extends StatelessWidget {
SubscriptionListPageBody(
showTopicListButtonInActionSheet: false,
hideChannelsIfUserCantPost: true,
+ allowGoToAllChannels: false,
onChannelSelect: (narrow) => _handleNarrowSelect(context, narrow),
// TODO(#412) add onTopicSelect, Currently when user lands on the
// channel feed page from subscription list page and they tap
diff --git a/lib/widgets/subscription_list.dart b/lib/widgets/subscription_list.dart
index d2b14b5426..50fd68cff0 100644
--- a/lib/widgets/subscription_list.dart
+++ b/lib/widgets/subscription_list.dart
@@ -2,9 +2,12 @@ import 'package:flutter/material.dart';
import '../api/model/model.dart';
import '../generated/l10n/zulip_localizations.dart';
+import '../model/channel.dart';
import '../model/narrow.dart';
import '../model/unreads.dart';
import 'action_sheet.dart';
+import 'all_channels.dart';
+import 'button.dart';
import 'icons.dart';
import 'message_list.dart';
import 'page.dart';
@@ -21,6 +24,7 @@ class SubscriptionListPageBody extends StatefulWidget {
super.key,
this.showTopicListButtonInActionSheet = true,
this.hideChannelsIfUserCantPost = false,
+ this.allowGoToAllChannels = true,
this.onChannelSelect,
});
@@ -31,6 +35,7 @@ class SubscriptionListPageBody extends StatefulWidget {
// https://github.com/zulip/zulip-flutter/pull/1774#discussion_r2249032503
final bool showTopicListButtonInActionSheet;
final bool hideChannelsIfUserCantPost;
+ final bool allowGoToAllChannels;
/// Callback to invoke when the user selects a channel from the list.
///
@@ -66,27 +71,12 @@ class _SubscriptionListPageBodyState extends State wit
});
}
- // TODO(linter): The linter incorrectly flags the following regexp string
- // as invalid. See: https://github.com/dart-lang/sdk/issues/61246
- // ignore: valid_regexps
- static final _startsWithEmojiRegex = RegExp(r'^\p{Emoji}', unicode: true);
-
void _sortSubs(List list) {
list.sort((a, b) {
if (a.isMuted && !b.isMuted) return 1;
if (!a.isMuted && b.isMuted) return -1;
- // A user gave feedback wanting zulip-flutter to match web in putting
- // emoji-prefixed channels first; see #1202.
- // For matching web's ordering completely, see:
- // https://github.com/zulip/zulip-flutter/issues/1165
- final aStartsWithEmoji = _startsWithEmojiRegex.hasMatch(a.name);
- final bStartsWithEmoji = _startsWithEmojiRegex.hasMatch(b.name);
- if (aStartsWithEmoji && !bStartsWithEmoji) return -1;
- if (!aStartsWithEmoji && bStartsWithEmoji) return 1;
-
- // TODO(i18n): add locale-aware sorting
- return a.name.toLowerCase().compareTo(b.name.toLowerCase());
+ return ChannelStore.compareChannelsByName(a, b);
});
}
@@ -118,6 +108,14 @@ class _SubscriptionListPageBodyState extends State wit
final store = PerAccountStoreWidget.of(context);
final zulipLocalizations = ZulipLocalizations.of(context);
+ final includeAllChannelsButton = widget.allowGoToAllChannels
+ // See Help Center doc:
+ // https://zulip.com/help/configure-who-can-subscribe
+ // > Guests can never subscribe themselves to a channel.
+ // (Web also hides the corresponding link for guests;
+ // see web/templates/left_sidebar.hbs.)
+ && store.selfUser.role.isAtLeast(UserRole.member);
+
final List pinned = [];
final List unpinned = [];
final now = DateTime.now();
@@ -138,9 +136,17 @@ class _SubscriptionListPageBodyState extends State wit
_sortSubs(unpinned);
if (pinned.isEmpty && unpinned.isEmpty) {
- return PageBodyEmptyContentPlaceholder(
- // TODO(#188) add e.g. "Go to 'All channels' and join some of them."
- message: zulipLocalizations.channelsEmptyPlaceholder);
+ if (includeAllChannelsButton) {
+ return PageBodyEmptyContentPlaceholder(
+ messageWithLinkMarkup:
+ zulipLocalizations.channelsEmptyPlaceholderWithAllChannelsLink(
+ zulipLocalizations.allChannelsPageTitle),
+ onTapLink: () => Navigator.push(context,
+ AllChannelsPage.buildRoute(context: context)));
+ } else {
+ return PageBodyEmptyContentPlaceholder(
+ message: zulipLocalizations.channelsEmptyPlaceholder);
+ }
}
return SafeArea(
@@ -174,7 +180,18 @@ class _SubscriptionListPageBodyState extends State wit
onChannelSelect: _handleChannelSelect),
],
- // TODO(#188): add button leading to "All Streams" page with ability to subscribe
+ if (includeAllChannelsButton) ...[
+ SliverPadding(
+ padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
+ sliver: SliverToBoxAdapter(
+ child: MenuButtonsShape(buttons: [
+ ZulipMenuItemButton(
+ label: zulipLocalizations.navButtonAllChannels,
+ icon: ZulipIcons.chevron_right,
+ onPressed: () => Navigator.push(context,
+ AllChannelsPage.buildRoute(context: context))),
+ ]))),
+ ],
// This ensures last item in scrollable can settle in an unobstructed area.
// (Noop in the home-page case; see comment on `bottom: false` arg in
diff --git a/lib/widgets/text.dart b/lib/widgets/text.dart
index f2f0edd968..3fe48484e3 100644
--- a/lib/widgets/text.dart
+++ b/lib/widgets/text.dart
@@ -438,9 +438,16 @@ TextBaseline localizedTextBaseline(BuildContext context) {
/// TODO(#1285): Generalize this to other styling, like code font and italics.
/// TODO(#1553): Generalize this to multiple links in one string.
class TextWithLink extends StatefulWidget {
- const TextWithLink({super.key, this.style, required this.onTap, required this.markup});
+ const TextWithLink({
+ super.key,
+ this.style,
+ this.textAlign,
+ required this.onTap,
+ required this.markup,
+ });
final TextStyle? style;
+ final TextAlign? textAlign;
/// A callback to be called when the user taps the link.
///
@@ -520,6 +527,9 @@ class _TextWithLinkState extends State {
]);
}
- return Text.rich(span, style: widget.style);
+ return Text.rich(
+ style: widget.style,
+ textAlign: widget.textAlign,
+ span);
}
}
diff --git a/test/api/model/model_checks.dart b/test/api/model/model_checks.dart
index 236d5b869f..96a223c084 100644
--- a/test/api/model/model_checks.dart
+++ b/test/api/model/model_checks.dart
@@ -53,6 +53,7 @@ extension SavedSnippetChecks on Subject {
}
extension ZulipStreamChecks on Subject {
+ Subject get streamId => has((x) => x.streamId, 'streamId');
}
extension TopicNameChecks on Subject {
diff --git a/test/example_data.dart b/test/example_data.dart
index 110a2f0ca8..b9f9d87f01 100644
--- a/test/example_data.dart
+++ b/test/example_data.dart
@@ -222,6 +222,14 @@ final UserGroup nobodyGroup = userGroup(
members: [], directSubgroupIds: [],
);
+GroupSettingValueNameless groupSetting({
+ List? members,
+ List? subgroups,
+}) => GroupSettingValueNameless(
+ directMembers: members ?? [],
+ directSubgroups: subgroups ?? [],
+);
+
RealmEmojiItem realmEmojiItem({
required String emojiCode,
required String emojiName,
diff --git a/test/widgets/all_channels_test.dart b/test/widgets/all_channels_test.dart
new file mode 100644
index 0000000000..c824e5fe9b
--- /dev/null
+++ b/test/widgets/all_channels_test.dart
@@ -0,0 +1,210 @@
+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:zulip/api/model/model.dart';
+import 'package:zulip/model/store.dart';
+import 'package:zulip/widgets/all_channels.dart';
+import 'package:zulip/widgets/app_bar.dart';
+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/theme.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 'checks.dart';
+import 'test_app.dart';
+
+void main() {
+ TestZulipBinding.ensureInitialized();
+
+ late PerAccountStore store;
+ late TransitionDurationObserver transitionDurationObserver;
+
+ final groupSettingWithSelf = eg.groupSetting(members: [eg.selfUser.userId]);
+
+ /// Sets up the page, with [channels], any of which may be [Subscription]s.
+ Future setupAllChannelsPage(WidgetTester tester, {
+ required List channels,
+ }) async {
+ addTearDown(testBinding.reset);
+ final subscriptions = channels.whereType().toList();
+ final initialSnapshot = eg.initialSnapshot(
+ subscriptions: subscriptions,
+ streams: channels,
+ );
+ await testBinding.globalStore.add(eg.selfAccount, initialSnapshot);
+ store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
+
+ transitionDurationObserver = TransitionDurationObserver();
+ await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
+ navigatorObservers: [transitionDurationObserver],
+ child: const AllChannelsPage()));
+
+ // global store, per-account store
+ await tester.pumpAndSettle();
+
+ check(find.byType(AllChannelsPageBody)).findsOne();
+ check(find.widgetWithText(ZulipAppBar, 'All channels')).findsOne();
+ }
+
+ Future addPrivateChannelWithContentAccess(String? name) async {
+ final channel = eg.stream(
+ name: name,
+ inviteOnly: true,
+ canSubscribeGroup: groupSettingWithSelf,
+ canAddSubscribersGroup: groupSettingWithSelf);
+ await store.addStream(channel);
+ check(store.selfHasContentAccess(channel)).isTrue();
+ return channel;
+ }
+
+ testWidgets('navigate to page', (tester) async {
+ addTearDown(testBinding.reset);
+
+ final channel = eg.stream();
+ final initialSnapshot = eg.initialSnapshot(
+ subscriptions: [eg.subscription(channel)],
+ streams: [channel],
+ );
+ await testBinding.globalStore.add(eg.selfAccount, initialSnapshot);
+ store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
+
+ transitionDurationObserver = TransitionDurationObserver();
+ await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
+ navigatorObservers: [transitionDurationObserver],
+ child: const HomePage()));
+
+ // global store, per-account store
+ await tester.pumpAndSettle();
+
+ // Switch to channels tab.
+ await tester.tap(find.byIcon(ZulipIcons.hash_italic));
+ await tester.pump();
+
+ // expect menu button at the end of the list
+ final finder = find.widgetWithText(
+ ZulipMenuItemButton, 'All channels', skipOffstage: false);
+ await tester.ensureVisible(finder);
+ await tester.pump();
+ await tester.tap(finder);
+ await tester.pump();
+ await transitionDurationObserver.pumpPastTransition(tester);
+
+ check(find.byType(AllChannelsPageBody)).findsOne();
+ check(find.widgetWithText(ZulipAppBar, 'All channels')).findsOne();
+ });
+
+ testWidgets('navigate to page from empty subscription list', (tester) async {
+ addTearDown(testBinding.reset);
+
+ final initialSnapshot = eg.initialSnapshot(
+ subscriptions: [],
+ streams: [],
+ );
+ await testBinding.globalStore.add(eg.selfAccount, initialSnapshot);
+ store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
+
+ transitionDurationObserver = TransitionDurationObserver();
+ await tester.pumpWidget(TestZulipApp(accountId: eg.selfAccount.id,
+ navigatorObservers: [transitionDurationObserver],
+ child: const HomePage()));
+
+ // global store, per-account store
+ await tester.pumpAndSettle();
+
+ // Switch to channels tab.
+ await tester.tap(find.byIcon(ZulipIcons.hash_italic));
+ await tester.pump();
+
+ // expect empty-content placeholder with link
+ await tester.tapOnText(find.textRange.ofSubstring('All channels'));
+ await tester.pump();
+ await transitionDurationObserver.pumpPastTransition(tester);
+
+ check(find.byType(AllChannelsPageBody)).findsOne();
+ check(find.widgetWithText(ZulipAppBar, 'All channels')).findsOne();
+ });
+
+ testWidgets('empty', (tester) async {
+ await setupAllChannelsPage(tester, channels: []);
+ check(find.widgetWithText(PageBodyEmptyContentPlaceholder,
+ 'There are no channels you can view in this organization.')).findsOne();
+ });
+
+ testWidgets('sorting/appearance', (tester) async {
+ final channel1 = eg.subscription(eg.stream(name: 'e', inviteOnly: true));
+ final channel2 = eg.subscription(eg.stream(name: 'A', inviteOnly: false, isWebPublic: false));
+ final channel3 = eg.subscription(eg.stream(name: 'b', inviteOnly: false, isWebPublic: true));
+ final channel4 = eg.stream(name: '😀 a', inviteOnly: true);
+ final channel5 = eg.stream(name: '😀 b', inviteOnly: false, isWebPublic: false);
+ final channel6 = eg.stream(name: 'f', inviteOnly: false, isWebPublic: true);
+
+ await setupAllChannelsPage(tester,
+ channels: [channel1, channel2, channel3, channel4, channel5, channel6]);
+
+ final channel7 = await addPrivateChannelWithContentAccess('d');
+ check(store.streams.length).equals(7);
+ await tester.pump();
+
+ final channelsInUiOrder =
+ [channel4, channel5, channel2, channel3, channel7, channel1, channel6];
+
+ // Check that the UI list shows exactly the intended channels, in order.
+ //
+ // …It seems like the list-building optimization (saving resources for
+ // offscreen items) would break this if there's much more than a screenful
+ // of channels. For expediency we just test with less than a screenful.
+ check(
+ tester.widgetList(find.byType(AllChannelsListEntry))
+ ).deepEquals(
+ channelsInUiOrder.map>((channel) =>
+ (it) => it.isA().channel
+ .streamId.equals(channel.streamId))
+ );
+
+ // Check details of the channels.
+ for (final channel in channelsInUiOrder) {
+ final findElement = find.byElementPredicate((element) {
+ final widget = element.widget;
+ return widget is AllChannelsListEntry && widget.channel.streamId == channel.streamId;
+ }, skipOffstage: false);
+ final element = tester.element(findElement);
+ Finder findInRow(Finder finder) =>
+ find.descendant(of: findElement, matching: finder);
+
+ final icon = tester.widget(findInRow(find.byIcon(iconDataForStream(channel))));
+ final maybeSubscription = channel is Subscription ? channel : null;
+ final colorSwatch = colorSwatchFor(element, maybeSubscription);
+ check(icon).color.equals(colorSwatch.iconOnPlainBackground);
+
+ check(findInRow(find.text(channel.name))).findsOne();
+
+ final maybeToggle = tester.widgetList(
+ findInRow(find.byType(Toggle))).singleOrNull;
+ if (store.selfHasContentAccess(channel)) {
+ final isSubscribed = channel is Subscription;
+ check(maybeToggle).isNotNull().value.equals(isSubscribed);
+ } else {
+ check(maybeToggle).isNull();
+ }
+
+ check(findInRow(find.byIcon(ZulipIcons.more_horizontal))).findsOne();
+ }
+ });
+
+ testWidgets('tapping three-dots button opens channel action sheet', (tester) async {
+ await setupAllChannelsPage(tester, channels: [eg.stream()]);
+
+ await tester.tap(find.byIcon(ZulipIcons.more_horizontal));
+ await tester.pump();
+ await transitionDurationObserver.pumpPastTransition(tester);
+
+ check(find.byType(BottomSheet)).findsOne();
+ });
+}
diff --git a/test/widgets/button_test.dart b/test/widgets/button_test.dart
index 62f2fad7d1..acbdb9cfac 100644
--- a/test/widgets/button_test.dart
+++ b/test/widgets/button_test.dart
@@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
import 'package:legacy_checks/legacy_checks.dart';
import 'package:zulip/widgets/button.dart';
+import 'package:zulip/widgets/icons.dart';
import '../flutter_checks.dart';
import '../model/binding.dart';
@@ -96,4 +97,23 @@ void main() {
testVerticalOuterPadding(sizeVariant: ZulipWebUiKitButtonSize.small);
testVerticalOuterPadding(sizeVariant: ZulipWebUiKitButtonSize.normal);
});
+
+ group('ZulipIconButton', () {
+ testWidgets('occupies a 40px square', (tester) async {
+ addTearDown(testBinding.reset);
+
+ await tester.pumpWidget(TestZulipApp(
+ child: UnconstrainedBox(
+ child: ZulipIconButton(
+ icon: ZulipIcons.follow,
+ onPressed: () {}))));
+ await tester.pump();
+
+ final element = tester.element(find.byType(ZulipIconButton));
+ final renderObject = element.renderObject as RenderBox;
+ check(renderObject).size.equals(Size.square(40));
+ });
+
+ // TODO test that the touch feedback fills the whole square
+ });
}
diff --git a/test/widgets/checks.dart b/test/widgets/checks.dart
index 5383aa7583..856b3ebebe 100644
--- a/test/widgets/checks.dart
+++ b/test/widgets/checks.dart
@@ -1,9 +1,12 @@
import 'package:checks/checks.dart';
import 'package:flutter/widgets.dart';
+import 'package:zulip/api/model/model.dart';
import 'package:zulip/api/route/realm.dart';
import 'package:zulip/model/emoji.dart';
import 'package:zulip/model/narrow.dart';
+import 'package:zulip/widgets/all_channels.dart';
+import 'package:zulip/widgets/button.dart';
import 'package:zulip/widgets/channel_colors.dart';
import 'package:zulip/widgets/compose_box.dart';
import 'package:zulip/widgets/content.dart';
@@ -102,3 +105,11 @@ extension UnicodeEmojiWidgetChecks on Subject {
extension EmojiPickerListEntryChecks on Subject {
Subject get emoji => has((x) => x.emoji, 'emoji');
}
+
+extension AllChannelsListEntryChecks on Subject {
+ Subject get channel => has((x) => x.channel, 'channel');
+}
+
+extension ToggleChecks on Subject {
+ Subject get value => has((x) => x.value, 'value');
+}
diff --git a/test/widgets/subscription_list_test.dart b/test/widgets/subscription_list_test.dart
index 57e8af8e29..d0eb50624d 100644
--- a/test/widgets/subscription_list_test.dart
+++ b/test/widgets/subscription_list_test.dart
@@ -62,7 +62,9 @@ void main() {
check(getItemCount()).equals(0);
check(isPinnedHeaderInTree()).isFalse();
check(isUnpinnedHeaderInTree()).isFalse();
- check(find.text('You are not subscribed to any channels yet.')).findsOne();
+ check(find.text(
+ 'You’re not subscribed to any channels yet. Try going to All channels and joining some of them.'
+ )).findsOne();
});
testWidgets('basic subscriptions', (tester) async {