Polish mobile top navigation (#4778)

## Summary

- Polish mobile Home, Activity, Search, and Settings navigation chrome.
- Add progressive Buzz gradients/frost, aligned theme colors, dividers,
typography, and section spacing.
- Refine Search and Settings motion, including automatic keyboard focus
on search activation.

<img width="630" height="1368"
alt="C78FA3CE-F2B3-45F2-B9F5-7EA7500778CC"
src="https://github.com/user-attachments/assets/5935514b-d894-4010-80dd-a938363fee93"
/>
<img width="630" height="1368"
alt="5C058A73-1879-476A-881C-531ACC256D84"
src="https://github.com/user-attachments/assets/5266c841-17ee-49e8-9841-b06d84f4195f"
/>
<img width="630" height="1368"
alt="3F50ADB7-9BDA-4A8D-A81E-20560C3B9EA6"
src="https://github.com/user-attachments/assets/f615f61e-9e96-4bce-b261-ae5ec54db872"
/>
<img width="630" height="1368"
alt="35ECE741-01F3-4B79-80C5-1DDD447121A7"
src="https://github.com/user-attachments/assets/b5145186-9884-44eb-8ebc-f3303831c0a4"
/>

## Validation


- `flutter analyze`
- Focused Home, Activity, Channels, Search, theme, and footer widget
tests
- Full pre-push checks, including mobile tests, desktop checks, and
Tauri checks
- On-device iPhone review during the visual polish pass

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: npub1glqcqfjxdens59scl477pmejh8lht4hqkhx0y4w38jxr6e6w6y2sm29y4e <47c18026466e670a1618fd7de0ef32b9ff75d6e0b5ccf255d13c8c3d674ed115@buzz.block.builderlab.xyz>
Signed-off-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz>
Signed-off-by: Kenny Lopez <klopez4212@gmail.com>
Co-authored-by: npub1glqcqfjxdens59scl477pmejh8lht4hqkhx0y4w38jxr6e6w6y2sm29y4e <47c18026466e670a1618fd7de0ef32b9ff75d6e0b5ccf255d13c8c3d674ed115@buzz.block.builderlab.xyz>
Co-authored-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz>
This commit is contained in:
klopez4212
2026-08-05 19:01:20 +01:00
committed by GitHub
parent 4674750b7e
commit ff0b7982f1
29 changed files with 2593 additions and 504 deletions

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@@ -59,7 +60,10 @@ EdgeInsets _activityScrollPadding(
/// navigation. Row taps deep-link to the represented message (oldest unread
/// for grouped conversations) rather than just opening the channel.
class ActivityPage extends HookConsumerWidget {
const ActivityPage({super.key});
const ActivityPage({this.tabReselection, super.key});
/// Notifies this page when its already-selected tab is tapped again.
final ValueListenable<int>? tabReselection;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -67,9 +71,41 @@ class ActivityPage extends HookConsumerWidget {
final channelsAsync = ref.watch(channelsProvider);
final filter = useState(InboxFilter.all);
final unreadOnly = useState(false);
final scrollController = useScrollController();
final reducedMotion = MediaQuery.disableAnimationsOf(context);
useEffect(() {
final tabReselection = this.tabReselection;
if (tabReselection == null) return null;
void scrollToTop() {
if (!scrollController.hasClients) return;
final position = scrollController.position;
if (position.pixels <= position.minScrollExtent + 0.5) return;
if (reducedMotion) {
scrollController.jumpTo(position.minScrollExtent);
return;
}
unawaited(
scrollController.animateTo(
position.minScrollExtent,
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
),
);
}
tabReselection.addListener(scrollToTop);
return () => tabReselection.removeListener(scrollToTop);
}, [tabReselection, scrollController, reducedMotion]);
final headerTitleStyle = context.textTheme.titleMedium?.copyWith(
fontSize: 22,
fontWeight: FontWeight.w600,
color: navigationPrimaryForeground(context),
);
final topSectionHeight = frostedAppBarHeight(
context,
titleStyle: headerTitleStyle,
bottomHeight: Grid.xxs,
);
final readState = ref.watch(readStateProvider);
@@ -244,12 +280,18 @@ class ActivityPage extends HookConsumerWidget {
]);
}
final Widget body;
late final Widget body;
var bodyRidesOverTopSection = false;
if (filter.value == InboxFilter.reminders) {
body = _RemindersList(onOpen: openReminder, onRefresh: refresh);
body = _RemindersList(
scrollController: scrollController,
onOpen: openReminder,
onRefresh: refresh,
);
} else if (filter.value == InboxFilter.drafts) {
body = _DraftsList(
drafts: drafts,
scrollController: scrollController,
channelById: channelById,
myPubkey: myPk,
onOpen: openDraft,
@@ -259,7 +301,7 @@ class ActivityPage extends HookConsumerWidget {
} else if (feedAsync.hasError && allItems.isEmpty) {
body = _ErrorView(onRetry: refresh);
} else if (!hasLoadedOnce.value && allItems.isEmpty) {
body = const _LoadingSkeleton();
body = _LoadingSkeleton(scrollController: scrollController);
} else if (visibleItems.isEmpty) {
body = _EmptyFilterState(
filter: filter.value,
@@ -274,54 +316,73 @@ class ActivityPage extends HookConsumerWidget {
? firstReadIndex
: -1;
bodyRidesOverTopSection = true;
body = RefreshIndicator(
edgeOffset: topSectionHeight,
onRefresh: refresh,
child: ListView.builder(
padding: _activityScrollPadding(context),
itemCount: visibleItems.length,
itemBuilder: (context, index) {
final item = visibleItems[index];
final channel = item.item.channelId != null
? channelById[item.item.channelId]
: null;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (index == newBoundaryIndex) const _NewBoundaryDivider(),
_InboxRow(
key: ValueKey(item.id),
item: item,
channel: channel,
currentPubkey: myPk,
isDone: isDone(item),
onTap: () => openItem(item),
onMarkRead: () => markItemRead(item),
onMarkUnread: () => markItemUnread(item),
child: CustomScrollView(
controller: scrollController,
slivers: [
SliverToBoxAdapter(child: SizedBox(height: topSectionHeight)),
DecoratedSliver(
decoration: BoxDecoration(
color: context.colors.surface,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(Radii.dialog),
),
],
);
},
),
sliver: SliverPadding(
padding: _activityScrollPadding(context),
sliver: SliverList.builder(
itemCount: visibleItems.length,
itemBuilder: (context, index) {
final item = visibleItems[index];
final channel = item.item.channelId != null
? channelById[item.item.channelId]
: null;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (index == newBoundaryIndex)
const _NewBoundaryDivider(),
_InboxRow(
key: ValueKey(item.id),
item: item,
channel: channel,
currentPubkey: myPk,
isDone: isDone(item),
onTap: () => openItem(item),
onMarkRead: () => markItemRead(item),
onMarkUnread: () => markItemUnread(item),
),
],
);
},
),
),
),
],
),
);
}
return FrostedScaffold(
backgroundColor: Colors.transparent,
backgroundColor: context.colors.surface,
appBar: FrostedAppBar(
gradient: context.appColors.topSectionGradient,
automaticallyImplyLeading: false,
title: const Text('Activity'),
horizontalInset: Grid.gutter,
showBottomDivider: true,
bottomDividerOpacity: 0.06,
title: Text('Activity', style: headerTitleStyle),
titleStyle: headerTitleStyle,
actions: [
_FilterMenuButton(
_ActivityActionsPill(
filter: filter.value,
dueReminderCount: dueReminderCount,
draftCount: drafts.length,
onChanged: (f) => filter.value = f,
),
_InboxOptionsButton(
unreadOnly: unreadOnly.value,
unreadCount: unreadVisibleCount,
onFilterChanged: (f) => filter.value = f,
onUnreadOnlyChanged: (v) => unreadOnly.value = v,
onMarkAllRead: () {
for (final item in visibleItems) {
@@ -330,17 +391,19 @@ class ActivityPage extends HookConsumerWidget {
},
),
],
bottomHeight: Grid.xxs,
bottom: const SizedBox.expand(),
),
body: SafeArea(
key: const ValueKey('activity-content-safe-area'),
top: false,
bottom: false,
child: Padding(
padding: EdgeInsets.only(
top: frostedAppBarHeight(context, titleStyle: headerTitleStyle),
),
child: body,
),
child: bodyRidesOverTopSection
? body
: Padding(
padding: EdgeInsets.only(top: topSectionHeight),
child: body,
),
),
);
}

View File

@@ -11,6 +11,59 @@ const _filterLabels = <InboxFilter, String>{
InboxFilter.drafts: 'Drafts',
};
class _ActivityActionsPill extends StatelessWidget {
final InboxFilter filter;
final int dueReminderCount;
final int draftCount;
final bool unreadOnly;
final int unreadCount;
final ValueChanged<InboxFilter> onFilterChanged;
final ValueChanged<bool> onUnreadOnlyChanged;
final VoidCallback onMarkAllRead;
const _ActivityActionsPill({
required this.filter,
required this.dueReminderCount,
required this.draftCount,
required this.unreadOnly,
required this.unreadCount,
required this.onFilterChanged,
required this.onUnreadOnlyChanged,
required this.onMarkAllRead,
});
@override
Widget build(BuildContext context) => ClipRRect(
borderRadius: BorderRadius.circular(Radii.full),
child: DecoratedBox(
decoration: BoxDecoration(
color: context.colors.primaryContainer,
borderRadius: BorderRadius.circular(Radii.full),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.quarter),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_FilterMenuButton(
filter: filter,
dueReminderCount: dueReminderCount,
draftCount: draftCount,
onChanged: onFilterChanged,
),
_InboxOptionsButton(
unreadOnly: unreadOnly,
unreadCount: unreadCount,
onUnreadOnlyChanged: onUnreadOnlyChanged,
onMarkAllRead: onMarkAllRead,
),
],
),
),
),
);
}
/// Compact filter dropdown replacing the old chip rail — mirrors desktop's
/// inbox filter menu (`FILTER_OPTIONS`).
class _FilterMenuButton extends StatelessWidget {
@@ -91,6 +144,7 @@ class _FilterMenuButton extends StatelessWidget {
Text(
_filterLabels[filter]!,
style: context.textTheme.labelLarge?.copyWith(
color: navigationPrimaryForeground(context),
fontWeight: FontWeight.w600,
),
),
@@ -98,7 +152,7 @@ class _FilterMenuButton extends StatelessWidget {
Icon(
LucideIcons.chevronDown,
size: 16,
color: context.colors.onSurfaceVariant,
color: navigationPrimaryForeground(context),
),
if (dueReminderCount > 0 || draftCount > 0) ...[
const SizedBox(width: Grid.quarter),
@@ -133,7 +187,7 @@ class _CountBadge extends StatelessWidget {
vertical: Grid.quarter,
),
decoration: BoxDecoration(
color: context.colors.primary,
color: navigationPrimaryForeground(context),
borderRadius: BorderRadius.circular(Grid.xxs),
),
child: Text(
@@ -168,6 +222,12 @@ class _InboxOptionsButton extends StatelessWidget {
builder: (buttonContext) => IconButton(
key: const ValueKey('activity-options-menu'),
tooltip: 'Activity options',
color: navigationPrimaryForeground(context),
padding: const EdgeInsets.symmetric(horizontal: Grid.xxs),
constraints: const BoxConstraints.tightFor(
width: Grid.xl,
height: Grid.xl,
),
icon: const Icon(LucideIcons.ellipsis, size: 20),
onPressed: () async {
final selected = await showAnchoredPopover<String>(

View File

@@ -3,10 +3,15 @@ part of '../activity_page.dart';
/// Reminders surface for the Reminders filter — due/pending NIP-ER
/// reminders that deep-link to their target message.
class _RemindersList extends ConsumerWidget {
final ScrollController scrollController;
final void Function(Reminder reminder) onOpen;
final Future<void> Function() onRefresh;
const _RemindersList({required this.onOpen, required this.onRefresh});
const _RemindersList({
required this.scrollController,
required this.onOpen,
required this.onRefresh,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -36,6 +41,7 @@ class _RemindersList extends ConsumerWidget {
return RefreshIndicator(
onRefresh: onRefresh,
child: ListView.builder(
controller: scrollController,
padding: _activityScrollPadding(context),
itemCount: reminders.length,
itemBuilder: (context, index) {
@@ -73,6 +79,7 @@ class _RemindersList extends ConsumerWidget {
/// text that reopens the target composer.
class _DraftsList extends StatelessWidget {
final List<ComposeDraft> drafts;
final ScrollController scrollController;
final Map<String, Channel> channelById;
final String? myPubkey;
final void Function(ComposeDraft draft) onOpen;
@@ -80,6 +87,7 @@ class _DraftsList extends StatelessWidget {
const _DraftsList({
required this.drafts,
required this.scrollController,
required this.channelById,
required this.myPubkey,
required this.onOpen,
@@ -97,6 +105,7 @@ class _DraftsList extends StatelessWidget {
}
return ListView.builder(
controller: scrollController,
padding: _activityScrollPadding(context),
itemCount: drafts.length,
itemBuilder: (context, index) {

View File

@@ -1,11 +1,14 @@
part of '../activity_page.dart';
class _LoadingSkeleton extends StatelessWidget {
const _LoadingSkeleton();
final ScrollController scrollController;
const _LoadingSkeleton({required this.scrollController});
@override
Widget build(BuildContext context) {
return ListView.separated(
controller: scrollController,
padding: _activityScrollPadding(
context,
horizontal: Grid.gutter,

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import 'dart:math' show max, min, pi;
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@@ -65,6 +66,14 @@ const double _kChannelLeadingWidth = 22.0;
const double _kChannelIconSize = 18.0;
const double _kChannelLabelGap = Grid.xxs;
const double _kChannelRowVerticalPadding = Grid.xxs + Grid.quarter;
const double _kSectionSpacingTightening = Grid.half;
const double _kSectionHeaderVerticalPadding =
_kChannelRowVerticalPadding - _kSectionSpacingTightening;
// Section headers include touch targets for their actions, so their visual
// centre sits lower than a channel row's. This keeps an expanded section's
// final row equally spaced from the following divider.
const double _kExpandedSectionTrailingPadding =
11.0 - _kSectionSpacingTightening;
const double _kChannelLabelInset =
_kChannelSectionInset + _kChannelLeadingWidth + _kChannelLabelGap;
@@ -74,22 +83,22 @@ const double _kChannelLabelInset =
/// sections while the labels stay on [_kChannelLabelInset].
const double _kDmAvatarSize = _kChannelIconSize;
const double _kTopSectionAvatarSize = 32.0;
const double _kTopSectionAvatarSize = 40.0;
const double _kTopSectionBottomPadding = Grid.xxs;
/// The top section's avatars are 32dp circles, which fill their box edge to
/// The top section's avatars are 40dp circles, which fill their box edge to
/// edge; the channel rows below lead with an 18dp glyph left-aligned in a 22dp
/// box at [_kChannelSectionInset]. Edge-aligning the two leaves the circles
/// looking pushed outward, so the bar is pulled in to sit the avatar's centre
/// on the channel-icon column (12 + 16 = 28dp against the glyph's ~29dp). Its
/// label gap is derived separately so both labels land on the same 50dp column.
/// near the channel-icon column.
const double _kTopSectionInset = Grid.twelve;
const double _kTopSectionLabelGap =
_kChannelLabelInset - _kTopSectionInset - _kTopSectionAvatarSize;
const Duration _kSectionExpandDuration = Duration(milliseconds: 220);
const Duration _kSectionCollapseDuration = Duration(milliseconds: 170);
const Curve _kSectionExpandCurve = Cubic(0.23, 1, 0.32, 1);
const Curve _kSectionCollapseCurve = Curves.easeInCubic;
const double _kSectionCollapsedScaleY = 0.98;
const double _kHeaderFrostScrollDistance = Grid.xxl;
const double _kHeaderFrostMaxBlurSigma = 23.12;
class _UnreadChannelState {
final Set<String> ids;
@@ -145,10 +154,21 @@ _UnreadChannelState _computeUnreadChannelState({
}
class ChannelsPage extends HookConsumerWidget {
const ChannelsPage({required this.settingsPageBuilder, super.key});
const ChannelsPage({
required this.settingsPageBuilder,
required this.onSettingsTransitionProgress,
this.tabReselection,
super.key,
});
final WidgetBuilder settingsPageBuilder;
/// Reports the Settings route's raw animation progress from 0 to 1.
final ValueChanged<double> onSettingsTransitionProgress;
/// Notifies this page when its already-selected tab is tapped again.
final ValueListenable<int>? tabReselection;
@override
Widget build(BuildContext context, WidgetRef ref) {
final channelsAsync = ref.watch(channelsProvider);
@@ -157,6 +177,59 @@ class ChannelsPage extends HookConsumerWidget {
.watch(profileProvider)
.whenData((value) => value?.pubkey)
.value;
final headerTitleStyle = context.textTheme.titleMedium?.copyWith(
fontSize: 22,
fontWeight: FontWeight.w600,
color: navigationPrimaryForeground(context),
);
final topSectionHeight = frostedAppBarHeight(
context,
titleStyle: headerTitleStyle,
bottomHeight: _kTopSectionBottomPadding,
);
final channelsScrollController = useScrollController();
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final headerFrostProgress = useState(0.0);
useEffect(() {
void updateHeaderTreatment() {
final nextProgress = !channelsScrollController.hasClients
? 0.0
: (channelsScrollController.offset / _kHeaderFrostScrollDistance)
.clamp(0.0, 1.0)
.toDouble();
if ((headerFrostProgress.value - nextProgress).abs() > 0.001) {
headerFrostProgress.value = nextProgress;
}
}
channelsScrollController.addListener(updateHeaderTreatment);
return () =>
channelsScrollController.removeListener(updateHeaderTreatment);
}, [channelsScrollController]);
useEffect(() {
final tabReselection = this.tabReselection;
if (tabReselection == null) return null;
void scrollToTop() {
if (!channelsScrollController.hasClients) return;
final position = channelsScrollController.position;
if (position.pixels <= position.minScrollExtent + 0.5) return;
if (reducedMotion) {
channelsScrollController.jumpTo(position.minScrollExtent);
return;
}
unawaited(
channelsScrollController.animateTo(
position.minScrollExtent,
duration: const Duration(milliseconds: 260),
curve: Curves.easeOutCubic,
),
);
}
tabReselection.addListener(scrollToTop);
return () => tabReselection.removeListener(scrollToTop);
}, [tabReselection, channelsScrollController, reducedMotion]);
// Cache the last successfully loaded channels so the UI never flashes
// back to a loading state when the provider rebuilds (e.g. reconnect).
@@ -223,32 +296,65 @@ class ChannelsPage extends HookConsumerWidget {
return timer.cancel;
}, [isReconnectingWithContent]);
void openCommunitySwitcher() {
unawaited(HapticFeedback.selectionClick());
ref.invalidate(communityIconProvider);
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (_) => const _CommunitySwitcherSheet(),
);
}
final topSectionGradient = context.appColors.topSectionGradient;
final usesPinnedGradient = topSectionGradient != null;
return FrostedScaffold(
backgroundColor: Colors.transparent,
backgroundColor: usesPinnedGradient
? Colors.transparent
: context.colors.surface,
backgroundGradient: topSectionGradient,
appBar: FrostedAppBar(
horizontalInset: _kTopSectionInset,
// Under a Buzz theme the community + account avatar strip carries the
// branded gradient, the way desktop paints it across the sidebar. Null
// under every other theme, leaving the default frosted fill.
gradient: context.appColors.topSectionGradient,
leading: _CommunityIndicator(
onTap: () {
ref.invalidate(communityIconProvider);
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (_) => const _CommunitySwitcherSheet(),
);
},
// Let the full Buzz gradient show at rest. Once the list begins to
// move beneath this row, build up blur over the first 64dp of scroll
// without adding the usual white frosted wash. The Buzz list is
// transparent, so the blurred pixels remain a continuation of the
// pinned gradient instead of turning into a white header.
frosted: !usesPinnedGradient || headerFrostProgress.value > 0,
frostedSurfaceOpacity: usesPinnedGradient ? 0 : 0.5,
frostedBlurSigma: usesPinnedGradient
? _kHeaderFrostMaxBlurSigma * headerFrostProgress.value
: 20,
showBottomDivider: false,
leading: _CommunityIndicator(onTap: openCommunitySwitcher),
titleStyle: headerTitleStyle,
title: _CommunityHeaderTitle(
style: headerTitleStyle,
onTap: openCommunitySwitcher,
),
title: const SizedBox.shrink(),
actions: [
ProfileAvatar(
onTap: () => Navigator.of(
context,
).push(MaterialPageRoute<void>(builder: settingsPageBuilder)),
SizedBox(
width: Grid.xl,
height: Grid.xl,
child: Center(
child: ProfileAvatar(
size: _kTopSectionAvatarSize,
onTap: () {
unawaited(HapticFeedback.lightImpact());
Navigator.of(context).push(
_SettingsPageRoute(
builder: settingsPageBuilder,
onTransitionProgress: onSettingsTransitionProgress,
),
);
},
),
),
),
],
bottomHeight: _kTopSectionBottomPadding,
bottom: const SizedBox.expand(),
),
body: _ChannelsBody(
channels: channels,
@@ -257,9 +363,96 @@ class ChannelsPage extends HookConsumerWidget {
sessionStatus: sessionState.status,
showConnectionSkeleton: showConnectionSkeleton.value,
currentPubkey: currentPubkey,
topSectionHeight: topSectionHeight,
usesPinnedGradient: usesPinnedGradient,
scrollController: channelsScrollController,
onRefresh: () => ref.read(channelsProvider.notifier).refresh(),
onSelectChannel: openChannel,
),
);
}
}
/// A custom route deliberately avoids [MaterialPageRoute]'s platform exit
/// transition on Home. Settings has a centered scale-and-fade transition, not
/// a lateral page push.
class _SettingsPageRoute extends PageRouteBuilder<void> {
_SettingsPageRoute({
required WidgetBuilder builder,
required this.onTransitionProgress,
}) : super(
pageBuilder: (context, animation, secondaryAnimation) =>
builder(context),
transitionsBuilder: _buildSettingsTransition,
opaque: false,
transitionDuration: const Duration(milliseconds: 190),
reverseTransitionDuration: const Duration(milliseconds: 190),
);
final ValueChanged<double> onTransitionProgress;
Animation<double>? _progressAnimation;
@override
void install() {
super.install();
_progressAnimation = animation?..addListener(_reportProgress);
_reportProgress();
}
void _reportProgress() {
onTransitionProgress(_progressAnimation?.value ?? 0);
}
@override
void dispose() {
_progressAnimation?.removeListener(_reportProgress);
super.dispose();
}
static Widget _buildSettingsTransition(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
if (MediaQuery.disableAnimationsOf(context)) return child;
final incoming = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeOutCubic,
);
return FadeTransition(
key: const ValueKey('settings-transition-opacity'),
opacity: _SettingsOpacityAnimation(incoming),
child: RepaintBoundary(
key: const ValueKey('settings-transition-layer'),
child: ScaleTransition(
scale: Tween<double>(begin: 1.04, end: 1).animate(incoming),
alignment: Alignment.center,
child: child,
),
),
);
}
}
/// Keeps Settings already composed on entry while retaining a complete exit
/// fade. Reading the parent live also keeps opacity synchronized with scale on
/// the route's first frame.
class _SettingsOpacityAnimation extends Animation<double>
with AnimationWithParentMixin<double> {
_SettingsOpacityAnimation(this.parent);
@override
final Animation<double> parent;
@override
double get value {
final progress = parent.value;
return parent.status == AnimationStatus.reverse
? progress
: 0.8 + (0.2 * progress);
}
}

View File

@@ -7,6 +7,9 @@ class _ChannelsBody extends StatelessWidget {
final SessionStatus sessionStatus;
final bool showConnectionSkeleton;
final String? currentPubkey;
final double topSectionHeight;
final bool usesPinnedGradient;
final ScrollController scrollController;
final Future<void> Function() onRefresh;
final Future<void> Function(Channel channel) onSelectChannel;
@@ -17,13 +20,16 @@ class _ChannelsBody extends StatelessWidget {
required this.sessionStatus,
required this.showConnectionSkeleton,
required this.currentPubkey,
required this.topSectionHeight,
required this.usesPinnedGradient,
required this.scrollController,
required this.onRefresh,
required this.onSelectChannel,
});
@override
Widget build(BuildContext context) {
final barHeight = frostedAppBarHeight(context);
final barHeight = topSectionHeight;
final loadedChannels = channels;
final loading =
showConnectionSkeleton || (loadedChannels == null && !showError);
@@ -38,13 +44,32 @@ class _ChannelsBody extends StatelessWidget {
edgeOffset: barHeight,
onRefresh: onRefresh,
child: CustomScrollView(
controller: scrollController,
// The transparent gap shows the top section and must not absorb
// taps meant for the community or profile controls beneath it.
hitTestBehavior: HitTestBehavior.deferToChild,
slivers: [
SliverToBoxAdapter(child: SizedBox(height: barHeight)),
_SliverChannelsList(
channels: loadedChannels,
currentPubkey: currentPubkey,
onSelectChannel: onSelectChannel,
),
if (usesPinnedGradient)
_SliverChannelsList(
channels: loadedChannels,
currentPubkey: currentPubkey,
onSelectChannel: onSelectChannel,
)
else
DecoratedSliver(
decoration: BoxDecoration(
color: context.colors.surface,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(Radii.dialog),
),
),
sliver: _SliverChannelsList(
channels: loadedChannels,
currentPubkey: currentPubkey,
onSelectChannel: onSelectChannel,
),
),
],
),
);

View File

@@ -26,6 +26,12 @@ class _ChannelTile extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final contentColor = isMuted
? navigationSecondaryForeground(context)
: navigationPrimaryForeground(
context,
).withValues(alpha: isUnread ? 1 : 0.8);
return InkWell(
borderRadius: BorderRadius.circular(Radii.md),
onTap: onTap,
@@ -47,8 +53,9 @@ class _ChannelTile extends ConsumerWidget {
? _DmAvatar(channel: channel, currentPubkey: currentPubkey)
: Icon(
channelIcon(channel),
key: ValueKey('channel-icon-${channel.id}'),
size: _kChannelIconSize,
color: context.colors.onSurface,
color: contentColor,
),
),
),
@@ -65,7 +72,7 @@ class _ChannelTile extends ConsumerWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: contentListTitleTextStyle.copyWith(
color: context.colors.onSurface,
color: contentColor,
fontWeight: isUnread ? FontWeight.w700 : FontWeight.w400,
),
),
@@ -81,7 +88,7 @@ class _ChannelTile extends ConsumerWidget {
Icon(
LucideIcons.bellOff,
size: 12,
color: context.colors.onSurfaceVariant,
color: context.colors.onSurface.withValues(alpha: 0.4),
),
],
if (!channel.isMember && !channel.isDm)

View File

@@ -471,37 +471,44 @@ class _CommunityIndicator extends ConsumerWidget {
final activeAsync = ref.watch(activeCommunityProvider);
final activeCommunity = activeAsync.value;
final name = activeCommunity?.name;
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_CommunityAvatar(name: name, relayUrl: activeCommunity?.relayUrl),
const SizedBox(width: _kTopSectionLabelGap),
if (name != null)
Flexible(
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
)
else
Text(
'Community',
child: _CommunityAvatar(
name: activeCommunity?.name,
relayUrl: activeCommunity?.relayUrl,
),
);
}
}
class _CommunityHeaderTitle extends ConsumerWidget {
final TextStyle? style;
final VoidCallback onTap;
const _CommunityHeaderTitle({required this.onTap, this.style});
@override
Widget build(BuildContext context, WidgetRef ref) {
final name = ref.watch(activeCommunityProvider).value?.name;
final title = name?.trim();
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: SizedBox.expand(
child: Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(left: Grid.xxs),
child: Text(
title == null || title.isEmpty ? 'Community' : title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w600,
),
style: style,
),
],
),
),
),
);
}

View File

@@ -77,6 +77,7 @@ class _CustomChannelSection extends StatelessWidget {
onMarkRead: () => onMarkChannelRead(channel),
sectionId: section.id,
),
const SizedBox(height: _kExpandedSectionTrailingPadding),
],
),
),
@@ -114,7 +115,7 @@ class _CustomSectionHeader extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final sectionColor = context.colors.primary;
final sectionColor = navigationSectionForeground(context);
final icon = section.icon;
final customEmoji = icon == null
? null
@@ -126,9 +127,9 @@ class _CustomSectionHeader extends ConsumerWidget {
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
Grid.twelve,
_kSectionHeaderVerticalPadding,
Grid.gutter,
_kChannelRowVerticalPadding,
_kSectionHeaderVerticalPadding,
),
child: Row(
children: [
@@ -445,6 +446,7 @@ class _ChannelSection extends StatelessWidget {
onMarkRead: null,
sectionId: null,
),
const SizedBox(height: _kExpandedSectionTrailingPadding),
],
),
),
@@ -495,7 +497,7 @@ class _SectionDivider extends StatelessWidget {
thickness: 1,
indent: _kChannelSectionInset,
endIndent: _kChannelSectionInset,
color: context.colors.outlineVariant.withValues(alpha: 0.72),
color: context.colors.primary.withValues(alpha: 0.15),
),
);
}
@@ -520,7 +522,7 @@ class _SectionHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
final sectionColor = context.colors.primary;
final sectionColor = navigationSectionForeground(context);
return GestureDetector(
onTap: onToggle,
@@ -528,9 +530,9 @@ class _SectionHeader extends StatelessWidget {
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
Grid.twelve,
_kSectionHeaderVerticalPadding,
Grid.gutter,
_kChannelRowVerticalPadding,
_kSectionHeaderVerticalPadding,
),
child: Row(
children: [

View File

@@ -36,6 +36,7 @@ class HomePage extends HookConsumerWidget {
static const double _fabClearance = _tabBarHeight + _tabBarBottomGap;
static const Duration _tabIconWeightDuration = Duration(milliseconds: 120);
static const Duration _tabUnreadBadgeDuration = Duration(milliseconds: 220);
static const double _settingsBackgroundScale = 0.97;
static const Duration _tabContentTransitionDuration = Duration(
milliseconds: 240,
);
@@ -71,6 +72,10 @@ class HomePage extends HookConsumerWidget {
final tabContentTransitionValue = useAnimation(
tabContentTransitionController,
);
final homeReselection = useValueNotifier(0);
final activityReselection = useValueNotifier(0);
final searchReselection = useValueNotifier(0);
final settingsTransitionProgress = useValueNotifier(0.0);
final reducedMotion = MediaQuery.of(context).disableAnimations;
final tabContentTransitionProgress = reducedMotion
? 1.0
@@ -82,77 +87,141 @@ class HomePage extends HookConsumerWidget {
);
final pages = [
ChannelsPage(settingsPageBuilder: settingsPageBuilder),
const ActivityPage(),
const SearchPage(),
];
return Scaffold(
backgroundColor: Colors.transparent,
// Keep the floating navigation and Home quick actions anchored while the
// keyboard is visible on any tab.
resizeToAvoidBottomInset: false,
extendBody: true,
body: SizedBox.expand(
child: Stack(
fit: StackFit.expand,
children: [
Positioned.fill(child: ColoredBox(color: context.colors.surface)),
Positioned.fill(
child: MediaQuery(
data: _mediaQueryWithFloatingTabBarClearance(
context,
HomePage._fabClearance,
),
child: DirectionalTransitionScope(
horizontalOffset:
tabContentTransitionDirection.value *
_tabContentTransitionDistance *
(1 - tabContentTransitionProgress),
opacity: tabContentTransitionProgress,
child: ClipRect(
child: IndexedStack(index: tabIndex.value, children: pages),
),
),
),
),
Align(
alignment: Alignment.bottomCenter,
child: IgnorePointer(
child: MobileTabFooterBackdrop(
height: mobileTabFooterBackdropHeight(context),
),
),
),
Positioned.fill(
child: ChannelQuickActionsLauncher(
visible: tabIndex.value == 0,
navigationBarHeight: HomePage._tabBarHeight,
navigationBarBottomGap: HomePage._tabBarBottomGap,
navigationBarWidth: navigationBarWidth,
systemBottomInset: systemBottomInset,
rightInset: Grid.sm,
),
),
],
),
),
bottomNavigationBar: _FloatingTabBar(
selectedIndex: tabIndex.value,
hasUnreadInbox: hasUnreadInbox,
onDestinationSelected: (i) {
if (i == tabIndex.value) return;
tabContentTransitionDirection.value = i > tabIndex.value ? 1 : -1;
unawaited(HapticFeedback.selectionClick());
tabIndex.value = i;
if (reducedMotion) {
tabContentTransitionController.value = 1;
} else {
unawaited(tabContentTransitionController.forward(from: 0));
ChannelsPage(
settingsPageBuilder: settingsPageBuilder,
tabReselection: homeReselection,
onSettingsTransitionProgress: (progress) {
if (settingsTransitionProgress.value != progress) {
settingsTransitionProgress.value = progress;
}
},
destinations: _destinations,
),
ActivityPage(tabReselection: activityReselection),
SearchPage(tabReselection: searchReselection),
];
final settingsTransitionGradient = tabIndex.value == 0
? context.appColors.topSectionGradient
: null;
return Stack(
fit: StackFit.expand,
children: [
Positioned.fill(
child: DecoratedBox(
key: const ValueKey('home-settings-transition-backdrop'),
decoration: BoxDecoration(
color: settingsTransitionGradient == null
? context.colors.surface
: null,
gradient: settingsTransitionGradient,
),
),
),
ValueListenableBuilder<double>(
valueListenable: settingsTransitionProgress,
child: Scaffold(
backgroundColor: Colors.transparent,
// Keep the floating navigation and Home quick actions anchored while the
// keyboard is visible on any tab.
resizeToAvoidBottomInset: false,
extendBody: true,
body: SizedBox.expand(
child: Stack(
fit: StackFit.expand,
children: [
Positioned.fill(
child: ColoredBox(color: context.colors.surface),
),
Positioned.fill(
child: MediaQuery(
data: _mediaQueryWithFloatingTabBarClearance(
context,
HomePage._fabClearance,
),
child: DirectionalTransitionScope(
horizontalOffset:
tabContentTransitionDirection.value *
_tabContentTransitionDistance *
(1 - tabContentTransitionProgress),
opacity: tabContentTransitionProgress,
child: ClipRect(
child: IndexedStack(
index: tabIndex.value,
children: pages,
),
),
),
),
),
Align(
alignment: Alignment.bottomCenter,
child: IgnorePointer(
child: MobileTabFooterBackdrop(
height: mobileTabFooterBackdropHeight(context),
tint: context.colors.primaryContainer,
),
),
),
Positioned.fill(
child: ChannelQuickActionsLauncher(
visible: tabIndex.value == 0,
navigationBarHeight: HomePage._tabBarHeight,
navigationBarBottomGap: HomePage._tabBarBottomGap,
navigationBarWidth: navigationBarWidth,
systemBottomInset: systemBottomInset,
rightInset: Grid.sm,
),
),
],
),
),
bottomNavigationBar: _FloatingTabBar(
selectedIndex: tabIndex.value,
hasUnreadInbox: hasUnreadInbox,
onDestinationSelected: (i) {
if (i == tabIndex.value) {
switch (i) {
case 0:
homeReselection.value++;
case 1:
activityReselection.value++;
case 2:
searchReselection.value++;
}
return;
}
tabContentTransitionDirection.value = i > tabIndex.value
? 1
: -1;
unawaited(HapticFeedback.selectionClick());
tabIndex.value = i;
if (reducedMotion) {
tabContentTransitionController.value = 1;
} else {
unawaited(tabContentTransitionController.forward(from: 0));
}
},
destinations: _destinations,
),
),
builder: (context, progress, child) {
final curvedProgress = reducedMotion
? 0.0
: Curves.easeOutCubic.transform(progress);
return Opacity(
key: const ValueKey('home-settings-transition-opacity'),
opacity: 1 - curvedProgress,
child: Transform.scale(
key: const ValueKey('home-settings-transition-scale'),
scale: lerpDouble(1, _settingsBackgroundScale, curvedProgress),
alignment: Alignment.center,
child: child,
),
);
},
),
],
);
}
}

View File

@@ -8,7 +8,7 @@ import 'profile_provider.dart';
import 'user_profile.dart';
/// Matches desktop's sidebar profile card, whose avatar is 32px.
const _avatarSize = 32.0;
const _defaultAvatarSize = 32.0;
/// The visible dot is smaller than the notch it sits in, so a ring of
/// background separates it from the avatar. Desktop's `h-2 w-2` dot inside a
@@ -24,7 +24,15 @@ class ProfileAvatar extends ConsumerWidget {
final VoidCallback? onTap;
final bool showPresence;
const ProfileAvatar({super.key, this.onTap, this.showPresence = true});
/// The avatar diameter in logical pixels; defaults to the 32px desktop match.
final double size;
const ProfileAvatar({
super.key,
this.onTap,
this.showPresence = true,
this.size = _defaultAvatarSize,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -45,7 +53,7 @@ class ProfileAvatar extends ConsumerWidget {
Widget _buildPlaceholder(BuildContext context) {
return CircleAvatar(
radius: _avatarSize / 2,
radius: size / 2,
backgroundColor: context.colors.primaryContainer,
);
}
@@ -58,7 +66,7 @@ class ProfileAvatar extends ConsumerWidget {
return GestureDetector(
onTap: onTap,
child: MaskedAvatarBadge(
size: _avatarSize,
size: size,
geometry: AvatarBadgeMaskGeometry.presenceDot,
avatar: ClipOval(
child: ColoredBox(

View File

@@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import 'user_profile.dart';
/// The current user's profile (kind:0 metadata) loaded over the relay
@@ -50,13 +51,26 @@ final profileProvider = AsyncNotifierProvider<ProfileNotifier, UserProfile?>(
/// [appLifecycleProvider] to send "away" when backgrounded.
class PresenceNotifier extends AsyncNotifier<String> {
static const _heartbeatInterval = Duration(seconds: 60);
static const _preferenceKeyPrefix = 'buzz_presence_preference_';
Timer? _heartbeatTimer;
String? _preferencePubkey;
String? _manualPresence;
@override
Future<String> build() {
ref.watch(relaySessionProvider);
ref.watch(profileProvider);
final pubkey = ref.watch(myPubkeyProvider)?.toLowerCase();
if (_preferencePubkey != pubkey) {
_preferencePubkey = pubkey;
final stored = pubkey == null
? null
: ref
.read(savedPrefsProvider)
.getString('$_preferenceKeyPrefix$pubkey');
_manualPresence = stored == 'away' || stored == 'offline' ? stored : null;
}
final lifecycle = ref.watch(appLifecycleProvider);
@@ -65,6 +79,13 @@ class PresenceNotifier extends AsyncNotifier<String> {
_heartbeatTimer = null;
});
final manualPresence = _manualPresence;
if (manualPresence != null) {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
return _setPresence(manualPresence);
}
if (lifecycle == AppLifecycleState.resumed) {
_startHeartbeat();
return _setPresence('online');
@@ -87,6 +108,33 @@ class PresenceNotifier extends AsyncNotifier<String> {
});
}
/// Updates the current user's presence preference and publishes it.
///
/// Online restores automatic lifecycle-driven presence. Away and Offline
/// remain selected until the user chooses another value.
Future<void> setPresence(String status) async {
if (status != 'online' && status != 'away' && status != 'offline') return;
_manualPresence = status == 'online' ? null : status;
final pubkey = ref.read(myPubkeyProvider)?.toLowerCase();
if (pubkey != null) {
await ref
.read(savedPrefsProvider)
.setString('$_preferenceKeyPrefix$pubkey', _manualPresence ?? 'auto');
}
if (_manualPresence == null &&
ref.read(appLifecycleProvider) == AppLifecycleState.resumed) {
_startHeartbeat();
} else {
_heartbeatTimer?.cancel();
_heartbeatTimer = null;
}
state = AsyncData(status);
await _setPresence(status);
}
/// Publish a kind:20001 presence event. Returns the requested status
/// optimistically — failures are silently absorbed and the next heartbeat
/// will retry.

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@@ -6,6 +8,7 @@ import '../../shared/custom_emoji/custom_emoji_provider.dart';
import '../../shared/custom_emoji/custom_emoji_render.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/anchored_popover_menu.dart';
import '../../shared/widgets/masked_avatar_badge.dart';
import 'profile_provider.dart';
import 'set_status_sheet.dart';
@@ -26,12 +29,13 @@ class SettingsProfileHeader extends ConsumerWidget {
final profile = ref.watch(profileProvider).asData?.value;
final status = ref.watch(userStatusProvider).asData?.value;
final hasStatus = status != null && !status.isEmpty;
final presence = ref.watch(presenceProvider).value ?? 'offline';
void openStatusSheet() =>
showSetStatusSheet(context, currentStatus: status);
return Padding(
padding: const EdgeInsets.only(top: Grid.xxs, bottom: Grid.sm),
padding: const EdgeInsets.only(top: Grid.sm, bottom: Grid.sm),
child: Column(
children: [
MaskedAvatarBadge(
@@ -59,8 +63,8 @@ class SettingsProfileHeader extends ConsumerWidget {
style: context.textTheme.titleMedium,
textAlign: TextAlign.center,
),
// No placeholder copy — the badge is the affordance, so this line
// appears only once there is an actual status to show.
// Keep the status text visible even when no emoji is set. NIP-38
// permits text-only statuses, which the avatar badge cannot represent.
if (hasStatus)
GestureDetector(
onTap: openStatusSheet,
@@ -82,12 +86,154 @@ class SettingsProfileHeader extends ConsumerWidget {
),
),
),
_PresencePill(
presence: presence,
onSelected: (nextPresence) => unawaited(
ref.read(presenceProvider.notifier).setPresence(nextPresence),
),
),
],
),
);
}
}
class _PresencePill extends StatelessWidget {
const _PresencePill({required this.presence, required this.onSelected});
final String presence;
final ValueChanged<String> onSelected;
@override
Widget build(BuildContext context) {
final effectivePresence = switch (presence) {
'online' || 'away' => presence,
_ => 'offline',
};
final (backgroundColor, foregroundColor) = switch (effectivePresence) {
'online' => (
context.appColors.success.withValues(alpha: 0.15),
context.appColors.success,
),
'away' => (
context.appColors.warning.withValues(alpha: 0.15),
context.appColors.warning,
),
_ => (
context.colors.onSurfaceVariant.withValues(alpha: 0.15),
context.colors.onSurfaceVariant,
),
};
final label = _presenceLabel(effectivePresence);
return Builder(
builder: (buttonContext) => Semantics(
button: true,
label: 'Presence: $label',
child: SizedBox(
key: const ValueKey('settings-presence-target'),
height: Grid.xl,
child: Material(
color: Colors.transparent,
child: InkWell(
key: const ValueKey('settings-presence-menu'),
borderRadius: BorderRadius.circular(Radii.full),
onTap: () async {
final selected = await showAnchoredPopover<String>(
context: buttonContext,
width: 176,
alignment: AnchoredPopoverAlignment.center,
offset: const Offset(0, Grid.half),
menuPadding: const EdgeInsets.symmetric(vertical: Grid.half),
surfaceKey: const ValueKey('settings-presence-popover'),
items: [
for (final option in const ['online', 'away', 'offline'])
PopupMenuItem<String>(
key: ValueKey('settings-presence-$option'),
value: option,
height: Grid.xl,
padding: const EdgeInsets.symmetric(
horizontal: Grid.twelve,
),
child: Row(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: _presenceColor(context, option),
shape: BoxShape.circle,
),
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Text(
_presenceLabel(option),
style: filterChipTextStyle.copyWith(
color: context.colors.onSurface,
fontWeight: option == effectivePresence
? FontWeight.w500
: FontWeight.w400,
),
),
),
if (option == effectivePresence)
Icon(
LucideIcons.check,
size: 16,
color: context.colors.primary,
),
],
),
),
],
);
if (buttonContext.mounted && selected != null) {
onSelected(selected);
}
},
child: Center(
child: Material(
key: const ValueKey('settings-presence-pill'),
color: backgroundColor,
borderRadius: BorderRadius.circular(Radii.full),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.xxs,
),
child: Text(
label,
key: const ValueKey('settings-presence-label'),
style: filterChipTextStyle.copyWith(
color: foregroundColor,
fontWeight: FontWeight.w500,
),
),
),
),
),
),
),
),
),
);
}
}
String _presenceLabel(String presence) => switch (presence) {
'online' => 'Online',
'away' => 'Away',
_ => 'Offline',
};
Color _presenceColor(BuildContext context, String presence) =>
switch (presence) {
'online' => context.appColors.success,
'away' => context.appColors.warning,
_ => context.colors.outline,
};
/// Fills the notch left by [MaskedAvatarBadge], so its size comes from the mask
/// geometry rather than being set here.
class _StatusBadge extends StatelessWidget {

View File

@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -26,13 +27,42 @@ import '../profile/user_profile.dart';
import 'recent_searches_provider.dart';
import 'search_provider.dart';
part 'search_page/motion_field.dart';
enum _SearchFilter { all, messages, channels, people }
const _searchFieldMinHeight = 36.0;
const _searchIdleFieldHeight = 45.0;
const _searchIdleTextSize = 15.0;
const _searchFieldVerticalPadding = Grid.xxs;
const _searchFieldHint = 'Search messages, channels, people\u2026';
const _searchCancelEnterDuration = Duration(milliseconds: 160);
const _searchCancelExitDuration = Duration(milliseconds: 120);
const _searchFieldMoveDuration = Duration(milliseconds: 160);
const _searchTitleReturnDuration = Duration(milliseconds: 80);
const _searchCancelEnterDuration = Duration(milliseconds: 80);
const _searchCancelExitDuration = Duration(milliseconds: 60);
const _searchIdleFieldTopInset = Grid.half;
const _searchActiveFieldTopOffset = 42.0;
const _searchBottomOverlap =
_searchActiveFieldTopOffset + _searchIdleFieldTopInset;
const _searchFilterChipVerticalPadding = Grid.xxs;
const _searchFilterBarVerticalPadding = Grid.xxs;
const _searchHeaderFiltersMinHeight = Grid.xl;
const _searchActiveFieldRightInsetMin = 72.0;
/// Reserves the Cancel action's scaled label, padding, and app-bar edge inset.
double _searchActiveFieldRightInset(BuildContext context) {
final textPainter = TextPainter(
text: TextSpan(
text: 'Cancel',
style: filterChipTextStyle.copyWith(fontWeight: FontWeight.w500),
),
textScaler: MediaQuery.textScalerOf(context),
textDirection: Directionality.of(context),
)..layout();
final cancelWidth = textPainter.width + Grid.half * 2 + Grid.twelve;
return cancelWidth > _searchActiveFieldRightInsetMin
? cancelWidth
: _searchActiveFieldRightInsetMin;
}
double _searchFieldHeight(BuildContext context) {
const style = searchInputTextStyle;
@@ -46,8 +76,35 @@ double _searchFieldHeight(BuildContext context) {
: _searchFieldMinHeight;
}
double _idleSearchFieldHeight(BuildContext context) {
final scaledFontSize = MediaQuery.textScalerOf(
context,
).scale(_searchIdleTextSize);
final contentHeight =
scaledFontSize * (20 / _searchIdleTextSize) +
_searchFieldVerticalPadding * 2;
return contentHeight > _searchIdleFieldHeight
? contentHeight
: _searchIdleFieldHeight;
}
double _searchHeaderFiltersHeight(BuildContext context) {
const style = filterChipTextStyle;
final scaledLabelHeight =
MediaQuery.textScalerOf(context).scale(style.fontSize ?? 15) *
(style.height ?? 1);
final chipHeight = scaledLabelHeight + _searchFilterChipVerticalPadding * 2;
final contentHeight = chipHeight + _searchFilterBarVerticalPadding * 2;
return contentHeight > _searchHeaderFiltersMinHeight
? contentHeight
: _searchHeaderFiltersMinHeight;
}
class SearchPage extends HookConsumerWidget {
const SearchPage({super.key});
const SearchPage({this.tabReselection, super.key});
/// Notifies this page when its already-selected tab is tapped again.
final ValueListenable<int>? tabReselection;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -59,202 +116,306 @@ class SearchPage extends HookConsumerWidget {
final activeFilter = useState(_SearchFilter.all);
final textController = useTextEditingController();
final focusNode = useFocusNode();
final isSearchFocused = useListenableSelector(
focusNode,
() => focusNode.hasFocus,
);
final isSearchEditing = useState(false);
final showSearchTitle = useState(true);
final isTabActivationInFlight = useRef(false);
final reduceMotion = MediaQuery.disableAnimationsOf(context);
final isBuzzTheme = context.appColors.topSectionGradient != null;
final buzzSearchColor = context.theme.brightness == Brightness.dark
? Colors.white
: Colors.black;
final searchSurfaceColor = isBuzzTheme
? buzzSearchColor.withValues(alpha: 0.04)
: context.colors.surfaceContainerHighest;
final searchMutedColor = isBuzzTheme
? buzzSearchColor.withValues(alpha: 0.4)
: context.colors.onSurfaceVariant;
final searchSurfaceColor = navigationSearchSurface(context);
final searchPrimaryColor = navigationPrimaryForeground(context);
final searchPlaceholderColor = navigationSecondaryForeground(context);
final headerTitleStyle = context.textTheme.titleMedium?.copyWith(
fontSize: 22,
fontWeight: FontWeight.w600,
);
final searchFieldHeight = _searchFieldHeight(context);
final searchControlHeight = searchFieldHeight > Grid.xl
? searchFieldHeight
final compactSearchFieldHeight = _searchFieldHeight(context);
final idleSearchFieldHeight = _idleSearchFieldHeight(context);
final searchHeaderFiltersHeight = _searchHeaderFiltersHeight(context);
final searchActiveFieldRightInset = _searchActiveFieldRightInset(context);
// Cancel remains an accessible target without giving the text action a
// visual button treatment.
final searchControlHeight = compactSearchFieldHeight > Grid.xl
? compactSearchFieldHeight
: Grid.xl;
final searchHeaderBottomHeight = searchControlHeight + Grid.twelve;
final searchHeaderBottomHeight = isSearchEditing.value
? _searchIdleFieldTopInset +
compactSearchFieldHeight +
searchHeaderFiltersHeight
: idleSearchFieldHeight + _searchIdleFieldTopInset + Grid.xxs;
final topSectionHeight = frostedAppBarHeight(
context,
titleStyle: headerTitleStyle,
bottomHeight: searchHeaderBottomHeight,
);
void activateSearch() {
showSearchTitle.value = false;
isSearchEditing.value = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted && isSearchEditing.value) {
focusNode.requestFocus();
}
});
}
void deactivateSearch() {
if (!isSearchEditing.value) return;
// The field is painted above the title while it returns to its idle
// position. Start this fade now so the title is already there beneath it
// and is revealed by the field's motion instead of arriving afterward.
showSearchTitle.value = true;
isSearchEditing.value = false;
}
useEffect(() {
final tabReselection = this.tabReselection;
if (tabReselection == null) return null;
void reactivateSearch() {
// The same tab gesture can report focus loss after this callback. Keep
// that notification from starting a competing return animation while
// the normal field activation path restores focus.
isTabActivationInFlight.value = true;
activateSearch();
WidgetsBinding.instance.addPostFrameCallback((_) {
isTabActivationInFlight.value = false;
});
}
tabReselection.addListener(reactivateSearch);
return () => tabReselection.removeListener(reactivateSearch);
}, [tabReselection, focusNode]);
useEffect(() {
void resetIdlePromptWhenFocusLeaves() {
if (!focusNode.hasFocus && !isTabActivationInFlight.value) {
deactivateSearch();
}
}
focusNode.addListener(resetIdlePromptWhenFocusLeaves);
return () => focusNode.removeListener(resetIdlePromptWhenFocusLeaves);
}, [focusNode]);
void runRecentSearch(String query) {
textController.value = TextEditingValue(
text: query,
selection: TextSelection.collapsed(offset: query.length),
);
focusNode.requestFocus();
activateSearch();
ref.read(recentSearchesProvider.notifier).record(query);
ref.read(searchProvider.notifier).search(query);
}
return FrostedScaffold(
backgroundColor: Colors.transparent,
backgroundColor: context.colors.surface,
// Keep the empty state centered in the page rather than the portion left
// above the keyboard.
resizeToAvoidBottomInset: false,
appBar: FrostedAppBar(
automaticallyImplyLeading: false,
gradient: context.appColors.topSectionGradient,
title: const Text('Search'),
horizontalInset: Grid.twelve,
showBottomDivider: true,
bottomDividerOpacity: 0.06,
titleStyle: headerTitleStyle,
bottomHeight: searchHeaderBottomHeight,
bottom: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.twelve,
// Keep this mounted through the search-field morph so it can fade in
// beneath the returning field rather than popping in afterward.
title: IgnorePointer(
child: AnimatedOpacity(
key: const Key('search-header-title-opacity'),
duration: reduceMotion ? Duration.zero : _searchTitleReturnDuration,
curve: Curves.easeOutCubic,
opacity: showSearchTitle.value ? 1 : 0,
child: Text(
'Search',
key: const ValueKey('search-header-title'),
style: headerTitleStyle,
),
),
child: Row(
children: [
Expanded(
child: Container(
key: const Key('search-field-container'),
height: searchFieldHeight,
padding: const EdgeInsets.symmetric(horizontal: Grid.half),
decoration: BoxDecoration(
color: searchSurfaceColor,
borderRadius: BorderRadius.circular(Radii.lg),
),
child: TextField(
key: const Key('search-field'),
controller: textController,
focusNode: focusNode,
decoration: InputDecoration(
hintText: isSearchFocused ? null : _searchFieldHint,
hintStyle: searchInputTextStyle.copyWith(
color: searchMutedColor,
),
prefixIcon: Icon(
LucideIcons.search,
size: 16,
color: searchMutedColor,
),
prefixIconConstraints: const BoxConstraints(minWidth: 32),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
vertical: _searchFieldVerticalPadding,
),
),
style: searchInputTextStyle.copyWith(
color: context.colors.onSurface,
),
textInputAction: TextInputAction.search,
onChanged: (value) =>
ref.read(searchProvider.notifier).search(value),
onSubmitted: (value) {
final query = value.trim();
if (query.isEmpty) return;
ref.read(recentSearchesProvider.notifier).record(query);
},
),
actions: [
AnimatedSwitcher(
duration: reduceMotion ? Duration.zero : _searchCancelEnterDuration,
reverseDuration: reduceMotion
? Duration.zero
: _searchCancelExitDuration,
transitionBuilder: (child, animation) {
final curvedAnimation = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
return SizeTransition(
sizeFactor: curvedAnimation,
axis: Axis.horizontal,
axisAlignment: 1,
child: FadeTransition(
opacity: curvedAnimation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.35, 0),
end: Offset.zero,
).animate(curvedAnimation),
child: child,
),
),
);
},
child: isSearchEditing.value
? Semantics(
key: const Key('search-cancel'),
button: true,
label: 'Cancel search',
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
textController.clear();
ref.read(searchProvider.notifier).clear();
deactivateSearch();
focusNode.unfocus();
},
child: SizedBox(
height: searchControlHeight,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.half,
),
child: Center(
child: Text(
'Cancel',
style: filterChipTextStyle.copyWith(
color: context.colors.primary,
fontWeight: FontWeight.w500,
),
),
),
),
),
),
)
: const SizedBox.shrink(key: ValueKey('search-cancel-hidden')),
),
],
bottomHeight: searchHeaderBottomHeight,
bottomOverlap: _searchBottomOverlap,
bottom: Stack(
clipBehavior: Clip.none,
children: [
AnimatedPositioned(
duration: reduceMotion ? Duration.zero : _searchFieldMoveDuration,
curve: Curves.easeInOutCubic,
left: Grid.gutter,
right: isSearchEditing.value
? searchActiveFieldRightInset
: Grid.gutter,
top: isSearchEditing.value
? _searchIdleFieldTopInset
: _searchBottomOverlap + _searchIdleFieldTopInset,
height: isSearchEditing.value
? compactSearchFieldHeight
: idleSearchFieldHeight,
// Do not key this subtree by the visual state: replacing the
// TextField immediately after its first tap can detach its
// native input connection before the keyboard is shown.
child: SizedBox(
key: const Key('search-field-container'),
child: _SearchMotionField(
controller: textController,
focusNode: focusNode,
iconColor: searchPrimaryColor,
inputColor: searchPrimaryColor,
placeholderColor: searchPlaceholderColor,
surfaceColor: searchSurfaceColor,
isSearchEditing: isSearchEditing.value,
reduceMotion: reduceMotion,
motionDuration: _searchFieldMoveDuration,
onTap: activateSearch,
onChanged: (value) =>
ref.read(searchProvider.notifier).search(value),
onSubmitted: (value) {
final query = value.trim();
if (query.isEmpty) return;
ref.read(recentSearchesProvider.notifier).record(query);
},
),
),
AnimatedSwitcher(
),
Positioned.fill(
child: AnimatedSwitcher(
duration: reduceMotion
? Duration.zero
: _searchCancelEnterDuration,
reverseDuration: reduceMotion
? Duration.zero
: _searchCancelExitDuration,
transitionBuilder: (child, animation) {
final curvedAnimation = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
return SizeTransition(
sizeFactor: curvedAnimation,
axis: Axis.horizontal,
axisAlignment: 1,
child: FadeTransition(
opacity: curvedAnimation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.35, 0),
end: Offset.zero,
).animate(curvedAnimation),
child: child,
),
),
);
},
child: isSearchFocused
? Padding(
key: const ValueKey('search-cancel-visible'),
padding: const EdgeInsets.only(left: Grid.xxs),
child: TextButton(
key: const Key('search-cancel'),
onPressed: () {
textController.clear();
ref.read(searchProvider.notifier).clear();
focusNode.unfocus();
},
style: TextButton.styleFrom(
foregroundColor: context.colors.primary,
minimumSize: Size(0, searchControlHeight),
padding: const EdgeInsets.symmetric(
horizontal: Grid.half,
vertical: Grid.xxs,
),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(
'Cancel',
style: filterChipTextStyle.copyWith(
color: context.colors.primary,
fontWeight: FontWeight.w500,
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, animation) => FadeTransition(
opacity: animation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.2),
end: Offset.zero,
).animate(animation),
child: child,
),
),
child: isSearchEditing.value
? Align(
alignment: Alignment.topCenter,
child: Padding(
padding: EdgeInsets.only(top: _searchBottomOverlap),
child: SizedBox(
key: const ValueKey('search-header-filters'),
height: searchHeaderFiltersHeight,
child: FilterChipBar<_SearchFilter>(
expandItems: true,
visualDensity: const VisualDensity(
horizontal: -2,
),
chipVerticalPadding:
_searchFilterChipVerticalPadding,
barVerticalPadding:
_searchFilterBarVerticalPadding,
selected: activeFilter.value,
onSelected: (f) => activeFilter.value = f,
items: [
for (final f in _SearchFilter.values)
FilterChipItem(id: f, label: f.label),
],
),
),
),
)
: const SizedBox.shrink(
key: ValueKey('search-cancel-hidden'),
key: ValueKey('search-header-filters-hidden'),
),
),
],
),
),
],
),
actions: const [],
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
height: frostedAppBarHeight(
context,
bottomHeight: searchHeaderBottomHeight,
titleStyle: headerTitleStyle,
),
),
FilterChipBar<_SearchFilter>(
expandItems: true,
visualDensity: const VisualDensity(horizontal: -2),
chipVerticalPadding: Grid.xxs,
barVerticalPadding: Grid.twelve,
selected: activeFilter.value,
onSelected: (f) => activeFilter.value = f,
items: [
for (final f in _SearchFilter.values)
FilterChipItem(id: f, label: f.label),
],
),
SizedBox(height: topSectionHeight),
Expanded(
child: _SearchBody(
state: searchState,
filter: activeFilter.value,
currentPubkey: currentPubkey,
onRecentSearchSelected: runRecentSearch,
child: ClipRRect(
borderRadius: const BorderRadius.vertical(
top: Radius.circular(Radii.dialog),
),
child: ColoredBox(
color: context.colors.surface,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: _SearchBody(
state: searchState,
filter: activeFilter.value,
currentPubkey: currentPubkey,
onRecentSearchSelected: runRecentSearch,
),
),
],
),
),
),
),
],

View File

@@ -0,0 +1,113 @@
part of '../search_page.dart';
const _searchIdleIconSize = 26.0;
const _searchCompactIconSize = 18.0;
const _searchFieldHint = 'Search messages, channels, and people';
const _searchIdleIconInset = Grid.xxs;
const _searchIdleTextInset =
_searchIdleIconInset + _searchIdleIconSize + Grid.xxs;
const _searchCompactTextInset =
_searchIdleIconInset + _searchCompactIconSize + Grid.xxs;
class _SearchMotionField extends StatelessWidget {
final TextEditingController controller;
final FocusNode focusNode;
final Color iconColor;
final Color inputColor;
final Color placeholderColor;
final Color surfaceColor;
final bool isSearchEditing;
final bool reduceMotion;
final Duration motionDuration;
final VoidCallback onTap;
final ValueChanged<String> onChanged;
final ValueChanged<String> onSubmitted;
const _SearchMotionField({
required this.controller,
required this.focusNode,
required this.iconColor,
required this.inputColor,
required this.placeholderColor,
required this.surfaceColor,
required this.isSearchEditing,
required this.reduceMotion,
required this.motionDuration,
required this.onTap,
required this.onChanged,
required this.onSubmitted,
});
@override
Widget build(BuildContext context) => DecoratedBox(
decoration: BoxDecoration(
color: surfaceColor,
borderRadius: BorderRadius.circular(Radii.lg),
),
child: Stack(
children: [
Positioned.fill(
child: Align(
alignment: Alignment.centerLeft,
child: SizedBox(
width: double.infinity,
child: TextField(
key: const Key('search-field'),
controller: controller,
focusNode: focusNode,
decoration: InputDecoration(
hintText: isSearchEditing ? null : _searchFieldHint,
hintStyle: searchInputTextStyle.copyWith(
color: placeholderColor,
fontSize: _searchIdleTextSize,
height: 20 / _searchIdleTextSize,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.only(
left: isSearchEditing
? _searchCompactTextInset
: _searchIdleTextInset,
right: Grid.xxs,
top: isSearchEditing ? _searchFieldVerticalPadding : 0,
bottom: isSearchEditing ? _searchFieldVerticalPadding : 0,
),
),
style: searchInputTextStyle.copyWith(color: inputColor),
textAlignVertical: TextAlignVertical.center,
textAlign: TextAlign.start,
textInputAction: TextInputAction.search,
onTap: onTap,
onChanged: onChanged,
onSubmitted: onSubmitted,
),
),
),
),
IgnorePointer(
child: Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(left: _searchIdleIconInset),
child: AnimatedScale(
duration: reduceMotion ? Duration.zero : motionDuration,
curve: Curves.easeInOutCubic,
scale: isSearchEditing
? _searchCompactIconSize / _searchIdleIconSize
: 1,
child: Icon(
LucideIcons.search,
key: const Key('search-moving-icon'),
size: _searchIdleIconSize,
color: iconColor,
),
),
),
),
),
],
),
);
}

View File

@@ -1,4 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@@ -28,17 +31,38 @@ class SettingsPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final packageInfoFuture = useMemoized(() => PackageInfo.fromPlatform());
final packageInfo = useFuture(packageInfoFuture);
final topSectionHeight = frostedAppBarHeight(
context,
bottomHeight: Grid.xxs,
);
return FrostedScaffold(
appBar: const FrostedAppBar(title: Text('Settings')),
backgroundColor: context.colors.surface,
appBar: FrostedAppBar(
automaticallyImplyLeading: false,
horizontalInset: Grid.gutter,
showBottomDivider: false,
leading: SizedBox(
width: Grid.xl,
height: Grid.xl,
child: IconButton(
tooltip: 'Close settings',
onPressed: () {
unawaited(HapticFeedback.lightImpact());
Navigator.of(context).pop();
},
color: navigationPrimaryForeground(context),
icon: const Icon(LucideIcons.x),
),
),
bottomHeight: Grid.xxs,
bottom: const SizedBox.expand(),
),
body: Column(
children: [
Expanded(
child: ListView(
padding: EdgeInsets.only(
top: frostedAppBarHeight(context),
bottom: Grid.xs,
),
padding: EdgeInsets.only(top: topSectionHeight, bottom: Grid.xs),
children: [
profileHeader,
const _AppearanceSection(),

View File

@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'app_colors.dart';
/// Name of the first-party Buzz theme. Buzz reuses the GitHub Light palette for
/// every base color; the one thing that sets it apart is a branded gradient
/// painted across the app's top section. Mirrors desktop, where the same
@@ -17,6 +19,50 @@ const buzzDarkThemeName = 'buzz-dark';
bool isBuzzTheme(String themeName) =>
themeName == buzzThemeName || themeName == buzzDarkThemeName;
/// Whether the current widget tree is using the first-party Buzz treatment.
bool isBuzzThemeContext(BuildContext context) =>
Theme.of(context).extension<AppColors>()?.topSectionGradient != null;
/// Primary foreground for the mobile top navigation.
///
/// Every theme uses its own [ColorScheme.onSurface]. Buzz is the exception:
/// its desktop-matching top gradient needs a neutral black or white foreground
/// rather than the accent-derived color scheme foreground.
Color navigationPrimaryForeground(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
if (!isBuzzThemeContext(context)) return scheme.onSurface;
return scheme.brightness == Brightness.dark ? Colors.white : Colors.black;
}
/// Secondary label and placeholder foreground for the mobile top navigation.
Color navigationSecondaryForeground(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
if (!isBuzzThemeContext(context)) return scheme.onSurfaceVariant;
return navigationPrimaryForeground(context).withValues(alpha: 0.4);
}
/// Channel-section label and icon foreground for the mobile side navigation.
///
/// Section labels need more hierarchy than a placeholder. Buzz therefore uses
/// a stronger neutral over its gradient, while all other themes preserve their
/// established secondary foreground token.
Color navigationSectionForeground(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
if (!isBuzzThemeContext(context)) return scheme.onSurfaceVariant;
return navigationPrimaryForeground(context).withValues(alpha: 0.8);
}
/// Search-field surface for the mobile top navigation.
Color navigationSearchSurface(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
if (!isBuzzThemeContext(context)) return scheme.surfaceContainerHighest;
return navigationPrimaryForeground(context).withValues(alpha: 0.04);
}
/// A low-contrast navigation divider derived from the active theme foreground.
Color navigationDivider(BuildContext context, double opacity) =>
navigationPrimaryForeground(context).withValues(alpha: opacity);
/// Gradient stops, matching desktop's `--buzz-gradient-*` custom properties.
const _lightTop = Color(0xFFE6E6B6);
const _lightBottom = Color(0xFFC4D0DA);

View File

@@ -32,6 +32,9 @@ enum AnchoredPopoverAlignment {
/// Aligns the popover's leading edge with the trigger's leading edge.
start,
/// Centers the popover horizontally on the trigger.
center,
/// Aligns the popover's trailing edge with the trigger's trailing edge.
end,
}
@@ -160,6 +163,7 @@ class _AnchoredPopoverRoute<T> extends PopupRoute<T> {
).animate(curvedAnimation);
final transformOrigin = switch (alignment) {
AnchoredPopoverAlignment.start => Alignment.topLeft,
AnchoredPopoverAlignment.center => Alignment.topCenter,
AnchoredPopoverAlignment.end => Alignment.topRight,
};
@@ -234,6 +238,11 @@ class _AnchoredPopoverLayoutDelegate extends SingleChildLayoutDelegate {
final anchorBottom = size.height - position.bottom;
final desiredX = switch (alignment) {
AnchoredPopoverAlignment.start => position.left + offset.dx,
AnchoredPopoverAlignment.center =>
position.left +
(size.width - position.left - position.right - childSize.width) /
2 +
offset.dx,
AnchoredPopoverAlignment.end =>
size.width - position.right - childSize.width + offset.dx,
};

View File

@@ -36,6 +36,22 @@ double _barContentHeight(
: _kBarContentMinHeight;
}
/// Height for a compact title rail below the app bar's action row.
///
/// The rail normally stays at 40dp, but grows with an accessible title rather
/// than clipping text at larger system text sizes.
double frostedAppBarLowerTitleHeight(
BuildContext context, {
TextStyle? titleStyle,
}) {
final style = _effectiveTitleStyle(context, titleStyle);
final scaledFontSize = MediaQuery.textScalerOf(
context,
).scale(style.fontSize ?? 20);
final titleHeight = scaledFontSize * (style.height ?? 1);
return titleHeight > 40 ? titleHeight : 40;
}
/// Returns the total height of the [FrostedAppBar] including safe area padding.
///
/// Use this to add top spacing to body content so it starts below the bar.
@@ -82,6 +98,11 @@ class FrostedAppBar extends StatelessWidget {
/// Height reserved for [bottom].
final double bottomHeight;
/// Extends [bottom] upward into the title row without moving the app bar's
/// outer bounds. This keeps overlapping controls inside the app bar's hit
/// test region as well as its paint region.
final double bottomOverlap;
/// Widgets displayed on the trailing (right) side.
final List<Widget> actions;
@@ -96,6 +117,24 @@ class FrostedAppBar extends StatelessWidget {
/// top section — see [buzzTopSectionGradient].
final Gradient? gradient;
/// Whether to apply the translucent blur treatment behind the app bar.
///
/// A page can leave its painted backdrop exposed at rest, then turn this on
/// when scrolling moves content beneath the controls.
final bool frosted;
/// Opacity of the frosted surface above the blurred backdrop.
final double frostedSurfaceOpacity;
/// Blur strength of the frosted backdrop.
final double frostedBlurSigma;
/// Whether to draw a divider below the app bar.
final bool showBottomDivider;
/// Opacity of the divider below the app bar.
final double bottomDividerOpacity;
const FrostedAppBar({
super.key,
this.leading,
@@ -105,11 +144,21 @@ class FrostedAppBar extends StatelessWidget {
this.titleContentHeight = 0,
this.bottom,
this.bottomHeight = 0,
this.bottomOverlap = 0,
this.actions = const [],
this.horizontalInset = Grid.quarter,
this.iconColor,
this.gradient,
}) : assert(bottom == null || bottomHeight > 0);
this.frosted = true,
this.frostedSurfaceOpacity = 0.5,
this.frostedBlurSigma = 20,
this.showBottomDivider = true,
this.bottomDividerOpacity = 0.15,
}) : assert(bottom == null || bottomHeight > 0),
assert(bottomOverlap >= 0),
assert(bottom != null || bottomOverlap == 0),
assert(frostedBlurSigma >= 0),
assert(bottomDividerOpacity >= 0 && bottomDividerOpacity <= 1);
@override
Widget build(BuildContext context) {
@@ -137,86 +186,112 @@ class FrostedAppBar extends StatelessWidget {
)
: null);
return Positioned(
top: 0,
left: 0,
right: 0,
child: ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Container(
key: const ValueKey('frosted-app-bar-background'),
padding: EdgeInsets.only(top: topPadding),
decoration: BoxDecoration(
// A gradient and a color cannot both paint, so the gradient
// replaces the frosted surface fill when one is supplied.
color: gradient == null
? context.colors.surface.withValues(alpha: 0.5)
: null,
gradient: gradient,
border: Border(
bottom: BorderSide(
color: context.colors.outlineVariant.withValues(alpha: 0.3),
width: _kBottomBorderWidth,
),
),
),
child: DirectionalTransitionMotion(
transformKey: const ValueKey(
'frosted-app-bar-content-transition-transform',
),
opacityKey: const ValueKey(
'frosted-app-bar-content-transition-opacity',
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: barContentHeight,
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: horizontalInset,
),
child: IconTheme.merge(
data: IconThemeData(color: iconColor),
child: Row(
children: [
?effectiveLeading,
if (title != null)
Expanded(
child: Padding(
padding: EdgeInsets.only(
left: effectiveLeading != null
? 0
: Grid.gutter - Grid.quarter,
right: actions.isEmpty
? Grid.gutter - Grid.quarter
: 0,
),
child: DefaultTextStyle.merge(
style: effectiveTitleStyle,
overflow: TextOverflow.ellipsis,
maxLines: 1,
child: title!,
),
),
)
else
const Spacer(),
...actions,
],
),
),
final titleRow = SizedBox(
height: barContentHeight,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: horizontalInset),
child: IconTheme.merge(
data: IconThemeData(color: iconColor),
child: Row(
children: [
?effectiveLeading,
if (title != null)
Expanded(
child: Padding(
padding: EdgeInsets.only(
left: effectiveLeading != null
? 0
: horizontalInset < Grid.gutter
? Grid.gutter - horizontalInset
: 0,
right: actions.isEmpty
? horizontalInset < Grid.gutter
? Grid.gutter - horizontalInset
: 0
: 0,
),
child: DefaultTextStyle.merge(
style: effectiveTitleStyle,
overflow: TextOverflow.ellipsis,
maxLines: 1,
child: title!,
),
),
if (bottom != null)
SizedBox(height: bottomHeight, child: bottom),
],
),
),
)
else
const Spacer(),
...actions,
],
),
),
),
);
final contentBody = bottom != null && bottomOverlap > 0
? SizedBox(
height: barContentHeight + bottomHeight,
child: Stack(
children: [
Positioned(top: 0, left: 0, right: 0, child: titleRow),
Positioned(
top: barContentHeight - bottomOverlap,
left: 0,
right: 0,
height: bottomHeight + bottomOverlap,
child: bottom!,
),
],
),
)
: Column(
mainAxisSize: MainAxisSize.min,
children: [
titleRow,
if (bottom != null) SizedBox(height: bottomHeight, child: bottom),
],
);
final content = DirectionalTransitionMotion(
transformKey: const ValueKey(
'frosted-app-bar-content-transition-transform',
),
opacityKey: const ValueKey('frosted-app-bar-content-transition-opacity'),
child: contentBody,
);
final background = Container(
key: const ValueKey('frosted-app-bar-background'),
padding: EdgeInsets.only(top: topPadding),
decoration: BoxDecoration(
color: !frosted
? Colors.transparent
: gradient == null
? context.colors.surface.withValues(alpha: frostedSurfaceOpacity)
: null,
gradient: gradient,
border: showBottomDivider
? Border(
bottom: BorderSide(
color: navigationDivider(context, bottomDividerOpacity),
width: _kBottomBorderWidth,
),
)
: null,
),
child: content,
);
final child = ClipRect(
child: frosted
? BackdropFilter(
filter: ImageFilter.blur(
sigmaX: frostedBlurSigma,
sigmaY: frostedBlurSigma,
),
child: background,
)
: background,
);
return Positioned(top: 0, left: 0, right: 0, child: child);
}
}

View File

@@ -26,6 +26,9 @@ class FrostedScaffold extends StatelessWidget {
/// surface behind this page.
final Color? backgroundColor;
/// A fixed gradient painted behind the app bar and scrolling body.
final Gradient? backgroundGradient;
const FrostedScaffold({
super.key,
required this.appBar,
@@ -33,6 +36,7 @@ class FrostedScaffold extends StatelessWidget {
this.floatingActionButton,
this.resizeToAvoidBottomInset,
this.backgroundColor,
this.backgroundGradient,
});
@override
@@ -41,20 +45,41 @@ class FrostedScaffold extends StatelessWidget {
backgroundColor: backgroundColor,
resizeToAvoidBottomInset: resizeToAvoidBottomInset,
floatingActionButton: floatingActionButton,
body: Stack(
children: [
DirectionalTransitionMotion(
transformKey: const ValueKey(
'frosted-scaffold-body-transition-transform',
),
opacityKey: const ValueKey(
'frosted-scaffold-body-transition-opacity',
),
child: body,
),
appBar,
],
),
body: Stack(children: _stackChildren()),
);
}
List<Widget> _stackChildren() {
final backdrop = backgroundGradient == null
? const <Widget>[]
: [
Positioned.fill(
child: _PinnedGradientBackground(gradient: backgroundGradient!),
),
];
final bodyMotion = DirectionalTransitionMotion(
transformKey: const ValueKey(
'frosted-scaffold-body-transition-transform',
),
opacityKey: const ValueKey('frosted-scaffold-body-transition-opacity'),
child: body,
);
// The bar must be painted after the scrollable sheet: [BackdropFilter]
// only samples pixels that were already painted behind it. This is the
// same composition as channel navigation, so top-level headers blur their
// content rather than only the fixed gradient.
return [...backdrop, bodyMotion, appBar];
}
}
class _PinnedGradientBackground extends StatelessWidget {
final Gradient gradient;
const _PinnedGradientBackground({required this.gradient});
@override
Widget build(BuildContext context) => DecoratedBox(
key: const ValueKey('frosted-scaffold-pinned-gradient'),
decoration: BoxDecoration(gradient: gradient),
);
}

View File

@@ -8,13 +8,8 @@ const mobileTabBarHeight = 56.0;
/// Gap between the floating mobile tab bar and the bottom safe area.
const mobileTabBarBottomGap = Grid.twelve;
/// Returns the shared footer backdrop height, including the logical safe area.
double mobileTabFooterBackdropHeight(BuildContext context) =>
mobileTabBarHeight +
mobileTabBarBottomGap +
MediaQuery.paddingOf(context).bottom +
Grid.xl +
Grid.gutter;
/// Fixed visual height of the shared footer fade behind the floating tab bar.
double mobileTabFooterBackdropHeight(BuildContext _) => 180;
/// Builds the shared transparent-to-surface footer fade.
///
@@ -22,11 +17,14 @@ double mobileTabFooterBackdropHeight(BuildContext context) =>
/// the channel composer can paint the exact same fade behind their own content.
LinearGradient mobileTabFooterBackdropGradient(
BuildContext context, {
List<double> stops = const [0, 0.5, 1],
List<double> opacities = const [0, 0.75, 1],
List<double> stops = const [0, 0.18, 0.38, 0.6, 0.8, 1],
List<double> opacities = const [0, 0.03, 0.12, 0.34, 0.7, 1],
Color? tint,
double tintBlend = 0,
}) {
assert(stops.length == opacities.length);
final surface = context.colors.surface;
assert(tintBlend >= 0 && tintBlend <= 1);
final surface = Color.lerp(context.colors.surface, tint, tintBlend)!;
return LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
@@ -48,15 +46,24 @@ class MobileTabFooterBackdrop extends StatelessWidget {
/// Surface-color alpha values paired with [stops].
final List<double> opacities;
/// Optional color blended into the surface before opacity is applied.
final Color? tint;
/// Amount of [tint] mixed into the surface, from 0 to 1.
final double tintBlend;
/// Creates a footer backdrop with the required [height].
///
/// Override [stops] and [opacities] together to customize the gradient.
const MobileTabFooterBackdrop({
super.key,
required this.height,
this.stops = const [0, 0.5, 1],
this.opacities = const [0, 0.75, 1],
}) : assert(stops.length == opacities.length);
this.stops = const [0, 0.18, 0.38, 0.6, 0.8, 1],
this.opacities = const [0, 0.03, 0.12, 0.34, 0.7, 1],
this.tint,
this.tintBlend = 0,
}) : assert(stops.length == opacities.length),
assert(tintBlend >= 0 && tintBlend <= 1);
@override
Widget build(BuildContext context) {
@@ -69,6 +76,8 @@ class MobileTabFooterBackdrop extends StatelessWidget {
context,
stops: stops,
opacities: opacities,
tint: tint,
tintBlend: tintBlend,
),
),
),

View File

@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:buzz/features/activity/activity_page.dart';
import 'package:buzz/features/activity/activity_provider.dart';
import 'package:buzz/features/activity/feed_item.dart';
@@ -115,6 +116,7 @@ void main() {
List<Channel>? channels,
TextScaler? textScaler,
EdgeInsets mediaPadding = EdgeInsets.zero,
ValueListenable<int>? tabReselection,
}) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
@@ -143,7 +145,7 @@ void main() {
).copyWith(textScaler: textScaler, padding: mediaPadding),
child: child!,
),
home: const ActivityPage(),
home: ActivityPage(tabReselection: tabReselection),
),
);
}
@@ -181,11 +183,43 @@ void main() {
await tester.pumpWidget(await buildTestable());
await tester.pumpAndSettle();
final appBar = tester.widget<FrostedAppBar>(find.byType(FrostedAppBar));
final appBar = tester.widget<FrostedAppBar>(
find.byType(FrostedAppBar).last,
);
expect(appBar.automaticallyImplyLeading, isFalse);
expect(appBar.gradient, isNull);
expect(appBar.frosted, isTrue);
expect(appBar.showBottomDivider, isTrue);
expect(appBar.bottomHeight, Grid.xxs);
expect(find.byTooltip('Back'), findsNothing);
});
testWidgets('sizes the Activity app bar for its custom title style', (
tester,
) async {
await tester.pumpWidget(
await buildTestable(textScaler: const TextScaler.linear(2)),
);
await tester.pumpAndSettle();
final appBar = tester.widget<FrostedAppBar>(
find.byType(FrostedAppBar).last,
);
final titleStyle = appBar.titleStyle!;
expect(titleStyle.fontSize, 22);
expect(
tester.getSize(find.byType(ClipRect).last).height,
closeTo(
frostedAppBarHeight(
tester.element(find.byType(FrostedAppBar).last),
titleStyle: titleStyle,
bottomHeight: Grid.xxs,
),
0.01,
),
);
});
testWidgets('keeps footer clearance inside the scrollable content', (
tester,
) async {
@@ -200,8 +234,48 @@ void main() {
expect(safeArea.top, isFalse);
expect(safeArea.bottom, isFalse);
final list = tester.widget<ListView>(find.byType(ListView));
expect(list.padding, const EdgeInsets.fromLTRB(0, Grid.xxs, 0, 96));
final padding = tester.widget<SliverPadding>(
find.descendant(
of: find.byType(CustomScrollView),
matching: find.byType(SliverPadding),
),
);
expect(padding.padding, const EdgeInsets.fromLTRB(0, Grid.xxs, 0, 96));
});
testWidgets('scrolls Activity to the top when its tab is selected again', (
tester,
) async {
tester.view.physicalSize = const Size(320, 180);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final tabReselection = ValueNotifier(0);
addTearDown(tabReselection.dispose);
await tester.pumpWidget(
await buildTestable(tabReselection: tabReselection),
);
await tester.pumpAndSettle();
final scrollable = tester.state<ScrollableState>(
find
.descendant(
of: find.byType(CustomScrollView),
matching: find.byType(Scrollable),
)
.first,
);
expect(scrollable.position.maxScrollExtent, greaterThan(0));
scrollable.position.jumpTo(scrollable.position.maxScrollExtent);
tabReselection.value++;
await tester.pump();
await tester.pump(const Duration(milliseconds: 130));
expect(
scrollable.position.pixels,
lessThan(scrollable.position.maxScrollExtent),
);
await tester.pumpAndSettle();
expect(scrollable.position.pixels, scrollable.position.minScrollExtent);
});
testWidgets('shows error view with retry button', (tester) async {
@@ -285,7 +359,13 @@ void main() {
await tester.tap(find.descendant(of: surface, matching: find.text('All')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const ValueKey('activity-options-menu')));
final optionsTrigger = find.byKey(const ValueKey('activity-options-menu'));
expect(
tester.getSize(optionsTrigger),
const Size(Grid.xl, Grid.xl),
reason: 'Activity options must retain a 48dp touch target.',
);
await tester.tap(optionsTrigger);
await tester.pump();
final optionsSurface = find.byKey(

View File

@@ -1,7 +1,9 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:hooks_riverpod/misc.dart';
@@ -22,6 +24,8 @@ import 'package:buzz/shared/community/community_icon_provider.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/avatar_image.dart';
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
import 'package:buzz/shared/widgets/masked_avatar_badge.dart';
import 'package:buzz/shared/widgets/skeleton.dart';
void main() {
@@ -34,6 +38,9 @@ void main() {
Map<String, String?> communityIcons = const {},
ValueChanged<String>? onCommunityIconLoad,
TextScaler textScaler = TextScaler.noScaling,
Gradient? topSectionGradient,
ValueChanged<double>? onSettingsTransitionProgress,
ValueListenable<int>? tabReselection,
}) {
return ProviderScope(
overrides: [
@@ -50,7 +57,7 @@ void main() {
...overrides,
],
child: MaterialApp(
theme: AppTheme.light(),
theme: AppTheme.light(topSectionGradient: topSectionGradient),
builder: (context, child) => MediaQuery(
data: MediaQuery.of(context).copyWith(
disableAnimations: disableAnimations,
@@ -60,10 +67,15 @@ void main() {
),
child: child!,
),
home: const Stack(
home: Stack(
children: [
ChannelsPage(settingsPageBuilder: _buildSettingsPage),
Positioned.fill(
ChannelsPage(
settingsPageBuilder: _buildSettingsPage,
onSettingsTransitionProgress:
onSettingsTransitionProgress ?? (_) {},
tabReselection: tabReselection,
),
const Positioned.fill(
child: ChannelQuickActionsLauncher(
visible: true,
navigationBarHeight: 60,
@@ -161,12 +173,65 @@ void main() {
final text = tester.widget<Text>(find.text(label));
expect(text.style?.fontSize, contentListTitleTextStyle.fontSize);
expect(text.style?.height, contentListTitleTextStyle.height);
expect(
text.style?.color,
Theme.of(
tester.element(find.text(label)),
).colorScheme.onSurface.withValues(alpha: 0.8),
);
}
final channelIcon = tester.widget<Icon>(
find.byKey(const ValueKey('channel-icon-1')),
);
expect(
channelIcon.color,
Theme.of(
tester.element(find.byKey(const ValueKey('channel-icon-1'))),
).colorScheme.onSurface.withValues(alpha: 0.8),
);
final sectionTitle = tester.widget<Text>(find.text('Channels'));
expect(sectionTitle.style?.fontSize, contentListTitleTextStyle.fontSize);
expect(sectionTitle.style?.fontWeight, FontWeight.w600);
});
testWidgets('sizes the community header for accessible text', (tester) async {
await tester.pumpWidget(
buildTestable(
textScaler: const TextScaler.linear(2),
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
final appBar = tester.widget<FrostedAppBar>(
find.byType(FrostedAppBar).last,
);
final titleStyle = appBar.titleStyle!;
expect(titleStyle.fontSize, 22);
expect(
tester
.getSize(
find.descendant(
of: find.byType(FrostedAppBar).last,
matching: find.byType(ClipRect),
),
)
.height,
closeTo(
frostedAppBarHeight(
tester.element(find.byType(FrostedAppBar).last),
titleStyle: titleStyle,
bottomHeight: appBar.bottomHeight,
) -
1,
0.01,
),
);
expect(tester.takeException(), isNull);
});
testWidgets('keeps the last channel above the floating tab bar', (
tester,
) async {
@@ -190,6 +255,149 @@ void main() {
expect((padding.padding as EdgeInsets).bottom, footerClearance);
});
testWidgets('balances an expanded section around its following divider', (
tester,
) async {
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
final lastChannel = tester.getRect(find.text('general'));
final divider = tester.getRect(find.byType(Divider).last);
final nextSectionHeader = tester.getRect(find.text('DMs'));
expect(
divider.top - lastChannel.bottom,
closeTo(nextSectionHeader.top - divider.bottom, 0.01),
);
});
testWidgets('keeps the Buzz background fixed behind the channels list', (
tester,
) async {
await tester.pumpWidget(
buildTestable(
topSectionGradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.yellow, Colors.blue],
),
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('frosted-scaffold-pinned-gradient')),
findsOneWidget,
);
expect(find.byType(DecoratedSliver), findsNothing);
final gradientBackground = tester.widget<DecoratedBox>(
find.byKey(const ValueKey('frosted-scaffold-pinned-gradient')),
);
final gradient =
(gradientBackground.decoration as BoxDecoration).gradient
as LinearGradient;
expect(gradient.end, Alignment.bottomCenter);
final appBar = tester.widget<FrostedAppBar>(
find.byType(FrostedAppBar).last,
);
expect(appBar.frosted, isFalse);
expect(appBar.frostedSurfaceOpacity, 0);
expect(appBar.frostedBlurSigma, 0);
expect(appBar.showBottomDivider, isFalse);
expect(appBar.bottomHeight, Grid.xxs);
});
testWidgets('builds Home header frost progressively while scrolling', (
tester,
) async {
tester.view.physicalSize = const Size(320, 160);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
await tester.pumpWidget(
buildTestable(
topSectionGradient: const LinearGradient(
colors: [Colors.yellow, Colors.blue],
),
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
final scrollable = tester.state<ScrollableState>(
find
.descendant(
of: find.byType(CustomScrollView),
matching: find.byType(Scrollable),
)
.first,
);
expect(scrollable.position.maxScrollExtent, greaterThanOrEqualTo(Grid.xxl));
scrollable.position.jumpTo(Grid.xl / 2);
await tester.pump();
var appBar = tester.widget<FrostedAppBar>(find.byType(FrostedAppBar).last);
expect(appBar.frosted, isTrue);
expect(appBar.frostedSurfaceOpacity, 0);
expect(appBar.frostedBlurSigma, closeTo(8.67, 0.001));
scrollable.position.jumpTo(Grid.xxl);
await tester.pump();
appBar = tester.widget<FrostedAppBar>(find.byType(FrostedAppBar).last);
expect(appBar.frostedSurfaceOpacity, 0);
expect(appBar.frostedBlurSigma, 23.12);
});
testWidgets('scrolls Home to the top when its tab is selected again', (
tester,
) async {
tester.view.physicalSize = const Size(320, 160);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final tabReselection = ValueNotifier(0);
addTearDown(tabReselection.dispose);
await tester.pumpWidget(
buildTestable(
tabReselection: tabReselection,
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
final scrollable = tester.state<ScrollableState>(
find
.descendant(
of: find.byType(CustomScrollView),
matching: find.byType(Scrollable),
)
.first,
);
scrollable.position.jumpTo(scrollable.position.maxScrollExtent);
tabReselection.value++;
await tester.pump();
await tester.pump(const Duration(milliseconds: 130));
expect(
scrollable.position.pixels,
lessThan(scrollable.position.maxScrollExtent),
);
await tester.pumpAndSettle();
expect(scrollable.position.pixels, scrollable.position.minScrollExtent);
});
testWidgets('truncates long custom section names beside the menu', (
tester,
) async {
@@ -322,7 +530,9 @@ void main() {
final topLabelX = tester.getTopLeft(find.text('Community')).dx;
final sectionLabelX = tester.getTopLeft(find.text('Channels')).dx;
final rowLabelX = tester.getTopLeft(find.text('general')).dx;
expect(topLabelX, sectionLabelX);
// The community title shares the leading row with its avatar. Channel
// labels stay aligned below it.
expect(topLabelX, Grid.twelve + 40 + Grid.xxs);
expect(sectionLabelX, rowLabelX);
relaySession.setReconnecting();
@@ -344,6 +554,32 @@ void main() {
expect(skeletonSectionLabelX, sectionLabelX);
});
testWidgets('matches the community and profile avatar circle sizes', (
tester,
) async {
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
final appBar = find.byType(FrostedAppBar).last;
final communityAvatar = find.descendant(
of: appBar,
matching: find.byType(AvatarImage),
);
final profileAvatar = find.descendant(
of: appBar,
matching: find.byType(MaskedAvatarBadge),
);
expect(tester.getSize(communityAvatar), const Size.square(40));
expect(tester.getSize(profileAvatar), const Size.square(40));
});
testWidgets('reveals channel content from same-slot reconnect skeletons', (
tester,
) async {
@@ -451,10 +687,128 @@ void main() {
);
await tester.pumpAndSettle();
expect(find.byType(Hero), findsNothing);
await tester.tap(find.byType(ProfileAvatar));
await tester.pumpAndSettle();
expect(find.text('Injected settings'), findsOneWidget);
final route = ModalRoute.of(tester.element(find.text('Injected settings')));
expect(route, isNot(isA<MaterialPageRoute<void>>()));
expect(route?.opaque, isFalse);
});
testWidgets('reports Settings progress in both directions', (tester) async {
final progress = <double>[];
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
onSettingsTransitionProgress: progress.add,
),
);
await tester.pumpAndSettle();
await tester.tap(find.byType(ProfileAvatar));
await tester.pumpAndSettle();
expect(progress.any((value) => value > 0 && value < 1), isTrue);
expect(progress.last, 1);
final reverseStart = progress.length;
Navigator.of(tester.element(find.text('Injected settings'))).pop();
await tester.pumpAndSettle();
expect(
progress.skip(reverseStart).any((value) => value > 0 && value < 1),
isTrue,
);
expect(progress.last, 0);
});
testWidgets('paints Settings content with its surface from the first frame', (
tester,
) async {
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byType(ProfileAvatar));
await tester.pump();
final transition = find.byKey(
const ValueKey('settings-transition-opacity'),
skipOffstage: false,
);
expect(transition, findsOneWidget);
expect(
find.descendant(
of: transition,
matching: find.byKey(
const ValueKey('settings-transition-layer'),
skipOffstage: false,
),
),
findsOneWidget,
);
expect(tester.widget<FadeTransition>(transition).opacity.value, 0.8);
await tester.pump(const Duration(milliseconds: 95));
expect(
tester.widget<FadeTransition>(transition).opacity.value,
inExclusiveRange(0.8, 1),
);
await tester.pumpAndSettle();
expect(tester.widget<FadeTransition>(transition).opacity.value, 1);
Navigator.of(tester.element(find.text('Injected settings'))).pop();
await tester.pump();
await tester.pump(const Duration(milliseconds: 95));
expect(
tester.widget<FadeTransition>(transition).opacity.value,
inExclusiveRange(0, 1),
reason: 'The complete Settings layer still fades out on exit.',
);
});
testWidgets('gives feedback for the profile and community controls', (
tester,
) async {
final hapticCalls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
if (call.method == 'HapticFeedback.vibrate') hapticCalls.add(call);
return null;
});
addTearDown(
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null),
);
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byType(ProfileAvatar));
await tester.pumpAndSettle();
expect(hapticCalls.single.arguments, 'HapticFeedbackType.lightImpact');
Navigator.of(tester.element(find.text('Injected settings'))).pop();
await tester.pumpAndSettle();
final communityAvatar = find.descendant(
of: find.byType(FrostedAppBar).last,
matching: find.byType(AvatarImage),
);
await tester.tap(communityAvatar);
await tester.pump();
expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick');
});
testWidgets('community switcher separates selection from edit removal', (
@@ -1360,6 +1714,10 @@ void main() {
tester.widget<Text>(find.text('general')).style?.fontWeight,
FontWeight.w700,
);
expect(
tester.widget<Text>(find.text('general')).style?.color,
Theme.of(tester.element(find.text('general'))).colorScheme.onSurface,
);
readState.markContextRead('1', 20);
await tester.pump();

View File

@@ -11,13 +11,14 @@ void main() {
Future<Widget> buildHome({
int unreadInboxCount = 0,
bool disableAnimations = false,
Gradient? topSectionGradient,
}) async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
return ProviderScope(
overrides: [savedPrefsProvider.overrideWithValue(prefs)],
child: MaterialApp(
theme: AppTheme.light(),
theme: AppTheme.light(topSectionGradient: topSectionGradient),
builder: (context, child) => MediaQuery(
data: MediaQuery.of(
context,
@@ -70,6 +71,46 @@ void main() {
);
});
testWidgets('keeps the Buzz backdrop behind the scalable Home screen', (
tester,
) async {
const gradient = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.yellow, Colors.blue],
);
await tester.pumpWidget(await buildHome(topSectionGradient: gradient));
await tester.pump();
final backdrop = find.byKey(
const ValueKey('home-settings-transition-backdrop'),
);
final decoration =
tester.widget<DecoratedBox>(backdrop).decoration as BoxDecoration;
expect(decoration.gradient, gradient);
expect(
find.byKey(const ValueKey('home-settings-transition-scale')),
findsOneWidget,
);
expect(
tester
.widget<Transform>(
find.byKey(const ValueKey('home-settings-transition-scale')),
)
.transform
.getMaxScaleOnAxis(),
1,
);
expect(
tester
.widget<Opacity>(
find.byKey(const ValueKey('home-settings-transition-opacity')),
)
.opacity,
1,
);
});
testWidgets('gives selection haptics only when the tab changes', (
tester,
) async {

View File

@@ -0,0 +1,90 @@
import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
test(
'manual presence persists until Online restores automatic mode',
() async {
SharedPreferences.setMockInitialValues({});
final prefs = await SharedPreferences.getInstance();
var container = _buildContainer(prefs);
expect(
await container
.read(presenceProvider.future)
.timeout(
const Duration(seconds: 2),
onTimeout: () =>
throw StateError('initial presence did not resolve'),
),
'online',
);
await container
.read(presenceProvider.notifier)
.setPresence('away')
.timeout(
const Duration(seconds: 2),
onTimeout: () => throw StateError('setting Away did not resolve'),
);
expect(container.read(presenceProvider).value, 'away');
expect(prefs.getString('buzz_presence_preference_aabb'), 'away');
container.dispose();
container = _buildContainer(prefs);
addTearDown(container.dispose);
expect(
await container
.read(presenceProvider.future)
.timeout(
const Duration(seconds: 2),
onTimeout: () =>
throw StateError('stored presence did not resolve'),
),
'away',
);
await container
.read(presenceProvider.notifier)
.setPresence('online')
.timeout(
const Duration(seconds: 2),
onTimeout: () => throw StateError('setting Online did not resolve'),
);
expect(container.read(presenceProvider).value, 'online');
expect(prefs.getString('buzz_presence_preference_aabb'), 'auto');
},
);
}
ProviderContainer _buildContainer(SharedPreferences prefs) => ProviderContainer(
overrides: [
savedPrefsProvider.overrideWithValue(prefs),
myPubkeyProvider.overrideWithValue('aabb'),
profileProvider.overrideWith(_FakeProfileNotifier.new),
relaySessionProvider.overrideWith(_DisconnectedRelaySession.new),
appLifecycleProvider.overrideWith(_ResumedLifecycle.new),
],
);
class _FakeProfileNotifier extends ProfileNotifier {
@override
Future<UserProfile?> build() async =>
const UserProfile(pubkey: 'aabb', displayName: 'Test');
}
class _DisconnectedRelaySession extends RelaySessionNotifier {
@override
SessionState build() =>
const SessionState(status: SessionStatus.disconnected);
}
class _ResumedLifecycle extends AppLifecycleNotifier {
@override
AppLifecycleState build() => AppLifecycleState.resumed;
}

View File

@@ -4,7 +4,9 @@ import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/features/profile/user_status.dart';
import 'package:buzz/features/profile/user_status_provider.dart';
import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/masked_avatar_badge.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@@ -19,6 +21,7 @@ void main() {
WidgetHelpers.testable(
overrides: [
profileProvider.overrideWith(_FakeProfileNotifier.new),
presenceProvider.overrideWith(() => _FakePresenceNotifier('online')),
userStatusProvider.overrideWith(
() => _FakeUserStatusNotifier(
const UserStatus(
@@ -35,6 +38,7 @@ void main() {
);
await tester.pumpAndSettle();
expect(find.byType(Hero), findsNothing);
final badge = find.byType(MaskedAvatarBadge);
expect(
find.descendant(of: badge, matching: find.text(missingShortcode)),
@@ -45,6 +49,99 @@ void main() {
findsOneWidget,
);
});
testWidgets(
'keeps text-only status visible beside a changeable presence pill',
(tester) async {
final presenceNotifier = _FakePresenceNotifier('away');
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
profileProvider.overrideWith(_FakeProfileNotifier.new),
presenceProvider.overrideWith(() => presenceNotifier),
userStatusProvider.overrideWith(
() => _FakeUserStatusNotifier(
const UserStatus(text: 'Focusing', emoji: '', updatedAt: 1),
),
),
customEmojiListProvider.overrideWithValue(const []),
],
child: const SettingsProfileHeader(),
),
);
await tester.pumpAndSettle();
expect(find.text('Focusing'), findsOneWidget);
await tester.tap(find.text('Focusing'));
await tester.pumpAndSettle();
expect(find.text('Set a status'), findsOneWidget);
expect(
tester.widget<TextField>(find.byType(TextField)).controller?.text,
'Focusing',
);
await tester.binding.handlePopRoute();
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('settings-presence-label')),
findsOneWidget,
);
expect(find.text('Away'), findsOneWidget);
expect(
tester
.widget<Text>(find.byKey(const ValueKey('settings-presence-label')))
.style
?.fontSize,
filterChipTextStyle.fontSize,
);
expect(
tester
.getSize(find.byKey(const ValueKey('settings-presence-target')))
.height,
48,
);
expect(
tester
.getSize(find.byKey(const ValueKey('settings-presence-pill')))
.height,
greaterThanOrEqualTo(31),
);
final presenceTarget = find.byKey(
const ValueKey('settings-presence-target'),
);
final targetRect = tester.getRect(presenceTarget);
await tester.tapAt(Offset(targetRect.center.dx, targetRect.bottom - 1));
await tester.pump();
final scale = tester.widget<ScaleTransition>(
find.byKey(const ValueKey('activity-popover-scale')),
);
expect(scale.alignment, Alignment.topCenter);
expect(
find.byKey(const ValueKey('settings-presence-popover')),
findsOneWidget,
);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('settings-presence-online')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('settings-presence-away')),
findsOneWidget,
);
expect(
find.byKey(const ValueKey('settings-presence-offline')),
findsOneWidget,
);
await tester.tap(find.byKey(const ValueKey('settings-presence-offline')));
await tester.pumpAndSettle();
expect(presenceNotifier.selected, ['offline']);
},
);
}
class _FakeProfileNotifier extends ProfileNotifier {
@@ -61,3 +158,18 @@ class _FakeUserStatusNotifier extends UserStatusNotifier {
@override
Future<UserStatus?> build() async => _status;
}
class _FakePresenceNotifier extends PresenceNotifier {
_FakePresenceNotifier(this._presence);
final String _presence;
final List<String> selected = [];
@override
Future<String> build() async => _presence;
@override
Future<void> setPresence(String status) async {
selected.add(status);
}
}

View File

@@ -11,6 +11,7 @@ import 'package:buzz/features/search/search_page.dart';
import 'package:buzz/features/search/search_provider.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@@ -18,6 +19,110 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../helpers/widget_helpers.dart';
void main() {
testWidgets('reselecting Search uses the field activation path', (
tester,
) async {
final tabReselection = ValueNotifier(0);
addTearDown(tabReselection.dispose);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
searchProvider.overrideWith(
() => _FakeSearchNotifier(const SearchState.initial()),
),
recentSearchesProvider.overrideWith(
() => _FakeRecentSearchesNotifier(const []),
),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
],
child: SearchPage(tabReselection: tabReselection),
),
);
await tester.pumpAndSettle();
expect(find.byKey(const Key('search-cancel')), findsNothing);
tabReselection.value++;
await tester.pump();
await tester.pump();
expect(find.byKey(const Key('search-cancel')), findsOneWidget);
expect(
tester.widget<TextField>(find.byType(TextField)).focusNode?.hasFocus,
isTrue,
);
final focusNode = tester
.widget<TextField>(find.byType(TextField))
.focusNode!;
// Reproduce the real tab-tap ordering where the destination callback can
// run immediately before the same pointer gesture dismisses the field.
tabReselection.value++;
focusNode.unfocus();
await tester.pump();
await tester.pump();
expect(find.byKey(const Key('search-cancel')), findsOneWidget);
expect(focusNode.hasFocus, isTrue);
expect(
tester
.widget<AnimatedOpacity>(
find.byKey(const Key('search-header-title-opacity')),
)
.opacity,
0,
reason: 'The tab gesture must not paint a close-and-reopen flicker.',
);
});
testWidgets('uses the shared frosted navigation surface', (tester) async {
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
searchProvider.overrideWith(
() => _FakeSearchNotifier(const SearchState.initial()),
),
recentSearchesProvider.overrideWith(
() => _FakeRecentSearchesNotifier(const []),
),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
],
child: const SearchPage(),
),
);
await tester.pumpAndSettle();
final appBar = tester.widget<FrostedAppBar>(find.byType(FrostedAppBar));
expect(appBar.gradient, isNull);
expect(appBar.frosted, isTrue);
expect(appBar.showBottomDivider, isTrue);
expect(appBar.bottomDividerOpacity, 0.06);
expect(appBar.bottomHeight, 57);
expect(appBar.leading, isNull);
expect(find.text('Search'), findsOneWidget);
final promptText = find.descendant(
of: find.byKey(const Key('search-field-container')),
matching: find.byType(Text),
);
expect(
tester.getRect(promptText).left,
closeTo(
tester.getRect(find.byKey(const Key('search-moving-icon'))).right +
Grid.xxs,
0.01,
),
);
expect(
tester.getRect(promptText).center.dy,
closeTo(
tester
.getRect(find.byKey(const Key('search-field-container')))
.center
.dy,
0.5,
),
);
});
testWidgets('empty state preserves large accessible text scaling', (
tester,
) async {
@@ -49,8 +154,30 @@ void main() {
);
await tester.pumpAndSettle();
final appBar = tester.widget<FrostedAppBar>(find.byType(FrostedAppBar));
final titleStyle = appBar.titleStyle!;
expect(titleStyle.fontSize, 22);
expect(
tester
.getSize(
find
.descendant(
of: find.byType(FrostedAppBar),
matching: find.byType(ClipRect),
)
.first,
)
.height,
closeTo(
frostedAppBarHeight(
tester.element(find.byType(FrostedAppBar)),
titleStyle: titleStyle,
bottomHeight: appBar.bottomHeight,
),
0.01,
),
);
final emptyState = find.byKey(const Key('search-empty-state'));
final message = find.text('Search messages, channels, and people');
final searchField = find.byKey(const Key('search-field-container'));
final searchFieldContext = tester.element(searchField);
final bodyStyle = Theme.of(searchFieldContext).textTheme.bodyMedium!;
@@ -66,12 +193,68 @@ void main() {
tester.getSize(searchField).height,
greaterThanOrEqualTo(scaledLineHeight + Grid.xxs * 2),
);
final input = tester.widget<TextField>(
find.byKey(const Key('search-field')),
final prompt = tester.widget<Text>(
find.descendant(
of: find.byKey(const Key('search-field-container')),
matching: find.byType(Text),
),
);
expect(prompt.style?.fontSize, 15);
expect(prompt.maxLines, 1);
expect(prompt.overflow, TextOverflow.ellipsis);
expect(
tester
.getSize(find.descendant(of: emptyState, matching: find.byType(Text)))
.height,
greaterThan(32),
);
expect(tester.takeException(), isNull);
});
testWidgets('search filters grow with accessible text', (tester) async {
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
searchProvider.overrideWith(
() => _FakeSearchNotifier(const SearchState.initial()),
),
recentSearchesProvider.overrideWith(
() => _FakeRecentSearchesNotifier(const []),
),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
],
child: Builder(
builder: (context) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(textScaler: const TextScaler.linear(2)),
child: const SearchPage(),
),
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('search-field')));
await tester.pumpAndSettle();
final filters = find.byKey(const Key('search-header-filters'));
final activeField = find.byKey(const Key('search-field-container'));
final cancel = find.byKey(const Key('search-cancel'));
expect(filters, findsOneWidget);
expect(cancel, findsOneWidget);
expect(
tester.getRect(activeField).right,
lessThanOrEqualTo(tester.getRect(cancel).left),
reason: 'Scaled Cancel must not overlap the active search field.',
);
expect(tester.getSize(filters).height, greaterThan(Grid.xl));
expect(
tester.getSize(filters).height,
greaterThanOrEqualTo(
tester.getSize(find.text('Messages')).height + Grid.xs * 2,
),
);
expect(input.style?.fontSize, searchInputTextStyle.fontSize);
expect(input.style?.height, searchInputTextStyle.height);
expect(tester.getSize(message).height, greaterThan(32));
expect(tester.takeException(), isNull);
});
@@ -93,20 +276,13 @@ void main() {
await tester.pumpAndSettle();
final searchField = find.byKey(const Key('search-field'));
final editingField = find.byType(TextField);
final searchFieldContainer = find.byKey(
const Key('search-field-container'),
);
final unfocusedWidth = tester.getSize(searchFieldContainer).width;
final unfocusedTop = tester.getRect(searchFieldContainer).top;
expect(find.byKey(const Key('search-cancel')), findsNothing);
expect(
tester.widget<TextField>(searchField).decoration?.hintText,
'Search messages, channels, people\u2026',
);
expect(
tester.widget<TextField>(searchField).textInputAction,
TextInputAction.search,
);
await tester.tap(searchField);
await tester.pump();
@@ -117,7 +293,14 @@ void main() {
greaterThanOrEqualTo(Grid.xl),
reason: 'Cancel must keep a 48dp touch target.',
);
expect(tester.widget<TextField>(searchField).decoration?.hintText, isNull);
final input = tester.widget<TextField>(editingField);
expect(input.decoration?.hintText, isNull);
expect(input.textInputAction, TextInputAction.search);
expect(
input.focusNode?.hasFocus,
isTrue,
reason: 'Tapping the idle search field opens the native keyboard.',
);
final enteringSlide = tester.widget<SlideTransition>(
find.ancestor(of: cancel, matching: find.byType(SlideTransition)).first,
);
@@ -125,28 +308,58 @@ void main() {
await tester.pump(const Duration(milliseconds: 160));
final focusedWidth = tester.getSize(searchFieldContainer).width;
final focusedRect = tester.getRect(searchFieldContainer);
expect(focusedWidth, lessThan(unfocusedWidth));
expect(
focusedRect.top,
lessThan(unfocusedTop),
reason: 'The active field translates upward into the title row.',
);
expect(find.byKey(const Key('search-header-filters')), findsOneWidget);
final settledSlide = tester.widget<SlideTransition>(
find.ancestor(of: cancel, matching: find.byType(SlideTransition)).first,
);
expect(settledSlide.position.value, Offset.zero);
final movingIcon = find.byKey(const Key('search-moving-icon'));
final iconScale = tester.widget<AnimatedScale>(
find.ancestor(of: movingIcon, matching: find.byType(AnimatedScale)),
);
final movingField = tester.widget<AnimatedPositioned>(
find.ancestor(of: movingIcon, matching: find.byType(AnimatedPositioned)),
);
expect(iconScale.scale, lessThan(1));
expect(movingField.top, Grid.half);
final appBarRect = tester.getRect(find.byType(FrostedAppBar));
expect(
appBarRect.contains(focusedRect.center),
isTrue,
reason: 'The translated field remains inside the app bar hit-test box.',
);
await tester.enterText(searchField, 'design');
await tester.enterText(editingField, 'design');
await tester.tap(cancel);
await tester.pump();
await tester.pump(const Duration(milliseconds: 60));
final exitingWidth = tester.getSize(searchFieldContainer).width;
expect(exitingWidth, greaterThan(focusedWidth));
expect(exitingWidth, lessThan(unfocusedWidth));
final titleOpacity = tester.widget<AnimatedOpacity>(
find.byKey(const Key('search-header-title-opacity')),
);
expect(
titleOpacity.opacity,
1,
reason:
'The title fades beneath the returning field instead of appearing after it.',
);
await tester.pump(const Duration(milliseconds: 159));
expect(
tester
.widget<AnimatedOpacity>(
find.byKey(const Key('search-header-title-opacity')),
)
.opacity,
1,
);
await tester.pumpAndSettle();
final input = tester.widget<TextField>(searchField);
expect(input.controller?.text, isEmpty);
expect(input.focusNode?.hasFocus, isFalse);
expect(
input.decoration?.hintText,
'Search messages, channels, people\u2026',
);
expect(find.byKey(const Key('search-cancel')), findsNothing);
expect(
tester.getSize(searchFieldContainer).width,
@@ -154,6 +367,42 @@ void main() {
);
});
testWidgets('keeps the search prompt calm until it is focused', (
tester,
) async {
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
searchProvider.overrideWith(
() => _FakeSearchNotifier(const SearchState.initial()),
),
recentSearchesProvider.overrideWith(
() => _FakeRecentSearchesNotifier(const []),
),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
],
child: const SearchPage(),
),
);
await tester.pumpAndSettle();
final searchField = find.byKey(const Key('search-field'));
final searchFieldContainer = find.byKey(
const Key('search-field-container'),
);
expect(find.text('Messages'), findsNothing);
expect(tester.getSize(searchFieldContainer).height, greaterThan(36));
await tester.tap(searchField);
await tester.pumpAndSettle();
expect(find.text('Messages'), findsOneWidget);
expect(
tester.getSize(searchFieldContainer).height,
greaterThanOrEqualTo(36),
);
});
testWidgets('only submitted queries are added to recent searches', (
tester,
) async {
@@ -243,7 +492,13 @@ void main() {
await tester.tap(find.byKey(const Key('clear-recent-searches')));
await tester.pumpAndSettle();
expect(find.byKey(const Key('recent-searches-list')), findsNothing);
expect(find.text('Search messages, channels, and people'), findsOneWidget);
expect(
find.descendant(
of: find.byKey(const Key('search-empty-state')),
matching: find.text('Search messages, channels, and people'),
),
findsOneWidget,
);
});
testWidgets('keeps recent searches scrollable above the keyboard', (

View File

@@ -183,6 +183,59 @@ void main() {
expect(decoration.gradient, isNull);
expect(decoration.color, isNotNull);
});
testWidgets('Buzz section labels use 80% neutral foreground', (
tester,
) async {
await tester.pumpWidget(
harness(
AppTheme.light(
topSectionGradient: buzzTopSectionGradient(
buzzThemeName,
Brightness.light,
),
),
),
);
final context = tester.element(find.text('Home'));
expect(
navigationSectionForeground(context),
Colors.black.withValues(alpha: 0.8),
);
});
testWidgets('navigation roles inherit non-Buzz theme tokens', (
tester,
) async {
const primaryForeground = Color(0xFF123456);
const secondaryForeground = Color(0xFF789ABC);
const searchSurface = Color(0xFFDEF012);
final theme = ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple).copyWith(
onSurface: primaryForeground,
onSurfaceVariant: secondaryForeground,
surfaceContainerHighest: searchSurface,
),
);
await tester.pumpWidget(
MaterialApp(
theme: theme,
home: const Scaffold(body: SizedBox()),
),
);
final context = tester.element(find.byType(SizedBox));
expect(navigationPrimaryForeground(context), primaryForeground);
expect(navigationSecondaryForeground(context), secondaryForeground);
expect(navigationSectionForeground(context), secondaryForeground);
expect(navigationSearchSurface(context), searchSurface);
expect(
navigationDivider(context, 0.15),
primaryForeground.withValues(alpha: 0.15),
);
});
});
group('isBuzzTheme', () {

View File

@@ -19,27 +19,25 @@ void main() {
),
);
expect(gradient?.stops, [0, 0.5, 1]);
expect(gradient?.stops, [0, 0.18, 0.38, 0.6, 0.8, 1]);
expect(gradient?.colors.first.a, 0);
expect(gradient?.colors[1].a, 0.75);
expect(gradient?.colors[1].a, closeTo(0.03, 0.01));
expect(gradient?.colors[3].a, closeTo(0.34, 0.01));
expect(gradient?.colors.last.a, 1);
});
testWidgets('uses the logical bottom safe-area inset', (tester) async {
testWidgets('uses a fixed 180px footer backdrop height', (tester) async {
double? height;
await tester.pumpWidget(
MediaQuery(
data: const MediaQueryData(padding: EdgeInsets.only(bottom: 34)),
child: Builder(
builder: (context) {
height = mobileTabFooterBackdropHeight(context);
return const SizedBox();
},
),
Builder(
builder: (context) {
height = mobileTabFooterBackdropHeight(context);
return const SizedBox();
},
),
);
expect(height, 170);
expect(height, 180);
});
}