Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions lib/model/unreads.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -230,6 +228,15 @@ class Unreads extends PerAccountStoreBase with ChangeNotifier {
// TODO: Implement unreads handling?
int countInKeywordSearchNarrow() => 0;

int countInAllDms() {
int c = 0;
for (final MapEntry(key: narrow, value: messageIds) in dms.entries) {
if (channelStore.shouldMuteDmConversation(narrow)) continue;
c += messageIds.length;
}
return c;
}

int countInNarrow(Narrow narrow) {
switch (narrow) {
case CombinedFeedNarrow():
Expand Down
108 changes: 81 additions & 27 deletions lib/widgets/home.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -330,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);

Expand All @@ -351,6 +357,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) {
Expand All @@ -366,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".
Expand All @@ -391,28 +408,29 @@ abstract class _MenuButton extends StatelessWidget {
~WidgetState.pressed: selected ? borderSideSelected : null,
}));

final trailing = buildTrailing(context);

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)))),
]))));
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;
Expand All @@ -428,7 +446,7 @@ abstract class _NavigationBarMenuButton extends _MenuButton {
}
}

class _SearchButton extends _MenuButton {
class _SearchButton extends MenuButton {
const _SearchButton();

@override
Expand Down Expand Up @@ -457,11 +475,23 @@ 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;
}

class _MentionsButton extends _MenuButton {
class _MentionsButton extends MenuButton {
const _MentionsButton();

@override
Expand All @@ -472,14 +502,26 @@ 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(
context: context, narrow: const MentionsNarrow()));
}
}

class _StarredMessagesButton extends _MenuButton {
class _StarredMessagesButton extends MenuButton {
const _StarredMessagesButton();

@override
Expand All @@ -497,7 +539,7 @@ class _StarredMessagesButton extends _MenuButton {
}
}

class _CombinedFeedButton extends _MenuButton {
class _CombinedFeedButton extends MenuButton {
const _CombinedFeedButton();

@override
Expand Down Expand Up @@ -541,11 +583,23 @@ 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;
}

class _MyProfileButton extends _MenuButton {
class _MyProfileButton extends MenuButton {
const _MyProfileButton();

@override
Expand All @@ -556,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,
);
Expand All @@ -575,7 +629,7 @@ class _MyProfileButton extends _MenuButton {
}
}

class _SwitchAccountButton extends _MenuButton {
class _SwitchAccountButton extends MenuButton {
const _SwitchAccountButton();

@override
Expand All @@ -592,7 +646,7 @@ class _SwitchAccountButton extends _MenuButton {
}
}

class _SettingsButton extends _MenuButton {
class _SettingsButton extends MenuButton {
const _SettingsButton();

@override
Expand All @@ -609,7 +663,7 @@ class _SettingsButton extends _MenuButton {
}
}

class _AboutZulipButton extends _MenuButton {
class _AboutZulipButton extends MenuButton {
const _AboutZulipButton();

@override
Expand Down
46 changes: 36 additions & 10 deletions lib/widgets/unread_count_badge.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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});

Expand Down
18 changes: 17 additions & 1 deletion test/model/unreads_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -191,16 +191,18 @@ 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: []),
eg.streamMessage(stream: stream1, topic: 'b', flags: []),
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 {
Expand Down Expand Up @@ -271,6 +273,20 @@ void main() {
]);
check(model.countInStarredMessagesNarrow()).equals(0);
});

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(2);
});
});

group('isUnread', () {
Expand Down
Loading