diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index 82a64e37514..dbae58d687d 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -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? 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, + ), ), ); } diff --git a/mobile/lib/features/activity/activity_page/header_actions.dart b/mobile/lib/features/activity/activity_page/header_actions.dart index 39bb29e7a39..08384b69e1f 100644 --- a/mobile/lib/features/activity/activity_page/header_actions.dart +++ b/mobile/lib/features/activity/activity_page/header_actions.dart @@ -11,6 +11,59 @@ const _filterLabels = { InboxFilter.drafts: 'Drafts', }; +class _ActivityActionsPill extends StatelessWidget { + final InboxFilter filter; + final int dueReminderCount; + final int draftCount; + final bool unreadOnly; + final int unreadCount; + final ValueChanged onFilterChanged; + final ValueChanged 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( diff --git a/mobile/lib/features/activity/activity_page/lists.dart b/mobile/lib/features/activity/activity_page/lists.dart index 9df193fe96e..7b4be901e12 100644 --- a/mobile/lib/features/activity/activity_page/lists.dart +++ b/mobile/lib/features/activity/activity_page/lists.dart @@ -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 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 drafts; + final ScrollController scrollController; final Map 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) { diff --git a/mobile/lib/features/activity/activity_page/status_views.dart b/mobile/lib/features/activity/activity_page/status_views.dart index 442634849c3..ec7e969034e 100644 --- a/mobile/lib/features/activity/activity_page/status_views.dart +++ b/mobile/lib/features/activity/activity_page/status_views.dart @@ -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, diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index c00792b9667..16b1c246bc3 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -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 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 onSettingsTransitionProgress; + + /// Notifies this page when its already-selected tab is tapped again. + final ValueListenable? 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( + 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( - 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(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 { + _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 onTransitionProgress; + + Animation? _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 animation, + Animation 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(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 + with AnimationWithParentMixin { + _SettingsOpacityAnimation(this.parent); + + @override + final Animation parent; + + @override + double get value { + final progress = parent.value; + return parent.status == AnimationStatus.reverse + ? progress + : 0.8 + (0.2 * progress); + } +} diff --git a/mobile/lib/features/channels/channels_page/body.dart b/mobile/lib/features/channels/channels_page/body.dart index 6ce4ce5f8fd..6b071e73829 100644 --- a/mobile/lib/features/channels/channels_page/body.dart +++ b/mobile/lib/features/channels/channels_page/body.dart @@ -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 Function() onRefresh; final Future 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, + ), + ), ], ), ); diff --git a/mobile/lib/features/channels/channels_page/channel_tile.dart b/mobile/lib/features/channels/channels_page/channel_tile.dart index 5443e6b9d0a..f7344086e50 100644 --- a/mobile/lib/features/channels/channels_page/channel_tile.dart +++ b/mobile/lib/features/channels/channels_page/channel_tile.dart @@ -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) diff --git a/mobile/lib/features/channels/channels_page/community.dart b/mobile/lib/features/channels/channels_page/community.dart index 73935f46314..4c8b26ca4b1 100644 --- a/mobile/lib/features/channels/channels_page/community.dart +++ b/mobile/lib/features/channels/channels_page/community.dart @@ -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, ), - ], + ), + ), ), ); } diff --git a/mobile/lib/features/channels/channels_page/sections.dart b/mobile/lib/features/channels/channels_page/sections.dart index b6d04cdb9b7..6e17845d744 100644 --- a/mobile/lib/features/channels/channels_page/sections.dart +++ b/mobile/lib/features/channels/channels_page/sections.dart @@ -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: [ diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index 79ccf319992..c691310df5d 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -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(), + ChannelsPage( + settingsPageBuilder: settingsPageBuilder, + tabReselection: homeReselection, + onSettingsTransitionProgress: (progress) { + if (settingsTransitionProgress.value != progress) { + settingsTransitionProgress.value = progress; + } + }, + ), + ActivityPage(tabReselection: activityReselection), + SearchPage(tabReselection: searchReselection), ]; - 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), + 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( + 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, + ), + ), + ], ), ), - Align( - alignment: Alignment.bottomCenter, - child: IgnorePointer( - child: MobileTabFooterBackdrop( - height: mobileTabFooterBackdropHeight(context), - ), - ), + 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, ), - Positioned.fill( - child: ChannelQuickActionsLauncher( - visible: tabIndex.value == 0, - navigationBarHeight: HomePage._tabBarHeight, - navigationBarBottomGap: HomePage._tabBarBottomGap, - navigationBarWidth: navigationBarWidth, - systemBottomInset: systemBottomInset, - rightInset: Grid.sm, + ), + 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, ), - ), - ], + ); + }, ), - ), - 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)); - } - }, - destinations: _destinations, - ), + ], ); } } diff --git a/mobile/lib/features/profile/profile_avatar.dart b/mobile/lib/features/profile/profile_avatar.dart index fb60fae1254..087cc909aa2 100644 --- a/mobile/lib/features/profile/profile_avatar.dart +++ b/mobile/lib/features/profile/profile_avatar.dart @@ -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( diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index 280ce75e4f7..66fceebb27b 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -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( /// [appLifecycleProvider] to send "away" when backgrounded. class PresenceNotifier extends AsyncNotifier { static const _heartbeatInterval = Duration(seconds: 60); + static const _preferenceKeyPrefix = 'buzz_presence_preference_'; Timer? _heartbeatTimer; + String? _preferencePubkey; + String? _manualPresence; @override Future 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 { _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 { }); } + /// 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 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. diff --git a/mobile/lib/features/profile/settings_profile_header.dart b/mobile/lib/features/profile/settings_profile_header.dart index 23816ccaa90..d33e0ce2f7a 100644 --- a/mobile/lib/features/profile/settings_profile_header.dart +++ b/mobile/lib/features/profile/settings_profile_header.dart @@ -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 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( + 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( + 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 { diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index 65fc12dfd97..b97d10f55f6 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -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? 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), + ), + 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( + begin: const Offset(0.35, 0), + end: Offset.zero, + ).animate(curvedAnimation), + child: child, ), - 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, + ), + ); + }, + 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, + ), + ), + ), + ), ), ), - 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); - }, - ), + ) + : 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( - 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( + 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, + ), + ), + ], + ), + ), ), ), ], diff --git a/mobile/lib/features/search/search_page/motion_field.dart b/mobile/lib/features/search/search_page/motion_field.dart new file mode 100644 index 00000000000..96a23849a15 --- /dev/null +++ b/mobile/lib/features/search/search_page/motion_field.dart @@ -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 onChanged; + final ValueChanged 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, + ), + ), + ), + ), + ), + ], + ), + ); +} diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index f089f639150..b86a28b88d5 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -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(), diff --git a/mobile/lib/shared/theme/buzz_theme.dart b/mobile/lib/shared/theme/buzz_theme.dart index 1214b97790b..92cec8ef34a 100644 --- a/mobile/lib/shared/theme/buzz_theme.dart +++ b/mobile/lib/shared/theme/buzz_theme.dart @@ -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()?.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); diff --git a/mobile/lib/shared/widgets/anchored_popover_menu.dart b/mobile/lib/shared/widgets/anchored_popover_menu.dart index 7188e021f99..94e72986825 100644 --- a/mobile/lib/shared/widgets/anchored_popover_menu.dart +++ b/mobile/lib/shared/widgets/anchored_popover_menu.dart @@ -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 extends PopupRoute { ).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, }; diff --git a/mobile/lib/shared/widgets/frosted_app_bar.dart b/mobile/lib/shared/widgets/frosted_app_bar.dart index 8e8a8b1ce13..2a2993f330f 100644 --- a/mobile/lib/shared/widgets/frosted_app_bar.dart +++ b/mobile/lib/shared/widgets/frosted_app_bar.dart @@ -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 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); } } diff --git a/mobile/lib/shared/widgets/frosted_scaffold.dart b/mobile/lib/shared/widgets/frosted_scaffold.dart index 9772fd02467..7243d662768 100644 --- a/mobile/lib/shared/widgets/frosted_scaffold.dart +++ b/mobile/lib/shared/widgets/frosted_scaffold.dart @@ -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', + body: Stack(children: _stackChildren()), + ); + } + + List _stackChildren() { + final backdrop = backgroundGradient == null + ? const [] + : [ + Positioned.fill( + child: _PinnedGradientBackground(gradient: backgroundGradient!), ), - child: body, - ), - appBar, - ], + ]; + 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), + ); +} diff --git a/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart b/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart index d972b8184fd..36e0ce573e5 100644 --- a/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart +++ b/mobile/lib/shared/widgets/mobile_tab_footer_backdrop.dart @@ -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 stops = const [0, 0.5, 1], - List opacities = const [0, 0.75, 1], + List stops = const [0, 0.18, 0.38, 0.6, 0.8, 1], + List 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 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, ), ), ), diff --git a/mobile/test/features/activity/activity_page_test.dart b/mobile/test/features/activity/activity_page_test.dart index 7e5aa0f4dd3..8619a513d9e 100644 --- a/mobile/test/features/activity/activity_page_test.dart +++ b/mobile/test/features/activity/activity_page_test.dart @@ -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? channels, TextScaler? textScaler, EdgeInsets mediaPadding = EdgeInsets.zero, + ValueListenable? 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(find.byType(FrostedAppBar)); + final appBar = tester.widget( + 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( + 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(find.byType(ListView)); - expect(list.padding, const EdgeInsets.fromLTRB(0, Grid.xxs, 0, 96)); + final padding = tester.widget( + 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( + 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( diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 77dc65fbc5e..f925724b543 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -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 communityIcons = const {}, ValueChanged? onCommunityIconLoad, TextScaler textScaler = TextScaler.noScaling, + Gradient? topSectionGradient, + ValueChanged? onSettingsTransitionProgress, + ValueListenable? 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(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( + 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(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( + 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( + 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( + 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( + 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(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(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( + 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>())); + expect(route?.opaque, isFalse); + }); + + testWidgets('reports Settings progress in both directions', (tester) async { + final progress = []; + 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(transition).opacity.value, 0.8); + + await tester.pump(const Duration(milliseconds: 95)); + expect( + tester.widget(transition).opacity.value, + inExclusiveRange(0.8, 1), + ); + await tester.pumpAndSettle(); + expect(tester.widget(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(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 = []; + 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(find.text('general')).style?.fontWeight, FontWeight.w700, ); + expect( + tester.widget(find.text('general')).style?.color, + Theme.of(tester.element(find.text('general'))).colorScheme.onSurface, + ); readState.markContextRead('1', 20); await tester.pump(); diff --git a/mobile/test/features/home/home_page_test.dart b/mobile/test/features/home/home_page_test.dart index f22c2585644..3a54332c982 100644 --- a/mobile/test/features/home/home_page_test.dart +++ b/mobile/test/features/home/home_page_test.dart @@ -11,13 +11,14 @@ void main() { Future 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(backdrop).decoration as BoxDecoration; + expect(decoration.gradient, gradient); + expect( + find.byKey(const ValueKey('home-settings-transition-scale')), + findsOneWidget, + ); + expect( + tester + .widget( + find.byKey(const ValueKey('home-settings-transition-scale')), + ) + .transform + .getMaxScaleOnAxis(), + 1, + ); + expect( + tester + .widget( + find.byKey(const ValueKey('home-settings-transition-opacity')), + ) + .opacity, + 1, + ); + }); + testWidgets('gives selection haptics only when the tab changes', ( tester, ) async { diff --git a/mobile/test/features/profile/profile_provider_test.dart b/mobile/test/features/profile/profile_provider_test.dart new file mode 100644 index 00000000000..9130153edb1 --- /dev/null +++ b/mobile/test/features/profile/profile_provider_test.dart @@ -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 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; +} diff --git a/mobile/test/features/profile/settings_profile_header_test.dart b/mobile/test/features/profile/settings_profile_header_test.dart index dbf27b5c818..a3c7b3dd9ac 100644 --- a/mobile/test/features/profile/settings_profile_header_test.dart +++ b/mobile/test/features/profile/settings_profile_header_test.dart @@ -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(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(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( + 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 build() async => _status; } + +class _FakePresenceNotifier extends PresenceNotifier { + _FakePresenceNotifier(this._presence); + + final String _presence; + final List selected = []; + + @override + Future build() async => _presence; + + @override + Future setPresence(String status) async { + selected.add(status); + } +} diff --git a/mobile/test/features/search/search_page_test.dart b/mobile/test/features/search/search_page_test.dart index c3db833fc17..55645313c95 100644 --- a/mobile/test/features/search/search_page_test.dart +++ b/mobile/test/features/search/search_page_test.dart @@ -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(find.byType(TextField)).focusNode?.hasFocus, + isTrue, + ); + + final focusNode = tester + .widget(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( + 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(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(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( - find.byKey(const Key('search-field')), + final prompt = tester.widget( + 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(searchField).decoration?.hintText, - 'Search messages, channels, people\u2026', - ); - expect( - tester.widget(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(searchField).decoration?.hintText, isNull); + final input = tester.widget(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( 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( 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( + find.ancestor(of: movingIcon, matching: find.byType(AnimatedScale)), + ); + final movingField = tester.widget( + 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)); - await tester.pumpAndSettle(); - final input = tester.widget(searchField); - expect(input.controller?.text, isEmpty); - expect(input.focusNode?.hasFocus, isFalse); + final titleOpacity = tester.widget( + 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( - input.decoration?.hintText, - 'Search messages, channels, people\u2026', + tester + .widget( + find.byKey(const Key('search-header-title-opacity')), + ) + .opacity, + 1, ); + await tester.pumpAndSettle(); + 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', ( diff --git a/mobile/test/shared/theme/buzz_theme_test.dart b/mobile/test/shared/theme/buzz_theme_test.dart index 613fec14678..bc9992be909 100644 --- a/mobile/test/shared/theme/buzz_theme_test.dart +++ b/mobile/test/shared/theme/buzz_theme_test.dart @@ -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', () { diff --git a/mobile/test/shared/widgets/mobile_tab_footer_backdrop_test.dart b/mobile/test/shared/widgets/mobile_tab_footer_backdrop_test.dart index e13d17a65eb..5d6882fddf0 100644 --- a/mobile/test/shared/widgets/mobile_tab_footer_backdrop_test.dart +++ b/mobile/test/shared/widgets/mobile_tab_footer_backdrop_test.dart @@ -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); }); }