From beec0553003ed71c34fcb11b9b7a64a4766f6b3c Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Fri, 6 Nov 2026 12:10:11 -0800 Subject: [PATCH] feat(mobile): move Activity to a Home bell and align Home spacing (#9212) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UI-only part of #9193 by @klopez4212, split out so it can land without the Activity sync changes. - Activity moves from a row in the Home list to a bell button in the Home header, with an unread dot driven by the existing Activity feed and read state. - Home filter capsules match native iOS and Android widths, with a subtle outline on Android when the header collapses. - Home section spacing is tighter. Left out: the reconnect rewrite in #9193's last commit (live events applied in place, no rebuild on reconnect, fatal fallback reads). The Activity provider is unchanged from main. Channel and Solo rows keep their bottom padding so they stay at the 44 pt minimum tap height. ### Validation - Kenny's added tests: `inbox_unread_provider_test` (unread dot) and the Home page bell and filter width tests. - Full mobile suite: all pass except 11 push and notification-toggle tests that fail identically on an unchanged checkout in this environment. - Automated simulator run (iPhone 17 Pro, iOS 27) of a build combining this PR with its sibling split: Home rows measure 44 pt, the bell opens Activity and shows its dot after a row is marked unread, type/sender/Unread filters each narrow the feed, a row opens its conversation, Search results scroll under the fade, and filter labels fit at the largest text size. Screenshots are in Buzz rather than here because they show internal test data. Android was covered only by widget tests. ### Before merge - [x] Automated simulator run of Home, the bell, Activity and channel navigation, with screenshots. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Tom Brow Co-authored-by: Tom Brow Co-authored-by: kenny lopez Co-authored-by: studio Co-authored-by: Claude Opus 5.5 --- .../ios/Runner/JumpToLatestGlassButton.swift | 1 + mobile/ios/Runner/NativeTabler.swift | 1 + .../activity/inbox_unread_provider.dart | 22 +++ .../lib/features/channels/channels_page.dart | 6 +- .../features/channels/channels_page/body.dart | 24 +-- .../channels/channels_page/sections.dart | 37 ++--- mobile/lib/features/channels/me/me_page.dart | 4 +- .../lib/features/channels/me/me_section.dart | 8 +- mobile/lib/features/home/home_page.dart | 99 ++++++++++++- .../lib/features/home/messages_filters.dart | 11 ++ .../android_expanding_menu_button.dart | 10 +- .../lib/shared/widgets/filter_chip_bar.dart | 90 ++++++++--- .../lib/shared/widgets/frosted_app_bar.dart | 1 + .../widgets/ios_glass_navigation_button.dart | 2 + .../widgets/navigation_button_surface.dart | 6 +- .../activity/inbox_unread_provider_test.dart | 86 +++++++++++ mobile/test/features/home/home_page_test.dart | 140 +++++++++++++++++- 17 files changed, 482 insertions(+), 66 deletions(-) create mode 100644 mobile/lib/features/activity/inbox_unread_provider.dart create mode 100644 mobile/test/features/activity/inbox_unread_provider_test.dart diff --git a/mobile/ios/Runner/JumpToLatestGlassButton.swift b/mobile/ios/Runner/JumpToLatestGlassButton.swift index b53295c4a6..d317e5eceb 100644 --- a/mobile/ios/Runner/JumpToLatestGlassButton.swift +++ b/mobile/ios/Runner/JumpToLatestGlassButton.swift @@ -543,6 +543,7 @@ final class NavigationGlassButtonPlatformView: NSObject, FlutterPlatformView { case "sun": buttonIconName = "sun.max" case "moon": buttonIconName = "moon" case "trash": buttonIconName = "trash" + case "bell": buttonIconName = "bell" case "more": buttonIconName = "ellipsis" case "reply": buttonIconName = "arrowshape.turn.up.left" case "systemAppearance": buttonIconName = "circle.lefthalf.filled" diff --git a/mobile/ios/Runner/NativeTabler.swift b/mobile/ios/Runner/NativeTabler.swift index d13e33d2a8..c12fd204f1 100644 --- a/mobile/ios/Runner/NativeTabler.swift +++ b/mobile/ios/Runner/NativeTabler.swift @@ -117,6 +117,7 @@ enum NativeTabler { "link": 0xeade, // link "checkmark": 0xea5e, // check "checkmark.circle": 0xea67, // circle-check + "bell": 0xea35, // bell "bell.slash": 0xece9, // bell-off "number": 0xeabc, // hash — matches BuzzIcons.hash "bubble.left": 0xeaef, // message diff --git a/mobile/lib/features/activity/inbox_unread_provider.dart b/mobile/lib/features/activity/inbox_unread_provider.dart new file mode 100644 index 0000000000..26d4ab985b --- /dev/null +++ b/mobile/lib/features/activity/inbox_unread_provider.dart @@ -0,0 +1,22 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/read_state/read_state_provider.dart'; +import 'activity_provider.dart'; +import 'inbox_local_state_provider.dart'; +import 'inbox_read_state.dart'; + +/// The bell reflects the same unread rows shown by the Activity inbox. +final inboxHasUnreadProvider = Provider((ref) { + final items = ref.watch(inboxItemsProvider); + final readState = ref.watch(readStateProvider); + final localState = ref.watch(inboxLocalStateProvider); + if (!readState.isReady) return false; + return items.any( + (item) => !isInboxItemDone( + item, + markerOf: readState.effectiveTimestamp, + localUnreadOverrides: localState.unreadIds, + localDoneSet: localState.doneIds, + ), + ); +}); diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 9f4cdbf7ff..45d5052162 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -129,7 +129,7 @@ class ChannelsPage extends HookConsumerWidget { }); final WidgetBuilder settingsPageBuilder; - final Widget? messagesFilters; + final Widget Function(bool isCollapsed)? messagesFilters; final List Function(VoidCallback openSwitcher, GlobalKey avatarKey)? messagesActionsBuilder; final Widget? soloSliver; @@ -738,7 +738,9 @@ class ChannelsPage extends HookConsumerWidget { padding: EdgeInsets.only( right: 60 * headerCollapse.value / filterContentHeight, ), - child: messagesFilters, + child: messagesFilters!( + headerCollapse.value >= filterContentHeight, + ), ), ), body: _ChannelsBody( diff --git a/mobile/lib/features/channels/channels_page/body.dart b/mobile/lib/features/channels/channels_page/body.dart index 5b53ef5b29..307317c213 100644 --- a/mobile/lib/features/channels/channels_page/body.dart +++ b/mobile/lib/features/channels/channels_page/body.dart @@ -38,7 +38,7 @@ class _ChannelsBody extends StatelessWidget { @override Widget build(BuildContext context) { final inbox = Semantics( - label: 'Inbox', + label: 'Activity', button: true, onTap: onInbox, excludeSemantics: true, @@ -59,18 +59,20 @@ class _ChannelsBody extends StatelessWidget { child: Icon( BuzzIcons.bell, size: _kChannelIconSize, - color: channelNavigationForeground(context), + color: navigationPrimaryForeground(context), ), ), ), const SizedBox(width: _kChannelLabelGap), Expanded( child: Text( - 'Inbox', + 'Activity', maxLines: 1, overflow: TextOverflow.ellipsis, - style: navigationChatTitleTextStyle.copyWith( - color: channelNavigationForeground(context), + style: navigationSectionTextStyle.copyWith( + fontSize: 17, + height: 22 / 17, + color: navigationPrimaryForeground(context), ), ), ), @@ -110,9 +112,9 @@ class _ChannelsBody extends StatelessWidget { child: Padding( padding: const EdgeInsets.fromLTRB( Grid.gutter, - Grid.xs, + Grid.lg, Grid.gutter, - Grid.twelve, + 0, ), child: Semantics( header: true, @@ -133,7 +135,7 @@ class _ChannelsBody extends StatelessWidget { child: Padding( padding: const EdgeInsets.fromLTRB( Grid.gutter, - Grid.sm, + Grid.lg, Grid.gutter, 0, ), @@ -389,7 +391,7 @@ class _SliverChannelsList extends HookConsumerWidget { for (final section in userSections) ...[ if (section != userSections.first || starredStreamChannels.isNotEmpty) - const SizedBox(height: Grid.twelve), + const SizedBox(height: Grid.xxs), _CustomChannelSection( section: section, channels: sortChannelsForList( @@ -484,7 +486,7 @@ class _SliverChannelsList extends HookConsumerWidget { ), ], if (userSections.isNotEmpty || starredStreamChannels.isNotEmpty) - const SizedBox(height: Grid.twelve), + const SizedBox(height: Grid.xxs), _ChannelSection( title: 'Channels', expanded: channelsExpanded.value, @@ -499,7 +501,7 @@ class _SliverChannelsList extends HookConsumerWidget { onSortModeChange: (mode) => setSortMode('channels', mode), onSelectChannel: onSelectChannel, ), - const SizedBox(height: Grid.twelve), + const SizedBox(height: Grid.xxs), _ChannelSection( title: 'DMs', expanded: dmsExpanded.value, diff --git a/mobile/lib/features/channels/channels_page/sections.dart b/mobile/lib/features/channels/channels_page/sections.dart index cf985e2c32..0f1f60d7df 100644 --- a/mobile/lib/features/channels/channels_page/sections.dart +++ b/mobile/lib/features/channels/channels_page/sections.dart @@ -518,25 +518,28 @@ class _SectionLabel extends StatelessWidget { final Color color; @override - Widget build(BuildContext context) => Row( - children: [ - Flexible( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: navigationSectionTextStyle.copyWith( - color: color, - fontWeight: FontWeight.w500, + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(top: Grid.xxs), + child: Row( + children: [ + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: navigationSectionTextStyle.copyWith( + color: color, + fontWeight: FontWeight.w500, + ), ), ), - ), - const SizedBox(width: Grid.quarter), - _SectionChevron( - expanded: expanded, - color: navigationAccessoryForeground(context), - ), - ], + const SizedBox(width: Grid.quarter), + _SectionChevron( + expanded: expanded, + color: navigationAccessoryForeground(context), + ), + ], + ), ); } diff --git a/mobile/lib/features/channels/me/me_page.dart b/mobile/lib/features/channels/me/me_page.dart index 83ec9b0b4f..7ad212d61d 100644 --- a/mobile/lib/features/channels/me/me_page.dart +++ b/mobile/lib/features/channels/me/me_page.dart @@ -211,7 +211,7 @@ class MePage extends HookConsumerWidget { ), for (final group in organization.groups) ...[ if (group != organization.groups.first) - const SizedBox(height: Grid.twelve), + const SizedBox(height: Grid.xxs), MeSection( key: ValueKey(group['id']), label: group['name'] as String, @@ -230,7 +230,7 @@ class MePage extends HookConsumerWidget { ), ], if (organization.groups.isNotEmpty) - const SizedBox(height: Grid.twelve), + const SizedBox(height: Grid.xxs), if (visible.isNotEmpty || organization.groups.isNotEmpty) MeSection( key: const ValueKey('me-ungrouped-chats'), diff --git a/mobile/lib/features/channels/me/me_section.dart b/mobile/lib/features/channels/me/me_section.dart index 4ca97df669..e9a43d753a 100644 --- a/mobile/lib/features/channels/me/me_section.dart +++ b/mobile/lib/features/channels/me/me_section.dart @@ -31,9 +31,11 @@ class MeSection extends HookWidget { borderRadius: BorderRadius.circular(Radii.md), onTap: () => expanded.value = !expanded.value, child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: Grid.xxs, - vertical: Grid.xxs, + padding: const EdgeInsets.fromLTRB( + Grid.xxs, + Grid.twelve, + Grid.xxs, + Grid.half, ), child: ConstrainedBox( constraints: const BoxConstraints(minHeight: 28), diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index 265880632b..aa35a2fdb0 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -6,14 +6,17 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/theme/buzz_icons.dart'; import '../../shared/community/community_membership_provider.dart'; import '../../shared/community/community_provider.dart'; import '../../shared/community/community_icon_provider.dart'; import '../../shared/community/community_avatar.dart'; import '../../shared/widgets/ios_message_create_button.dart'; +import '../../shared/widgets/ios_glass_navigation_button.dart'; import '../../shared/widgets/bottom_up_page_route.dart'; import '../../shared/widgets/mobile_tab_footer_backdrop.dart'; import '../activity/activity_page.dart'; +import '../activity/inbox_unread_provider.dart'; import '../channels/channels_page.dart'; import '../search/search_page.dart'; import 'messages_filters.dart'; @@ -56,6 +59,8 @@ class HomePage extends HookConsumerWidget { final communitySurfaceProgress = useState(0.0); final filter = useState('All'); + final hasUnreadInbox = ref.watch(inboxHasUnreadProvider); + final inboxLabel = hasUnreadInbox ? 'Activity, unread items' : 'Activity'; final reselect = useValueNotifier(0); final newChat = useValueNotifier(null); final newWorkspace = useValueNotifier(null); @@ -85,14 +90,14 @@ class HomePage extends HookConsumerWidget { void openPage(WidgetBuilder builder) => Navigator.of(context).push(MaterialPageRoute(builder: builder)); final solo = mePageBuilder?.call(context, newChat, newWorkspace); + void openInbox() => Navigator.of(context).push( + BottomUpPageRoute( + builder: (_) => const ActivityPage(), + reducedMotion: reducedMotion, + ), + ); final messages = ChannelsPage( settingsPageBuilder: settingsPageBuilder, - onInbox: () => Navigator.of(context).push( - BottomUpPageRoute( - builder: (_) => const ActivityPage(), - reducedMotion: reducedMotion, - ), - ), messagesActionsBuilder: (openSwitcher, avatarKey) { final moreActions = [ ( @@ -120,6 +125,84 @@ class HomePage extends HookConsumerWidget { (label: 'Settings', symbol: 'gearshape', onPressed: openSettings), ]; return [ + if (defaultTargetPlatform == TargetPlatform.iOS && + !nativeControlsVisible) + const SizedBox(width: 44, height: 44) + else + Stack( + alignment: Alignment.center, + children: [ + defaultTargetPlatform == TargetPlatform.iOS + ? IosGlassNavigationButton( + icon: IosGlassNavigationIcon.bell, + semanticLabel: inboxLabel, + width: 44, + height: 44, + controlSize: 44, + foregroundColor: context.colors.onSurface, + onPressed: openInbox, + ) + : IconButton.filledTonal( + tooltip: inboxLabel, + style: IconButton.styleFrom( + backgroundColor: + context.colors.surfaceContainerHighest, + foregroundColor: context.colors.onSurface, + minimumSize: const Size.square(48), + ), + icon: const Icon(BuzzIcons.bell), + onPressed: openInbox, + ), + Positioned.fill( + child: IgnorePointer( + child: ExcludeSemantics( + child: Center( + child: AnimatedOpacity( + opacity: hasUnreadInbox ? 1 : 0, + duration: reducedMotion + ? Duration.zero + : const Duration(milliseconds: 150), + curve: Curves.easeOut, + child: SizedBox.square( + key: const Key('home-inbox-badge'), + dimension: 24, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + top: 0, + right: 2, + child: Container( + key: const Key('home-inbox-dot'), + width: 10, + height: 10, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: context.colors.primary, + border: Border.all( + color: + defaultTargetPlatform == + TargetPlatform.android + ? context + .colors + .surfaceContainerHighest + : context.controlSurface, + width: 2, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ], + ), + const SizedBox(width: Grid.xs), SizedBox( key: avatarKey, child: defaultTargetPlatform == TargetPlatform.iOS @@ -138,6 +221,7 @@ class HomePage extends HookConsumerWidget { ) : AndroidExpandingMenuButton( label: 'Community menu', + backgroundColor: context.colors.surfaceContainerHighest, actions: moreActions, dividerBefore: const {1}, width: 48, @@ -151,7 +235,8 @@ class HomePage extends HookConsumerWidget { ), ]; }, - messagesFilters: MessagesFilters( + messagesFilters: (isCollapsed) => MessagesFilters( + isCollapsed: isCollapsed, selected: filter.value, onSelected: (value) { filter.value = value; diff --git a/mobile/lib/features/home/messages_filters.dart b/mobile/lib/features/home/messages_filters.dart index 29bbb2277f..f55366cf6a 100644 --- a/mobile/lib/features/home/messages_filters.dart +++ b/mobile/lib/features/home/messages_filters.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import '../../shared/theme/theme.dart'; @@ -9,9 +10,11 @@ import '../../shared/widgets/filter_chip_bar.dart'; class MessagesFilters extends StatelessWidget { const MessagesFilters({ super.key, + this.isCollapsed = false, required this.selected, required this.onSelected, }); + final bool isCollapsed; final String selected; final ValueChanged onSelected; @@ -20,6 +23,14 @@ class MessagesFilters extends StatelessWidget { width: double.infinity, child: FilterChipBar( useNativeGlass: true, + matchNativeWidths: true, + labelOffset: defaultTargetPlatform == TargetPlatform.android + ? const Offset(-0.5, -1) + : Offset.zero, + side: defaultTargetPlatform == TargetPlatform.android && isCollapsed + ? BorderSide(color: context.colors.primary.withValues(alpha: 0.02)) + : BorderSide.none, + minControlHeight: defaultTargetPlatform == TargetPlatform.iOS ? 44 : 48, horizontalPadding: Grid.gutter, selected: selected, onSelected: (value) { diff --git a/mobile/lib/shared/widgets/android_expanding_menu_button.dart b/mobile/lib/shared/widgets/android_expanding_menu_button.dart index e9cda3e9a9..31b195d877 100644 --- a/mobile/lib/shared/widgets/android_expanding_menu_button.dart +++ b/mobile/lib/shared/widgets/android_expanding_menu_button.dart @@ -18,6 +18,7 @@ class AndroidExpandingMenuButton extends HookWidget { this.label = 'New', this.expansionBoundsContext, this.trigger, + this.backgroundColor, this.width = 112, this.height = 56, this.dividerBefore = const {}, @@ -29,6 +30,7 @@ class AndroidExpandingMenuButton extends HookWidget { final BuildContext? expansionBoundsContext; final String label; final Widget? trigger; + final Color? backgroundColor; final double width, height; final Set dividerBefore; final Map sectionTitles; @@ -43,6 +45,7 @@ class AndroidExpandingMenuButton extends HookWidget { child: Opacity( opacity: open.value ? 0 : 1, child: NavigationButtonSurface( + backgroundColor: backgroundColor, child: Builder( builder: (buttonContext) => Tooltip( message: label, @@ -74,6 +77,7 @@ class AndroidExpandingMenuButton extends HookWidget { final captured = actions; final route = _CreateMenuRoute( anchor: rect, + backgroundColor: backgroundColor ?? context.controlSurface, expansionBounds: expansionBounds, label: label, trigger: trigger ?? const _NewLabel(), @@ -130,6 +134,7 @@ class _CreateMenuRoute extends PopupRoute { required this.expansionBounds, required this.label, required this.trigger, + required this.backgroundColor, required this.dividerBefore, required this.sectionTitles, required this.actions, @@ -140,6 +145,7 @@ class _CreateMenuRoute extends PopupRoute { final Rect? expansionBounds; final String label; final Widget trigger; + final Color backgroundColor; final Set dividerBefore; final Map sectionTitles; final List<({String label, String symbol, VoidCallback? onPressed})> actions; @@ -230,9 +236,7 @@ class _CreateMenuRoute extends PopupRoute { child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 16 * t, sigmaY: 16 * t), child: Material( - color: context.controlSurface.withValues( - alpha: 1 - .12 * t, - ), + color: backgroundColor.withValues(alpha: 1 - .12 * t), child: Stack( children: [ Positioned.fill( diff --git a/mobile/lib/shared/widgets/filter_chip_bar.dart b/mobile/lib/shared/widgets/filter_chip_bar.dart index 47dc896e98..2d58a2a635 100644 --- a/mobile/lib/shared/widgets/filter_chip_bar.dart +++ b/mobile/lib/shared/widgets/filter_chip_bar.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; import 'ios_glass_navigation_button.dart'; @@ -46,6 +48,18 @@ class FilterChipBar extends StatelessWidget { /// Uses native glass capsules on iOS for navigation-style filters. final bool useNativeGlass; + /// Uses the native iOS label widths for Material capsules too. + final bool matchNativeWidths; + + /// Optional outline for Material capsules. + final BorderSide side; + + /// Optical adjustment for the visible label within its centered layout box. + final Offset labelOffset; + + /// Minimum visible capsule height; the hit target may be larger. + final double minControlHeight; + /// Material density applied to each chip. final VisualDensity visualDensity; @@ -65,6 +79,10 @@ class FilterChipBar extends StatelessWidget { required this.onSelected, this.expandItems = false, this.useNativeGlass = false, + this.matchNativeWidths = false, + this.side = BorderSide.none, + this.labelOffset = Offset.zero, + this.minControlHeight = 0, this.visualDensity = VisualDensity.compact, this.chipVerticalPadding = Grid.quarter, this.barVerticalPadding = Grid.xxs, @@ -73,21 +91,25 @@ class FilterChipBar extends StatelessWidget { @override Widget build(BuildContext context) { + final widths = [ + for (final item in items) + (TextPainter( + text: TextSpan( + text: item.label, + style: filterChipTextStyle.copyWith( + fontWeight: FontWeight.w600, + ), + ), + textScaler: MediaQuery.textScalerOf(context), + textDirection: Directionality.of(context), + )..layout()).width + + 32, + ]; if (useNativeGlass && defaultTargetPlatform == TargetPlatform.iOS) { - final textStyle = filterChipTextStyle.copyWith( - fontWeight: FontWeight.w600, + final height = math.max( + MediaQuery.textScalerOf(context).scale(16) * 1.4 + 16, + math.max(48.0, minControlHeight), ); - final height = (MediaQuery.textScalerOf(context).scale(16) * 1.4 + 16) - .clamp(48.0, double.infinity); - final widths = [ - for (final item in items) - (TextPainter( - text: TextSpan(text: item.label, style: textStyle), - textScaler: MediaQuery.textScalerOf(context), - textDirection: Directionality.of(context), - )..layout()).width + - 32, - ]; return LayoutBuilder( builder: (context, constraints) { final available = constraints.maxWidth - horizontalPadding * 2; @@ -122,7 +144,9 @@ class FilterChipBar extends StatelessWidget { isSelected: selected == items[index].id, width: widths[index] + extra, height: height, - controlSize: height - 8, + controlSize: math + .max(height - 8, minControlHeight) + .toDouble(), fillWidth: true, foregroundColor: context.colors.primary, ), @@ -139,6 +163,11 @@ class FilterChipBar extends StatelessWidget { if (index > 0) const SizedBox(width: Grid.xxs), if (expandItems) Expanded(child: _chip(context, items[index], fillWidth: true)) + else if (matchNativeWidths) + SizedBox( + width: widths[index], + child: _chip(context, items[index], fillWidth: true), + ) else _chip(context, items[index]), ], @@ -207,11 +236,26 @@ class FilterChipBar extends StatelessWidget { textAlign: fillWidth ? TextAlign.center : TextAlign.start, style: labelStyle, ); + final sizedLabel = minControlHeight > 0 + ? ConstrainedBox( + constraints: BoxConstraints( + minHeight: math.max( + 0, + minControlHeight - chipVerticalPadding * 2, + ), + ), + child: Center( + widthFactor: 1, + heightFactor: 1, + child: Transform.translate(offset: labelOffset, child: label), + ), + ) + : label; final centeredLabel = Align( alignment: Alignment.center, widthFactor: fillWidth ? null : 1, heightFactor: 1, - child: label, + child: sizedLabel, ); final chip = FilterChip( selected: isSelected, @@ -221,7 +265,7 @@ class FilterChipBar extends StatelessWidget { : RoundedRectangleBorder( borderRadius: BorderRadius.circular(Radii.lg), ), - side: BorderSide.none, + side: side, label: fillWidth ? SizedBox(width: double.infinity, child: centeredLabel) : centeredLabel, @@ -231,13 +275,21 @@ class FilterChipBar extends StatelessWidget { horizontal: fillWidth ? Grid.quarter : Grid.twelve, vertical: chipVerticalPadding, ), - visualDensity: visualDensity, + visualDensity: minControlHeight > 0 + ? VisualDensity.standard + : visualDensity, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ); - final control = fillWidth - ? SizedBox(width: double.infinity, child: chip) + final sizedChip = minControlHeight > 0 + ? ConstrainedBox( + constraints: BoxConstraints(minHeight: minControlHeight), + child: chip, + ) : chip; + final control = fillWidth + ? SizedBox(width: double.infinity, child: sizedChip) + : sizedChip; if (!item.hasUnread) return control; return Semantics( label: '$text, unread', diff --git a/mobile/lib/shared/widgets/frosted_app_bar.dart b/mobile/lib/shared/widgets/frosted_app_bar.dart index 2018441721..778ab762c6 100644 --- a/mobile/lib/shared/widgets/frosted_app_bar.dart +++ b/mobile/lib/shared/widgets/frosted_app_bar.dart @@ -538,6 +538,7 @@ IosNavigationAction? _nativeAction(Widget? widget) { if (widget is IosGlassNavigationButton) { final symbol = switch (widget.icon) { IosGlassNavigationIcon.more => 'ellipsis', + IosGlassNavigationIcon.bell => 'bell', IosGlassNavigationIcon.trash => 'trash', IosGlassNavigationIcon.reply => 'arrowshape.turn.up.left', IosGlassNavigationIcon.back => 'chevron.backward', diff --git a/mobile/lib/shared/widgets/ios_glass_navigation_button.dart b/mobile/lib/shared/widgets/ios_glass_navigation_button.dart index 9fd1236947..d12ec9031d 100644 --- a/mobile/lib/shared/widgets/ios_glass_navigation_button.dart +++ b/mobile/lib/shared/widgets/ios_glass_navigation_button.dart @@ -30,6 +30,7 @@ enum IosGlassNavigationIcon { more, reply, trash, + bell, } /// Leading width used by iOS channel-style headers. @@ -246,6 +247,7 @@ class IosGlassNavigationButton extends HookWidget { : Icon( switch (icon) { IosGlassNavigationIcon.more => BuzzIcons.ellipsis, + IosGlassNavigationIcon.bell => BuzzIcons.bell, IosGlassNavigationIcon.trash => BuzzIcons.trash2, IosGlassNavigationIcon.reply => BuzzIcons.cornerUpLeft, diff --git a/mobile/lib/shared/widgets/navigation_button_surface.dart b/mobile/lib/shared/widgets/navigation_button_surface.dart index a62ec6e65c..e12cf4e8ad 100644 --- a/mobile/lib/shared/widgets/navigation_button_surface.dart +++ b/mobile/lib/shared/widgets/navigation_button_surface.dart @@ -9,11 +9,15 @@ class NavigationButtonSurface extends StatelessWidget { const NavigationButtonSurface({ required this.child, this.capsule = false, + this.backgroundColor, super.key, }); final Widget child; + /// Optional Material background; native iOS glass retains its own treatment. + final Color? backgroundColor; + /// Uses fully rounded ends instead of device-concentric corners. final bool capsule; @@ -22,7 +26,7 @@ class NavigationButtonSurface extends StatelessWidget { if (NavigationGlassGroup.maybeOf(context)) return child; if (defaultTargetPlatform != TargetPlatform.iOS) { return Material( - color: context.controlSurface, + color: backgroundColor ?? context.controlSurface, shape: const StadiumBorder(), clipBehavior: Clip.antiAlias, child: child, diff --git a/mobile/test/features/activity/inbox_unread_provider_test.dart b/mobile/test/features/activity/inbox_unread_provider_test.dart new file mode 100644 index 0000000000..ed8a749574 --- /dev/null +++ b/mobile/test/features/activity/inbox_unread_provider_test.dart @@ -0,0 +1,86 @@ +import 'package:buzz/features/activity/activity_provider.dart'; +import 'package:buzz/features/activity/feed_item.dart'; +import 'package:buzz/features/activity/inbox_item.dart'; +import 'package:buzz/features/activity/inbox_local_state_provider.dart'; +import 'package:buzz/features/activity/inbox_unread_provider.dart'; +import 'package:buzz/shared/read_state/read_state_provider.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class _ReadState extends ReadStateNotifier { + @override + ReadStateState build() => const ReadStateState.inert(); + + void load(Map contexts) => state = ReadStateState( + isReady: true, + pubkey: 'reader', + contexts: contexts, + version: state.version + 1, + ); +} + +class _LocalState extends InboxLocalStateNotifier { + @override + InboxLocalState build() => const InboxLocalState.empty(); +} + +FeedItem item(String id, {String? channelId}) => FeedItem( + id: id, + kind: 9, + pubkey: 'sender', + content: 'An update', + createdAt: 100, + channelId: channelId, + channelName: 'general', + tags: const [], + category: 'mention', +); + +void main() { + test('bell follows read markers and local unread overrides', () { + final container = ProviderContainer( + overrides: [ + inboxItemsProvider.overrideWithValue( + buildInboxItems([item('mention', channelId: 'channel')]), + ), + readStateProvider.overrideWith(_ReadState.new), + inboxLocalStateProvider.overrideWith(_LocalState.new), + ], + ); + addTearDown(container.dispose); + expect(container.read(inboxHasUnreadProvider), isFalse); + final read = container.read(readStateProvider.notifier) as _ReadState; + read.load({}); + expect(container.read(inboxHasUnreadProvider), isTrue); + read.load({'channel': 100}); + expect(container.read(inboxHasUnreadProvider), isFalse); + final local = container.read(inboxLocalStateProvider.notifier); + local.markUnread(['mention']); + expect(container.read(inboxHasUnreadProvider), isTrue); + local.clearUnread(['mention']); + expect(container.read(inboxHasUnreadProvider), isFalse); + }); + + test('channel-less items clear when done and empty inbox has no dot', () { + final container = ProviderContainer( + overrides: [ + inboxItemsProvider.overrideWithValue( + buildInboxItems([item('approval')]), + ), + readStateProvider.overrideWith(_ReadState.new), + inboxLocalStateProvider.overrideWith(_LocalState.new), + ], + ); + addTearDown(container.dispose); + (container.read(readStateProvider.notifier) as _ReadState).load({}); + expect(container.read(inboxHasUnreadProvider), isTrue); + container.read(inboxLocalStateProvider.notifier).markDone('approval'); + expect(container.read(inboxHasUnreadProvider), isFalse); + container.updateOverrides([ + inboxItemsProvider.overrideWithValue(const []), + readStateProvider.overrideWith(_ReadState.new), + inboxLocalStateProvider.overrideWith(_LocalState.new), + ]); + expect(container.read(inboxHasUnreadProvider), isFalse); + }); +} diff --git a/mobile/test/features/home/home_page_test.dart b/mobile/test/features/home/home_page_test.dart index 25424ddc65..52a8e1d87f 100644 --- a/mobile/test/features/home/home_page_test.dart +++ b/mobile/test/features/home/home_page_test.dart @@ -2,6 +2,7 @@ import 'package:buzz/features/channels/default_agent_launcher.dart'; import 'package:buzz/features/agents/agents_page.dart'; import 'package:buzz/features/channels/me/me_section.dart'; import 'package:buzz/features/activity/activity_page.dart'; +import 'package:buzz/features/activity/inbox_unread_provider.dart'; import 'package:buzz/features/channels/channel.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/shared/me/default_agent.dart'; @@ -10,6 +11,7 @@ import 'package:buzz/features/home/home_page.dart'; import 'package:buzz/features/home/messages_filters.dart'; import 'package:buzz/features/home/messages_navigation_bar.dart'; import 'package:buzz/shared/widgets/ios_message_create_button.dart'; +import 'package:buzz/shared/widgets/ios_glass_navigation_button.dart'; import 'package:buzz/features/search/search_page.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:flutter/foundation.dart'; @@ -18,9 +20,21 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; +// Production iOS capsules fit the semibold label plus 16 points on each side. +double nativeFilterWidth(String label) => + (TextPainter( + text: TextSpan( + text: label, + style: filterChipTextStyle.copyWith(fontWeight: FontWeight.w600), + ), + textDirection: TextDirection.ltr, + )..layout()).width + + 32; + void main() { Future buildHome({ bool longSolo = false, + bool inboxUnread = false, bool disableAnimations = false, Gradient? topSectionGradient, ValueChanged>? onNewChatAction, @@ -30,6 +44,7 @@ void main() { return ProviderScope( overrides: [ savedPrefsProvider.overrideWithValue(prefs), + inboxHasUnreadProvider.overrideWithValue(inboxUnread), defaultAgentProvider.overrideWith((ref) => Stream.value(null)), channelsProvider.overrideWith(_Channels.new), ], @@ -121,6 +136,23 @@ void main() { expect(header.titleStyle?.fontSize, 22); expect(header.centerTitle, isFalse); + for (final label in ['All', 'Solo', 'Team']) { + final chip = find.ancestor( + of: find.descendant( + of: find.byType(MessagesFilters), + matching: find.text(label), + ), + matching: find.byType(FilterChip), + ); + expect(tester.getSize(chip).height, 48); + final paintedChip = find.descendant( + of: chip, + matching: find.byType(Material), + ); + expect(tester.getSize(paintedChip).height, 48); + expect(tester.getSize(paintedChip).width, nativeFilterWidth(label)); + } + await tester.tap( find.descendant( of: find.byType(MessagesFilters), @@ -161,7 +193,8 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Personal workspace').hitTestable(), findsOneWidget); expect(find.text('General').hitTestable(), findsOneWidget); - await tester.tap(find.text('Inbox')); + expect(find.text('Activity'), findsNothing); + await tester.tap(find.byTooltip('Activity')); await tester.pumpAndSettle(); expect(find.byType(ActivityPage), findsOneWidget); Navigator.of(tester.element(find.byType(ActivityPage))).pop(); @@ -202,6 +235,17 @@ void main() { final expandedHeader = tester.widget( find.byType(FrostedAppBar), ); + BorderSide filterOutline() => tester + .widget( + find + .descendant( + of: find.byType(MessagesFilters), + matching: find.byType(FilterChip), + ) + .first, + ) + .side!; + expect(filterOutline(), BorderSide.none); scroll.jumpTo(expandedHeader.bottomHeight / 4); await tester.pump(); expect( @@ -216,6 +260,7 @@ void main() { .opacity, closeTo(0.75, 0.01), ); + expect(filterOutline(), BorderSide.none); scroll.jumpTo(expandedHeader.bottomHeight); await tester.pump(); expect( @@ -226,9 +271,12 @@ void main() { .opacity, 0, ); + expect(filterOutline().width, 1); + expect(filterOutline().color.a, closeTo(0.02, 0.001)); scroll.jumpTo(0); await tester.pump(); expect(tester.getTopLeft(title).dy, titleTop); + expect(filterOutline(), BorderSide.none); await tester.drag( find.byType(CustomScrollView).first, const Offset(0, -2000), @@ -293,6 +341,54 @@ void main() { }); } + for (final platform in [TargetPlatform.android, TargetPlatform.iOS]) { + testWidgets('$platform bell follows unread inbox state', (tester) async { + debugDefaultTargetPlatformOverride = platform; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + for (final unread in [false, true, false]) { + await tester.pumpWidget(await buildHome(inboxUnread: unread)); + await tester.pumpAndSettle(); + expect( + tester.getCenter(find.byKey(const Key('home-inbox-dot'))) - + tester.getTopLeft(find.byKey(const Key('home-inbox-badge'))), + const Offset(17, 5), + ); + final opacity = tester.widget( + find.ancestor( + of: find.byKey(const Key('home-inbox-badge')), + matching: find.byType(AnimatedOpacity), + ), + ); + expect(opacity.opacity, unread ? 1 : 0); + expect(opacity.duration, const Duration(milliseconds: 150)); + expect( + tester.getSize(find.byKey(const Key('home-inbox-badge'))), + const Size.square(24), + ); + expect( + find.byTooltip(unread ? 'Activity, unread items' : 'Activity'), + findsOneWidget, + ); + } + await tester.pumpWidget( + await buildHome(inboxUnread: true, disableAnimations: true), + ); + await tester.pump(); + expect( + tester + .widget( + find.ancestor( + of: find.byKey(const Key('home-inbox-badge')), + matching: find.byType(AnimatedOpacity), + ), + ) + .duration, + Duration.zero, + ); + debugDefaultTargetPlatformOverride = null; + }); + } + testWidgets( 'iOS shows Messages above filters with the native creation menu', (tester) async { @@ -348,6 +444,48 @@ void main() { closeTo(initialWidth, .01), ); expect(search.hitTestable(), findsOneWidget); + final inbox = find.byWidgetPredicate( + (w) => w is IosGlassNavigationButton && w.semanticLabel == 'Activity', + ); + expect(inbox, findsOneWidget); + expect(find.text('Activity'), findsNothing); + final inboxButton = tester.widget(inbox); + expect(inboxButton.icon, IosGlassNavigationIcon.bell); + + for (final label in ['All', 'Solo', 'Team']) { + final chip = tester.widget( + find.descendant( + of: find.byType(MessagesFilters), + matching: find.byWidgetPredicate( + (w) => w is IosGlassNavigationButton && w.label == label, + ), + ), + ); + expect(chip.controlSize, inboxButton.controlSize); + expect(chip.width, nativeFilterWidth(label)); + } + expect(tester.getSize(inbox), const Size(44, 44)); + final communityTrigger = find.byWidgetPredicate( + (w) => + w is IosMessageCreateButton && + w.accessibilityLabel == 'Community menu', + ); + expect( + tester.getRect(inbox).right, + lessThan(tester.getRect(communityTrigger).left), + ); + inboxButton.onPressed!(); + await tester.pumpAndSettle(); + expect(find.byType(ActivityPage), findsOneWidget); + expect( + find.byWidgetPredicate( + (w) => w is IosGlassNavigationButton && w.semanticLabel == 'Activity', + skipOffstage: false, + ), + findsNothing, + ); + Navigator.of(tester.element(find.byType(ActivityPage))).pop(); + await tester.pumpAndSettle(); final communityMenu = tester.widget( find.byWidgetPredicate( (w) =>