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 {