diff --git a/assets/icons/ZulipIcons.ttf b/assets/icons/ZulipIcons.ttf
index 84e19a9cfa..0ef0c3f461 100644
Binary files a/assets/icons/ZulipIcons.ttf and b/assets/icons/ZulipIcons.ttf differ
diff --git a/assets/icons/topics.svg b/assets/icons/topics.svg
new file mode 100644
index 0000000000..c07afa80b3
--- /dev/null
+++ b/assets/icons/topics.svg
@@ -0,0 +1,3 @@
+
\ No newline at end of file
diff --git a/assets/l10n/app_en.arb b/assets/l10n/app_en.arb
index d2d7b53033..ef795279e1 100644
--- a/assets/l10n/app_en.arb
+++ b/assets/l10n/app_en.arb
@@ -84,6 +84,10 @@
"@actionSheetOptionMarkChannelAsRead": {
"description": "Label for marking a channel as read."
},
+ "actionSheetOptionListOfTopics": "List of topics",
+ "@actionSheetOptionListOfTopics": {
+ "description": "Label for navigating to a channel's topic-list page."
+ },
"actionSheetOptionMuteTopic": "Mute topic",
"@actionSheetOptionMuteTopic": {
"description": "Label for muting a topic on action sheet."
@@ -713,6 +717,10 @@
"@mainMenuMyProfile": {
"description": "Label for main-menu button leading to the user's own profile."
},
+ "topicsButtonLabel": "TOPICS",
+ "@topicsButtonLabel": {
+ "description": "Label for message list button leading to topic-list page. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)"
+ },
"channelFeedButtonTooltip": "Channel feed",
"@channelFeedButtonTooltip": {
"description": "Tooltip for button to navigate to a given channel's feed"
diff --git a/lib/generated/l10n/zulip_localizations.dart b/lib/generated/l10n/zulip_localizations.dart
index 6181c7b39b..4bfda5a566 100644
--- a/lib/generated/l10n/zulip_localizations.dart
+++ b/lib/generated/l10n/zulip_localizations.dart
@@ -236,6 +236,12 @@ abstract class ZulipLocalizations {
/// **'Mark channel as read'**
String get actionSheetOptionMarkChannelAsRead;
+ /// Label for navigating to a channel's topic-list page.
+ ///
+ /// In en, this message translates to:
+ /// **'List of topics'**
+ String get actionSheetOptionListOfTopics;
+
/// Label for muting a topic on action sheet.
///
/// In en, this message translates to:
@@ -1064,6 +1070,12 @@ abstract class ZulipLocalizations {
/// **'My profile'**
String get mainMenuMyProfile;
+ /// Label for message list button leading to topic-list page. (Use ALL CAPS for cased alphabets: Latin, Greek, Cyrillic, etc.)
+ ///
+ /// In en, this message translates to:
+ /// **'TOPICS'**
+ String get topicsButtonLabel;
+
/// Tooltip for button to navigate to a given channel's feed
///
/// 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 f26b9d017c..2db28c9000 100644
--- a/lib/generated/l10n/zulip_localizations_ar.dart
+++ b/lib/generated/l10n/zulip_localizations_ar.dart
@@ -76,6 +76,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
@override
String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read';
+ @override
+ String get actionSheetOptionListOfTopics => 'List of topics';
+
@override
String get actionSheetOptionMuteTopic => 'Mute topic';
@@ -584,6 +587,9 @@ class ZulipLocalizationsAr extends ZulipLocalizations {
@override
String get mainMenuMyProfile => 'My profile';
+ @override
+ String get topicsButtonLabel => 'TOPICS';
+
@override
String get channelFeedButtonTooltip => 'Channel feed';
diff --git a/lib/generated/l10n/zulip_localizations_en.dart b/lib/generated/l10n/zulip_localizations_en.dart
index bfb14645d5..ee6791c12b 100644
--- a/lib/generated/l10n/zulip_localizations_en.dart
+++ b/lib/generated/l10n/zulip_localizations_en.dart
@@ -76,6 +76,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
@override
String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read';
+ @override
+ String get actionSheetOptionListOfTopics => 'List of topics';
+
@override
String get actionSheetOptionMuteTopic => 'Mute topic';
@@ -584,6 +587,9 @@ class ZulipLocalizationsEn extends ZulipLocalizations {
@override
String get mainMenuMyProfile => 'My profile';
+ @override
+ String get topicsButtonLabel => 'TOPICS';
+
@override
String get channelFeedButtonTooltip => 'Channel feed';
diff --git a/lib/generated/l10n/zulip_localizations_ja.dart b/lib/generated/l10n/zulip_localizations_ja.dart
index 35088555e2..a2ec010081 100644
--- a/lib/generated/l10n/zulip_localizations_ja.dart
+++ b/lib/generated/l10n/zulip_localizations_ja.dart
@@ -76,6 +76,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
@override
String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read';
+ @override
+ String get actionSheetOptionListOfTopics => 'List of topics';
+
@override
String get actionSheetOptionMuteTopic => 'Mute topic';
@@ -584,6 +587,9 @@ class ZulipLocalizationsJa extends ZulipLocalizations {
@override
String get mainMenuMyProfile => 'My profile';
+ @override
+ String get topicsButtonLabel => 'TOPICS';
+
@override
String get channelFeedButtonTooltip => 'Channel feed';
diff --git a/lib/generated/l10n/zulip_localizations_nb.dart b/lib/generated/l10n/zulip_localizations_nb.dart
index ae79d99ea9..8da3c4410d 100644
--- a/lib/generated/l10n/zulip_localizations_nb.dart
+++ b/lib/generated/l10n/zulip_localizations_nb.dart
@@ -76,6 +76,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
@override
String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read';
+ @override
+ String get actionSheetOptionListOfTopics => 'List of topics';
+
@override
String get actionSheetOptionMuteTopic => 'Mute topic';
@@ -584,6 +587,9 @@ class ZulipLocalizationsNb extends ZulipLocalizations {
@override
String get mainMenuMyProfile => 'My profile';
+ @override
+ String get topicsButtonLabel => 'TOPICS';
+
@override
String get channelFeedButtonTooltip => 'Channel feed';
diff --git a/lib/generated/l10n/zulip_localizations_pl.dart b/lib/generated/l10n/zulip_localizations_pl.dart
index 15f61bd882..ca263760e4 100644
--- a/lib/generated/l10n/zulip_localizations_pl.dart
+++ b/lib/generated/l10n/zulip_localizations_pl.dart
@@ -78,6 +78,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
String get actionSheetOptionMarkChannelAsRead =>
'Oznacz kanał jako przeczytany';
+ @override
+ String get actionSheetOptionListOfTopics => 'List of topics';
+
@override
String get actionSheetOptionMuteTopic => 'Wycisz wątek';
@@ -593,6 +596,9 @@ class ZulipLocalizationsPl extends ZulipLocalizations {
@override
String get mainMenuMyProfile => 'Mój profil';
+ @override
+ String get topicsButtonLabel => 'TOPICS';
+
@override
String get channelFeedButtonTooltip => 'Strumień kanału';
diff --git a/lib/generated/l10n/zulip_localizations_ru.dart b/lib/generated/l10n/zulip_localizations_ru.dart
index e4248179e2..950e5eee88 100644
--- a/lib/generated/l10n/zulip_localizations_ru.dart
+++ b/lib/generated/l10n/zulip_localizations_ru.dart
@@ -78,6 +78,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
String get actionSheetOptionMarkChannelAsRead =>
'Отметить канал как прочитанный';
+ @override
+ String get actionSheetOptionListOfTopics => 'List of topics';
+
@override
String get actionSheetOptionMuteTopic => 'Отключить тему';
@@ -597,6 +600,9 @@ class ZulipLocalizationsRu extends ZulipLocalizations {
@override
String get mainMenuMyProfile => 'Мой профиль';
+ @override
+ String get topicsButtonLabel => 'TOPICS';
+
@override
String get channelFeedButtonTooltip => 'Лента канала';
diff --git a/lib/generated/l10n/zulip_localizations_sk.dart b/lib/generated/l10n/zulip_localizations_sk.dart
index baded578ba..64fef81e7b 100644
--- a/lib/generated/l10n/zulip_localizations_sk.dart
+++ b/lib/generated/l10n/zulip_localizations_sk.dart
@@ -76,6 +76,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
@override
String get actionSheetOptionMarkChannelAsRead => 'Mark channel as read';
+ @override
+ String get actionSheetOptionListOfTopics => 'List of topics';
+
@override
String get actionSheetOptionMuteTopic => 'Stlmiť tému';
@@ -586,6 +589,9 @@ class ZulipLocalizationsSk extends ZulipLocalizations {
@override
String get mainMenuMyProfile => 'Môj profil';
+ @override
+ String get topicsButtonLabel => 'TOPICS';
+
@override
String get channelFeedButtonTooltip => 'Channel feed';
diff --git a/lib/generated/l10n/zulip_localizations_uk.dart b/lib/generated/l10n/zulip_localizations_uk.dart
index 50b1029dd7..b2da43a5e8 100644
--- a/lib/generated/l10n/zulip_localizations_uk.dart
+++ b/lib/generated/l10n/zulip_localizations_uk.dart
@@ -79,6 +79,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations {
String get actionSheetOptionMarkChannelAsRead =>
'Позначити канал як прочитаний';
+ @override
+ String get actionSheetOptionListOfTopics => 'List of topics';
+
@override
String get actionSheetOptionMuteTopic => 'Заглушити тему';
@@ -596,6 +599,9 @@ class ZulipLocalizationsUk extends ZulipLocalizations {
@override
String get mainMenuMyProfile => 'Мій профіль';
+ @override
+ String get topicsButtonLabel => 'TOPICS';
+
@override
String get channelFeedButtonTooltip => 'Стрічка каналу';
diff --git a/lib/widgets/action_sheet.dart b/lib/widgets/action_sheet.dart
index 6aa7239a0c..2b28106562 100644
--- a/lib/widgets/action_sheet.dart
+++ b/lib/widgets/action_sheet.dart
@@ -28,6 +28,7 @@ import 'page.dart';
import 'store.dart';
import 'text.dart';
import 'theme.dart';
+import 'topic_list.dart';
void _showActionSheet(
BuildContext context, {
@@ -174,24 +175,43 @@ void showChannelActionSheet(BuildContext context, {
final pageContext = PageRoot.contextOf(context);
final store = PerAccountStoreWidget.of(pageContext);
- final optionButtons = [];
+ final optionButtons = [
+ TopicListButton(pageContext: pageContext, channelId: channelId),
+ ];
+
final unreadCount = store.unreads.countInChannelNarrow(channelId);
if (unreadCount > 0) {
optionButtons.add(
MarkChannelAsReadButton(pageContext: pageContext, channelId: channelId));
}
- if (optionButtons.isEmpty) {
- // TODO(a11y): This case makes a no-op gesture handler; as a consequence,
- // we're presenting some UI (to people who use screen-reader software) as
- // though it offers a gesture interaction that it doesn't meaningfully
- // offer, which is confusing. The solution here is probably to remove this
- // is-empty case by having at least one button that's always present,
- // such as "copy link to channel".
- return;
- }
+
_showActionSheet(pageContext, optionButtons: optionButtons);
}
+class TopicListButton extends ActionSheetMenuItemButton {
+ const TopicListButton({
+ super.key,
+ required this.channelId,
+ required super.pageContext,
+ });
+
+ final int channelId;
+
+ @override
+ IconData get icon => ZulipIcons.topics;
+
+ @override
+ String label(ZulipLocalizations zulipLocalizations) {
+ return zulipLocalizations.actionSheetOptionListOfTopics;
+ }
+
+ @override
+ void onPressed() {
+ Navigator.push(pageContext,
+ TopicListPage.buildRoute(context: pageContext, streamId: channelId));
+ }
+}
+
class MarkChannelAsReadButton extends ActionSheetMenuItemButton {
const MarkChannelAsReadButton({
super.key,
diff --git a/lib/widgets/content.dart b/lib/widgets/content.dart
index 40b510305d..62801ab867 100644
--- a/lib/widgets/content.dart
+++ b/lib/widgets/content.dart
@@ -26,6 +26,7 @@ import 'poll.dart';
import 'scrolling.dart';
import 'store.dart';
import 'text.dart';
+import 'theme.dart';
/// A central place for styles for Zulip content (rendered Zulip Markdown).
///
@@ -988,7 +989,7 @@ class WebsitePreview extends StatelessWidget {
// TODO(#488) use different color for non-message contexts
// TODO(#647) use different color for highlighted messages
// TODO(#681) use different color for DM messages
- color: MessageListTheme.of(context).bgMessageRegular,
+ color: DesignVariables.of(context).bgMessageRegular,
child: ClipRect(
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: 80),
diff --git a/lib/widgets/icons.dart b/lib/widgets/icons.dart
index bab7b152de..be088afd48 100644
--- a/lib/widgets/icons.dart
+++ b/lib/widgets/icons.dart
@@ -144,11 +144,14 @@ abstract final class ZulipIcons {
/// The Zulip custom icon "topic".
static const IconData topic = IconData(0xf128, fontFamily: "Zulip Icons");
+ /// The Zulip custom icon "topics".
+ static const IconData topics = IconData(0xf129, fontFamily: "Zulip Icons");
+
/// The Zulip custom icon "unmute".
- static const IconData unmute = IconData(0xf129, fontFamily: "Zulip Icons");
+ static const IconData unmute = IconData(0xf12a, fontFamily: "Zulip Icons");
/// The Zulip custom icon "user".
- static const IconData user = IconData(0xf12a, fontFamily: "Zulip Icons");
+ static const IconData user = IconData(0xf12b, fontFamily: "Zulip Icons");
// END GENERATED ICON DATA
}
diff --git a/lib/widgets/message_list.dart b/lib/widgets/message_list.dart
index 98beda3205..70e74c24ea 100644
--- a/lib/widgets/message_list.dart
+++ b/lib/widgets/message_list.dart
@@ -24,11 +24,11 @@ import 'sticky_header.dart';
import 'store.dart';
import 'text.dart';
import 'theme.dart';
+import 'topic_list.dart';
/// Message-list styles that differ between light and dark themes.
class MessageListTheme extends ThemeExtension {
static final light = MessageListTheme._(
- bgMessageRegular: const HSLColor.fromAHSL(1, 0, 0, 1).toColor(),
dmRecipientHeaderBg: const HSLColor.fromAHSL(1, 46, 0.35, 0.93).toColor(),
labelTime: const HSLColor.fromAHSL(0.49, 0, 0, 0).toColor(),
senderBotIcon: const HSLColor.fromAHSL(1, 180, 0.08, 0.65).toColor(),
@@ -46,7 +46,6 @@ class MessageListTheme extends ThemeExtension {
);
static final dark = MessageListTheme._(
- bgMessageRegular: const HSLColor.fromAHSL(1, 0, 0, 0.11).toColor(),
dmRecipientHeaderBg: const HSLColor.fromAHSL(1, 46, 0.15, 0.2).toColor(),
labelTime: const HSLColor.fromAHSL(0.5, 0, 0, 1).toColor(),
senderBotIcon: const HSLColor.fromAHSL(1, 180, 0.05, 0.5).toColor(),
@@ -63,7 +62,6 @@ class MessageListTheme extends ThemeExtension {
);
MessageListTheme._({
- required this.bgMessageRegular,
required this.dmRecipientHeaderBg,
required this.labelTime,
required this.senderBotIcon,
@@ -82,7 +80,6 @@ class MessageListTheme extends ThemeExtension {
return extension!;
}
- final Color bgMessageRegular;
final Color dmRecipientHeaderBg;
final Color labelTime;
final Color senderBotIcon;
@@ -92,7 +89,6 @@ class MessageListTheme extends ThemeExtension {
@override
MessageListTheme copyWith({
- Color? bgMessageRegular,
Color? dmRecipientHeaderBg,
Color? labelTime,
Color? senderBotIcon,
@@ -101,7 +97,6 @@ class MessageListTheme extends ThemeExtension {
Color? unreadMarkerGap,
}) {
return MessageListTheme._(
- bgMessageRegular: bgMessageRegular ?? this.bgMessageRegular,
dmRecipientHeaderBg: dmRecipientHeaderBg ?? this.dmRecipientHeaderBg,
labelTime: labelTime ?? this.labelTime,
senderBotIcon: senderBotIcon ?? this.senderBotIcon,
@@ -117,7 +112,6 @@ class MessageListTheme extends ThemeExtension {
return this;
}
return MessageListTheme._(
- bgMessageRegular: Color.lerp(bgMessageRegular, other.bgMessageRegular, t)!,
dmRecipientHeaderBg: Color.lerp(dmRecipientHeaderBg, other.dmRecipientHeaderBg, t)!,
labelTime: Color.lerp(labelTime, other.labelTime, t)!,
senderBotIcon: Color.lerp(senderBotIcon, other.senderBotIcon, t)!,
@@ -227,14 +221,40 @@ class _MessageListPageState extends State implements MessageLis
removeAppBarBottomBorder = true;
}
- List? actions;
- if (narrow case TopicNarrow(:final streamId)) {
- (actions ??= []).add(IconButton(
- icon: const Icon(ZulipIcons.message_feed),
- tooltip: zulipLocalizations.channelFeedButtonTooltip,
- onPressed: () => Navigator.push(context,
- MessageListPage.buildRoute(context: context,
- narrow: ChannelNarrow(streamId)))));
+ List actions = [];
+ switch (narrow) {
+ case CombinedFeedNarrow():
+ case MentionsNarrow():
+ case StarredMessagesNarrow():
+ case DmNarrow():
+ break;
+ case ChannelNarrow(:final streamId):
+ final designVariables = DesignVariables.of(context);
+ final zulipLocalizations = ZulipLocalizations.of(context);
+ actions.add(GestureDetector(
+ onTap: () {
+ Navigator.of(context).push(TopicListPage.buildRoute(
+ context: context, streamId: streamId));
+ },
+ behavior: HitTestBehavior.opaque,
+ child: Padding(
+ padding: EdgeInsetsDirectional.fromSTEB(4, 8, 12, 8),
+ child: Center(child: Text(zulipLocalizations.topicsButtonLabel,
+ style: TextStyle(
+ color: designVariables.icon,
+ fontSize: 18,
+ height: 19 / 18,
+ // This is equivalent to css `all-small-caps`, see:
+ // https://developer.mozilla.org/en-US/docs/Web/CSS/font-variant-caps#all-small-caps
+ fontFeatures: const [FontFeature.enable('c2sc'), FontFeature.enable('smcp')],
+ ).merge(weightVariableTextStyle(context, wght: 600)))))));
+ case TopicNarrow(:final streamId):
+ actions.add(IconButton(
+ icon: const Icon(ZulipIcons.message_feed),
+ tooltip: zulipLocalizations.channelFeedButtonTooltip,
+ onPressed: () => Navigator.push(context,
+ MessageListPage.buildRoute(context: context,
+ narrow: ChannelNarrow(streamId)))));
}
// Insert a PageRoot here, to provide a context that can be used for
@@ -951,13 +971,12 @@ class DateSeparator extends StatelessWidget {
// to align with the vertically centered divider lines.
const textBottomPadding = 2.0;
- final messageListTheme = MessageListTheme.of(context);
final designVariables = DesignVariables.of(context);
final line = BorderSide(width: 0, color: designVariables.foreground);
// TODO(#681) use different color for DM messages
- return ColoredBox(color: messageListTheme.bgMessageRegular,
+ return ColoredBox(color: designVariables.bgMessageRegular,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 2),
child: Row(children: [
@@ -996,11 +1015,11 @@ class MessageItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
- final messageListTheme = MessageListTheme.of(context);
+ final designVariables = DesignVariables.of(context);
final item = this.item;
Widget child = ColoredBox(
- color: messageListTheme.bgMessageRegular,
+ color: designVariables.bgMessageRegular,
child: Column(children: [
switch (item) {
MessageListMessageItem() => MessageWithPossibleSender(item: item),
diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart
index a533311869..1e165abf34 100644
--- a/lib/widgets/theme.dart
+++ b/lib/widgets/theme.dart
@@ -135,6 +135,7 @@ class DesignVariables extends ThemeExtension {
bgCounterUnread: const Color(0xff666699).withValues(alpha: 0.15),
bgMenuButtonActive: Colors.black.withValues(alpha: 0.05),
bgMenuButtonSelected: Colors.white,
+ bgMessageRegular: const HSLColor.fromAHSL(1, 0, 0, 1).toColor(),
bgTopBar: const Color(0xfff5f5f5),
borderBar: Colors.black.withValues(alpha: 0.2),
borderMenuButtonSelected: Colors.black.withValues(alpha: 0.2),
@@ -192,6 +193,7 @@ class DesignVariables extends ThemeExtension {
bgCounterUnread: const Color(0xff666699).withValues(alpha: 0.37),
bgMenuButtonActive: Colors.black.withValues(alpha: 0.2),
bgMenuButtonSelected: Colors.black.withValues(alpha: 0.25),
+ bgMessageRegular: const HSLColor.fromAHSL(1, 0, 0, 0.11).toColor(),
bgTopBar: const Color(0xff242424),
borderBar: const Color(0xffffffff).withValues(alpha: 0.1),
borderMenuButtonSelected: Colors.white.withValues(alpha: 0.1),
@@ -257,6 +259,7 @@ class DesignVariables extends ThemeExtension {
required this.bgCounterUnread,
required this.bgMenuButtonActive,
required this.bgMenuButtonSelected,
+ required this.bgMessageRegular,
required this.bgTopBar,
required this.borderBar,
required this.borderMenuButtonSelected,
@@ -323,6 +326,7 @@ class DesignVariables extends ThemeExtension {
final Color bgCounterUnread;
final Color bgMenuButtonActive;
final Color bgMenuButtonSelected;
+ final Color bgMessageRegular;
final Color bgTopBar;
final Color borderBar;
final Color borderMenuButtonSelected;
@@ -384,6 +388,7 @@ class DesignVariables extends ThemeExtension {
Color? bgCounterUnread,
Color? bgMenuButtonActive,
Color? bgMenuButtonSelected,
+ Color? bgMessageRegular,
Color? bgTopBar,
Color? borderBar,
Color? borderMenuButtonSelected,
@@ -440,6 +445,7 @@ class DesignVariables extends ThemeExtension {
bgCounterUnread: bgCounterUnread ?? this.bgCounterUnread,
bgMenuButtonActive: bgMenuButtonActive ?? this.bgMenuButtonActive,
bgMenuButtonSelected: bgMenuButtonSelected ?? this.bgMenuButtonSelected,
+ bgMessageRegular: bgMessageRegular ?? this.bgMessageRegular,
bgTopBar: bgTopBar ?? this.bgTopBar,
borderBar: borderBar ?? this.borderBar,
borderMenuButtonSelected: borderMenuButtonSelected ?? this.borderMenuButtonSelected,
@@ -503,6 +509,7 @@ class DesignVariables extends ThemeExtension {
bgCounterUnread: Color.lerp(bgCounterUnread, other.bgCounterUnread, t)!,
bgMenuButtonActive: Color.lerp(bgMenuButtonActive, other.bgMenuButtonActive, t)!,
bgMenuButtonSelected: Color.lerp(bgMenuButtonSelected, other.bgMenuButtonSelected, t)!,
+ bgMessageRegular: Color.lerp(bgMessageRegular, other.bgMessageRegular, t)!,
bgTopBar: Color.lerp(bgTopBar, other.bgTopBar, t)!,
borderBar: Color.lerp(borderBar, other.borderBar, t)!,
borderMenuButtonSelected: Color.lerp(borderMenuButtonSelected, other.borderMenuButtonSelected, t)!,
diff --git a/lib/widgets/topic_list.dart b/lib/widgets/topic_list.dart
new file mode 100644
index 0000000000..199f6068a9
--- /dev/null
+++ b/lib/widgets/topic_list.dart
@@ -0,0 +1,333 @@
+import 'package:flutter/material.dart';
+
+import '../api/model/model.dart';
+import '../api/route/channels.dart';
+import '../generated/l10n/zulip_localizations.dart';
+import '../model/narrow.dart';
+import '../model/unreads.dart';
+import 'action_sheet.dart';
+import 'app_bar.dart';
+import 'color.dart';
+import 'icons.dart';
+import 'message_list.dart';
+import 'page.dart';
+import 'store.dart';
+import 'text.dart';
+import 'theme.dart';
+
+class TopicListPage extends StatelessWidget {
+ const TopicListPage({super.key, required this.streamId});
+
+ final int streamId;
+
+ static AccountRoute buildRoute({
+ required BuildContext context,
+ required int streamId,
+ }) {
+ return MaterialAccountWidgetRoute(
+ context: context,
+ page: TopicListPage(streamId: streamId));
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final store = PerAccountStoreWidget.of(context);
+ final zulipLocalizations = ZulipLocalizations.of(context);
+ final designVariables = DesignVariables.of(context);
+ final appBarBackgroundColor = colorSwatchFor(
+ context, store.subscriptions[streamId]).barBackground;
+
+ return PageRoot(child: Scaffold(
+ appBar: ZulipAppBar(
+ buildTitle: (willCenterTitle) =>
+ _TopicListAppBarTitle(streamId: streamId, willCenterTitle: willCenterTitle),
+ actions: [
+ IconButton(
+ icon: const Icon(ZulipIcons.message_feed),
+ tooltip: zulipLocalizations.channelFeedButtonTooltip,
+ onPressed: () => Navigator.push(context,
+ MessageListPage.buildRoute(context: context,
+ narrow: ChannelNarrow(streamId)))),
+ ],
+ backgroundColor: appBarBackgroundColor,
+ shape: Border(bottom: BorderSide(
+ width: 1, color: designVariables.borderBar))),
+ body: _TopicList(streamId: streamId)));
+ }
+}
+
+// This is adapted from [MessageListAppBarTitle].
+class _TopicListAppBarTitle extends StatelessWidget {
+ const _TopicListAppBarTitle({
+ required this.streamId,
+ required this.willCenterTitle,
+ });
+
+ final int streamId;
+ final bool willCenterTitle;
+
+ Widget _buildAppBarRow(BuildContext context) {
+ // TODO(#1039) implement a consistent app bar design here
+ final zulipLocalizations = ZulipLocalizations.of(context);
+ final designVariables = DesignVariables.of(context);
+ final store = PerAccountStoreWidget.of(context);
+ final stream = store.streams[streamId];
+ final channelIconColor = colorSwatchFor(
+ context, store.subscriptions[streamId]).iconOnBarBackground;
+
+ // A null [Icon.icon] makes a blank space.
+ final icon = stream != null ? iconDataForStream(stream) : null;
+ return Row(
+ mainAxisSize: MainAxisSize.min,
+ // TODO(design): The vertical alignment of the stream privacy icon is a bit ad hoc.
+ // For screenshots of some experiments, see:
+ // https://github.com/zulip/zulip-flutter/pull/219#discussion_r1281024746
+ crossAxisAlignment: CrossAxisAlignment.center,
+ children: [
+ Padding(padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 6),
+ child: Icon(size: 18, icon, color: channelIconColor)),
+ Flexible(child: Text(
+ stream?.name ?? zulipLocalizations.unknownChannelName,
+ style: TextStyle(
+ fontSize: 20,
+ height: 30 / 20,
+ color: designVariables.title,
+ ).merge(weightVariableTextStyle(context, wght: 600)))),
+ ]);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final alignment = willCenterTitle
+ ? Alignment.center
+ : AlignmentDirectional.centerStart;
+ return SizedBox(
+ width: double.infinity,
+ child: GestureDetector(
+ behavior: HitTestBehavior.translucent,
+ onLongPress: () {
+ showChannelActionSheet(context, channelId: streamId);
+ },
+ child: Align(alignment: alignment,
+ child: _buildAppBarRow(context))));
+ }
+}
+
+class _TopicList extends StatefulWidget {
+ const _TopicList({required this.streamId});
+
+ final int streamId;
+
+ @override
+ State<_TopicList> createState() => _TopicListState();
+}
+
+class _TopicListState extends State<_TopicList> with PerAccountStoreAwareStateMixin {
+ Unreads? unreadsModel;
+ // TODO(#1499): store the results on [ChannelStore], and keep them
+ // up-to-date by handling events
+ List? lastFetchedTopics;
+
+ @override
+ void onNewStore() {
+ unreadsModel?.removeListener(_modelChanged);
+ final store = PerAccountStoreWidget.of(context);
+ unreadsModel = store.unreads..addListener(_modelChanged);
+ _fetchTopics();
+ }
+
+ @override
+ void dispose() {
+ super.dispose();
+ unreadsModel?.removeListener(_modelChanged);
+ }
+
+ void _modelChanged() {
+ setState(() {
+ // The actual state lives in `unreadsModel`.
+ });
+ }
+
+ void _fetchTopics() async {
+ // Do nothing when the fetch fails; the topic-list will stay on
+ // the loading screen, until the user navigates away and back.
+ // TODO(design) show a nice error message on screen when this fails
+ final store = PerAccountStoreWidget.of(context);
+ final result = await getStreamTopics(store.connection,
+ streamId: widget.streamId,
+ allowEmptyTopicName: true);
+ if (!mounted) return;
+ setState(() {
+ lastFetchedTopics = result.topics;
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ if (lastFetchedTopics == null) {
+ return const Center(child: CircularProgressIndicator());
+ }
+
+ // TODO(design) handle the rare case when `lastFetchTopics` is empty
+
+ // This is adapted from parts of the build method on [_InboxPageState].
+ final topicItems = <_TopicItemData>[];
+ for (final GetStreamTopicsEntry(:maxId, name: topic) in lastFetchedTopics!) {
+ final unreadMessageIds =
+ unreadsModel!.streams[widget.streamId]?[topic] ?? [];
+ final countInTopic = unreadMessageIds.length;
+ final hasMention = unreadMessageIds.any((messageId) =>
+ unreadsModel!.mentions.contains(messageId));
+ topicItems.add(_TopicItemData(
+ topic: topic,
+ unreadCount: countInTopic,
+ hasMention: hasMention,
+ // `lastFetchedTopics.maxId` can become outdated when a new message
+ // arrives or when there are message moves, until we re-fetch.
+ // TODO(#1499): track changes to this
+ maxId: maxId,
+ ));
+ }
+ topicItems.sort((a, b) {
+ final aMaxId = a.maxId;
+ final bMaxId = b.maxId;
+ return bMaxId.compareTo(aMaxId);
+ });
+
+ return ListView.builder(
+ itemCount: topicItems.length,
+ itemBuilder: (BuildContext context, int index) =>
+ _TopicItem(streamId: widget.streamId, data: topicItems[index]));
+ }
+}
+
+class _TopicItemData {
+ final TopicName topic;
+ final int unreadCount;
+ final bool hasMention;
+ final int maxId;
+
+ const _TopicItemData({
+ required this.topic,
+ required this.unreadCount,
+ required this.hasMention,
+ required this.maxId,
+ });
+}
+
+// This is adapted from `_TopicItem` in lib/widgets/inbox.dart.
+class _TopicItem extends StatelessWidget {
+ const _TopicItem({required this.streamId, required this.data});
+
+ final int streamId;
+ final _TopicItemData data;
+
+ @override
+ Widget build(BuildContext context) {
+ final _TopicItemData(
+ :topic, :unreadCount, :hasMention, :maxId) = data;
+
+ final store = PerAccountStoreWidget.of(context);
+
+ final designVariables = DesignVariables.of(context);
+ final isTopicVisibleInStream = store.isTopicVisibleInStream(streamId, topic);
+ final visibilityIcon = iconDataForTopicVisibilityPolicy(
+ store.topicVisibilityPolicy(streamId, topic));
+
+ final trailingWidgets = [
+ if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign),
+ if (visibilityIcon != null) _IconMarker(icon: visibilityIcon),
+ if (unreadCount > 0) _UnreadCountBadge(count: unreadCount),
+ ];
+
+ return Material(
+ color: designVariables.bgMessageRegular,
+ child: InkWell(
+ onTap: () {
+ final narrow = TopicNarrow(streamId, topic);
+ Navigator.push(context,
+ MessageListPage.buildRoute(context: context, narrow: narrow));
+ },
+ onLongPress: () => showTopicActionSheet(context,
+ channelId: streamId,
+ topic: topic,
+ someMessageIdInTopic: maxId),
+ splashFactory: NoSplash.splashFactory,
+ child: Padding(padding: EdgeInsetsDirectional.fromSTEB(6, 8, 12, 8),
+ child: Row(
+ spacing: 8,
+ // In the Figma design, the text and icons on the topic item row
+ // are aligned to the start on the cross axis
+ // (i.e., `align-items: flex-start`). The icons are padded down
+ // 2px relative to the start, to visibly sit on the baseline.
+ // To account for scaled text, we align everything on the row
+ // to [CrossAxisAlignment.center] instead ([Row]'s default),
+ // like we do for the topic items on the inbox page.
+ // CZO discussion:
+ // https://chat.zulip.org/#narrow/channel/243-mobile-team/topic/topic.20list.20item.20alignment/near/2173252
+ children: [
+ // A null [Icon.icon] makes a blank space.
+ _IconMarker(icon: topic.isResolved ? ZulipIcons.check : null),
+ Expanded(child: Opacity(
+ opacity: isTopicVisibleInStream ? 1 : 0.5,
+ child: Text(
+ style: TextStyle(
+ fontSize: 17,
+ height: 20 / 17,
+ fontStyle: topic.displayName == null ? FontStyle.italic : null,
+ color: designVariables.textMessage,
+ ),
+ topic.unresolve().displayName ?? store.realmEmptyTopicDisplayName))),
+ Opacity(opacity: isTopicVisibleInStream ? 1 : 0.5, child: Row(
+ spacing: 4,
+ children: [
+ ...trailingWidgets,
+ if (trailingWidgets.isEmpty)
+ const SizedBox(width: 53),
+ ])),
+ ]))));
+ }
+}
+
+class _IconMarker extends StatelessWidget {
+ const _IconMarker({required this.icon});
+
+ final IconData? icon;
+
+ @override
+ Widget build(BuildContext context) {
+ final designVariables = DesignVariables.of(context);
+ final textScaler = MediaQuery.textScalerOf(context);
+ // Since we align the icons to [CrossAxisAlignment.center], the top padding
+ // from the Figma design is omitted.
+ return Icon(icon,
+ size: textScaler.clamp(maxScaleFactor: 1.5).scale(16),
+ color: designVariables.textMessage.withFadedAlpha(0.4));
+ }
+}
+
+// This is adapted from [UnreadCountBadge].
+class _UnreadCountBadge extends StatelessWidget {
+ const _UnreadCountBadge({required this.count});
+
+ final int count;
+
+ @override
+ Widget build(BuildContext context) {
+ final designVariables = DesignVariables.of(context);
+
+ return DecoratedBox(
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(5),
+ color: designVariables.bgCounterUnread,
+ ),
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
+ child: Text(count.toString(),
+ style: TextStyle(
+ fontSize: 15,
+ height: 16 / 15,
+ color: designVariables.labelCounterUnread,
+ ).merge(weightVariableTextStyle(context, wght: 500)))));
+ }
+}
diff --git a/test/widgets/action_sheet_test.dart b/test/widgets/action_sheet_test.dart
index 7d6a5205bb..2061976502 100644
--- a/test/widgets/action_sheet_test.dart
+++ b/test/widgets/action_sheet_test.dart
@@ -201,6 +201,7 @@ void main() {
void checkButtons() {
check(actionSheetFinder).findsOne();
checkButton('Mark channel as read');
+ checkButton('List of topics');
}
testWidgets('show from inbox', (tester) async {
@@ -218,7 +219,7 @@ void main() {
testWidgets('show with no unread messages', (tester) async {
await prepare(hasUnreadMessages: false);
await showFromSubscriptionList(tester);
- check(actionSheetFinder).findsNothing();
+ check(findButtonForLabel('Mark channel as read')).findsNothing();
});
testWidgets('show from app bar in channel narrow', (tester) async {
@@ -242,6 +243,19 @@ void main() {
});
});
+ testWidgets('TopicListButton', (tester) async {
+ await prepare();
+ await showFromAppBar(tester,
+ narrow: ChannelNarrow(someChannel.streamId));
+
+ connection.prepare(json: GetStreamTopicsResult(topics: [
+ eg.getStreamTopicsEntry(name: 'some topic foo'),
+ ]).toJson());
+ await tester.tap(findButtonForLabel('List of topics'));
+ await tester.pumpAndSettle();
+ check(find.text('some topic foo')).findsOne();
+ });
+
group('MarkChannelAsReadButton', () {
void checkRequest(int channelId) {
check(connection.takeRequests()).single.isA()
diff --git a/test/widgets/message_list_test.dart b/test/widgets/message_list_test.dart
index f4de7b54ae..1a5843ebd8 100644
--- a/test/widgets/message_list_test.dart
+++ b/test/widgets/message_list_test.dart
@@ -11,6 +11,7 @@ import 'package:zulip/api/model/events.dart';
import 'package:zulip/api/model/initial_snapshot.dart';
import 'package:zulip/api/model/model.dart';
import 'package:zulip/api/model/narrow.dart';
+import 'package:zulip/api/route/channels.dart';
import 'package:zulip/api/route/messages.dart';
import 'package:zulip/model/actions.dart';
import 'package:zulip/model/localizations.dart';
@@ -26,6 +27,8 @@ import 'package:zulip/widgets/message_list.dart';
import 'package:zulip/widgets/page.dart';
import 'package:zulip/widgets/store.dart';
import 'package:zulip/widgets/channel_colors.dart';
+import 'package:zulip/widgets/theme.dart';
+import 'package:zulip/widgets/topic_list.dart';
import '../api/fake_api.dart';
import '../example_data.dart' as eg;
@@ -241,6 +244,25 @@ void main() {
of: find.byType(MessageListAppBarTitle),
matching: find.byIcon(ZulipIcons.mute))).findsOne();
});
+
+ testWidgets('has topic-list action for channel narrows', (tester) async {
+ final channel = eg.stream(name: 'channel foo');
+ await setupMessageListPage(tester,
+ narrow: ChannelNarrow(channel.streamId),
+ streams: [channel],
+ messages: [eg.streamMessage(stream: channel, topic: 'topic foo')]);
+
+ connection.prepare(json: GetStreamTopicsResult(topics: [
+ eg.getStreamTopicsEntry(name: 'topic foo'),
+ ]).toJson());
+ await tester.tap(find.text('TOPICS'));
+ await tester.pump(); // tap the button
+ await tester.pump(Duration.zero); // wait for request
+ check(find.descendant(
+ of: find.byType(TopicListPage),
+ matching: find.text('channel foo')),
+ ).findsOne();
+ });
});
group('presents message content appropriately', () {
@@ -280,17 +302,17 @@ void main() {
return widget.color;
}
- check(backgroundColor()).isSameColorAs(MessageListTheme.light.bgMessageRegular);
+ check(backgroundColor()).isSameColorAs(DesignVariables.light.bgMessageRegular);
tester.platformDispatcher.platformBrightnessTestValue = Brightness.dark;
await tester.pump();
await tester.pump(kThemeAnimationDuration * 0.4);
- final expectedLerped = MessageListTheme.light.lerp(MessageListTheme.dark, 0.4);
+ final expectedLerped = DesignVariables.light.lerp(DesignVariables.dark, 0.4);
check(backgroundColor()).isSameColorAs(expectedLerped.bgMessageRegular);
await tester.pump(kThemeAnimationDuration * 0.6);
- check(backgroundColor()).isSameColorAs(MessageListTheme.dark.bgMessageRegular);
+ check(backgroundColor()).isSameColorAs(DesignVariables.dark.bgMessageRegular);
});
group('fetch initial batch of messages', () {
diff --git a/test/widgets/topic_list_tests.dart b/test/widgets/topic_list_tests.dart
new file mode 100644
index 0000000000..972907d517
--- /dev/null
+++ b/test/widgets/topic_list_tests.dart
@@ -0,0 +1,327 @@
+import 'package:checks/checks.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_checks/flutter_checks.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:http/http.dart' as http;
+import 'package:zulip/api/model/initial_snapshot.dart';
+import 'package:zulip/api/model/model.dart';
+import 'package:zulip/api/route/channels.dart';
+import 'package:zulip/model/narrow.dart';
+import 'package:zulip/model/store.dart';
+import 'package:zulip/widgets/app_bar.dart';
+import 'package:zulip/widgets/icons.dart';
+import 'package:zulip/widgets/message_list.dart';
+import 'package:zulip/widgets/topic_list.dart';
+
+import '../api/fake_api.dart';
+import '../example_data.dart' as eg;
+import '../model/binding.dart';
+import '../model/test_store.dart';
+import '../stdlib_checks.dart';
+import 'test_app.dart';
+
+void main() {
+ TestZulipBinding.ensureInitialized();
+
+ late PerAccountStore store;
+ late FakeApiConnection connection;
+
+ Future prepare(WidgetTester tester, {
+ ZulipStream? channel,
+ List? topics,
+ List userTopics = const [],
+ List? messages,
+ }) async {
+ addTearDown(testBinding.reset);
+ await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
+ store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
+ connection = store.connection as FakeApiConnection;
+
+ await store.addUser(eg.selfUser);
+ channel ??= eg.stream();
+ await store.addStream(channel);
+ await store.addSubscription(eg.subscription(channel));
+ for (final userTopic in userTopics) {
+ await store.addUserTopic(
+ channel, userTopic.topicName.apiName, userTopic.visibilityPolicy);
+ }
+ topics ??= [eg.getStreamTopicsEntry()];
+ messages ??= [eg.streamMessage(stream: channel, topic: topics.first.name.apiName)];
+ await store.addMessages(messages);
+
+ connection.prepare(json: GetStreamTopicsResult(topics: topics).toJson());
+ await tester.pumpWidget(TestZulipApp(
+ accountId: eg.selfAccount.id,
+ child: TopicListPage(streamId: channel.streamId)));
+ await tester.pumpAndSettle();
+ check(connection.takeRequests()).single.isA()
+ ..method.equals('GET')
+ ..url.path.equals('/api/v1/users/me/${channel.streamId}/topics')
+ ..url.queryParameters.deepEquals({'allow_empty_topic_name': 'true'});
+ }
+
+ group('app bar', () {
+ testWidgets('unknown channel name', (tester) async {
+ addTearDown(testBinding.reset);
+ await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
+ final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
+ final channel = eg.stream();
+
+ (store.connection as FakeApiConnection).prepare(
+ json: GetStreamTopicsResult(topics: []).toJson());
+ await tester.pumpWidget(TestZulipApp(
+ accountId: eg.selfAccount.id,
+ child: TopicListPage(streamId: channel.streamId)));
+ await tester.pumpAndSettle();
+ check(find.widgetWithText(ZulipAppBar, '(unknown channel)')).findsOne();
+ });
+
+ testWidgets('navigate to channel feed', (tester) async {
+ final channel = eg.stream(name: 'channel foo');
+ await prepare(tester, channel: channel);
+
+ connection.prepare(json: eg.newestGetMessagesResult(
+ foundOldest: true, messages: [eg.streamMessage(stream: channel)]).toJson());
+ await tester.tap(find.byIcon(ZulipIcons.message_feed));
+ await tester.pump();
+ await tester.pump(Duration.zero);
+ check(find.descendant(
+ of: find.byType(MessageListPage),
+ matching: find.text('channel foo')),
+ ).findsOne();
+ });
+
+ testWidgets('show channel action sheet', (tester) async {
+ final channel = eg.stream(name: 'channel foo');
+ await prepare(tester, channel: channel,
+ messages: [eg.streamMessage(stream: channel)]);
+
+ await tester.longPress(find.text('channel foo'));
+ await tester.pump(Duration(milliseconds: 100)); // bottom-sheet animation
+ check(find.text('Mark channel as read')).findsOne();
+ });
+ });
+
+ testWidgets('show loading indicator', (tester) async {
+ addTearDown(testBinding.reset);
+ await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
+ final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
+ final channel = eg.stream();
+
+ (store.connection as FakeApiConnection).prepare(
+ json: GetStreamTopicsResult(topics: []).toJson(),
+ delay: Duration(seconds: 1),
+ );
+ await tester.pumpWidget(TestZulipApp(
+ accountId: eg.selfAccount.id,
+ child: TopicListPage(streamId: channel.streamId)));
+ await tester.pump();
+ check(find.byType(CircularProgressIndicator)).findsOne();
+
+ await tester.pump(Duration(seconds: 1));
+ check(find.byType(CircularProgressIndicator)).findsNothing();
+ });
+
+ testWidgets('fetch again when navigating away and back', (tester) async {
+ addTearDown(testBinding.reset);
+ await testBinding.globalStore.add(eg.selfAccount, eg.initialSnapshot());
+ final store = await testBinding.globalStore.perAccount(eg.selfAccount.id);
+ final connection = store.connection as FakeApiConnection;
+ final channel = eg.stream();
+
+ // Start from a message list page in a channel narrow.
+ connection.prepare(json: eg.newestGetMessagesResult(
+ foundOldest: true, messages: []).toJson());
+ await tester.pumpWidget(TestZulipApp(
+ accountId: eg.selfAccount.id,
+ child: MessageListPage(initNarrow: ChannelNarrow(channel.streamId))));
+ await tester.pumpAndSettle();
+
+ // Tap "TOPICS" button navigating to the topic-list page…
+ connection.prepare(json: GetStreamTopicsResult(
+ topics: [eg.getStreamTopicsEntry(name: 'topic A')]).toJson());
+ await tester.tap(find.text('TOPICS'));
+ await tester.pump();
+ await tester.pump(Duration.zero);
+ check(find.text('topic A')).findsOne();
+
+ // … go back to the message list page…
+ await tester.pageBack();
+ await tester.pump();
+
+ // … then back to the topic-list page, expecting to fetch again.
+ connection.prepare(json: GetStreamTopicsResult(
+ topics: [eg.getStreamTopicsEntry(name: 'topic B')]).toJson());
+ await tester.tap(find.text('TOPICS'));
+ await tester.pump();
+ await tester.pump(Duration.zero);
+ check(find.text('topic A')).findsNothing();
+ check(find.text('topic B')).findsOne();
+ });
+
+ group('_TopicItem', () {
+ Finder topicItemFinder = find.descendant(
+ of: find.byType(ListView),
+ matching: find.byType(Material));
+
+ Finder findInTopicItemAt(int index, Finder finder) => find.descendant(
+ of: topicItemFinder.at(index),
+ matching: finder);
+
+ testWidgets('show topic action sheet', (tester) async {
+ final channel = eg.stream();
+ await prepare(tester, channel: channel,
+ topics: [eg.getStreamTopicsEntry(name: 'topic foo')]);
+ await tester.longPress(topicItemFinder);
+ await tester.pump(Duration(milliseconds: 150)); // bottom-sheet animation
+
+ connection.prepare(json: {});
+ await tester.tap(find.text('Mute topic'));
+ await tester.pump();
+ await tester.pump(Duration.zero);
+ check(connection.takeRequests()).single.isA()
+ ..method.equals('POST')
+ ..url.path.equals('/api/v1/user_topics')
+ ..bodyFields.deepEquals({
+ 'stream_id': channel.streamId.toString(),
+ 'topic': 'topic foo',
+ 'visibility_policy': UserTopicVisibilityPolicy.muted.apiValue.toString(),
+ });
+ });
+
+ testWidgets('sort topics by maxId', (tester) async {
+ await prepare(tester, topics: [
+ eg.getStreamTopicsEntry(name: 'A', maxId: 3),
+ eg.getStreamTopicsEntry(name: 'B', maxId: 2),
+ eg.getStreamTopicsEntry(name: 'C', maxId: 4),
+ ]);
+
+ check(findInTopicItemAt(0, find.text('C'))).findsOne();
+ check(findInTopicItemAt(1, find.text('A'))).findsOne();
+ check(findInTopicItemAt(2, find.text('B'))).findsOne();
+ });
+
+ testWidgets('resolved and unresolved topics', (tester) async {
+ final resolvedTopic = TopicName('resolved').resolve();
+ final unresolvedTopic = TopicName('unresolved');
+ await prepare(tester, topics: [
+ eg.getStreamTopicsEntry(name: resolvedTopic.apiName),
+ eg.getStreamTopicsEntry(name: unresolvedTopic.apiName),
+ ]);
+
+ assert(resolvedTopic.displayName == '✔ resolved', resolvedTopic.displayName);
+ check(findInTopicItemAt(0, find.text('✔ resovled'))).findsNothing();
+
+ check(findInTopicItemAt(0, find.text('resolved'))).findsOne();
+ check(findInTopicItemAt(0, find.byIcon(ZulipIcons.check))).findsOne();
+
+ check(findInTopicItemAt(1, find.text('unresolved'))).findsOne();
+ check(findInTopicItemAt(1, find.byType(Icon))).findsNothing();
+ });
+
+ testWidgets('handle empty topics', (tester) async {
+ await prepare(tester, topics: [
+ eg.getStreamTopicsEntry(name: ''),
+ ]);
+ check(findInTopicItemAt(0,
+ find.text(eg.defaultRealmEmptyTopicDisplayName))).findsOne();
+ });
+
+ group('unreads', () {
+ testWidgets('muted and non-muted topics', (tester) async {
+ final channel = eg.stream();
+ await prepare(tester, channel: channel,
+ topics: [
+ eg.getStreamTopicsEntry(name: 'muted'),
+ eg.getStreamTopicsEntry(name: 'non-muted'),
+ ],
+ userTopics: [
+ eg.userTopicItem(channel, 'muted', UserTopicVisibilityPolicy.muted),
+ ],
+ messages: [
+ eg.streamMessage(stream: channel, topic: 'muted'),
+ eg.streamMessage(stream: channel, topic: 'non-muted'),
+ eg.streamMessage(stream: channel, topic: 'non-muted'),
+ ]);
+
+ check(findInTopicItemAt(0, find.text('1'))).findsOne();
+ check(findInTopicItemAt(0, find.text('muted'))).findsOne();
+ check(findInTopicItemAt(0, find.byIcon(ZulipIcons.mute))).findsOne();
+
+ check(findInTopicItemAt(1, find.text('2'))).findsOne();
+ check(findInTopicItemAt(1, find.text('non-muted'))).findsOne();
+ check(findInTopicItemAt(1, find.byType(Icon))).findsNothing();
+ });
+
+ testWidgets('with and without unread mentions', (tester) async {
+ final channel = eg.stream();
+ await prepare(tester, channel: channel,
+ topics: [
+ eg.getStreamTopicsEntry(name: 'not mentioned'),
+ eg.getStreamTopicsEntry(name: 'mentioned'),
+ ],
+ messages: [
+ eg.streamMessage(stream: channel, topic: 'not mentioned'),
+ eg.streamMessage(stream: channel, topic: 'not mentioned'),
+ eg.streamMessage(stream: channel, topic: 'not mentioned',
+ flags: [MessageFlag.mentioned, MessageFlag.read]),
+ eg.streamMessage(stream: channel, topic: 'mentioned',
+ flags: [MessageFlag.mentioned]),
+ ]);
+
+ check(findInTopicItemAt(0, find.text('2'))).findsOne();
+ check(findInTopicItemAt(0, find.text('not mentioned'))).findsOne();
+ check(findInTopicItemAt(0, find.byType(Icons))).findsNothing();
+
+ check(findInTopicItemAt(1, find.text('1'))).findsOne();
+ check(findInTopicItemAt(1, find.text('mentioned'))).findsOne();
+ check(findInTopicItemAt(1, find.byIcon(ZulipIcons.at_sign))).findsOne();
+
+ });
+ });
+
+ group('topic visibility', () {
+ testWidgets('default', (tester) async {
+ final channel = eg.stream();
+ await prepare(tester, channel: channel,
+ topics: [eg.getStreamTopicsEntry(name: 'topic')]);
+
+ check(find.descendant(of: topicItemFinder,
+ matching: find.byType(Icons))).findsNothing();
+ });
+
+ testWidgets('muted', (tester) async {
+ final channel = eg.stream();
+ await prepare(tester, channel: channel,
+ topics: [eg.getStreamTopicsEntry(name: 'topic')],
+ userTopics: [
+ eg.userTopicItem(channel, 'topic', UserTopicVisibilityPolicy.muted),
+ ]);
+ check(find.descendant(of: topicItemFinder,
+ matching: find.byIcon(ZulipIcons.mute))).findsOne();
+ });
+
+ testWidgets('unmuted', (tester) async {
+ final channel = eg.stream();
+ await prepare(tester, channel: channel,
+ topics: [eg.getStreamTopicsEntry(name: 'topic')],
+ userTopics: [
+ eg.userTopicItem(channel, 'topic', UserTopicVisibilityPolicy.unmuted),
+ ]);
+ check(find.descendant(of: topicItemFinder,
+ matching: find.byIcon(ZulipIcons.unmute))).findsOne();
+ });
+
+ testWidgets('followed', (tester) async {
+ final channel = eg.stream();
+ await prepare(tester, channel: channel,
+ topics: [eg.getStreamTopicsEntry(name: 'topic')],
+ userTopics: [
+ eg.userTopicItem(channel, 'topic', UserTopicVisibilityPolicy.followed),
+ ]);
+ check(find.descendant(of: topicItemFinder,
+ matching: find.byIcon(ZulipIcons.follow))).findsOne();
+ });
+ });
+ });
+}