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) =>