feat(mobile): move Activity to a Home bell and align Home spacing (#9212)

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 <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Tom Brow
2026-11-06 12:10:11 -08:00
committed by GitHub
parent 446e9e0762
commit beec055300
17 changed files with 482 additions and 66 deletions

View File

@@ -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"

View File

@@ -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

View File

@@ -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<bool>((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,
),
);
});

View File

@@ -129,7 +129,7 @@ class ChannelsPage extends HookConsumerWidget {
});
final WidgetBuilder settingsPageBuilder;
final Widget? messagesFilters;
final Widget Function(bool isCollapsed)? messagesFilters;
final List<Widget> 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(

View File

@@ -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,

View File

@@ -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),
),
],
),
);
}

View File

@@ -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'),

View File

@@ -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),

View File

@@ -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<VoidCallback?>(null);
final newWorkspace = useValueNotifier<VoidCallback?>(null);
@@ -85,14 +90,14 @@ class HomePage extends HookConsumerWidget {
void openPage(WidgetBuilder builder) =>
Navigator.of(context).push(MaterialPageRoute<void>(builder: builder));
final solo = mePageBuilder?.call(context, newChat, newWorkspace);
void openInbox() => Navigator.of(context).push(
BottomUpPageRoute<void>(
builder: (_) => const ActivityPage(),
reducedMotion: reducedMotion,
),
);
final messages = ChannelsPage(
settingsPageBuilder: settingsPageBuilder,
onInbox: () => Navigator.of(context).push(
BottomUpPageRoute<void>(
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;

View File

@@ -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<String> onSelected;
@@ -20,6 +23,14 @@ class MessagesFilters extends StatelessWidget {
width: double.infinity,
child: FilterChipBar<String>(
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) {

View File

@@ -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<int> dividerBefore;
final Map<int, String> 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<int> {
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<int> {
final Rect? expansionBounds;
final String label;
final Widget trigger;
final Color backgroundColor;
final Set<int> dividerBefore;
final Map<int, String> sectionTitles;
final List<({String label, String symbol, VoidCallback? onPressed})> actions;
@@ -230,9 +236,7 @@ class _CreateMenuRoute extends PopupRoute<int> {
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(

View File

@@ -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<T> 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<T> 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<T> 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<T> 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<T> 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<T> 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<T> 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<T> 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',

View File

@@ -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',

View File

@@ -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,

View File

@@ -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,

View File

@@ -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<String, int> 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);
});
}

View File

@@ -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<Widget> buildHome({
bool longSolo = false,
bool inboxUnread = false,
bool disableAnimations = false,
Gradient? topSectionGradient,
ValueChanged<ValueNotifier<VoidCallback?>>? 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<FrostedAppBar>(
find.byType(FrostedAppBar),
);
BorderSide filterOutline() => tester
.widget<FilterChip>(
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<AnimatedOpacity>(
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<AnimatedOpacity>(
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<IosGlassNavigationButton>(inbox);
expect(inboxButton.icon, IosGlassNavigationIcon.bell);
for (final label in ['All', 'Solo', 'Team']) {
final chip = tester.widget<IosGlassNavigationButton>(
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<IosMessageCreateButton>(
find.byWidgetPredicate(
(w) =>