mirror of
https://github.com/block/buzz.git
synced 2026-11-07 01:41:06 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
22
mobile/lib/features/activity/inbox_unread_provider.dart
Normal file
22
mobile/lib/features/activity/inbox_unread_provider.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user