From a3d7fd8044150f17e8f2068b21dd8af7b7940143 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 19 Nov 2025 14:47:24 -0800 Subject: [PATCH 1/8] unreads [nfc]: Factor out countInAllDms, for public use and as a helper --- lib/model/unreads.dart | 12 +++++++++--- test/model/unreads_test.dart | 10 ++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/lib/model/unreads.dart b/lib/model/unreads.dart index 543cde28b6..e5183b12d3 100644 --- a/lib/model/unreads.dart +++ b/lib/model/unreads.dart @@ -160,9 +160,7 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier { // TODO(#370): maintain this count incrementally, rather than recomputing from scratch int countInCombinedFeedNarrow() { int c = 0; - for (final messageIds in dms.values) { - c = c + messageIds.length; - } + c += countInAllDms(); for (final MapEntry(key: streamId, value: topics) in streams.entries) { for (final MapEntry(key: topic, value: messageIds) in topics.entries) { if (channelStore.isTopicVisible(streamId, topic)) { @@ -230,6 +228,14 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier { // TODO: Implement unreads handling? int countInKeywordSearchNarrow() => 0; + int countInAllDms() { + int c = 0; + for (final messageIds in dms.values) { + c += messageIds.length; + } + return c; + } + int countInNarrow(Narrow narrow) { switch (narrow) { case CombinedFeedNarrow(): diff --git a/test/model/unreads_test.dart b/test/model/unreads_test.dart index 089ef315cb..f0f18ae97d 100644 --- a/test/model/unreads_test.dart +++ b/test/model/unreads_test.dart @@ -271,6 +271,16 @@ void main() { ]); check(model.countInStarredMessagesNarrow()).equals(0); }); + + test('countInAllDms', () async { + prepare(); + fillWithMessages([ + eg.dmMessage(from: eg.otherUser, to: [eg.selfUser], flags: []), + eg.dmMessage(from: eg.thirdUser, to: [eg.selfUser], flags: []), + eg.dmMessage(from: eg.thirdUser, to: [eg.selfUser, eg.otherUser], flags: []), + ]); + check(model.countInCombinedFeedNarrow()).equals(3); + }); }); group('isUnread', () { From 45bfcb023f36932d685ec265f3ff39918631b91e Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 19 Nov 2025 14:58:04 -0800 Subject: [PATCH 2/8] unreads: Exclude muted DM conversations in combined-feed and all-dm counts --- lib/model/unreads.dart | 3 ++- test/model/unreads_test.dart | 10 ++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/lib/model/unreads.dart b/lib/model/unreads.dart index e5183b12d3..a145bf8a4c 100644 --- a/lib/model/unreads.dart +++ b/lib/model/unreads.dart @@ -230,7 +230,8 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier { int countInAllDms() { int c = 0; - for (final messageIds in dms.values) { + for (final MapEntry(key: narrow, value: messageIds) in dms.entries) { + if (channelStore.shouldMuteDmConversation(narrow)) continue; c += messageIds.length; } return c; diff --git a/test/model/unreads_test.dart b/test/model/unreads_test.dart index f0f18ae97d..9ab5feef84 100644 --- a/test/model/unreads_test.dart +++ b/test/model/unreads_test.dart @@ -191,6 +191,7 @@ void main() { await store.addSubscription(eg.subscription(stream2)); await store.addSubscription(eg.subscription(stream3, isMuted: true)); await store.setUserTopic(stream1, 'a', UserTopicVisibilityPolicy.muted); + await store.setMutedUsers([eg.thirdUser.userId]); fillWithMessages([ eg.streamMessage(stream: stream1, topic: 'a', flags: []), eg.streamMessage(stream: stream1, topic: 'b', flags: []), @@ -198,9 +199,10 @@ void main() { eg.streamMessage(stream: stream2, topic: 'c', flags: []), eg.streamMessage(stream: stream3, topic: 'd', flags: []), eg.dmMessage(from: eg.otherUser, to: [eg.selfUser], flags: []), + // Exclude because user is muted eg.dmMessage(from: eg.thirdUser, to: [eg.selfUser], flags: []), ]); - check(model.countInCombinedFeedNarrow()).equals(5); + check(model.countInCombinedFeedNarrow()).equals(4); }); test('countInChannel/Narrow', () async { @@ -274,12 +276,16 @@ void main() { test('countInAllDms', () async { prepare(); + await store.setMutedUsers([eg.thirdUser.userId]); fillWithMessages([ + // No one is muted: don't exclude eg.dmMessage(from: eg.otherUser, to: [eg.selfUser], flags: []), + // Everyone is muted: exclude eg.dmMessage(from: eg.thirdUser, to: [eg.selfUser], flags: []), + // One is muted, one isn't: don't exclude eg.dmMessage(from: eg.thirdUser, to: [eg.selfUser, eg.otherUser], flags: []), ]); - check(model.countInCombinedFeedNarrow()).equals(3); + check(model.countInCombinedFeedNarrow()).equals(2); }); }); From 248cab243c69d9f9e7d625aef1f62ece7cee20e6 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 19 Nov 2025 12:18:21 -0800 Subject: [PATCH 3/8] home: Show unread counts in main menu Fixes-partly: #1088 --- lib/widgets/home.dart | 42 ++++++++++++++++++++++++++ lib/widgets/unread_count_badge.dart | 46 ++++++++++++++++++++++------- 2 files changed, 78 insertions(+), 10 deletions(-) diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart index 62c09c0857..7c3fedbf9e 100644 --- a/lib/widgets/home.dart +++ b/lib/widgets/home.dart @@ -22,6 +22,7 @@ import 'store.dart'; import 'subscription_list.dart'; import 'text.dart'; import 'theme.dart'; +import 'unread_count_badge.dart'; import 'user.dart'; enum _HomePageTab { @@ -351,6 +352,8 @@ abstract class _MenuButton extends StatelessWidget { color: selected ? designVariables.iconSelected : designVariables.icon); } + Widget? buildTrailing(BuildContext context) => null; + void onPressed(BuildContext context); void _handlePress(BuildContext context) { @@ -391,6 +394,8 @@ abstract class _MenuButton extends StatelessWidget { ~WidgetState.pressed: selected ? borderSideSelected : null, })); + final trailing = buildTrailing(context); + return AnimatedScaleOnTap( duration: const Duration(milliseconds: 100), scaleEnd: 0.95, @@ -407,6 +412,7 @@ abstract class _MenuButton extends StatelessWidget { overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 19, height: 26 / 19) .merge(weightVariableTextStyle(context, wght: selected ? 600 : 400)))), + ?trailing, ])))); } } @@ -457,6 +463,18 @@ class _InboxButton extends _NavigationBarMenuButton { return zulipLocalizations.inboxPageTitle; } + @override + Widget? buildTrailing(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final unreadCount = store.unreads.countInCombinedFeedNarrow(); + if (unreadCount == 0) return null; + return UnreadCountBadge( + style: UnreadCountBadgeStyle.mainMenu, + count: unreadCount, + channelIdForBackground: null, + ); + } + @override _HomePageTab get navigationTarget => _HomePageTab.inbox; } @@ -472,6 +490,18 @@ class _MentionsButton extends _MenuButton { return zulipLocalizations.mentionsPageTitle; } + @override + Widget? buildTrailing(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final unreadCount = store.unreads.countInMentionsNarrow(); + if (unreadCount == 0) return null; + return UnreadCountBadge( + style: UnreadCountBadgeStyle.mainMenu, + count: unreadCount, + channelIdForBackground: null, + ); + } + @override void onPressed(BuildContext context) { Navigator.of(context).push(MessageListPage.buildRoute( @@ -541,6 +571,18 @@ class _DirectMessagesButton extends _NavigationBarMenuButton { return zulipLocalizations.recentDmConversationsPageTitle; } + @override + Widget? buildTrailing(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + final unreadCount = store.unreads.countInAllDms(); + if (unreadCount == 0) return null; + return UnreadCountBadge( + style: UnreadCountBadgeStyle.mainMenu, + count: unreadCount, + channelIdForBackground: null, + ); + } + @override _HomePageTab get navigationTarget => _HomePageTab.directMessages; } diff --git a/lib/widgets/unread_count_badge.dart b/lib/widgets/unread_count_badge.dart index 828d724dc9..3208a28fb7 100644 --- a/lib/widgets/unread_count_badge.dart +++ b/lib/widgets/unread_count_badge.dart @@ -10,23 +10,20 @@ import 'theme.dart'; /// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=2037-186671&m=dev /// It looks like that component was created for the main menu, /// then adapted for various other contexts, like the Inbox page. +/// See [UnreadCountBadgeStyle]. /// -/// Currently this widget supports only those other contexts (not the main menu) -/// and only the component's "kind=unread" variant (not "kind=quantity"). -/// For example, the "Channels" page and the topic-list page: -/// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6205-26001&m=dev -/// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6823-37113&m=dev -/// (We use this for the topic-list page even though the Figma makes it a bit -/// more compact there…the inconsistency seems worse and might be accidental.) -// TODO support the main-menu context, update dartdoc +/// Currently this widget supports only the component's "kind=unread" variant, +/// not "kind=quantity". // TODO support the "kind=quantity" variant, update dartdoc class UnreadCountBadge extends StatelessWidget { const UnreadCountBadge({ super.key, + this.style = UnreadCountBadgeStyle.other, required this.count, required this.channelIdForBackground, }); + final UnreadCountBadgeStyle style; final int count; /// An optional [Subscription.streamId], for a channel-colorized background. @@ -55,23 +52,52 @@ class UnreadCountBadge extends StatelessWidget { backgroundColor = designVariables.bgCounterUnread; } + final padding = switch (style) { + UnreadCountBadgeStyle.mainMenu => + const EdgeInsets.symmetric(horizontal: 5, vertical: 4), + UnreadCountBadgeStyle.other => + const EdgeInsets.symmetric(horizontal: 5, vertical: 3), + }; + + final double wght = switch (style) { + UnreadCountBadgeStyle.mainMenu => 600, + UnreadCountBadgeStyle.other => 500, + }; + return DecoratedBox( decoration: BoxDecoration( borderRadius: BorderRadius.circular(5), color: backgroundColor, ), child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 3), + padding: padding, child: Text( style: TextStyle( fontSize: 16, height: (16 / 16), color: textColor, - ).merge(weightVariableTextStyle(context, wght: 500)), + ).merge(weightVariableTextStyle(context, wght: wght)), count.toString()))); } } +enum UnreadCountBadgeStyle { + /// The style to use in the main menu. + /// + /// Figma: + /// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=2037-185126&m=dev + mainMenu, + + /// The style to use in other contexts besides the main menu. + /// + /// Other contexts include the "Channels" page and the topic-list page: + /// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6205-26001&m=dev + /// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6823-37113&m=dev + /// (We use this for the topic-list page even though the Figma makes it a bit + /// more compact there…the inconsistency seems worse and might be accidental.) + other, +} + class MutedUnreadBadge extends StatelessWidget { const MutedUnreadBadge({super.key}); From e8f51b28c139f5a80e0a51cc0b4fca5612dee2ea Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 19 Nov 2025 12:19:34 -0800 Subject: [PATCH 4/8] home: Tweak main-menu buttons to follow Figma The buttons were 48px instead of 44px tall, and the label's line height was 26px instead of 23px. --- lib/widgets/home.dart | 68 ++++++++++++++++++++++--------------- test/widgets/home_test.dart | 16 +++++++++ 2 files changed, 56 insertions(+), 28 deletions(-) diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart index 7c3fedbf9e..d09df5c7f9 100644 --- a/lib/widgets/home.dart +++ b/lib/widgets/home.dart @@ -331,8 +331,13 @@ void _showMainMenu(BuildContext context, { }); } -abstract class _MenuButton extends StatelessWidget { - const _MenuButton(); +/// A button in the main menu. +/// +/// See Figma: +/// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=2037-243759&m=dev +@visibleForTesting +abstract class MenuButton extends StatelessWidget { + const MenuButton({super.key}); String label(ZulipLocalizations zulipLocalizations); @@ -369,11 +374,20 @@ abstract class _MenuButton extends StatelessWidget { final designVariables = DesignVariables.of(context); final zulipLocalizations = ZulipLocalizations.of(context); + // Make [TextButton] set 44 instead of 48 for the height. + final visualDensity = VisualDensity(vertical: -1); + // A value that [TextButton] adds to some of its layout parameters; + // we can cancel out those adjustments by subtracting it. + final densityVerticalAdjustment = visualDensity.baseSizeAdjustment.dy; + final borderSideSelected = BorderSide(width: 1, strokeAlign: BorderSide.strokeAlignOutside, color: designVariables.borderMenuButtonSelected); final buttonStyle = TextButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 9, horizontal: 8), + // Make the button 44px instead of 48px tall, to match the Figma. + visualDensity: visualDensity, + padding: EdgeInsets.symmetric( + vertical: 10 - densityVerticalAdjustment, horizontal: 8), foregroundColor: designVariables.labelMenuButton, // This has a default behavior of affecting the background color of the // button for states including "hovered", "focused" and "pressed". @@ -399,26 +413,24 @@ abstract class _MenuButton extends StatelessWidget { return AnimatedScaleOnTap( duration: const Duration(milliseconds: 100), scaleEnd: 0.95, - child: ConstrainedBox( - constraints: const BoxConstraints(minHeight: 44), - child: TextButton( - onPressed: () => _handlePress(context), - style: buttonStyle, - child: Row(spacing: 8, children: [ - SizedBox.square(dimension: _iconSize, - child: buildLeading(context)), - Expanded(child: Text(label(zulipLocalizations), - // TODO(design): determine if we prefer to wrap - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 19, height: 26 / 19) - .merge(weightVariableTextStyle(context, wght: selected ? 600 : 400)))), - ?trailing, - ])))); + child: TextButton( + onPressed: () => _handlePress(context), + style: buttonStyle, + child: Row(spacing: 8, children: [ + SizedBox.square(dimension: _iconSize, + child: buildLeading(context)), + Expanded(child: Text(label(zulipLocalizations), + // TODO(design): determine if we prefer to wrap + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 19, height: 23 / 19) + .merge(weightVariableTextStyle(context, wght: selected ? 600 : 400)))), + ?trailing, + ]))); } } /// A menu button controlling the selected [_HomePageTab] on the bottom nav bar. -abstract class _NavigationBarMenuButton extends _MenuButton { +abstract class _NavigationBarMenuButton extends MenuButton { const _NavigationBarMenuButton({required this.tabNotifier}); final ValueNotifier<_HomePageTab> tabNotifier; @@ -434,7 +446,7 @@ abstract class _NavigationBarMenuButton extends _MenuButton { } } -class _SearchButton extends _MenuButton { +class _SearchButton extends MenuButton { const _SearchButton(); @override @@ -479,7 +491,7 @@ class _InboxButton extends _NavigationBarMenuButton { _HomePageTab get navigationTarget => _HomePageTab.inbox; } -class _MentionsButton extends _MenuButton { +class _MentionsButton extends MenuButton { const _MentionsButton(); @override @@ -509,7 +521,7 @@ class _MentionsButton extends _MenuButton { } } -class _StarredMessagesButton extends _MenuButton { +class _StarredMessagesButton extends MenuButton { const _StarredMessagesButton(); @override @@ -527,7 +539,7 @@ class _StarredMessagesButton extends _MenuButton { } } -class _CombinedFeedButton extends _MenuButton { +class _CombinedFeedButton extends MenuButton { const _CombinedFeedButton(); @override @@ -587,7 +599,7 @@ class _DirectMessagesButton extends _NavigationBarMenuButton { _HomePageTab get navigationTarget => _HomePageTab.directMessages; } -class _MyProfileButton extends _MenuButton { +class _MyProfileButton extends MenuButton { const _MyProfileButton(); @override @@ -598,7 +610,7 @@ class _MyProfileButton extends _MenuButton { final store = PerAccountStoreWidget.of(context); return Avatar( userId: store.selfUserId, - size: _MenuButton._iconSize, + size: MenuButton._iconSize, borderRadius: 4, showPresence: false, ); @@ -617,7 +629,7 @@ class _MyProfileButton extends _MenuButton { } } -class _SwitchAccountButton extends _MenuButton { +class _SwitchAccountButton extends MenuButton { const _SwitchAccountButton(); @override @@ -634,7 +646,7 @@ class _SwitchAccountButton extends _MenuButton { } } -class _SettingsButton extends _MenuButton { +class _SettingsButton extends MenuButton { const _SettingsButton(); @override @@ -651,7 +663,7 @@ class _SettingsButton extends _MenuButton { } } -class _AboutZulipButton extends _MenuButton { +class _AboutZulipButton extends MenuButton { const _AboutZulipButton(); @override diff --git a/test/widgets/home_test.dart b/test/widgets/home_test.dart index 1ee0a0ae8e..1e10fb5bcc 100644 --- a/test/widgets/home_test.dart +++ b/test/widgets/home_test.dart @@ -224,6 +224,22 @@ void main () { .isSameColorAs(designVariables.icon); } + testWidgets('buttons are 44px tall', (tester) async { + await prepare(tester); + + await tapOpenMenuAndAwait(tester); + checkIconSelected(tester, inboxMenuIconFinder); + checkIconNotSelected(tester, channelsMenuIconFinder); + + final inboxElement = tester.element( + find.ancestor(of: inboxMenuIconFinder, matching: find.bySubtype())); + check((inboxElement.renderObject as RenderBox).size).height.equals(44); + + final channelsElement = tester.element( + find.ancestor(of: inboxMenuIconFinder, matching: find.bySubtype())); + check((channelsElement.renderObject as RenderBox).size).height.equals(44); + }); + testWidgets('navigation states reflect on navigation bar menu buttons', (tester) async { await prepare(tester); From 247a3d47fe91e0534cdaaa6f4f49ae02a1d34b01 Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 19 Nov 2025 11:41:16 -0800 Subject: [PATCH 5/8] api: Add UserSettings.starredMessageCounts --- lib/api/model/events.dart | 1 + lib/api/model/events.g.dart | 1 + lib/api/model/initial_snapshot.dart | 2 ++ lib/api/model/initial_snapshot.g.dart | 3 +++ lib/api/model/model.dart | 1 + lib/api/model/model.g.dart | 1 + lib/api/route/settings.dart | 1 + lib/model/store.dart | 2 ++ test/api/route/settings_test.dart | 3 +++ test/example_data.dart | 1 + 10 files changed, 16 insertions(+) diff --git a/lib/api/model/events.dart b/lib/api/model/events.dart index e095c5360b..f1f16af8ea 100644 --- a/lib/api/model/events.dart +++ b/lib/api/model/events.dart @@ -185,6 +185,7 @@ class UserSettingsUpdateEvent extends Event { switch (UserSettingName.fromRawString(json['property'] as String)) { case UserSettingName.twentyFourHourTime: return TwentyFourHourTimeMode.fromApiValue(value as bool?); + case UserSettingName.starredMessageCounts: case UserSettingName.displayEmojiReactionUsers: return value as bool; case UserSettingName.emojiset: diff --git a/lib/api/model/events.g.dart b/lib/api/model/events.g.dart index dff0d21fa2..26eff18df9 100644 --- a/lib/api/model/events.g.dart +++ b/lib/api/model/events.g.dart @@ -65,6 +65,7 @@ Map _$UserSettingsUpdateEventToJson( const _$UserSettingNameEnumMap = { UserSettingName.twentyFourHourTime: 'twenty_four_hour_time', + UserSettingName.starredMessageCounts: 'starred_message_counts', UserSettingName.displayEmojiReactionUsers: 'display_emoji_reaction_users', UserSettingName.emojiset: 'emojiset', UserSettingName.presenceEnabled: 'presence_enabled', diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index eeedcde14d..ab507e9d1e 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -294,6 +294,7 @@ class UserSettings { ) TwentyFourHourTimeMode twentyFourHourTime; + bool starredMessageCounts; bool displayEmojiReactionUsers; Emojiset emojiset; bool presenceEnabled; @@ -306,6 +307,7 @@ class UserSettings { UserSettings({ required this.twentyFourHourTime, + required this.starredMessageCounts, required this.displayEmojiReactionUsers, required this.emojiset, required this.presenceEnabled, diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart index 1c5505a653..36cd7e05e3 100644 --- a/lib/api/model/initial_snapshot.g.dart +++ b/lib/api/model/initial_snapshot.g.dart @@ -256,6 +256,7 @@ UserSettings _$UserSettingsFromJson(Map json) => UserSettings( twentyFourHourTime: TwentyFourHourTimeMode.fromApiValue( json['twenty_four_hour_time'] as bool?, ), + starredMessageCounts: json['starred_message_counts'] as bool, displayEmojiReactionUsers: json['display_emoji_reaction_users'] as bool, emojiset: $enumDecode(_$EmojisetEnumMap, json['emojiset']), presenceEnabled: json['presence_enabled'] as bool, @@ -263,6 +264,7 @@ UserSettings _$UserSettingsFromJson(Map json) => UserSettings( const _$UserSettingsFieldMap = { 'twentyFourHourTime': 'twenty_four_hour_time', + 'starredMessageCounts': 'starred_message_counts', 'displayEmojiReactionUsers': 'display_emoji_reaction_users', 'emojiset': 'emojiset', 'presenceEnabled': 'presence_enabled', @@ -273,6 +275,7 @@ Map _$UserSettingsToJson(UserSettings instance) => 'twenty_four_hour_time': TwentyFourHourTimeMode.staticToJson( instance.twentyFourHourTime, ), + 'starred_message_counts': instance.starredMessageCounts, 'display_emoji_reaction_users': instance.displayEmojiReactionUsers, 'emojiset': instance.emojiset, 'presence_enabled': instance.presenceEnabled, diff --git a/lib/api/model/model.dart b/lib/api/model/model.dart index da12823520..17315ba54f 100644 --- a/lib/api/model/model.dart +++ b/lib/api/model/model.dart @@ -337,6 +337,7 @@ class UserStatusChange { @JsonEnum(fieldRename: FieldRename.snake, alwaysCreate: true) enum UserSettingName { twentyFourHourTime, + starredMessageCounts, displayEmojiReactionUsers, emojiset, presenceEnabled, diff --git a/lib/api/model/model.g.dart b/lib/api/model/model.g.dart index b835c493c5..3d32de2594 100644 --- a/lib/api/model/model.g.dart +++ b/lib/api/model/model.g.dart @@ -523,6 +523,7 @@ Map _$DmMessageToJson(DmMessage instance) => { const _$UserSettingNameEnumMap = { UserSettingName.twentyFourHourTime: 'twenty_four_hour_time', + UserSettingName.starredMessageCounts: 'starred_message_counts', UserSettingName.displayEmojiReactionUsers: 'display_emoji_reaction_users', UserSettingName.emojiset: 'emojiset', UserSettingName.presenceEnabled: 'presence_enabled', diff --git a/lib/api/route/settings.dart b/lib/api/route/settings.dart index 4e98140d76..1c184cc226 100644 --- a/lib/api/route/settings.dart +++ b/lib/api/route/settings.dart @@ -16,6 +16,7 @@ Future updateSettings(ApiConnection connection, { // TODO(server-future) allow localeDefault for servers that support it assert(mode != TwentyFourHourTimeMode.localeDefault); value = mode.toJson(); + case UserSettingName.starredMessageCounts: case UserSettingName.displayEmojiReactionUsers: value = valueRaw as bool; case UserSettingName.emojiset: diff --git a/lib/model/store.dart b/lib/model/store.dart index fda6e5b695..8df014d8e0 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -776,6 +776,8 @@ class PerAccountStore extends PerAccountStoreBase with switch (event.property!) { case UserSettingName.twentyFourHourTime: userSettings.twentyFourHourTime = event.value as TwentyFourHourTimeMode; + case UserSettingName.starredMessageCounts: + userSettings.starredMessageCounts = event.value as bool; case UserSettingName.displayEmojiReactionUsers: userSettings.displayEmojiReactionUsers = event.value as bool; case UserSettingName.emojiset: diff --git a/test/api/route/settings_test.dart b/test/api/route/settings_test.dart index 8b31caa646..bc2a46a985 100644 --- a/test/api/route/settings_test.dart +++ b/test/api/route/settings_test.dart @@ -19,6 +19,9 @@ void main() { case UserSettingName.twentyFourHourTime: newSettings[name] = TwentyFourHourTimeMode.twelveHour; expectedBodyFields['twenty_four_hour_time'] = 'false'; + case UserSettingName.starredMessageCounts: + newSettings[name] = false; + expectedBodyFields['starred_message_counts'] = 'false'; case UserSettingName.displayEmojiReactionUsers: newSettings[name] = false; expectedBodyFields['display_emoji_reaction_users'] = 'false'; diff --git a/test/example_data.dart b/test/example_data.dart index a6e3e9655d..6ad34fc967 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -1389,6 +1389,7 @@ InitialSnapshot initialSnapshot({ userStatuses: userStatuses ?? {}, userSettings: userSettings ?? UserSettings( twentyFourHourTime: TwentyFourHourTimeMode.twelveHour, + starredMessageCounts: true, displayEmojiReactionUsers: true, emojiset: Emojiset.google, presenceEnabled: true, From a22b49fa858ac0d5af6a0d2c28e3223b95a3928a Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 19 Nov 2025 15:29:04 -0800 Subject: [PATCH 6/8] api: Add starredMessages to initial snapshot --- lib/api/model/initial_snapshot.dart | 3 +++ lib/api/model/initial_snapshot.g.dart | 4 ++++ test/example_data.dart | 2 ++ 3 files changed, 9 insertions(+) diff --git a/lib/api/model/initial_snapshot.dart b/lib/api/model/initial_snapshot.dart index ab507e9d1e..b9e900392c 100644 --- a/lib/api/model/initial_snapshot.dart +++ b/lib/api/model/initial_snapshot.dart @@ -58,6 +58,8 @@ class InitialSnapshot { final UnreadMessagesSnapshot unreadMsgs; + final List starredMessages; + final List streams; // In register-queue, the name of this field is the singular "user_status", @@ -175,6 +177,7 @@ class InitialSnapshot { required this.subscriptions, required this.channelFolders, required this.unreadMsgs, + required this.starredMessages, required this.streams, required this.userStatuses, required this.userSettings, diff --git a/lib/api/model/initial_snapshot.g.dart b/lib/api/model/initial_snapshot.g.dart index 36cd7e05e3..af99c6aeb8 100644 --- a/lib/api/model/initial_snapshot.g.dart +++ b/lib/api/model/initial_snapshot.g.dart @@ -70,6 +70,9 @@ InitialSnapshot _$InitialSnapshotFromJson( unreadMsgs: UnreadMessagesSnapshot.fromJson( json['unread_msgs'] as Map, ), + starredMessages: (json['starred_messages'] as List) + .map((e) => (e as num).toInt()) + .toList(), streams: (json['streams'] as List) .map((e) => ZulipStream.fromJson(e as Map)) .toList(), @@ -173,6 +176,7 @@ Map _$InitialSnapshotToJson( 'subscriptions': instance.subscriptions, 'channel_folders': instance.channelFolders, 'unread_msgs': instance.unreadMsgs, + 'starred_messages': instance.starredMessages, 'streams': instance.streams, 'user_status': instance.userStatuses.map((k, e) => MapEntry(k.toString(), e)), 'user_settings': instance.userSettings, diff --git a/test/example_data.dart b/test/example_data.dart index 6ad34fc967..57e5211020 100644 --- a/test/example_data.dart +++ b/test/example_data.dart @@ -1327,6 +1327,7 @@ InitialSnapshot initialSnapshot({ List? subscriptions, List? channelFolders, UnreadMessagesSnapshot? unreadMsgs, + List? starredMessages, List? streams, Map? userStatuses, UserSettings? userSettings, @@ -1385,6 +1386,7 @@ InitialSnapshot initialSnapshot({ subscriptions: subscriptions ?? [], // TODO add subscriptions to default channelFolders: channelFolders ?? [], unreadMsgs: unreadMsgs ?? _unreadMsgs(), + starredMessages: starredMessages ?? [], streams: streams ?? [], // TODO add streams to default userStatuses: userStatuses ?? {}, userSettings: userSettings ?? UserSettings( From 34f9a0b56feb1f593a644e6a225cf60bee52e9fa Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 19 Nov 2025 16:11:55 -0800 Subject: [PATCH 7/8] message: Add MessageStore.starredMessages --- lib/model/message.dart | 39 ++++++++++++++++++---- lib/model/store.dart | 11 +++++-- test/model/message_test.dart | 64 +++++++++++++++++++++++++++++++++++- test/model/store_checks.dart | 1 + 4 files changed, 104 insertions(+), 11 deletions(-) diff --git a/lib/model/message.dart b/lib/model/message.dart index 936bc3bb1f..b6dabeab2d 100644 --- a/lib/model/message.dart +++ b/lib/model/message.dart @@ -24,6 +24,9 @@ mixin MessageStore on ChannelStore { /// All known messages, indexed by [Message.id]. Map get messages; + /// All starred messages, as message IDs. + Set get starredMessages; + /// [OutboxMessage]s sent by the user, indexed by [OutboxMessage.localMessageId]. Map get outboxMessages; @@ -208,6 +211,8 @@ mixin ProxyMessageStore on MessageStore { @override Map get messages => messageStore.messages; @override + Set get starredMessages => messageStore.starredMessages; + @override Map get outboxMessages => messageStore.outboxMessages; @override void registerMessageList(MessageListView view) => @@ -261,14 +266,19 @@ class _EditMessageRequestStatus { } class MessageStoreImpl extends HasChannelStore with MessageStore, _OutboxMessageStore { - MessageStoreImpl({required super.channels}) - : // There are no messages in InitialSnapshot, so we don't have - // a use case for initializing MessageStore with nonempty [messages]. - messages = {}; + MessageStoreImpl({ + required super.channels, + required List initialStarredMessages, + }) : + messages = {}, + starredMessages = Set.of(initialStarredMessages); @override final Map messages; + @override + final Set starredMessages; + @override final Set _messageListViews = {}; @@ -717,23 +727,29 @@ class MessageStoreImpl extends HasChannelStore with MessageStore, _OutboxMessage } } - void handleDeleteMessageEvent(DeleteMessageEvent event) { + /// Handle a [DeleteMessageEvent] + /// and return whether the [PerAccountStore] should notify listeners. + bool handleDeleteMessageEvent(DeleteMessageEvent event) { + bool perAccountStoreShouldNotify = false; for (final messageId in event.messageIds) { messages.remove(messageId); + perAccountStoreShouldNotify |= starredMessages.remove(messageId); _maybeStaleChannelMessages.remove(messageId); _editMessageRequests.remove(messageId); } for (final view in _messageListViews) { view.handleDeleteMessageEvent(event); } + return perAccountStoreShouldNotify; } - void handleUpdateMessageFlagsEvent(UpdateMessageFlagsEvent event) { + /// Handle an [UpdateMessageFlagsEvent] + /// and return whether the [PerAccountStore] should notify listeners. + bool handleUpdateMessageFlagsEvent(UpdateMessageFlagsEvent event) { final isAdd = switch (event) { UpdateMessageFlagsAddEvent() => true, UpdateMessageFlagsRemoveEvent() => false, }; - if (isAdd && (event as UpdateMessageFlagsAddEvent).all) { for (final message in messages.values) { message.flags.add(event.flag); @@ -766,6 +782,15 @@ class MessageStoreImpl extends HasChannelStore with MessageStore, _OutboxMessage _notifyMessageListViews(event.messages); } } + + if (event.flag == MessageFlag.starred) { + isAdd + ? starredMessages.addAll(event.messages) + : starredMessages.removeAll(event.messages); + return true; + } + + return false; } void handleReactionEvent(ReactionEvent event) { diff --git a/lib/model/store.dart b/lib/model/store.dart index 8df014d8e0..6eaee5076c 100644 --- a/lib/model/store.dart +++ b/lib/model/store.dart @@ -564,7 +564,8 @@ class PerAccountStore extends PerAccountStoreBase with presence: Presence(realm: realm, initial: initialSnapshot.presences), channels: channels, - messages: MessageStoreImpl(channels: channels), + messages: MessageStoreImpl(channels: channels, + initialStarredMessages: initialSnapshot.starredMessages), unreads: Unreads(core: core, channelStore: channels, initial: initialSnapshot.unreadMsgs), recentDmConversationsView: RecentDmConversationsView(core: core, @@ -879,12 +880,16 @@ class PerAccountStore extends PerAccountStoreBase with // specifically, their `senderId`s. By calling this after the // aforementioned line, we'll lose reference to those messages. recentSenders.handleDeleteMessageEvent(event, messages); - _messages.handleDeleteMessageEvent(event); + if (_messages.handleDeleteMessageEvent(event)) { + notifyListeners(); + } unreads.handleDeleteMessageEvent(event); case UpdateMessageFlagsEvent(): assert(debugLog("server event: update_message_flags/${event.op} ${event.flag.toJson()}")); - _messages.handleUpdateMessageFlagsEvent(event); + if (_messages.handleUpdateMessageFlagsEvent(event)) { + notifyListeners(); + } unreads.handleUpdateMessageFlagsEvent(event); case SubmessageEvent(): diff --git a/test/model/message_test.dart b/test/model/message_test.dart index 96d95c0c04..5eb1733db0 100644 --- a/test/model/message_test.dart +++ b/test/model/message_test.dart @@ -55,12 +55,15 @@ void main() { Future prepare({ ZulipStream? stream, bool isChannelSubscribed = true, + List? starredMessages = const [], int? zulipFeatureLevel, }) async { stream ??= eg.stream(streamId: eg.defaultStreamMessageStreamId); final selfAccount = eg.selfAccount.copyWith(zulipFeatureLevel: zulipFeatureLevel); store = eg.store(account: selfAccount, - initialSnapshot: eg.initialSnapshot(zulipFeatureLevel: zulipFeatureLevel)); + initialSnapshot: eg.initialSnapshot( + starredMessages: starredMessages, + zulipFeatureLevel: zulipFeatureLevel)); await store.addStream(stream); if (isChannelSubscribed) { subscription = eg.subscription(stream); @@ -1634,6 +1637,17 @@ void main() { checkNotifiedOnce(); check(store).messages.values.single.id.equals(message1.id); }); + + test('delete a starred message', () async { + final message = eg.streamMessage(flags: [MessageFlag.starred]); + await prepare(starredMessages: [message.id]); + await prepareMessages([message]); + check(store).starredMessages.single.equals(message.id); + await store.handleEvent(eg.deleteMessageEvent([message])); + checkNotifiedOnce(); + check(store).messages.isEmpty(); + check(store).starredMessages.isEmpty(); + }); }); group('handleUpdateMessageFlagsEvent', () { @@ -1702,6 +1716,30 @@ void main() { check(store).messages.values .single.flags.deepEquals([MessageFlag.starred, MessageFlag.read]); }); + + test('add to starredMessages', () async { + int perAccountStoreNotifiedCount = 0; + void checkPerAccountStoreNotified({required int count}) { + check(perAccountStoreNotifiedCount).equals(count); + notifiedCount = 0; + } + + final message1 = eg.streamMessage(flags: []); + final message2 = eg.streamMessage(flags: []); + + await prepare(starredMessages: []); + + store.addListener(() { + perAccountStoreNotifiedCount++; + }); + + await prepareMessages([message1, message2]); + check(store).starredMessages.isEmpty(); + await store.handleEvent( + mkAddEvent(MessageFlag.starred, [message1.id, message2.id])); + checkPerAccountStoreNotified(count: 1); + check(store).starredMessages.deepEquals([message1.id, message2.id]); + }); }); group('remove flag', () { @@ -1737,6 +1775,30 @@ void main() { check(store).messages.values .single.flags.deepEquals([MessageFlag.starred]); }); + + test('remove from starredMessages', () async { + int perAccountStoreNotifiedCount = 0; + void checkPerAccountStoreNotified({required int count}) { + check(perAccountStoreNotifiedCount).equals(count); + notifiedCount = 0; + } + + final message1 = eg.streamMessage(flags: [MessageFlag.starred]); + final message2 = eg.streamMessage(flags: [MessageFlag.starred]); + + await prepare(starredMessages: [message1.id, message2.id]); + + store.addListener(() { + perAccountStoreNotifiedCount++; + }); + + await prepareMessages([message1, message2]); + check(store).starredMessages.deepEquals([message1.id, message2.id]); + await store.handleEvent( + mkRemoveEvent(MessageFlag.starred, [message1, message2])); + checkPerAccountStoreNotified(count: 1); + check(store).starredMessages.isEmpty(); + }); }); }); diff --git a/test/model/store_checks.dart b/test/model/store_checks.dart index dac078a434..7d38e8160c 100644 --- a/test/model/store_checks.dart +++ b/test/model/store_checks.dart @@ -68,6 +68,7 @@ extension PerAccountStoreChecks on Subject { Subject> get streamsByName => has((x) => x.streamsByName, 'streamsByName'); Subject> get subscriptions => has((x) => x.subscriptions, 'subscriptions'); Subject> get messages => has((x) => x.messages, 'messages'); + Subject> get starredMessages => has((x) => x.starredMessages, 'starredMessages'); Subject get unreads => has((x) => x.unreads, 'unreads'); Subject get recentDmConversationsView => has((x) => x.recentDmConversationsView, 'recentDmConversationsView'); Subject get autocompleteViewManager => has((x) => x.autocompleteViewManager, 'autocompleteViewManager'); From 1e74454c73c66722bbbe510da5e98a783835dd2e Mon Sep 17 00:00:00 2001 From: Chris Bobbe Date: Wed, 19 Nov 2025 15:27:19 -0800 Subject: [PATCH 8/8] home: Show starred-message count in main menu, subject to user setting Fixes-partly: #1088 --- lib/widgets/home.dart | 27 +++++-- lib/widgets/inbox.dart | 13 +++- lib/widgets/recent_dm_conversations.dart | 4 +- lib/widgets/subscription_list.dart | 3 +- lib/widgets/theme.dart | 7 ++ lib/widgets/topic_list.dart | 3 +- lib/widgets/unread_count_badge.dart | 94 +++++++++++++++-------- test/widgets/checks.dart | 2 +- test/widgets/inbox_test.dart | 8 +- test/widgets/subscription_list_test.dart | 8 +- test/widgets/unread_count_badge_test.dart | 13 +++- 11 files changed, 125 insertions(+), 57 deletions(-) diff --git a/lib/widgets/home.dart b/lib/widgets/home.dart index d09df5c7f9..759e2db2d1 100644 --- a/lib/widgets/home.dart +++ b/lib/widgets/home.dart @@ -480,8 +480,9 @@ class _InboxButton extends _NavigationBarMenuButton { final store = PerAccountStoreWidget.of(context); final unreadCount = store.unreads.countInCombinedFeedNarrow(); if (unreadCount == 0) return null; - return UnreadCountBadge( - style: UnreadCountBadgeStyle.mainMenu, + return Counter( + kind: CounterKind.unread, + style: CounterStyle.mainMenu, count: unreadCount, channelIdForBackground: null, ); @@ -507,8 +508,9 @@ class _MentionsButton extends MenuButton { final store = PerAccountStoreWidget.of(context); final unreadCount = store.unreads.countInMentionsNarrow(); if (unreadCount == 0) return null; - return UnreadCountBadge( - style: UnreadCountBadgeStyle.mainMenu, + return Counter( + kind: CounterKind.unread, + style: CounterStyle.mainMenu, count: unreadCount, channelIdForBackground: null, ); @@ -532,6 +534,18 @@ class _StarredMessagesButton extends MenuButton { return zulipLocalizations.starredMessagesPageTitle; } + @override + Widget? buildTrailing(BuildContext context) { + final store = PerAccountStoreWidget.of(context); + if (!store.userSettings.starredMessageCounts) return null; + return Counter( + kind: CounterKind.quantity, + style: CounterStyle.mainMenu, + count: store.starredMessages.length, + channelIdForBackground: null, + ); + } + @override void onPressed(BuildContext context) { Navigator.of(context).push(MessageListPage.buildRoute( @@ -588,8 +602,9 @@ class _DirectMessagesButton extends _NavigationBarMenuButton { final store = PerAccountStoreWidget.of(context); final unreadCount = store.unreads.countInAllDms(); if (unreadCount == 0) return null; - return UnreadCountBadge( - style: UnreadCountBadgeStyle.mainMenu, + return Counter( + kind: CounterKind.unread, + style: CounterStyle.mainMenu, count: unreadCount, channelIdForBackground: null, ); diff --git a/lib/widgets/inbox.dart b/lib/widgets/inbox.dart index ce7b200327..75086d06bd 100644 --- a/lib/widgets/inbox.dart +++ b/lib/widgets/inbox.dart @@ -309,7 +309,9 @@ abstract class _HeaderItem extends StatelessWidget { const SizedBox(width: 12), if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign), Padding(padding: const EdgeInsetsDirectional.only(end: 16), - child: UnreadCountBadge( + child: Counter( + // TODO(design) use CounterKind.quantity, following Figma + kind: CounterKind.unread, channelIdForBackground: channelId, count: count)), ]))); @@ -431,7 +433,10 @@ class _DmItem extends StatelessWidget { const SizedBox(width: 12), if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign), Padding(padding: const EdgeInsetsDirectional.only(end: 16), - child: UnreadCountBadge(channelIdForBackground: null, + child: Counter( + // TODO(design) use CounterKind.quantity, following Figma + kind: CounterKind.unread, + channelIdForBackground: null, count: count)), ])))); } @@ -565,7 +570,9 @@ class _TopicItem extends StatelessWidget { // TODO(design) copies the "@" marker color; is there a better color? if (visibilityIcon != null) _IconMarker(icon: visibilityIcon), Padding(padding: const EdgeInsetsDirectional.only(end: 16), - child: UnreadCountBadge( + child: Counter( + // TODO(design) use CounterKind.quantity, following Figma + kind: CounterKind.unread, channelIdForBackground: streamId, count: count)), ])))); diff --git a/lib/widgets/recent_dm_conversations.dart b/lib/widgets/recent_dm_conversations.dart index 36f6a40aab..2c3d49a6e5 100644 --- a/lib/widgets/recent_dm_conversations.dart +++ b/lib/widgets/recent_dm_conversations.dart @@ -234,7 +234,9 @@ class RecentDmConversationsItem extends StatelessWidget { const SizedBox(width: 12), unreadCount > 0 ? Padding(padding: const EdgeInsetsDirectional.only(end: 16), - child: UnreadCountBadge(channelIdForBackground: null, + child: Counter( + kind: CounterKind.unread, + channelIdForBackground: null, count: unreadCount)) : const SizedBox(), ])))); diff --git a/lib/widgets/subscription_list.dart b/lib/widgets/subscription_list.dart index 03714c734a..0cae76c1a1 100644 --- a/lib/widgets/subscription_list.dart +++ b/lib/widgets/subscription_list.dart @@ -336,7 +336,8 @@ class SubscriptionItem extends StatelessWidget { // TODO(#747) show @-mention indicator when it applies Opacity( opacity: opacity, - child: UnreadCountBadge( + child: Counter( + kind: CounterKind.unread, count: unreadCount, channelIdForBackground: subscription.streamId)), ] else if (showMutedUnreadBadge) ...[ diff --git a/lib/widgets/theme.dart b/lib/widgets/theme.dart index 18540139d0..eb244bd1e4 100644 --- a/lib/widgets/theme.dart +++ b/lib/widgets/theme.dart @@ -184,6 +184,7 @@ class DesignVariables extends ThemeExtension { foreground: const Color(0xff000000), icon: const Color(0xff6159e1), iconSelected: const Color(0xff222222), + labelCounterQuantity: const Color(0xff222222).withValues(alpha: 0.6), labelCounterUnread: const Color(0xff1a1a1a), labelEdited: const HSLColor.fromAHSL(0.35, 0, 0, 0).toColor(), labelMenuButton: const Color(0xff222222), @@ -285,6 +286,7 @@ class DesignVariables extends ThemeExtension { foreground: const Color(0xffffffff), icon: const Color(0xff7977fe), iconSelected: Colors.white.withValues(alpha: 0.8), + labelCounterQuantity: const Color(0xffffffff).withValues(alpha: 0.7), labelCounterUnread: const Color(0xffffffff).withValues(alpha: 0.95), labelEdited: const HSLColor.fromAHSL(0.35, 0, 0, 1).toColor(), labelMenuButton: const Color(0xffffffff).withValues(alpha: 0.85), @@ -395,6 +397,7 @@ class DesignVariables extends ThemeExtension { required this.fabShadow, required this.icon, required this.iconSelected, + required this.labelCounterQuantity, required this.labelCounterUnread, required this.labelEdited, required this.labelMenuButton, @@ -496,6 +499,7 @@ class DesignVariables extends ThemeExtension { final Color foreground; final Color icon; final Color iconSelected; + final Color labelCounterQuantity; final Color labelCounterUnread; final Color labelEdited; final Color labelMenuButton; @@ -592,6 +596,7 @@ class DesignVariables extends ThemeExtension { Color? foreground, Color? icon, Color? iconSelected, + Color? labelCounterQuantity, Color? labelCounterUnread, Color? labelEdited, Color? labelMenuButton, @@ -683,6 +688,7 @@ class DesignVariables extends ThemeExtension { fabShadow: fabShadow ?? this.fabShadow, icon: icon ?? this.icon, iconSelected: iconSelected ?? this.iconSelected, + labelCounterQuantity: labelCounterQuantity ?? this.labelCounterQuantity, labelCounterUnread: labelCounterUnread ?? this.labelCounterUnread, labelEdited: labelEdited ?? this.labelEdited, labelMenuButton: labelMenuButton ?? this.labelMenuButton, @@ -781,6 +787,7 @@ class DesignVariables extends ThemeExtension { fabShadow: Color.lerp(fabShadow, other.fabShadow, t)!, icon: Color.lerp(icon, other.icon, t)!, iconSelected: Color.lerp(iconSelected, other.iconSelected, t)!, + labelCounterQuantity: Color.lerp(labelCounterQuantity, other.labelCounterQuantity, t)!, labelCounterUnread: Color.lerp(labelCounterUnread, other.labelCounterUnread, t)!, labelEdited: Color.lerp(labelEdited, other.labelEdited, t)!, labelMenuButton: Color.lerp(labelMenuButton, other.labelMenuButton, t)!, diff --git a/lib/widgets/topic_list.dart b/lib/widgets/topic_list.dart index 846f2202f7..2a0cf12a35 100644 --- a/lib/widgets/topic_list.dart +++ b/lib/widgets/topic_list.dart @@ -303,7 +303,8 @@ class _TopicItem extends StatelessWidget { if (hasMention) const _IconMarker(icon: ZulipIcons.at_sign), if (visibilityIcon != null) _IconMarker(icon: visibilityIcon), if (unreadCount > 0) - UnreadCountBadge( + Counter( + kind: CounterKind.unread, count: unreadCount, channelIdForBackground: null), ])), diff --git a/lib/widgets/unread_count_badge.dart b/lib/widgets/unread_count_badge.dart index 3208a28fb7..65c2323743 100644 --- a/lib/widgets/unread_count_badge.dart +++ b/lib/widgets/unread_count_badge.dart @@ -4,31 +4,29 @@ import 'store.dart'; import 'text.dart'; import 'theme.dart'; -/// A widget to display a given number of unreads in a conversation. +/// A widget to display a given number (e.g. of unread messages or of users). /// /// See Figma's "counter-menu" component, which this is based on: /// https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=2037-186671&m=dev /// It looks like that component was created for the main menu, /// then adapted for various other contexts, like the Inbox page. -/// See [UnreadCountBadgeStyle]. -/// -/// Currently this widget supports only the component's "kind=unread" variant, -/// not "kind=quantity". -// TODO support the "kind=quantity" variant, update dartdoc -class UnreadCountBadge extends StatelessWidget { - const UnreadCountBadge({ +/// See [CounterStyle] and [CounterKind] for the possible variants. +class Counter extends StatelessWidget { + const Counter({ super.key, - this.style = UnreadCountBadgeStyle.other, + this.style = CounterStyle.other, + required this.kind, required this.count, required this.channelIdForBackground, - }); + }) : assert(!(kind == CounterKind.quantity && channelIdForBackground != null)); - final UnreadCountBadgeStyle style; + final CounterStyle style; + final CounterKind kind; final int count; /// An optional [Subscription.streamId], for a channel-colorized background. /// - /// Useful when this badge represents messages in one specific channel. + /// Useful when this counter represents unreads in one specific channel. /// /// If null, the default neutral background will be used. // TODO remove; the Figma doesn't use this anymore. @@ -48,40 +46,54 @@ class UnreadCountBadge extends StatelessWidget { final swatch = colorSwatchFor(context, subscription); backgroundColor = swatch.unreadCountBadgeBackground; } else { - textColor = designVariables.labelCounterUnread; + textColor = switch (kind) { + CounterKind.unread => designVariables.labelCounterUnread, + CounterKind.quantity => designVariables.labelCounterQuantity, + }; backgroundColor = designVariables.bgCounterUnread; } final padding = switch (style) { - UnreadCountBadgeStyle.mainMenu => + CounterStyle.mainMenu => const EdgeInsets.symmetric(horizontal: 5, vertical: 4), - UnreadCountBadgeStyle.other => + CounterStyle.other => const EdgeInsets.symmetric(horizontal: 5, vertical: 3), }; - final double wght = switch (style) { - UnreadCountBadgeStyle.mainMenu => 600, - UnreadCountBadgeStyle.other => 500, + final double wght = switch ((style, kind)) { + (CounterStyle.mainMenu, CounterKind.unread ) => 600, + (CounterStyle.mainMenu, CounterKind.quantity) => 500, + (CounterStyle.other, CounterKind.unread ) => 500, + (CounterStyle.other, CounterKind.quantity) => 500, }; - return DecoratedBox( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(5), - color: backgroundColor, - ), - child: Padding( - padding: padding, - child: Text( - style: TextStyle( - fontSize: 16, - height: (16 / 16), - color: textColor, - ).merge(weightVariableTextStyle(context, wght: wght)), - count.toString()))); + Widget result = Padding( + padding: padding, + child: Text( + style: TextStyle( + fontSize: 16, + height: (16 / 16), + color: textColor, + ).merge(weightVariableTextStyle(context, wght: wght)), + count.toString())); + + switch (kind) { + case CounterKind.unread: + result = DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(5), + color: backgroundColor, + ), + child: result); + case CounterKind.quantity: + // no decoration + } + + return result; } } -enum UnreadCountBadgeStyle { +enum CounterStyle { /// The style to use in the main menu. /// /// Figma: @@ -98,6 +110,22 @@ enum UnreadCountBadgeStyle { other, } +enum CounterKind { + /// The counter counts unread messages. + /// + /// Figma: + /// Main-menu style: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=2037-185125&m=dev + /// Other style: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6205-26001&m=dev + unread, + + /// The counter counts something else, like users or starred messages. + /// + /// Figma: + /// Main-menu style: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=2037-186672&m=dev + /// Other style: https://www.figma.com/design/1JTNtYo9memgW7vV6d0ygq/Zulip-Mobile?node-id=6025-293468&m=dev + quantity, +} + class MutedUnreadBadge extends StatelessWidget { const MutedUnreadBadge({super.key}); diff --git a/test/widgets/checks.dart b/test/widgets/checks.dart index ef3f9b634f..f192ea9828 100644 --- a/test/widgets/checks.dart +++ b/test/widgets/checks.dart @@ -92,7 +92,7 @@ extension PerAccountStoreWidgetChecks on Subject { Subject get child => has((x) => x.child, 'child'); } -extension UnreadCountBadgeChecks on Subject { +extension UnreadCountBadgeChecks on Subject { Subject get count => has((b) => b.count, 'count'); Subject get channelIdForBackground => has((b) => b.channelIdForBackground, 'channelIdForBackground'); } diff --git a/test/widgets/inbox_test.dart b/test/widgets/inbox_test.dart index 63aad6292c..77ac0c9f39 100644 --- a/test/widgets/inbox_test.dart +++ b/test/widgets/inbox_test.dart @@ -222,7 +222,7 @@ void main() { find.descendant( of: find.byWidget(findRowByLabel(tester, channel.name)!), matching: find.descendant( - of: find.byType(UnreadCountBadge), + of: find.byType(Counter), matching: find.text('1')))); final expectedTextColor = DesignVariables.light.unreadCountBadgeTextForChannel; @@ -406,19 +406,19 @@ void main() { check(find.descendant( of: find.byWidget(findRowByLabel(tester, 'aaa')!), - matching: find.widgetWithText(UnreadCountBadge, '1'))).findsOne(); + matching: find.widgetWithText(Counter, '1'))).findsOne(); await store.handleEvent(eg.updateMessageFlagsRemoveEvent(MessageFlag.read, [message2])); await tester.pump(); check(find.descendant( of: find.byWidget(findRowByLabel(tester, 'aaa')!), - matching: find.widgetWithText(UnreadCountBadge, '2'))).findsOne(); + matching: find.widgetWithText(Counter, '2'))).findsOne(); await store.handleEvent(eg.updateMessageFlagsRemoveEvent(MessageFlag.read, [message3])); await tester.pump(); check(find.descendant( of: find.byWidget(findRowByLabel(tester, 'aaa')!), - matching: find.widgetWithText(UnreadCountBadge, '3'))).findsOne(); + matching: find.widgetWithText(Counter, '3'))).findsOne(); }); }); diff --git a/test/widgets/subscription_list_test.dart b/test/widgets/subscription_list_test.dart index 70ede6f70a..8633137bd4 100644 --- a/test/widgets/subscription_list_test.dart +++ b/test/widgets/subscription_list_test.dart @@ -186,7 +186,7 @@ void main() { await setupStreamListPage(tester, subscriptions: [ eg.subscription(stream), ], unreadMsgs: unreadMsgs); - check(find.byType(UnreadCountBadge).evaluate()).length.equals(1); + check(find.byType(Counter).evaluate()).length.equals(1); check(find.byType(MutedUnreadBadge).evaluate().length).equals(0); }); @@ -206,7 +206,7 @@ void main() { )], unreadMsgs: unreadMsgs); check(tester.widget(find.descendant( - of: find.byType(UnreadCountBadge), matching: find.byType(Text)))) + of: find.byType(Counter), matching: find.byType(Text)))) .data.equals('1'); check(find.byType(MutedUnreadBadge).evaluate().length).equals(0); }); @@ -217,7 +217,7 @@ void main() { await setupStreamListPage(tester, subscriptions: [ eg.subscription(stream), ], unreadMsgs: unreadMsgs); - check(find.byType(UnreadCountBadge).evaluate()).length.equals(0); + check(find.byType(Counter).evaluate()).length.equals(0); check(find.byType(MutedUnreadBadge).evaluate().length).equals(0); }); @@ -274,7 +274,7 @@ void main() { check(tester.widget(find.byIcon(iconDataForStream(stream))).color) .isNotNull().isSameColorAs(swatch.iconOnPlainBackground); - final unreadCountBadgeRenderBox = tester.renderObject(find.byType(UnreadCountBadge)); + final unreadCountBadgeRenderBox = tester.renderObject(find.byType(Counter)); check(unreadCountBadgeRenderBox).legacyMatcher( // `paints` isn't a [Matcher] so we wrap it with `equals`; // awkward but it works diff --git a/test/widgets/unread_count_badge_test.dart b/test/widgets/unread_count_badge_test.dart index 739146050a..f69985b868 100644 --- a/test/widgets/unread_count_badge_test.dart +++ b/test/widgets/unread_count_badge_test.dart @@ -36,7 +36,10 @@ void main() { testWidgets('smoke test; no crash', (tester) async { await prepare(tester, - child: const UnreadCountBadge(count: 1, channelIdForBackground: null)); + child: const Counter( + kind: CounterKind.unread, + count: 1, + channelIdForBackground: null)); tester.widget(find.text("1")); }); @@ -49,7 +52,10 @@ void main() { testWidgets('default color', (tester) async { await prepare(tester, - child: UnreadCountBadge(count: 1, channelIdForBackground: null)); + child: Counter( + kind: CounterKind.unread, + count: 1, + channelIdForBackground: null)); check(findBackgroundColor(tester)).isNotNull().isSameColorAs(const Color(0x26666699)); }); @@ -57,7 +63,8 @@ void main() { final subscription = eg.subscription(eg.stream(), color: 0xff76ce90); await prepare(tester, subscription: subscription, - child: UnreadCountBadge( + child: Counter( + kind: CounterKind.unread, count: 1, channelIdForBackground: subscription.streamId)); check(findBackgroundColor(tester)).isNotNull()