diff --git a/mobile/ios/Runner/NativeEmojiPickerModel.swift b/mobile/ios/Runner/NativeEmojiPickerModel.swift index 75bf46446cd..9df1d7d0146 100644 --- a/mobile/ios/Runner/NativeEmojiPickerModel.swift +++ b/mobile/ios/Runner/NativeEmojiPickerModel.swift @@ -392,6 +392,22 @@ struct NativeEmojiSectionOffsetsKey: PreferenceKey { } } +/// Keeps scroll-driven category selection out of the picker view's own state. +/// The category buttons observe this object directly, so updating the rail does +/// not invalidate and rebuild the scroll container underneath an active drag. +final class NativeEmojiCategorySelection: ObservableObject { + @Published private(set) var selectedSectionID: String? + + init(initialSectionID: String?) { + selectedSectionID = initialSectionID + } + + func select(_ sectionID: String?) { + guard sectionID != selectedSectionID else { return } + selectedSectionID = sectionID + } +} + /// Pure selection logic: the highlighted section is the last one whose header /// has scrolled to or above the top of the viewport. Extracted so the /// scroll-tracking behaviour can be unit-tested without a live scroll view. @@ -401,7 +417,8 @@ enum NativeEmojiCategoryTracker { offsets: [String: CGFloat], viewportTop: CGFloat, viewportBottom: CGFloat? = nil, - contentBottom: CGFloat? = nil + contentBottom: CGFloat? = nil, + currentSelection: String? = nil ) -> String? { // At the clamped bottom of an overflowing list, a final section shorter // than the viewport can never scroll its header to the top, so the @@ -432,6 +449,44 @@ enum NativeEmojiCategoryTracker { break } } - return selected ?? order.first + + let candidate = selected ?? currentSelection ?? order.first + guard + let candidate, + let currentSelection, + let candidateIndex = order.firstIndex(of: candidate), + let currentIndex = order.firstIndex(of: currentSelection), + candidateIndex < currentIndex + else { + return candidate + } + + // Pinned headers can briefly report competing or incomplete positions as + // one section pushes another off the top. Once the next section is active, + // retain it through that small boundary jitter. A real upward scroll moves + // its header clearly back into the viewport and then releases the latch. + guard let currentTop = offsets[currentSelection] else { + // A LazyVStack can discard the old pinned header after a fast upward + // fling. Once an earlier header is a valid candidate, absence of the old + // header is evidence to release the latch rather than retain it forever. + return candidate + } + if currentTop <= viewportTop + 8 { + return currentSelection + } + + // The final, short section is selected from the content boundary rather + // than its header. Keep that bottom selection stable until the content end + // has visibly moved away from the viewport edge. + if currentSelection == order.last { + guard let viewportBottom, let contentBottom else { + return currentSelection + } + if contentBottom <= viewportBottom + 8 { + return currentSelection + } + } + + return candidate } } diff --git a/mobile/ios/Runner/NativeEmojiPickerView.swift b/mobile/ios/Runner/NativeEmojiPickerView.swift index a71de4b815a..988b55bb245 100644 --- a/mobile/ios/Runner/NativeEmojiPickerView.swift +++ b/mobile/ios/Runner/NativeEmojiPickerView.swift @@ -10,7 +10,7 @@ struct NativeEmojiPickerView: View { let onClose: () -> Void @State private var query = "" - @State private var selectedSectionID: String? + @State private var categorySelection: NativeEmojiCategorySelection @State private var selectedSkinTone: Int private let columns = Array( @@ -33,6 +33,11 @@ struct NativeEmojiPickerView: View { self.onSelect = onSelect self.onSkinToneChanged = onSkinToneChanged self.onClose = onClose + _categorySelection = State( + initialValue: NativeEmojiCategorySelection( + initialSectionID: data.sections.first?.id + ) + ) _selectedSkinTone = State( initialValue: validNativeEmojiSkinTone(initialSkinTone) ) @@ -49,9 +54,6 @@ struct NativeEmojiPickerView: View { pickerContent } .background(Color(uiColor: appearance.surface)) - .onAppear { - selectedSectionID = data.sections.first?.id - } } } @@ -103,34 +105,17 @@ struct NativeEmojiPickerView: View { private func categoryRail(_ proxy: ScrollViewProxy) -> some View { HStack(spacing: 0) { ForEach(data.sections) { section in - Button { - selectedSectionID = section.id - withAnimation(.easeOut(duration: 0.24)) { - proxy.scrollTo("section-\(section.id)", anchor: .top) - } - } label: { - Image(systemName: section.systemImage) - .font(.system(size: 18, weight: .medium)) - .foregroundStyle( - Color( - uiColor: selectedSectionID == section.id - ? appearance.accent : appearance.secondaryText - ) - ) - .frame(maxWidth: .infinity) - .frame(height: 36) - .background( - selectedSectionID == section.id - ? Color(uiColor: appearance.control) : Color.clear, - in: Circle() - ) + NativeEmojiCategoryButton( + section: section, + appearance: appearance, + selection: categorySelection + ) { + categorySelection.select(section.id) + // Category navigation is a frequent shortcut. Keeping it immediate + // means an in-progress proxy animation can never fight a finger drag. + proxy.scrollTo("section-\(section.id)", anchor: .top) } .frame(maxWidth: .infinity) - .buttonStyle(.plain) - .accessibilityLabel(section.title) - .accessibilityAddTraits( - selectedSectionID == section.id ? .isSelected : [] - ) } Divider() .frame(height: 24) @@ -302,12 +287,15 @@ struct NativeEmojiPickerView: View { .scrollDismissesKeyboard(.interactively) .onPreferenceChange(NativeEmojiSectionOffsetsKey.self) { offsets in guard tracksSelection else { return } - selectedSectionID = NativeEmojiCategoryTracker.selectedSectionID( - order: data.sections.map(\.id), - offsets: offsets, - viewportTop: 0, - viewportBottom: offsets[nativeEmojiViewportBottomKey], - contentBottom: offsets[nativeEmojiContentBottomKey] + categorySelection.select( + NativeEmojiCategoryTracker.selectedSectionID( + order: data.sections.map(\.id), + offsets: offsets, + viewportTop: 0, + viewportBottom: offsets[nativeEmojiViewportBottomKey], + contentBottom: offsets[nativeEmojiContentBottomKey], + currentSelection: categorySelection.selectedSectionID + ) ) } } @@ -381,6 +369,38 @@ struct NativeEmojiPickerView: View { } } +/// Observes only the rail selection. Keeping this in a leaf view prevents a +/// scroll-frame highlight update from rebuilding the picker grid itself. +private struct NativeEmojiCategoryButton: View { + let section: NativeEmojiSection + let appearance: NativeEmojiPickerAppearance + @ObservedObject var selection: NativeEmojiCategorySelection + let onSelect: () -> Void + + var body: some View { + let isSelected = selection.selectedSectionID == section.id + Button(action: onSelect) { + Image(systemName: section.systemImage) + .font(.system(size: 18, weight: .medium)) + .foregroundStyle( + Color( + uiColor: isSelected + ? appearance.accent : appearance.secondaryText + ) + ) + .frame(maxWidth: .infinity) + .frame(height: 36) + .background( + isSelected ? Color(uiColor: appearance.control) : Color.clear, + in: Circle() + ) + } + .buttonStyle(.plain) + .accessibilityLabel(section.title) + .accessibilityAddTraits(isSelected ? .isSelected : []) + } +} + struct NativeEmojiRemoteImage: View { let url: URL let fallbackColor: UIColor diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index e4d56c4ed5a..01f4c89b2cc 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -522,6 +522,50 @@ class RunnerTests: XCTestCase { ) } + func testCategoryTrackerDoesNotFlickerBackAtPinnedHeaderBoundary() { + let order = ["people", "nature", "flags"] + + // Nature has just become selected. A subsequent layout pass can briefly + // report its pinned header a couple of points below the boundary while the + // previous header is still pinned. Keep Nature selected through that + // transient frame instead of alternating the category rail. + XCTAssertEqual( + NativeEmojiCategoryTracker.selectedSectionID( + order: order, + offsets: ["people": 0, "nature": 2, "flags": 400], + viewportTop: 0, + currentSelection: "nature" + ), + "nature" + ) + } + + func testCategoryTrackerReleasesBoundaryLatchOnRealUpwardScroll() { + let order = ["people", "nature", "flags"] + + XCTAssertEqual( + NativeEmojiCategoryTracker.selectedSectionID( + order: order, + offsets: ["people": 0, "nature": 24, "flags": 424], + viewportTop: 0, + currentSelection: "nature" + ), + "people" + ) + } + + func testCategoryTrackerReleasesSelectionWhenOldHeaderIsMissing() { + XCTAssertEqual( + NativeEmojiCategoryTracker.selectedSectionID( + order: ["people", "nature", "flags"], + offsets: ["people": 0, "flags": 400], + viewportTop: 0, + currentSelection: "nature" + ), + "people" + ) + } + func testRemoteEmojiLoaderLimitsConcurrentDownloads() async throws { let maximumConcurrentDownloads = 3 let taskCount = 8 diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 83f32e2827d..224094df145 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -46,6 +46,7 @@ import 'dm_channel_labels.dart'; import 'ephemeral_channel_display.dart'; import 'ime_metrics_settle_observer.dart'; import 'jump_to_latest_button.dart'; +import 'jump_to_latest_switcher.dart'; import 'members_sheet.dart'; import 'message_actions.dart'; import 'message_long_press_region.dart'; diff --git a/mobile/lib/features/channels/channel_detail_page/message_list.dart b/mobile/lib/features/channels/channel_detail_page/message_list.dart index 06308063ab0..781b96c33ac 100644 --- a/mobile/lib/features/channels/channel_detail_page/message_list.dart +++ b/mobile/lib/features/channels/channel_detail_page/message_list.dart @@ -341,8 +341,8 @@ class _MessageList extends HookConsumerWidget { await itemScrollController.scrollTo( index: 0, alignment: latestAlignment(), - duration: const Duration(milliseconds: 220), - curve: Curves.easeOutCubic, + duration: jumpToLatestScrollDuration, + curve: jumpToLatestScrollCurve, ); if (context.mounted && !hasUserScrolled.value) { isAtLatest.value = true; @@ -882,32 +882,10 @@ class _MessageList extends HookConsumerWidget { right: 0, bottom: navigationBottomInset + Grid.xs, child: Center( - child: AnimatedSwitcher( - key: const ValueKey('channel-jump-to-latest-switcher'), - duration: MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : const Duration(milliseconds: 180), - reverseDuration: MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : const Duration(milliseconds: 160), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - transitionBuilder: (child, animation) => FadeTransition( - opacity: animation, - child: ScaleTransition( - scale: _JumpToLatestScaleAnimation(animation), - alignment: Alignment.bottomCenter, - child: child, - ), - ), - child: !isJumpToLatestVisible.value - ? const SizedBox.shrink( - key: ValueKey('channel-jump-to-latest-hidden'), - ) - : JumpToLatestButton( - key: const ValueKey('channel-jump-to-latest'), - onPressed: scrollToLatest, - ), + child: JumpToLatestSwitcher( + id: 'channel', + visible: isJumpToLatestVisible.value, + onPressed: scrollToLatest, ), ), ), @@ -915,16 +893,3 @@ class _MessageList extends HookConsumerWidget { ); } } - -class _JumpToLatestScaleAnimation extends Animation - with AnimationWithParentMixin { - @override - final Animation parent; - - _JumpToLatestScaleAnimation(this.parent); - - @override - double get value => parent.status == AnimationStatus.reverse - ? parent.value - : 0.92 + (0.08 * parent.value); -} diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 7f990a98c5a..d50ed8c46e8 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -86,7 +86,8 @@ const double _kChannelLabelInset = /// sections while the labels stay on [_kChannelLabelInset]. const double _kDmAvatarSize = _kChannelIconSize; -const double _kTopSectionAvatarSize = 40.0; +const double _kTopSectionCommunityAvatarSize = 40.0; +const double _kTopSectionProfileAvatarSize = 36.0; const double _kTopSectionBottomPadding = Grid.xxs; /// The top section's avatars are 40dp circles, which fill their box edge to @@ -344,7 +345,7 @@ class ChannelsPage extends HookConsumerWidget { height: Grid.xl, child: Center( child: ProfileAvatar( - size: _kTopSectionAvatarSize, + size: _kTopSectionProfileAvatarSize, onTap: () { unawaited(HapticFeedback.lightImpact()); final route = _SettingsPageRoute( diff --git a/mobile/lib/features/channels/channels_page/community.dart b/mobile/lib/features/channels/channels_page/community.dart index bd64ccc7260..f5a42ae2131 100644 --- a/mobile/lib/features/channels/channels_page/community.dart +++ b/mobile/lib/features/channels/channels_page/community.dart @@ -483,7 +483,7 @@ class _CommunityAvatar extends ConsumerWidget { super.key, required this.name, this.relayUrl, - this.size = _kTopSectionAvatarSize, + this.size = _kTopSectionCommunityAvatarSize, }); @override diff --git a/mobile/lib/features/channels/emoji_picker.dart b/mobile/lib/features/channels/emoji_picker.dart index b66d526e7ee..22b3ed03e01 100644 --- a/mobile/lib/features/channels/emoji_picker.dart +++ b/mobile/lib/features/channels/emoji_picker.dart @@ -183,11 +183,9 @@ class _EmojiPickerContent extends HookConsumerWidget { activeSection.value = index; if (!scrollController.hasClients) return; final max = scrollController.position.maxScrollExtent; - scrollController.animateTo( - offsets[index].clamp(0.0, max), - duration: const Duration(milliseconds: 240), - curve: Curves.easeOutCubic, - ); + // The rail is a frequent navigation shortcut. An instant jump cannot + // remain active and pull against a drag that begins immediately after it. + scrollController.jumpTo(offsets[index].clamp(0.0, max)); } // Recompute only when the query or the underlying sets change — scanning diff --git a/mobile/lib/features/channels/initial_thread_tail_settle.dart b/mobile/lib/features/channels/initial_thread_tail_settle.dart index 2e41d18e5b0..b85a06c840e 100644 --- a/mobile/lib/features/channels/initial_thread_tail_settle.dart +++ b/mobile/lib/features/channels/initial_thread_tail_settle.dart @@ -36,12 +36,14 @@ class InitialThreadTailSettle { required int? targetIndex, required double hiddenTopFraction, required double hiddenBottomFraction, + required VoidCallback onSettled, }) { if (_isComplete) return; final generation = ++_generation; if (targetIndex == null) { _isComplete = true; + onSettled(); return; } @@ -67,8 +69,13 @@ class InitialThreadTailSettle { // head. A clipped tail still takes the measured correction path. if (targetIsFullyVisible) { _isComplete = true; + onSettled(); return; } + // This package uses a temporary second list for distant targets. The + // caller keeps the hydrated viewport unpainted until this one-frame + // placement completes, so that implementation detail cannot appear as + // an entry bounce. controller .scrollTo( index: targetIndex, @@ -76,7 +83,9 @@ class InitialThreadTailSettle { duration: const Duration(milliseconds: 1), ) .whenComplete(() { - if (generation == _generation) _isComplete = true; + if (generation != _generation) return; + _isComplete = true; + onSettled(); }); }); // A post-frame callback does not itself request the frame in which it diff --git a/mobile/lib/features/channels/jump_to_latest_button.dart b/mobile/lib/features/channels/jump_to_latest_button.dart index cb6b703a353..00f1ca65926 100644 --- a/mobile/lib/features/channels/jump_to_latest_button.dart +++ b/mobile/lib/features/channels/jump_to_latest_button.dart @@ -6,21 +6,30 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.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'; import '../../shared/theme/theme.dart'; +import 'message_action_backdrop_state.dart'; + +/// Shared channel/thread motion for an explicit return-to-latest action. +const jumpToLatestScrollDuration = Duration(milliseconds: 220); +const jumpToLatestScrollCurve = Curves.easeOutCubic; /// Compact conversation control that returns a detached timeline to its tail. -class JumpToLatestButton extends HookConsumerWidget { +class JumpToLatestButton extends HookWidget { + final String id; final VoidCallback onPressed; - const JumpToLatestButton({required this.onPressed, super.key}); + const JumpToLatestButton({ + required this.onPressed, + this.id = 'channel', + super.key, + }); static const _iosViewType = 'buzz/jump_to_latest_glass'; @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final nativeChannel = useState(null); final onPressedRef = useRef(onPressed)..value = onPressed; final brightness = context.theme.brightness.name; @@ -45,6 +54,42 @@ class JumpToLatestButton extends HookConsumerWidget { final borderColor = context.colors.onSurface.withValues(alpha: 0.08); final usesNativeIosGlass = defaultTargetPlatform == TargetPlatform.iOS; + Widget buildFlutterSurface() { + return Material( + color: Colors.transparent, + child: InkResponse( + containedInkWell: true, + customBorder: const CircleBorder(), + onTap: onPressed, + radius: Grid.sm, + child: Align( + key: ValueKey('$id-jump-to-latest-visual-anchor'), + alignment: Alignment.bottomCenter, + child: ClipOval( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + key: ValueKey('$id-jump-to-latest-surface'), + width: Grid.lg, + height: Grid.lg, + decoration: BoxDecoration( + color: context.colors.surface.withValues(alpha: 0.72), + shape: BoxShape.circle, + border: Border.all(color: borderColor), + ), + child: Icon( + LucideIcons.arrowDown, + size: Grid.gutter, + color: context.colors.onSurfaceVariant, + ), + ), + ), + ), + ), + ), + ); + } + return Semantics( button: true, label: 'Jump to latest message', @@ -53,9 +98,16 @@ class JumpToLatestButton extends HookConsumerWidget { message: 'Jump to latest message', child: SizedBox.square( dimension: Grid.xl, - child: usesNativeIosGlass - ? UiKitView( - key: const ValueKey('channel-jump-to-latest-ios-glass'), + child: ValueListenableBuilder( + valueListenable: messageActionBackdropActive, + builder: (context, backdropActive, _) { + if (!usesNativeIosGlass || backdropActive) { + return buildFlutterSurface(); + } + return SizedBox.expand( + key: ValueKey('$id-jump-to-latest-surface'), + child: UiKitView( + key: ValueKey('$id-jump-to-latest-ios-glass'), viewType: _iosViewType, hitTestBehavior: PlatformViewHitTestBehavior.opaque, creationParams: {'brightness': brightness}, @@ -65,46 +117,10 @@ class JumpToLatestButton extends HookConsumerWidget { '$_iosViewType/$viewId', ); }, - ) - : Material( - color: Colors.transparent, - child: InkResponse( - containedInkWell: true, - customBorder: const CircleBorder(), - onTap: onPressed, - radius: Grid.sm, - child: Align( - key: const ValueKey( - 'channel-jump-to-latest-visual-anchor', - ), - alignment: Alignment.bottomCenter, - child: ClipOval( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), - child: Container( - key: const ValueKey( - 'channel-jump-to-latest-surface', - ), - width: Grid.lg, - height: Grid.lg, - decoration: BoxDecoration( - color: context.colors.surface.withValues( - alpha: 0.72, - ), - shape: BoxShape.circle, - border: Border.all(color: borderColor), - ), - child: Icon( - LucideIcons.arrowDown, - size: Grid.gutter, - color: context.colors.onSurfaceVariant, - ), - ), - ), - ), - ), - ), ), + ); + }, + ), ), ), ); diff --git a/mobile/lib/features/channels/jump_to_latest_switcher.dart b/mobile/lib/features/channels/jump_to_latest_switcher.dart new file mode 100644 index 00000000000..916150dfda9 --- /dev/null +++ b/mobile/lib/features/channels/jump_to_latest_switcher.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +import 'jump_to_latest_button.dart'; + +/// Shared channel/thread visibility transition for [JumpToLatestButton]. +class JumpToLatestSwitcher extends StatelessWidget { + final String id; + final bool visible; + final VoidCallback onPressed; + + const JumpToLatestSwitcher({ + required this.id, + required this.visible, + required this.onPressed, + super.key, + }); + + @override + Widget build(BuildContext context) { + final reduceMotion = MediaQuery.disableAnimationsOf(context); + return AnimatedSwitcher( + key: ValueKey('$id-jump-to-latest-switcher'), + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 180), + reverseDuration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 160), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: ScaleTransition( + scale: _JumpToLatestScaleAnimation(animation), + alignment: Alignment.bottomCenter, + child: child, + ), + ), + child: visible + ? JumpToLatestButton( + key: ValueKey('$id-jump-to-latest'), + id: id, + onPressed: onPressed, + ) + : SizedBox.shrink(key: ValueKey('$id-jump-to-latest-hidden')), + ); + } +} + +class _JumpToLatestScaleAnimation extends Animation + with AnimationWithParentMixin { + @override + final Animation parent; + + _JumpToLatestScaleAnimation(this.parent); + + @override + double get value => parent.status == AnimationStatus.reverse + ? parent.value + : 0.92 + (0.08 * parent.value); +} diff --git a/mobile/lib/features/channels/latest_message_button.dart b/mobile/lib/features/channels/latest_message_button.dart deleted file mode 100644 index 81abc8034d9..00000000000 --- a/mobile/lib/features/channels/latest_message_button.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'dart:ui'; - -import 'package:flutter/material.dart'; -import 'package:lucide_icons_flutter/lucide_icons.dart'; - -import '../../shared/theme/theme.dart'; - -/// Shared channel/thread control for returning to the newest message. -class LatestMessageButton extends StatelessWidget { - /// Returns the message list to its newest item. - final VoidCallback onPressed; - - /// Optional key for inspecting or measuring the decorated glass surface. - final Key? surfaceKey; - - /// Creates the shared control for returning to the newest message. - const LatestMessageButton({ - required this.onPressed, - this.surfaceKey, - super.key, - }); - - @override - Widget build(BuildContext context) { - final borderRadius = BorderRadius.circular(Radii.full); - return Semantics( - button: true, - child: ClipRRect( - borderRadius: borderRadius, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), - child: Container( - key: surfaceKey, - decoration: BoxDecoration( - color: context.colors.surface.withValues(alpha: 0.5), - borderRadius: borderRadius, - border: Border.all( - color: Colors.black.withValues(alpha: 0.04), - width: 1, - ), - ), - child: Material( - type: MaterialType.transparency, - child: InkWell( - onTap: onPressed, - borderRadius: borderRadius, - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: Grid.gutter, - vertical: Grid.xxs, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - LucideIcons.arrowDown, - size: 16, - color: context.colors.onSurface, - ), - const SizedBox(width: Grid.half), - Text( - 'Latest', - style: context.textTheme.labelLarge?.copyWith( - color: context.colors.onSurface, - ), - ), - ], - ), - ), - ), - ), - ), - ), - ), - ); - } -} diff --git a/mobile/lib/features/channels/message_action_backdrop_state.dart b/mobile/lib/features/channels/message_action_backdrop_state.dart new file mode 100644 index 00000000000..9bd6a7aadde --- /dev/null +++ b/mobile/lib/features/channels/message_action_backdrop_state.dart @@ -0,0 +1,8 @@ +import 'package:flutter/foundation.dart'; + +/// Whether a message-action backdrop is covering the conversation timeline. +/// +/// iOS platform views sit outside Flutter's composited scene, so a Flutter +/// backdrop filter cannot blur them. Glass controls listen to this value and +/// temporarily render their Flutter fallback while the backdrop is visible. +final ValueNotifier messageActionBackdropActive = ValueNotifier(false); diff --git a/mobile/lib/features/channels/message_actions.dart b/mobile/lib/features/channels/message_actions.dart index da2050802e3..53dd4c30f65 100644 --- a/mobile/lib/features/channels/message_actions.dart +++ b/mobile/lib/features/channels/message_actions.dart @@ -29,6 +29,7 @@ import '../../shared/reminders/remind_me_later_sheet.dart'; import '../../shared/reminders/reminder_service.dart'; import 'channel_management_provider.dart'; import 'emoji_picker.dart'; +import 'message_action_backdrop_state.dart'; import 'reaction_row.dart'; import 'recent_emoji_provider.dart'; import '../../shared/read_state/message_read_state.dart'; @@ -41,11 +42,18 @@ import 'timeline_message.dart'; part 'message_actions/reaction_popover.dart'; part 'message_actions/quick_reaction_row.dart'; part 'message_actions/message_action_popover.dart'; +part 'message_actions/message_action_popover_widgets.dart'; part 'message_actions/message_reaction_tray.dart'; /// Preview length for reminder targets — matches desktop's /// `msg.body.slice(0, 100)`. const _reminderPreviewLength = 100; +const _messageActionBackdropBlurSigma = 20.0; +const _messageActionBackdropTintOpacity = 0.10; +final _messageActionBackdropFilter = ImageFilter.blur( + sigmaX: _messageActionBackdropBlurSigma, + sigmaY: _messageActionBackdropBlurSigma, +); /// Presents the actions for [message] as an anchored popover when both /// [anchorRect] and [captureAnchorSnapshot] are supplied, otherwise as a sheet. diff --git a/mobile/lib/features/channels/message_actions/message_action_popover.dart b/mobile/lib/features/channels/message_actions/message_action_popover.dart index 350afff82b3..f56fb7d3acb 100644 --- a/mobile/lib/features/channels/message_actions/message_action_popover.dart +++ b/mobile/lib/features/channels/message_actions/message_action_popover.dart @@ -15,7 +15,7 @@ const _iosNativeMessageActionSurfaceChannel = MethodChannel( 'buzz/native_message_action_surface', ); -bool _messageActionsPresentationInFlight = false; +bool _messageActionPresentationInFlight = false; bool? _iosNativeMessageActionSurfaceSupported; Future _supportsIosNativeMessageActionSurface() async { @@ -113,8 +113,8 @@ Future _showMessageActionsPopover({ required VoidCallback? restoreComposerFocus, required bool shouldRestoreComposerFocus, }) async { - if (_messageActionsPresentationInFlight) return true; - _messageActionsPresentationInFlight = true; + if (_messageActionPresentationInFlight) return true; + _messageActionPresentationInFlight = true; try { final actions = _buildPopoverMessageActions( @@ -153,6 +153,16 @@ Future _showMessageActionsPopover({ final reduceMotion = MediaQuery.disableAnimationsOf(context); if (shouldRestoreComposerFocus) composerFocusNode!.unfocus(); + messageActionBackdropActive.value = true; + // Give the timeline one frame to replace UIKit glass platform views with + // composable Flutter stand-ins before the full-screen blur is presented. + await WidgetsBinding.instance.endOfFrame; + if (!context.mounted) { + messageActionBackdropActive.value = false; + snapshot.dispose(); + return false; + } + String? selectedActionId; final dialogRoute = RawDialogRoute( barrierDismissible: true, @@ -188,6 +198,7 @@ Future _showMessageActionsPopover({ selectedActionId = await popResult; } finally { if (routePushed) await dialogRoute.completed; + messageActionBackdropActive.value = false; snapshot.dispose(); if (context.mounted) onPopoverDismissed?.call(); } @@ -204,7 +215,7 @@ Future _showMessageActionsPopover({ } return true; } finally { - _messageActionsPresentationInFlight = false; + _messageActionPresentationInFlight = false; } } @@ -640,10 +651,8 @@ class _MessageActionsPopover extends HookWidget { children: [ Positioned.fill( child: BackdropFilter( - filter: ui.ImageFilter.blur( - sigmaX: defaultTargetPlatform == TargetPlatform.iOS ? 4 : 8, - sigmaY: defaultTargetPlatform == TargetPlatform.iOS ? 4 : 8, - ), + key: const ValueKey('message-actions-backdrop-filter'), + filter: _messageActionBackdropFilter, child: AnimatedBuilder( animation: animation, builder: (context, child) { @@ -653,7 +662,7 @@ class _MessageActionsPopover extends HookWidget { return ColoredBox( key: const ValueKey('message-actions-background'), color: context.colors.inverseSurface.withValues( - alpha: 0.14 * opacity, + alpha: _messageActionBackdropTintOpacity * opacity, ), ); }, @@ -777,223 +786,3 @@ class _MessageActionsPopover extends HookWidget { ); } } - -class _MessageActionPreviewVisibility extends HookWidget { - final bool visible; - final ValueChanged? onChanged; - final Widget child; - const _MessageActionPreviewVisibility({ - required this.visible, - required this.onChanged, - required this.child, - }); - @override - Widget build(BuildContext context) { - useEffect(() { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (context.mounted) onChanged?.call(visible); - }); - return null; - }, [visible, onChanged]); - return child; - } -} - -class _LiftedMessagePreview extends StatelessWidget { - final ui.Image anchorSnapshot; - - const _LiftedMessagePreview({required this.anchorSnapshot}); - - @override - Widget build(BuildContext context) { - return DecoratedBox( - key: const ValueKey('message-action-preview'), - decoration: BoxDecoration( - color: context.colors.surfaceContainerHigh, - borderRadius: BorderRadius.circular(Radii.md), - border: Border.all( - color: context.colors.outlineVariant.withValues(alpha: 0.7), - width: 0.5, - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.18), - blurRadius: 18, - offset: const Offset(0, 8), - ), - ], - ), - child: Padding( - padding: const EdgeInsets.all(_messageActionPreviewInset), - child: ClipRRect( - borderRadius: BorderRadius.circular(Radii.xs), - child: RawImage( - image: anchorSnapshot, - fit: BoxFit.fill, - filterQuality: FilterQuality.medium, - ), - ), - ), - ); - } -} - -class _MessageActionSurface extends StatelessWidget { - final List<_PopoverMessageAction> actions; - final ValueChanged onSelected; - - const _MessageActionSurface({ - required this.actions, - required this.onSelected, - }); - - @override - Widget build(BuildContext context) { - final menuLayout = _MessageActionSurfaceLayout.from(context, actions); - return Material( - key: const ValueKey('message-action-surface'), - color: context.colors.surface, - surfaceTintColor: Colors.transparent, - elevation: 10, - shadowColor: Colors.black.withValues(alpha: 0.22), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(Radii.dialog), - side: BorderSide( - color: context.colors.outlineVariant.withValues(alpha: 0.55), - width: 0.5, - ), - ), - clipBehavior: Clip.antiAlias, - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: _messageActionVerticalInset, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (var index = 0; index < actions.length; index++) ...[ - if (index > 0 && - actions[index - 1].group != actions[index].group) - Divider( - key: ValueKey( - 'message-action-divider-${actions[index].group.name}', - ), - height: _messageActionSeparatorHeight, - thickness: _messageActionSeparatorHeight, - indent: Grid.xs, - endIndent: Grid.xs, - ), - _MessageActionRow( - action: actions[index], - height: menuLayout.rowHeight, - onSelected: onSelected, - ), - ], - ], - ), - ), - ), - ); - } -} - -class _MessageActionRow extends StatelessWidget { - final _PopoverMessageAction action; - final double height; - final ValueChanged onSelected; - - const _MessageActionRow({ - required this.action, - required this.height, - required this.onSelected, - }); - - @override - Widget build(BuildContext context) { - final foreground = action.destructive - ? context.colors.error - : context.colors.onSurface; - return Semantics( - button: true, - label: action.title, - excludeSemantics: true, - child: InkWell( - key: ValueKey('message-action-${action.id}'), - onTap: () { - unawaited(HapticFeedback.lightImpact()); - onSelected(action.id); - }, - child: SizedBox( - height: height, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: Grid.xs), - child: Row( - children: [ - SizedBox( - width: 32, - child: Center( - child: Icon(action.icon, size: 22, color: foreground), - ), - ), - const SizedBox(width: Grid.twelve), - Expanded( - child: Text( - action.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.bodyLarge?.copyWith( - color: foreground, - ), - ), - ), - ], - ), - ), - ), - ), - ); - } -} - -class _MessageActionSurfaceLayout { - final double rowHeight, preferredHeight; - - const _MessageActionSurfaceLayout({ - required this.rowHeight, - required this.preferredHeight, - }); - - factory _MessageActionSurfaceLayout.from( - BuildContext context, - List<_PopoverMessageAction> actions, - ) { - final textPainter = TextPainter( - text: TextSpan( - text: 'Message action', - style: context.textTheme.bodyLarge, - ), - textDirection: Directionality.of(context), - textScaler: MediaQuery.textScalerOf(context), - maxLines: 1, - )..layout(); - final rowHeight = math.max( - _messageActionRowHeight, - textPainter.height + (_messageActionRowVerticalPadding * 2), - ); - textPainter.dispose(); - - var separatorCount = 0; - for (var index = 1; index < actions.length; index++) { - if (actions[index - 1].group != actions[index].group) separatorCount += 1; - } - final preferredHeight = - (_messageActionVerticalInset * 2) + - (actions.length * rowHeight) + - (separatorCount * _messageActionSeparatorHeight); - return _MessageActionSurfaceLayout( - rowHeight: rowHeight, - preferredHeight: preferredHeight, - ); - } -} diff --git a/mobile/lib/features/channels/message_actions/message_action_popover_widgets.dart b/mobile/lib/features/channels/message_actions/message_action_popover_widgets.dart new file mode 100644 index 00000000000..f8bf649c154 --- /dev/null +++ b/mobile/lib/features/channels/message_actions/message_action_popover_widgets.dart @@ -0,0 +1,223 @@ +part of '../message_actions.dart'; + +class _MessageActionPreviewVisibility extends HookWidget { + final bool visible; + final ValueChanged? onChanged; + final Widget child; + + const _MessageActionPreviewVisibility({ + required this.visible, + required this.onChanged, + required this.child, + }); + + @override + Widget build(BuildContext context) { + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) onChanged?.call(visible); + }); + return null; + }, [visible, onChanged]); + return child; + } +} + +class _LiftedMessagePreview extends StatelessWidget { + final ui.Image anchorSnapshot; + + const _LiftedMessagePreview({required this.anchorSnapshot}); + + @override + Widget build(BuildContext context) { + return DecoratedBox( + key: const ValueKey('message-action-preview'), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHigh, + borderRadius: BorderRadius.circular(Radii.md), + border: Border.all( + color: context.colors.outlineVariant.withValues(alpha: 0.7), + width: 0.5, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.18), + blurRadius: 18, + offset: const Offset(0, 8), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.all(_messageActionPreviewInset), + child: ClipRRect( + borderRadius: BorderRadius.circular(Radii.xs), + child: RawImage( + image: anchorSnapshot, + fit: BoxFit.fill, + filterQuality: FilterQuality.medium, + ), + ), + ), + ); + } +} + +class _MessageActionSurface extends StatelessWidget { + final List<_PopoverMessageAction> actions; + final ValueChanged onSelected; + + const _MessageActionSurface({ + required this.actions, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + final menuLayout = _MessageActionSurfaceLayout.from(context, actions); + return Material( + key: const ValueKey('message-action-surface'), + color: context.colors.surface, + surfaceTintColor: Colors.transparent, + elevation: 10, + shadowColor: Colors.black.withValues(alpha: 0.22), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(Radii.dialog), + side: BorderSide( + color: context.colors.outlineVariant.withValues(alpha: 0.55), + width: 0.5, + ), + ), + clipBehavior: Clip.antiAlias, + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: _messageActionVerticalInset, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var index = 0; index < actions.length; index++) ...[ + if (index > 0 && + actions[index - 1].group != actions[index].group) + Divider( + key: ValueKey( + 'message-action-divider-${actions[index].group.name}', + ), + height: _messageActionSeparatorHeight, + thickness: _messageActionSeparatorHeight, + indent: Grid.xs, + endIndent: Grid.xs, + ), + _MessageActionRow( + action: actions[index], + height: menuLayout.rowHeight, + onSelected: onSelected, + ), + ], + ], + ), + ), + ), + ); + } +} + +class _MessageActionRow extends StatelessWidget { + final _PopoverMessageAction action; + final double height; + final ValueChanged onSelected; + + const _MessageActionRow({ + required this.action, + required this.height, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + final foreground = action.destructive + ? context.colors.error + : context.colors.onSurface; + return Semantics( + button: true, + label: action.title, + excludeSemantics: true, + child: InkWell( + key: ValueKey('message-action-${action.id}'), + onTap: () { + unawaited(HapticFeedback.lightImpact()); + onSelected(action.id); + }, + child: SizedBox( + height: height, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.xs), + child: Row( + children: [ + SizedBox( + width: 32, + child: Center( + child: Icon(action.icon, size: 22, color: foreground), + ), + ), + const SizedBox(width: Grid.twelve), + Expanded( + child: Text( + action.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodyLarge?.copyWith( + color: foreground, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _MessageActionSurfaceLayout { + final double rowHeight, preferredHeight; + + const _MessageActionSurfaceLayout({ + required this.rowHeight, + required this.preferredHeight, + }); + + factory _MessageActionSurfaceLayout.from( + BuildContext context, + List<_PopoverMessageAction> actions, + ) { + final textPainter = TextPainter( + text: TextSpan( + text: 'Message action', + style: context.textTheme.bodyLarge, + ), + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + maxLines: 1, + )..layout(); + final rowHeight = math.max( + _messageActionRowHeight, + textPainter.height + (_messageActionRowVerticalPadding * 2), + ); + textPainter.dispose(); + + var separatorCount = 0; + for (var index = 1; index < actions.length; index++) { + if (actions[index - 1].group != actions[index].group) separatorCount += 1; + } + final preferredHeight = + (_messageActionVerticalInset * 2) + + (actions.length * rowHeight) + + (separatorCount * _messageActionSeparatorHeight); + return _MessageActionSurfaceLayout( + rowHeight: rowHeight, + preferredHeight: preferredHeight, + ); + } +} diff --git a/mobile/lib/features/channels/message_actions/reaction_popover.dart b/mobile/lib/features/channels/message_actions/reaction_popover.dart index 11ea608b70a..b5b3e5a0c97 100644 --- a/mobile/lib/features/channels/message_actions/reaction_popover.dart +++ b/mobile/lib/features/channels/message_actions/reaction_popover.dart @@ -20,27 +20,71 @@ void _showMessageReactionPopover({ required Rect anchorRect, required EdgeInsets spotlightPadding, }) { - unawaited(HapticFeedback.mediumImpact()); - final reduceMotion = MediaQuery.disableAnimationsOf(context); - showGeneralDialog( - context: context, - barrierDismissible: true, - barrierLabel: 'Dismiss reaction picker', - barrierColor: Colors.transparent, - transitionDuration: reduceMotion ? Duration.zero : _reactionPopoverDuration, - transitionBuilder: (context, animation, secondaryAnimation, child) => child, - pageBuilder: (dialogContext, animation, secondaryAnimation) => - _MessageReactionPopover( - anchorRect: anchorRect, - spotlightPadding: spotlightPadding, - animation: animation, - message: message, - pageContext: context, - pageRef: ref, - ), + unawaited( + _presentMessageReactionPopover( + context: context, + ref: ref, + message: message, + anchorRect: anchorRect, + spotlightPadding: spotlightPadding, + ), ); } +Future _presentMessageReactionPopover({ + required BuildContext context, + required WidgetRef ref, + required TimelineMessage message, + required Rect anchorRect, + required EdgeInsets spotlightPadding, +}) async { + if (_messageActionPresentationInFlight) return; + _messageActionPresentationInFlight = true; + + try { + unawaited(HapticFeedback.mediumImpact()); + final reduceMotion = MediaQuery.disableAnimationsOf(context); + final dialogRoute = RawDialogRoute( + barrierDismissible: true, + barrierLabel: 'Dismiss reaction picker', + barrierColor: Colors.transparent, + transitionDuration: reduceMotion + ? Duration.zero + : _reactionPopoverDuration, + transitionBuilder: (context, animation, secondaryAnimation, child) => + child, + pageBuilder: (dialogContext, animation, secondaryAnimation) => + _MessageReactionPopover( + anchorRect: anchorRect, + spotlightPadding: spotlightPadding, + animation: animation, + message: message, + pageContext: context, + pageRef: ref, + ), + ); + var routePushed = false; + messageActionBackdropActive.value = true; + try { + // Remove UIKit glass views from Flutter's platform-view overlay before the + // backdrop filter paints, otherwise they leave sharp rectangular holes. + await WidgetsBinding.instance.endOfFrame; + if (!context.mounted) return; + final popResult = Navigator.of( + context, + rootNavigator: true, + ).push(dialogRoute); + routePushed = true; + await popResult; + } finally { + if (routePushed) await dialogRoute.completed; + messageActionBackdropActive.value = false; + } + } finally { + _messageActionPresentationInFlight = false; + } +} + class _MessageReactionPopover extends HookWidget { final Rect anchorRect; final EdgeInsets spotlightPadding; @@ -103,31 +147,29 @@ class _MessageReactionPopover extends HookWidget { return Stack( children: [ Positioned.fill( - child: AnimatedBuilder( - animation: animation, - builder: (context, child) { - final blurProgress = const Interval( - 0, - 0.30, - curve: Curves.easeOutCubic, - ).transform(animation.value); - final sigma = 20 * blurProgress; - return ClipPath( - key: const ValueKey('reaction-popover-background'), - clipper: _OutsideAnchorClipper( - anchorRect, - spotlightPadding, - ), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: sigma, sigmaY: sigma), - child: ColoredBox( + child: ClipPath( + key: const ValueKey('reaction-popover-background'), + clipper: _OutsideAnchorClipper(anchorRect, spotlightPadding), + child: BackdropFilter( + key: const ValueKey('reaction-popover-backdrop-filter'), + filter: _messageActionBackdropFilter, + child: AnimatedBuilder( + animation: animation, + builder: (context, child) { + final opacity = const Interval( + 0, + 0.30, + curve: Curves.easeOutCubic, + ).transform(animation.value); + return ColoredBox( + key: const ValueKey('reaction-popover-background-tint'), color: context.colors.inverseSurface.withValues( - alpha: 0.10 * blurProgress, + alpha: _messageActionBackdropTintOpacity * opacity, ), - ), - ), - ); - }, + ); + }, + ), + ), ), ), Positioned.fill( diff --git a/mobile/lib/features/channels/sticky_date_header.dart b/mobile/lib/features/channels/sticky_date_header.dart index f6d17d2b9b2..f4d10eb0776 100644 --- a/mobile/lib/features/channels/sticky_date_header.dart +++ b/mobile/lib/features/channels/sticky_date_header.dart @@ -7,9 +7,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/theme/theme.dart'; +import 'message_action_backdrop_state.dart'; /// The active date and vertical push-off applied to a sticky date header. @immutable @@ -90,28 +90,33 @@ class StickyDateHeader extends StatelessWidget { borderRadius: BorderRadius.circular(Radii.full), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), - child: Container( + child: ConstrainedBox( key: const ValueKey('channel-sticky-date-header-surface'), - padding: const EdgeInsets.symmetric( - horizontal: Grid.twelve, - vertical: Grid.half, - ), - decoration: BoxDecoration( - color: context.colors.surface.withValues(alpha: 0.82), - borderRadius: BorderRadius.circular(Radii.full), - border: Border.all( - color: context.colors.onSurface.withValues(alpha: 0.08), + constraints: BoxConstraints(minHeight: heightOf(context)), + child: DecoratedBox( + decoration: BoxDecoration( + color: context.colors.surface.withValues(alpha: 0.82), + borderRadius: BorderRadius.circular(Radii.full), + border: Border.all( + color: context.colors.onSurface.withValues(alpha: 0.08), + ), ), - ), - child: Semantics( - header: true, - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.onSurfaceVariant, - fontWeight: FontWeight.w500, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.twelve, + vertical: Grid.half, + ), + child: Semantics( + header: true, + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), ), ), ), @@ -128,23 +133,43 @@ class StickyDateHeader extends StatelessWidget { return ValueListenableBuilder( valueListenable: state, builder: (context, value, _) { - return IgnorePointer( - child: ExcludeSemantics( - excluding: !value.isVisible, - child: AnimatedOpacity( - duration: reducedMotion - ? Duration.zero - : const Duration(milliseconds: 120), - curve: Curves.easeOutCubic, - opacity: value.isVisible ? 1 : 0, - child: Transform.translate( - offset: Offset(0, value.translateY), - child: Center( - child: RepaintBoundary( - child: defaultTargetPlatform == TargetPlatform.iOS - ? _buildIosGlass(context, value.label ?? '') - : _buildFlutterSurface(context, value.label ?? ''), - ), + final stickyHeight = heightOf(context); + return SizedBox( + height: stickyHeight, + child: IgnorePointer( + child: ExcludeSemantics( + excluding: !value.isVisible, + child: AnimatedOpacity( + duration: reducedMotion + ? Duration.zero + : const Duration(milliseconds: 120), + curve: Curves.easeOutCubic, + opacity: value.isVisible ? 1 : 0, + child: ValueListenableBuilder( + valueListenable: messageActionBackdropActive, + builder: (context, backdropActive, _) { + final useNativeGlass = + defaultTargetPlatform == TargetPlatform.iOS && + !backdropActive; + final surface = Center( + child: RepaintBoundary( + child: useNativeGlass + ? _buildIosGlass(context, value.label ?? '') + : _buildFlutterSurface(context, value.label ?? ''), + ), + ); + + final pushOffProgress = + (-value.translateY / (stickyHeight + 5)).clamp( + 0.0, + 1.0, + ); + return Opacity( + key: const ValueKey('sticky-date-push-off-opacity'), + opacity: 1 - pushOffProgress, + child: surface, + ); + }, ), ), ), @@ -155,7 +180,7 @@ class StickyDateHeader extends StatelessWidget { } } -class _IosStickyDateGlass extends HookConsumerWidget { +class _IosStickyDateGlass extends HookWidget { final String label; final double width; final double height; @@ -167,7 +192,7 @@ class _IosStickyDateGlass extends HookConsumerWidget { }); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final nativeChannel = useState(null); final brightness = context.theme.brightness.name; diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index 3d113b5db6c..407cb3cd566 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -29,7 +30,8 @@ import 'day_divider.dart'; import 'ime_metrics_settle_observer.dart'; import 'initial_thread_tail_settle.dart'; import 'laid_out_viewport.dart'; -import 'latest_message_button.dart'; +import 'jump_to_latest_button.dart'; +import 'jump_to_latest_switcher.dart'; import '../profile/user_profile_sheet.dart'; import 'message_actions.dart'; import 'message_long_press_region.dart'; @@ -39,9 +41,12 @@ import '../../shared/read_state/read_state_format.dart'; import '../../shared/read_state/read_state_provider.dart'; import 'send_message_provider.dart'; import 'small_avatar.dart'; +import 'sticky_date_header.dart'; import 'timeline_message.dart'; part 'thread_detail_page/nested_thread_summary_row.dart'; +part 'thread_detail_page/message_list.dart'; +part 'thread_detail_page/sticky_date.dart'; part 'thread_detail_helpers.dart'; part 'thread_detail_page/tail_alignment.dart'; part 'thread_detail_page/thread_message.dart'; @@ -52,6 +57,10 @@ const _landingHighlightDelay = Duration(milliseconds: 50); const _landingHighlightTransitionDuration = Duration(milliseconds: 300); const _landingHighlightOpacity = 0.12; +// Keep the direct-position correction finite in case the viewport cannot +// expose its tail (for example, continuously changing media dimensions). +const _latestTailCorrectionLimit = 8; + /// Full-screen thread detail page. /// /// Shows the thread head message, direct replies, typing indicators scoped to @@ -102,6 +111,7 @@ class ThreadDetailPage extends HookConsumerWidget { ); final relayReplyState = ref.watch(threadRepliesProvider(repliesArgs)); final repliesState = ref.watch(threadRepliesWithLocalProvider(repliesArgs)); + final relayRepliesAvailable = relayReplyState.value != null; // The thread query is one-shot and asks only for content kinds, so a // reaction, edit, or deletion that lands while the thread is open never // reaches it — a new pill (and its burst) only showed up after leaving and @@ -118,6 +128,7 @@ class ThreadDetailPage extends HookConsumerWidget { }); final fetchedReplies = replyMessages.value; + final hasFetchedReplies = fetchedReplies != null; // A terminal query error cannot produce a more authoritative list. Keep // loading states provisional, but let the hydrated route snapshot drive // the one-shot target jump when the relay query has definitively failed. @@ -213,12 +224,19 @@ class ThreadDetailPage extends HookConsumerWidget { } final replies = childrenByParent[threadHead.id] ?? const []; + final liveHead = + allMsgs.where((m) => m.id == threadHead.id).firstOrNull ?? threadHead; final itemScrollController = useMemoized(ItemScrollController.new); final itemPositionsListener = useMemoized(ItemPositionsListener.create); final listViewport = useMemoized(LaidOutViewport.new); useEffect(() => listViewport.dispose, [listViewport]); + final stickyDateHeaderState = useValueNotifier( + StickyDateHeaderState.hidden, + ); + final stickyDayTimestamp = useValueNotifier(null); final didJumpToInitialMessage = useRef(false); final initialHighlightTargetIndex = useState(null); + final initialViewportReady = useState(false); final followsThreadTail = useRef(false); final userOptedOutOfTailFollow = useRef(false); final userDragDetachedTailFollow = useRef(false); @@ -226,6 +244,8 @@ class ThreadDetailPage extends HookConsumerWidget { final initialTailSettle = useMemoized(InitialThreadTailSettle.new); final isAtThreadTail = useState(true); final tailCorrectionInProgress = useRef(false); + final tailCorrectionGeneration = useRef(0); + final activeThreadScrollPosition = useRef(null); final viewportHeight = useListenable(listViewport.height).value; final previousViewportHeight = useRef(viewportHeight); final settledImeLift = usesFixedAndroidImeViewport @@ -240,11 +260,31 @@ class ThreadDetailPage extends HookConsumerWidget { ? settledImeLift : 0); final navigationBottomInset = composerDockHeight.value + settledImeLift; + // Keep the route snapshot usable while the relay query is pending. Once + // authoritative replies arrive, suppress only the frame(s) used to place + // the hydrated target, then reveal the settled viewport. + final threadViewportVisible = + !relayRepliesAvailable || initialViewportReady.value; // Item 0 is the thread head; reply `i` lives at `i + 1`. const headIndex = 0; int indexForReply(int chronologicalIndex) => chronologicalIndex + 1; final tailAnchorIndex = replies.length + 1; + final stickyDateIndex = _ThreadStickyDateIndex( + head: liveHead, + replies: replies, + ); + + void updateStickyDateHeader(Iterable positions) { + final update = stickyDateIndex.resolve( + positions: positions, + viewportHeight: viewportHeight, + stickyTop: frostedAppBarHeight(context) + Grid.twelve, + stickyHeaderHeight: StickyDateHeader.heightOf(context), + ); + stickyDateHeaderState.value = update.state; + stickyDayTimestamp.value = update.activeDayTimestamp; + } double threadTailAlignment() => _threadTailAlignmentForViewport( // This reporter already reflects Scaffold resize, typing rows, and every @@ -279,77 +319,174 @@ class ThreadDetailPage extends HookConsumerWidget { ); } + Widget trackActiveScrollPosition(Widget child) { + return Builder( + builder: (itemContext) { + activeThreadScrollPosition.value = Scrollable.of( + itemContext, + ).position; + return child; + }, + ); + } + + bool jumpActiveScrollPositionToTail() { + final position = activeThreadScrollPosition.value; + if (position == null || !position.hasContentDimensions) return false; + // Move the one active viewport to its exact end. Unlike indexed + // jumpTo/scrollTo, this does not reset or cross-fade through a second + // list, so iOS never exposes the intermediate top-of-thread frame. + position.jumpTo(position.maxScrollExtent); + return true; + } + + Future animateActiveScrollPositionToTail() async { + final position = activeThreadScrollPosition.value; + if (position == null || !position.hasContentDimensions) return false; + if (MediaQuery.disableAnimationsOf(context)) { + position.jumpTo(position.maxScrollExtent); + return true; + } + // Match the channel's visible Latest glide while moving only the active + // thread viewport. The indexed-list animation path can create a temporary + // second list for distant targets, which caused the old top-frame bounce. + await position.animateTo( + position.maxScrollExtent, + duration: jumpToLatestScrollDuration, + curve: jumpToLatestScrollCurve, + ); + return true; + } + + void finishThreadTailCorrection({ + required bool revealViewport, + required int generation, + int corrections = 0, + }) { + if (!context.mounted) return; + if (generation != tailCorrectionGeneration.value) return; + // A finger drag interrupts the correction. Do not take control back + // from the user with another jump after they detach from the tail. + if (!tailCorrectionInProgress.value || + tailIntent.isDragging || + userOptedOutOfTailFollow.value) { + tailCorrectionInProgress.value = false; + isAtThreadTail.value = threadTailIsVisible(); + return; + } + final reachedTail = threadTailIsVisible(); + // Lazy children can revise maxScrollExtent for several frames. Keep + // moving the same active position until the measured tail is visible; + // the cap only guards pathological layouts that never stabilize. + if (!reachedTail && corrections < _latestTailCorrectionLimit) { + jumpActiveScrollPositionToTail(); + WidgetsBinding.instance.addPostFrameCallback( + (_) => finishThreadTailCorrection( + revealViewport: revealViewport, + generation: generation, + corrections: corrections + 1, + ), + ); + WidgetsBinding.instance.scheduleFrame(); + return; + } + tailCorrectionInProgress.value = false; + if (revealViewport) initialViewportReady.value = true; + isAtThreadTail.value = reachedTail; + } + void correctThreadTailInstantly() { - if (!itemScrollController.isAttached) return; + if (!jumpActiveScrollPositionToTail()) return; tailCorrectionInProgress.value = true; - isAtThreadTail.value = true; - itemScrollController.jumpTo( - index: tailAnchorIndex, - alignment: threadTailAlignment(), + final generation = ++tailCorrectionGeneration.value; + isAtThreadTail.value = threadTailIsVisible(); + WidgetsBinding.instance.addPostFrameCallback( + (_) => finishThreadTailCorrection( + revealViewport: false, + generation: generation, + ), ); - WidgetsBinding.instance.addPostFrameCallback((_) { - tailCorrectionInProgress.value = false; - if (context.mounted && followsThreadTail.value) { - isAtThreadTail.value = true; - } - }); + WidgetsBinding.instance.scheduleFrame(); } void followThreadTailFromComposer() { if (userDragDetachedTailFollow.value) return; initialTailSettle.abandon(); + initialViewportReady.value = true; tailIntent.endDrag(); tailIntent.detach(); userOptedOutOfTailFollow.value = false; followsThreadTail.value = true; - isAtThreadTail.value = true; - if (!threadTailIsVisible()) correctThreadTailInstantly(); + final reachedTail = threadTailIsVisible(); + isAtThreadTail.value = reachedTail; + if (!reachedTail) correctThreadTailInstantly(); } - useEffect(() { - void onPositionsChanged() { - final tailIsVisible = threadTailIsVisible(); - if (!userOptedOutOfTailFollow.value && tailIsVisible) { - followsThreadTail.value = true; - } - if (tailCorrectionInProgress.value) return; - if (isAtThreadTail.value != tailIsVisible) { - isAtThreadTail.value = tailIsVisible; + useEffect( + () { + void onPositionsChanged() { + updateStickyDateHeader(itemPositionsListener.itemPositions.value); + final tailIsVisible = threadTailIsVisible(); + if (!userOptedOutOfTailFollow.value && tailIsVisible) { + followsThreadTail.value = true; + } + if (tailCorrectionInProgress.value) return; + if (isAtThreadTail.value != tailIsVisible) { + isAtThreadTail.value = tailIsVisible; + } } - } - itemPositionsListener.itemPositions.addListener(onPositionsChanged); - return () => itemPositionsListener.itemPositions.removeListener( - onPositionsChanged, - ); - }, [itemPositionsListener, replies.length]); + itemPositionsListener.itemPositions.addListener(onPositionsChanged); + return () => itemPositionsListener.itemPositions.removeListener( + onPositionsChanged, + ); + }, + [ + itemPositionsListener, + replies.length, + liveHead.createdAt, + viewportHeight, + ], + ); + + useEffect(() { + stickyDateHeaderState.value = StickyDateHeaderState.hidden; + stickyDayTimestamp.value = null; + return null; + }, [threadHead.id]); - Future scrollToThreadLatest() async { + void scrollToThreadLatest() { if (!itemScrollController.isAttached) return; initialTailSettle.abandon(); tailIntent.endDrag(); + tailIntent.detach(); + // An explicit Latest tap supersedes a still-pending Inbox/Activity + // deep-link. Without consuming that one-shot intent, the authoritative + // thread query can finish after this navigation and jump back to the + // originally linked older message. + didJumpToInitialMessage.value = true; + initialHighlightTargetIndex.value = null; + initialTargetReadyForHighlight.value = false; userOptedOutOfTailFollow.value = false; userDragDetachedTailFollow.value = false; followsThreadTail.value = true; - isAtThreadTail.value = true; - tailIntent.scheduleNextFrame( - allowed: true, - revalidate: () => - context.mounted && - itemScrollController.isAttached && - !tailIntent.isDragging, - action: () async { - await itemScrollController.scrollTo( - index: tailAnchorIndex, - alignment: threadTailAlignment(), - duration: const Duration(milliseconds: 220), - curve: Curves.easeOutCubic, - ); - if (context.mounted && threadTailIsVisible()) { - isAtThreadTail.value = true; + tailCorrectionInProgress.value = true; + final generation = ++tailCorrectionGeneration.value; + + Future navigateToTail() async { + if (!await animateActiveScrollPositionToTail()) { + if (generation == tailCorrectionGeneration.value) { + tailCorrectionInProgress.value = false; } - }, - ); + return; + } + finishThreadTailCorrection( + revealViewport: true, + generation: generation, + ); + } + + unawaited(navigateToTail()); } useEffect( @@ -368,7 +505,12 @@ class ThreadDetailPage extends HookConsumerWidget { : chronologicalIndex < 0 ? null : indexForReply(chronologicalIndex); - if (targetIndex == null || didJumpToInitialMessage.value) return null; + if (targetIndex == null) { + initialTailSettle.abandon(); + initialViewportReady.value = true; + return null; + } + if (didJumpToInitialMessage.value) return null; didJumpToInitialMessage.value = true; initialTailSettle.abandon(); userOptedOutOfTailFollow.value = true; @@ -416,7 +558,9 @@ class ThreadDetailPage extends HookConsumerWidget { } completionScheduled = true; WidgetsBinding.instance.addPostFrameCallback((_) { - if (context.mounted) initialTargetReadyForHighlight.value = true; + if (!context.mounted) return; + initialTargetReadyForHighlight.value = true; + initialViewportReady.value = true; }); } @@ -435,7 +579,6 @@ class ThreadDetailPage extends HookConsumerWidget { // reversed one did, so follow the tail explicitly: when a reply arrives // while the last item is on screen, scroll it into view. If the user has // scrolled up to read, leave them where they are. - final hasFetchedReplies = fetchedReplies != null; final previousReplyCount = useRef(replies.length); final topOverlayFraction = viewportHeight > 0 ? frostedAppBarHeight(context) / viewportHeight @@ -468,6 +611,9 @@ class ThreadDetailPage extends HookConsumerWidget { hiddenTopFraction: topOverlayFraction, hiddenBottomFraction: (composerDockHeight.value + settledImeLift) / viewportHeight, + onSettled: () { + if (context.mounted) initialViewportReady.value = true; + }, ); return null; } @@ -544,10 +690,6 @@ class ThreadDetailPage extends HookConsumerWidget { ) .toList(); - // Resolve thread head from live data (reactions/edits may have changed). - final liveHead = - allMsgs.where((m) => m.id == threadHead.id).firstOrNull ?? threadHead; - // The root of the entire thread chain. If the current thread head is // itself a root message its rootId is null, so fall back to its own id. final effectiveRootId = threadHead.rootId ?? threadHead.id; @@ -673,203 +815,74 @@ class ThreadDetailPage extends HookConsumerWidget { Column( children: [ Expanded( - child: LaidOutViewportReporter( + child: _ThreadMessageList( viewport: listViewport, - child: KeyboardDismissOnDrag( - onUserScrollStart: () { - initialTailSettle.abandon(); - tailIntent.beginDrag(); - userOptedOutOfTailFollow.value = true; - userDragDetachedTailFollow.value = true; - followsThreadTail.value = false; - }, - onUserScrollEnd: () { - tailIntent.endDrag(); - tailIntent.schedule( - allowed: userOptedOutOfTailFollow.value, - revalidate: () => - context.mounted && - itemScrollController.isAttached && - !tailIntent.isDragging && - userOptedOutOfTailFollow.value, - action: () { - _resumeThreadTailFollow( - isVisible: threadTailIsVisible, - userOptedOut: userOptedOutOfTailFollow, - followsTail: followsThreadTail, - ); - if (!userOptedOutOfTailFollow.value) { - userDragDetachedTailFollow.value = false; - } - }, - ); - }, - child: ScrollablePositionedList.builder( - key: const ValueKey('thread-message-list'), - itemScrollController: itemScrollController, - itemPositionsListener: itemPositionsListener, - // Top-anchored, head first, replies flowing down — matching - // desktop's thread panel. The old reversed list bottom-anchored - // the content, which jammed the head against the composer - // whenever a thread had only a handful of replies. - padding: EdgeInsets.only( - left: Grid.gutter, - right: Grid.gutter, - top: frostedAppBarHeight(context), - bottom: Grid.xs + timelineBottomInset, - ), - // Head + replies + a stable zero-content tail target. The - // anchor lets Latest align the end directly rather than - // asking the final reply's leading edge to overshoot the - // viewport and rebound against the scroll extent. - itemCount: replies.length + 2, - itemBuilder: (context, index) { - if (index == tailAnchorIndex) { - return const SizedBox( - key: ValueKey('thread-tail-anchor'), - height: 1, - ); - } - if (index == headIndex) { - if (liveDeletionHidesHead) { - return const Padding( - key: ValueKey('thread-message-deleted'), - padding: EdgeInsets.only(bottom: Grid.xs), - child: Text('This message was deleted'), - ); - } - return Padding( - key: ValueKey( - 'thread-message-group-${liveHead.id}', - ), - padding: const EdgeInsets.only(bottom: Grid.xs), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - DayDivider( - label: formatDayHeading(liveHead.createdAt), - ), - _ThreadMessage( - message: liveHead, - channelNames: channelNamesMap, - channelId: channelId, - currentPubkey: currentPubkey, - showAuthor: true, - isHighlighted: - liveHead.id == highlightedMessageId.value, - allMessages: allMsgs, - isMember: isMember, - isArchived: isArchived, - isThreadHead: true, - composerFocusNode: composerFocusNode, - restoreComposerFocus: () => - restoreComposerFocus.value?.call(), - ), - Padding( - padding: const EdgeInsets.symmetric( - vertical: Grid.xxs, - ), - child: Row( - children: [ - Text( - '${replies.length} ${replies.length == 1 ? 'reply' : 'replies'}', - style: context.textTheme.labelMedium - ?.copyWith( - color: context - .colors - .onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(width: Grid.xxs), - Expanded( - child: Divider( - color: context.colors.outlineVariant, - ), - ), - ], - ), - ), - ], - ), - ); - } - - // Chronological list: index 1 = oldest reply. - final chronIdx = index - 1; - final reply = replies[chronIdx]; - final prevReply = chronIdx > 0 - ? replies[chronIdx - 1] - : null; - final previousMessage = prevReply ?? liveHead; - final showDayDivider = !isSameDay( - previousMessage.createdAt, - reply.createdAt, - ); - final showAuthor = - prevReply == null || - showDayDivider || - prevReply.pubkey.toLowerCase() != - reply.pubkey.toLowerCase() || - (reply.createdAt - prevReply.createdAt) > 300; - - // Check if this reply itself has children (nested thread). - final nestedChildren = childrenByParent[reply.id]; - final nestedSummary = - nestedChildren != null && nestedChildren.isNotEmpty - ? _buildNestedSummary(reply.id, nestedChildren) - : null; - - return Padding( - key: ValueKey('thread-message-group-${reply.id}'), - // Tail spacing comes from the list's own bottom padding now - // that the list runs top-down; the reversed list used to - // need it here because item 0 sat against the composer. - padding: EdgeInsets.zero, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showDayDivider) - DayDivider( - label: formatDayHeading(reply.createdAt), - ), - _ThreadMessage( - message: reply, - channelNames: channelNamesMap, - channelId: channelId, - currentPubkey: currentPubkey, - showAuthor: showAuthor, - isHighlighted: - reply.id == highlightedMessageId.value, - allMessages: allMsgs, - isMember: isMember, - isArchived: isArchived, - composerFocusNode: composerFocusNode, - restoreComposerFocus: () => - restoreComposerFocus.value?.call(), - ), - if (nestedSummary != null) - _NestedThreadSummaryRow( - summary: nestedSummary, - replyMessage: reply, - allMessages: allMsgs, - channelId: channelId, - currentPubkey: currentPubkey, - isMember: isMember, - isArchived: isArchived, - ), - ], - ), + onUserScrollStart: () { + initialTailSettle.abandon(); + initialViewportReady.value = true; + tailCorrectionInProgress.value = false; + tailIntent.beginDrag(); + userOptedOutOfTailFollow.value = true; + userDragDetachedTailFollow.value = true; + followsThreadTail.value = false; + }, + onUserScrollEnd: () { + tailIntent.endDrag(); + tailIntent.schedule( + allowed: userOptedOutOfTailFollow.value, + revalidate: () => + context.mounted && + itemScrollController.isAttached && + !tailIntent.isDragging && + userOptedOutOfTailFollow.value, + action: () { + _resumeThreadTailFollow( + isVisible: threadTailIsVisible, + userOptedOut: userOptedOutOfTailFollow, + followsTail: followsThreadTail, ); + if (!userOptedOutOfTailFollow.value) { + userDragDetachedTailFollow.value = false; + } }, - ), - ), + ); + }, + visible: threadViewportVisible, + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + bottomInset: timelineBottomInset, + replies: replies, + trackActiveScrollPosition: trackActiveScrollPosition, + headIsDeleted: liveDeletionHidesHead, + head: liveHead, + stickyDayTimestamp: stickyDayTimestamp, + channelNames: channelNamesMap, + channelId: channelId, + currentPubkey: currentPubkey, + highlightedMessageId: highlightedMessageId.value, + allMessages: allMsgs, + isMember: isMember, + isArchived: isArchived, + composerFocusNode: composerFocusNode, + restoreComposerFocus: () => + restoreComposerFocus.value?.call(), + childrenByParent: childrenByParent, ), ), if (!isMember || isArchived) _ThreadTypingIndicator(entries: threadTyping, animated: false), ], ), + if (threadViewportVisible) + Positioned( + left: 0, + right: 0, + top: frostedAppBarHeight(context) + Grid.twelve, + child: StickyDateHeader( + key: const ValueKey('thread-sticky-date-header'), + state: stickyDateHeaderState, + ), + ), if (isMember && !isArchived) AndroidImeLift( child: Align( @@ -910,19 +923,21 @@ class ThreadDetailPage extends HookConsumerWidget { ), ), ), - if (hasFetchedReplies && !isAtThreadTail.value) - Positioned( - left: 0, - right: 0, - bottom: navigationBottomInset + Grid.xs, - child: Center( - child: LatestMessageButton( - key: const ValueKey('thread-jump-to-latest'), - surfaceKey: const ValueKey('thread-jump-to-latest-surface'), - onPressed: scrollToThreadLatest, - ), + Positioned( + left: 0, + right: 0, + bottom: navigationBottomInset + Grid.xs, + child: Center( + child: JumpToLatestSwitcher( + id: 'thread', + visible: + threadViewportVisible && + hasFetchedReplies && + !isAtThreadTail.value, + onPressed: scrollToThreadLatest, ), ), + ), ], ), ); diff --git a/mobile/lib/features/channels/thread_detail_page/message_list.dart b/mobile/lib/features/channels/thread_detail_page/message_list.dart new file mode 100644 index 00000000000..0010cd7f8da --- /dev/null +++ b/mobile/lib/features/channels/thread_detail_page/message_list.dart @@ -0,0 +1,235 @@ +part of '../thread_detail_page.dart'; + +class _ThreadMessageList extends StatelessWidget { + final LaidOutViewport viewport; + final VoidCallback onUserScrollStart; + final VoidCallback onUserScrollEnd; + final bool visible; + final ItemScrollController itemScrollController; + final ItemPositionsListener itemPositionsListener; + final double bottomInset; + final List replies; + final Widget Function(Widget child) trackActiveScrollPosition; + final bool headIsDeleted; + final TimelineMessage head; + final ValueNotifier stickyDayTimestamp; + final Map channelNames; + final String channelId; + final String? currentPubkey; + final String? highlightedMessageId; + final List allMessages; + final bool isMember; + final bool isArchived; + final FocusNode composerFocusNode; + final VoidCallback restoreComposerFocus; + final Map> childrenByParent; + + const _ThreadMessageList({ + required this.viewport, + required this.onUserScrollStart, + required this.onUserScrollEnd, + required this.visible, + required this.itemScrollController, + required this.itemPositionsListener, + required this.bottomInset, + required this.replies, + required this.trackActiveScrollPosition, + required this.headIsDeleted, + required this.head, + required this.stickyDayTimestamp, + required this.channelNames, + required this.channelId, + required this.currentPubkey, + required this.highlightedMessageId, + required this.allMessages, + required this.isMember, + required this.isArchived, + required this.composerFocusNode, + required this.restoreComposerFocus, + required this.childrenByParent, + }); + + @override + Widget build(BuildContext context) { + const headIndex = 0; + final tailAnchorIndex = replies.length + 1; + + return LaidOutViewportReporter( + viewport: viewport, + child: KeyboardDismissOnDrag( + onUserScrollStart: onUserScrollStart, + onUserScrollEnd: onUserScrollEnd, + child: Opacity( + key: const ValueKey('thread-initial-viewport-gate'), + opacity: visible ? 1 : 0, + child: IgnorePointer( + ignoring: !visible, + child: ScrollablePositionedList.builder( + key: const ValueKey('thread-message-list'), + itemScrollController: itemScrollController, + itemPositionsListener: itemPositionsListener, + // Top-anchored, head first, replies flowing down — matching + // desktop's thread panel. The old reversed list bottom-anchored + // the content, which jammed the head against the composer + // whenever a thread had only a handful of replies. + padding: EdgeInsets.only( + left: Grid.gutter, + right: Grid.gutter, + top: frostedAppBarHeight(context), + bottom: Grid.xs + bottomInset, + ), + // Head + replies + a stable zero-content tail target. The + // anchor lets Latest align the end directly rather than + // asking the final reply's leading edge to overshoot the + // viewport and rebound against the scroll extent. + itemCount: replies.length + 2, + itemBuilder: (context, index) { + if (index == tailAnchorIndex) { + return trackActiveScrollPosition( + const SizedBox( + key: ValueKey('thread-tail-anchor'), + height: 1, + ), + ); + } + if (index == headIndex) { + if (headIsDeleted) { + return trackActiveScrollPosition( + const Padding( + key: ValueKey('thread-message-deleted'), + padding: EdgeInsets.only(bottom: Grid.xs), + child: Text('This message was deleted'), + ), + ); + } + return trackActiveScrollPosition( + Padding( + key: ValueKey('thread-message-group-${head.id}'), + padding: const EdgeInsets.only(bottom: Grid.xs), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DayDivider( + label: formatDayHeading(head.createdAt), + dayTimestamp: head.createdAt, + stickyDayTimestamp: stickyDayTimestamp, + ), + _ThreadMessage( + message: head, + channelNames: channelNames, + channelId: channelId, + currentPubkey: currentPubkey, + showAuthor: true, + isHighlighted: head.id == highlightedMessageId, + allMessages: allMessages, + isMember: isMember, + isArchived: isArchived, + isThreadHead: true, + composerFocusNode: composerFocusNode, + restoreComposerFocus: restoreComposerFocus, + ), + Padding( + padding: const EdgeInsets.symmetric( + vertical: Grid.xxs, + ), + child: Row( + children: [ + Text( + '${replies.length} ${replies.length == 1 ? 'reply' : 'replies'}', + style: context.textTheme.labelMedium + ?.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: Grid.xxs), + Expanded( + child: Divider( + color: context.colors.outlineVariant, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + // Chronological list: index 1 = oldest reply. + final chronologicalIndex = index - 1; + final reply = replies[chronologicalIndex]; + final previousReply = chronologicalIndex > 0 + ? replies[chronologicalIndex - 1] + : null; + final previousMessage = previousReply ?? head; + final showDayDivider = !isSameDay( + previousMessage.createdAt, + reply.createdAt, + ); + final showAuthor = + previousReply == null || + showDayDivider || + previousReply.pubkey.toLowerCase() != + reply.pubkey.toLowerCase() || + (reply.createdAt - previousReply.createdAt) > 300; + + // Check if this reply itself has children (nested thread). + final nestedChildren = childrenByParent[reply.id]; + final nestedSummary = + nestedChildren != null && nestedChildren.isNotEmpty + ? _buildNestedSummary(reply.id, nestedChildren) + : null; + + return trackActiveScrollPosition( + Padding( + key: ValueKey('thread-message-group-${reply.id}'), + // Tail spacing comes from the list's own bottom padding now + // that the list runs top-down; the reversed list used to + // need it here because item 0 sat against the composer. + padding: EdgeInsets.zero, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showDayDivider) + DayDivider( + label: formatDayHeading(reply.createdAt), + dayTimestamp: reply.createdAt, + stickyDayTimestamp: stickyDayTimestamp, + ), + _ThreadMessage( + message: reply, + channelNames: channelNames, + channelId: channelId, + currentPubkey: currentPubkey, + showAuthor: showAuthor, + isHighlighted: reply.id == highlightedMessageId, + allMessages: allMessages, + isMember: isMember, + isArchived: isArchived, + composerFocusNode: composerFocusNode, + restoreComposerFocus: restoreComposerFocus, + ), + if (nestedSummary != null) + _NestedThreadSummaryRow( + summary: nestedSummary, + replyMessage: reply, + allMessages: allMessages, + channelId: channelId, + currentPubkey: currentPubkey, + isMember: isMember, + isArchived: isArchived, + ), + ], + ), + ), + ); + }, + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/channels/thread_detail_page/sticky_date.dart b/mobile/lib/features/channels/thread_detail_page/sticky_date.dart new file mode 100644 index 00000000000..4ac8cd01124 --- /dev/null +++ b/mobile/lib/features/channels/thread_detail_page/sticky_date.dart @@ -0,0 +1,124 @@ +part of '../thread_detail_page.dart'; + +class _ThreadStickyDateUpdate { + final StickyDateHeaderState state; + final int? activeDayTimestamp; + + const _ThreadStickyDateUpdate({ + required this.state, + required this.activeDayTimestamp, + }); + + static const hidden = _ThreadStickyDateUpdate( + state: StickyDateHeaderState.hidden, + activeDayTimestamp: null, + ); +} + +class _ThreadStickyDateIndex { + final Map _dayTimestampByIndex = {}; + final Map _dayStartByIndex = {}; + final Set _dayHeaderIndices = {}; + final int messageCount; + + _ThreadStickyDateIndex({ + required TimelineMessage head, + required List replies, + }) : messageCount = replies.length + 1 { + final messages = [head, ...replies]; + var activeDayTimestamp = head.createdAt; + var activeDayStartIndex = 0; + for (var index = 0; index < messages.length; index += 1) { + final message = messages[index]; + final startsDay = + index == 0 || + !isSameDay(messages[index - 1].createdAt, message.createdAt); + if (startsDay) { + activeDayTimestamp = message.createdAt; + activeDayStartIndex = index; + _dayHeaderIndices.add(index); + } + _dayTimestampByIndex[index] = activeDayTimestamp; + _dayStartByIndex[index] = activeDayStartIndex; + } + } + + _ThreadStickyDateUpdate resolve({ + required Iterable positions, + required double viewportHeight, + required double stickyTop, + required double stickyHeaderHeight, + }) { + if (viewportHeight <= 0 || messageCount == 0) { + return _ThreadStickyDateUpdate.hidden; + } + + final visiblePositions = positions + .where( + (position) => + position.index < messageCount && + position.itemLeadingEdge < 1 && + position.itemTrailingEdge > 0, + ) + .toList(); + if (visiblePositions.isEmpty) return _ThreadStickyDateUpdate.hidden; + + double physicalTop(ItemPosition position) => + viewportHeight * position.itemLeadingEdge; + double physicalBottom(ItemPosition position) => + viewportHeight * position.itemTrailingEdge; + + final positionAtStickyTop = visiblePositions + .where( + (position) => + physicalTop(position) <= stickyTop && + physicalBottom(position) > stickyTop, + ) + .firstOrNull; + if (positionAtStickyTop == null) return _ThreadStickyDateUpdate.hidden; + + final activeDayTimestamp = _dayTimestampByIndex[positionAtStickyTop.index]; + final activeDayStartIndex = _dayStartByIndex[positionAtStickyTop.index]; + if (activeDayTimestamp == null || activeDayStartIndex == null) { + return _ThreadStickyDateUpdate.hidden; + } + + final activeHeaderPosition = visiblePositions + .where((position) => position.index == activeDayStartIndex) + .firstOrNull; + final firstVisibleIndex = visiblePositions + .map((position) => position.index) + .reduce((left, right) => left < right ? left : right); + final activeHeaderHasCrossed = activeHeaderPosition != null + ? physicalTop(activeHeaderPosition) <= stickyTop + : activeDayStartIndex < firstVisibleIndex; + if (!activeHeaderHasCrossed) return _ThreadStickyDateUpdate.hidden; + + double? nextHeaderTop; + for (final position in visiblePositions) { + if (!_dayHeaderIndices.contains(position.index) || + position.index <= activeDayStartIndex) { + continue; + } + final top = physicalTop(position); + if (top <= stickyTop || (nextHeaderTop != null && top >= nextHeaderTop)) { + continue; + } + nextHeaderTop = top; + } + + final rawTranslateY = nextHeaderTop == null + ? 0.0 + : math.min(0.0, nextHeaderTop - stickyTop - stickyHeaderHeight - 5); + final translateY = rawTranslateY + .clamp(-(stickyHeaderHeight + 5), 0.0) + .toDouble(); + return _ThreadStickyDateUpdate( + state: StickyDateHeaderState( + label: formatDayHeading(activeDayTimestamp), + translateY: (translateY * 2).round() / 2, + ), + activeDayTimestamp: activeDayTimestamp, + ); + } +} diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 7bab1f525e6..facf466daab 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -22,6 +22,8 @@ import 'package:buzz/features/channels/date_formatters.dart'; import 'package:buzz/features/channels/day_divider.dart'; import 'package:buzz/features/channels/emoji_picker.dart'; import 'package:buzz/features/channels/ime_metrics_settle_observer.dart'; +import 'package:buzz/features/channels/message_action_backdrop_state.dart'; +import 'package:buzz/features/channels/message_actions.dart'; import 'package:buzz/features/channels/reaction_row.dart'; import 'package:buzz/features/channels/thread_detail_page.dart'; import 'package:buzz/features/channels/thread_replies_provider.dart'; @@ -1912,6 +1914,12 @@ void main() { expect(findRichText('joined the channel'), findsOneWidget); expect(hapticCalls, hasLength(1)); expect(hapticCalls.single.arguments, 'HapticFeedbackType.mediumImpact'); + + Navigator.of( + tester.element(find.byKey(const ValueKey('reaction-popover-tray'))), + ).pop(); + await tester.pumpAndSettle(); + expect(messageActionBackdropActive.value, isFalse); }); testWidgets('reaction popover leaves existing reactions in the blur', ( @@ -1966,6 +1974,162 @@ void main() { expect(blurPath.contains(messageCenter - backgroundOrigin), isFalse); expect(blurPath.contains(reactionPillTop - backgroundOrigin), isTrue); expect(blurPath.contains(reactionCenter - backgroundOrigin), isTrue); + + Navigator.of( + tester.element(find.byKey(const ValueKey('reaction-popover-tray'))), + ).pop(); + await tester.pumpAndSettle(); + expect(messageActionBackdropActive.value, isFalse); + }); + + testWidgets('reaction-only and full actions share the stronger backdrop', ( + tester, + ) async { + addTearDown(() => messageActionBackdropActive.value = false); + await tester.pumpWidget( + _buildTestable( + messages: [ + _systemMsg( + id: 'reaction-only-blur', + payload: { + 'type': 'member_joined', + 'actor': 'alice', + 'target': 'alice', + }, + ), + _textMsg( + id: 'full-action-blur', + pubkey: 'alice', + content: 'Full action target', + createdAt: 1100, + ), + ], + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + }, + ), + ); + await tester.pumpAndSettle(); + + await tester.longPress( + find.byKey(const ValueKey('system-message-row-reaction-only-blur')), + ); + await tester.pumpAndSettle(); + expect(messageActionBackdropActive.value, isTrue); + final reactionBackdrop = tester.widget( + find.byKey(const ValueKey('reaction-popover-backdrop-filter')), + ); + final reactionTint = tester.widget( + find.byKey(const ValueKey('reaction-popover-background-tint')), + ); + + Navigator.of( + tester.element(find.byKey(const ValueKey('reaction-popover-tray'))), + ).pop(); + await tester.pumpAndSettle(); + expect(messageActionBackdropActive.value, isFalse); + + await tester.longPress( + find.byKey(const ValueKey('message-row-full-action-blur')), + ); + await tester.pumpAndSettle(); + expect(messageActionBackdropActive.value, isTrue); + final fullBackdrop = tester.widget( + find.byKey(const ValueKey('message-actions-backdrop-filter')), + ); + final fullTint = tester.widget( + find.byKey(const ValueKey('message-actions-background')), + ); + + expect(fullBackdrop.filter, same(reactionBackdrop.filter)); + expect(fullTint.color, reactionTint.color); + + Navigator.of( + tester.element(find.byKey(const ValueKey('message-action-surface'))), + ).pop(); + await tester.pumpAndSettle(); + expect(messageActionBackdropActive.value, isFalse); + }); + + testWidgets('reaction-only popovers serialize concurrent requests', ( + tester, + ) async { + await tester.pumpWidget( + _buildTestable( + messages: const [], + home: Scaffold( + body: Consumer( + builder: (context, ref, _) => TextButton( + key: const ValueKey('concurrent-reaction-launcher'), + onPressed: () { + final message = formatTimeline([ + _systemMsg( + id: 'concurrent-reaction', + payload: { + 'type': 'member_joined', + 'actor': 'alice', + 'target': 'alice', + }, + ), + ]).single; + const anchorRect = Rect.fromLTWH(32, 260, 300, 72); + showMessageActions( + context: context, + ref: ref, + message: message, + channelId: _channelId, + canManageMessage: false, + anchorRect: anchorRect, + ); + showMessageActions( + context: context, + ref: ref, + message: message, + channelId: _channelId, + canManageMessage: false, + anchorRect: anchorRect, + ); + }, + child: const Text('Open concurrent reactions'), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap( + find.byKey(const ValueKey('concurrent-reaction-launcher')), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('reaction-popover-tray')), + findsOneWidget, + ); + expect(messageActionBackdropActive.value, isTrue); + + Navigator.of( + tester.element(find.byKey(const ValueKey('reaction-popover-tray'))), + ).pop(); + await tester.pumpAndSettle(); + expect(messageActionBackdropActive.value, isFalse); + + await tester.tap( + find.byKey(const ValueKey('concurrent-reaction-launcher')), + ); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('reaction-popover-tray')), + findsOneWidget, + reason: 'The presentation latch must release after dismissal.', + ); + + Navigator.of( + tester.element(find.byKey(const ValueKey('reaction-popover-tray'))), + ).pop(); + await tester.pumpAndSettle(); + expect(messageActionBackdropActive.value, isFalse); }); testWidgets('reaction popover grows right from a fixed left edge', ( @@ -2006,6 +2170,12 @@ void main() { expect(laterRect.left, moreOrLessEquals(earlyRect.left)); expect(laterRect.width, greaterThan(earlyRect.width)); await tester.pumpAndSettle(); + + Navigator.of( + tester.element(find.byKey(const ValueKey('reaction-popover-tray'))), + ).pop(); + await tester.pumpAndSettle(); + expect(messageActionBackdropActive.value, isFalse); }); testWidgets('long press survives a message rebuild during the hold', ( @@ -2048,6 +2218,12 @@ void main() { find.byKey(const ValueKey('reaction-popover-tray')), findsOneWidget, ); + + Navigator.of( + tester.element(find.byKey(const ValueKey('reaction-popover-tray'))), + ).pop(); + await tester.pumpAndSettle(); + expect(messageActionBackdropActive.value, isFalse); }); testWidgets('long press works over nested rich message content', ( @@ -2102,6 +2278,12 @@ void main() { ); expect(find.byType(BottomSheet), findsNothing); expect(find.text('Copy text'), findsOneWidget); + + Navigator.of( + tester.element(find.byKey(const ValueKey('message-action-surface'))), + ).pop(); + await tester.pumpAndSettle(); + expect(messageActionBackdropActive.value, isFalse); }); testWidgets( @@ -5450,7 +5632,12 @@ void main() { ); replyCompleter.complete(replies); - await tester.pumpAndSettle(); + // Flush hydration, target placement, and the paint gate without + // advancing the 50 ms highlight delay through the Latest control's own + // entrance animation. + for (var frame = 0; frame < 8; frame += 1) { + await tester.pump(); + } final target = find.byKey(const ValueKey('thread-message-reply-30')); expect(target, findsOneWidget); @@ -5555,7 +5742,11 @@ void main() { expect(expiredJumpDecoration.color, Colors.transparent); await tester.pump(const Duration(seconds: 30)); - await tester.pumpAndSettle(); + // Settle the retry's microtasks without advancing the shared Latest + // control's entrance animation past the highlight's 50 ms delay. + for (var frame = 0; frame < 8; frame += 1) { + await tester.pump(); + } expect(attempts, 2); expect( @@ -6016,8 +6207,20 @@ void main() { await tester.pumpAndSettle(); expect(find.byType(DayDivider), findsNWidgets(2)); - expect(find.text(formatDayHeading(rootCreatedAt)), findsOneWidget); - expect(find.text(formatDayHeading(nextDayCreatedAt)), findsOneWidget); + expect( + find.descendant( + of: find.byType(DayDivider), + matching: find.text(formatDayHeading(rootCreatedAt)), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byType(DayDivider), + matching: find.text(formatDayHeading(nextDayCreatedAt)), + ), + findsOneWidget, + ); // The list runs top-down (head first), so tail spacing lives on the list // and reply groups carry none. final threadList = tester.widget( @@ -6068,6 +6271,97 @@ void main() { ); }); + testWidgets('thread pins and updates the active date while scrolling', ( + tester, + ) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + int timestampForDay(int day, int minute) => + DateTime(2025, 1, day, 12, minute).toUtc().millisecondsSinceEpoch ~/ + 1000; + final rootEvent = _textMsg( + id: 'sticky-thread-root', + pubkey: 'alice', + content: 'Thread root', + createdAt: timestampForDay(1, 0), + ); + final replies = [ + for (var i = 0; i < 90; i++) + _textMsg( + id: 'sticky-reply-$i', + pubkey: 'bob', + content: 'Reply $i', + createdAt: timestampForDay(1 + (i ~/ 30), i % 30), + extraTags: const [ + ['e', 'sticky-thread-root', '', 'reply'], + ], + ), + ]; + + await tester.pumpWidget( + _buildTestable( + messages: [rootEvent], + threadReplies: {'sticky-thread-root': replies}, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + ), + ); + await tester.pumpAndSettle(); + + final threadHead = formatTimeline([rootEvent]).single; + Navigator.of(tester.element(find.byType(ChannelDetailPage))).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: threadHead, + allMessages: [threadHead], + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + ), + ), + ); + await tester.pumpAndSettle(); + + final listFinder = find.byKey(const ValueKey('thread-message-list')); + final list = tester.widget(listFinder); + list.itemScrollController!.jumpTo(index: 40); + await tester.pumpAndSettle(); + + final stickyHeader = find.byKey( + const ValueKey('thread-sticky-date-header'), + ); + expect( + find.descendant( + of: stickyHeader, + matching: find.text(formatDayHeading(timestampForDay(2, 0))), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: stickyHeader, + matching: find.byType(BackdropFilter), + ), + findsOneWidget, + ); + + list.itemScrollController!.jumpTo(index: 70); + await tester.pumpAndSettle(); + expect( + find.descendant( + of: stickyHeader, + matching: find.text(formatDayHeading(timestampForDay(3, 0))), + ), + findsOneWidget, + ); + }); + testWidgets('thread keeps its tail above a growing composer dock', ( tester, ) async { @@ -8007,6 +8301,7 @@ void main() { ) async { final previousPlatform = debugDefaultTargetPlatformOverride; debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = previousPlatform); tester.view.physicalSize = const Size(400, 800); tester.view.devicePixelRatio = 1; tester.view.viewPadding = const FakeViewPadding(bottom: 20); @@ -8090,8 +8385,23 @@ void main() { .jumpTo(index: 5); await tester.pumpAndSettle(); final latestButton = find.byKey(const ValueKey('thread-jump-to-latest')); + expect( + latestReply, + findsNothing, + reason: 'Detaching from the tail should unmount the final reply.', + ); final latestButtonWasVisible = latestButton.evaluate().length == 1; - await tester.tap(latestButton); + final nativeView = tester.widget( + find.byKey(const ValueKey('thread-jump-to-latest-ios-glass')), + ); + nativeView.onPlatformViewCreated!(42); + await tester.pump(); + const nativeChannel = MethodChannel('buzz/jump_to_latest_glass/42'); + await tester.binding.defaultBinaryMessenger.handlePlatformMessage( + nativeChannel.name, + nativeChannel.codec.encodeMethodCall(const MethodCall('pressed')), + (_) {}, + ); await tester.pumpAndSettle(); final latestReplyBottom = tester.getBottomLeft(latestReply).dy; debugDefaultTargetPlatformOverride = previousPlatform; @@ -8204,6 +8514,275 @@ void main() { ); } + testWidgets('thread hides initial tail placement until it is settled', ( + tester, + ) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final rootEvent = _textMsg( + id: 'thread-root', + pubkey: 'alice', + content: 'Thread root', + createdAt: 1000, + ); + final replies = [ + for (var i = 0; i < 40; i++) + _textMsg( + id: 'reply-$i', + pubkey: 'bob', + content: 'Reply $i', + createdAt: 1100 + i, + extraTags: const [ + ['e', 'thread-root', '', 'reply'], + ], + ), + ]; + + await tester.pumpWidget( + _buildTestable( + messages: [rootEvent], + threadReplies: {'thread-root': replies}, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + ), + ); + await tester.pumpAndSettle(); + + Navigator.of(tester.element(find.byType(ChannelDetailPage))).push( + MaterialPageRoute( + builder: (_) => ThreadDetailPage( + threadHead: formatTimeline([rootEvent]).single, + allMessages: formatTimeline([rootEvent, ...replies]), + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + ), + ), + ); + await tester.pump(); + await tester.pump(); + + final gate = find.byKey(const ValueKey('thread-initial-viewport-gate')); + expect(tester.widget(gate).opacity, 0); + + final list = find.byKey(const ValueKey('thread-message-list')); + final scrollable = tester.state( + find.descendant(of: list, matching: find.byType(Scrollable)).first, + ); + expect(scrollable.position.isScrollingNotifier.value, isFalse); + + await tester.pumpAndSettle(); + + expect(tester.widget(gate).opacity, 1); + final latestReply = find.byKey( + const ValueKey('thread-message-group-reply-39'), + ); + expect( + tester.getTopLeft(latestReply).dy, + greaterThanOrEqualTo(frostedAppBarHeight(tester.element(latestReply))), + ); + expect( + tester.getTopLeft(latestReply).dy, + lessThan( + tester.getTopLeft(find.byKey(const ValueKey('composer-surface'))).dy, + ), + ); + }); + + testWidgets('thread Latest reaches the tail after inbox hydration', ( + tester, + ) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final rootEvent = _textMsg( + id: 'thread-root', + pubkey: 'alice', + content: 'Thread root', + createdAt: 1000, + ); + final replies = [ + for (var i = 0; i < 40; i++) + _textMsg( + id: 'reply-$i', + pubkey: 'bob', + content: 'Reply $i', + createdAt: 1100 + i, + extraTags: const [ + ['e', 'thread-root', '', 'reply'], + ], + ), + ]; + final authoritativeReplies = Completer>(); + + await tester.pumpWidget( + _buildTestable( + messages: [rootEvent], + pendingThreadReplies: {'thread-root': authoritativeReplies.future}, + localThreadReplies: {'thread-root': replies}, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + home: ThreadDetailPage( + threadHead: formatTimeline([rootEvent]).single, + allMessages: formatTimeline([rootEvent, replies[5]]), + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + initialMessageId: 'reply-5', + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + tester + .widget( + find.byKey(const ValueKey('thread-initial-viewport-gate')), + ) + .opacity, + 1, + reason: + 'Pending local replies must not make an in-flight relay query ' + 'look hydrated and blank the route snapshot.', + ); + expect( + find.byKey(const ValueKey('thread-message-group-reply-5')), + findsOneWidget, + ); + + authoritativeReplies.complete(replies); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('thread-jump-to-latest')), + findsOneWidget, + ); + await tester.tap(find.byKey(const ValueKey('thread-jump-to-latest'))); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('thread-message-group-reply-39')), + findsOneWidget, + ); + final composerTop = tester + .getTopLeft(find.byKey(const ValueKey('composer-surface'))) + .dy; + expect( + tester.getTopLeft(find.byKey(const ValueKey('thread-tail-anchor'))).dy, + lessThanOrEqualTo(composerTop), + reason: 'Latest must place the actual thread tail above the composer.', + ); + expect( + tester.getTopLeft(find.byKey(const ValueKey('thread-tail-anchor'))).dy, + lessThanOrEqualTo(composerTop), + reason: 'The hydrated Inbox thread must remain at its actual tail.', + ); + expect(find.byKey(const ValueKey('thread-jump-to-latest')), findsNothing); + }); + + testWidgets('thread Latest settles across expanding lazy scroll extents', ( + tester, + ) async { + tester.view.physicalSize = const Size(400, 800); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final rootEvent = _textMsg( + id: 'thread-root', + pubkey: 'alice', + content: 'Thread root', + createdAt: 1000, + ); + final replies = [ + for (var i = 0; i < 160; i++) + _textMsg( + id: 'reply-$i', + pubkey: 'bob', + content: [ + 'Reply $i', + ...List.filled( + 1 + (i ~/ 6), + 'Variable-height reply line for lazy layout.', + ), + ].join('\n'), + createdAt: 1100 + i, + extraTags: const [ + ['e', 'thread-root', '', 'reply'], + ], + ), + ]; + + await tester.pumpWidget( + _buildTestable( + messages: [rootEvent], + threadReplies: {'thread-root': replies}, + users: const { + 'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'), + 'bob': UserProfile(pubkey: 'bob', displayName: 'Bob'), + }, + home: ThreadDetailPage( + threadHead: formatTimeline([rootEvent]).single, + allMessages: formatTimeline([rootEvent, replies[5]]), + channelId: _channelId, + currentPubkey: 'self', + isMember: true, + isArchived: false, + initialMessageId: 'reply-5', + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('thread-message-group-reply-5')), + findsOneWidget, + ); + const latestButton = ValueKey('thread-jump-to-latest'); + expect(find.byKey(latestButton), findsOneWidget); + final scrollable = tester.state( + find + .descendant( + of: find.byKey(const ValueKey('thread-message-list')), + matching: find.byType(Scrollable), + ) + .first, + ); + final initialMaxScrollExtent = scrollable.position.maxScrollExtent; + + await tester.tap(find.byKey(latestButton)); + await tester.pumpAndSettle(); + + expect( + scrollable.position.maxScrollExtent, + greaterThan(initialMaxScrollExtent), + reason: + 'The fixture must exercise a lazy extent that expands after ' + 'Latest starts.', + ); + expect( + find.byKey(const ValueKey('thread-message-group-reply-159')), + findsOneWidget, + ); + expect( + find.byKey(const ValueKey('thread-jump-to-latest-hidden')), + findsOneWidget, + reason: + 'Latest must keep correcting after the lazy extent expands ' + 'beyond the first three layout frames.', + ); + }); + testWidgets( 'thread shows Latest after browsing history and returns to tail', (tester) async { @@ -8275,18 +8854,61 @@ void main() { find.byKey(const ValueKey('thread-jump-to-latest')), findsOneWidget, ); + expect( + tester.getSize(find.byKey(const ValueKey('thread-jump-to-latest'))), + const Size.square(Grid.xl), + ); + expect( + tester.getSize( + find.byKey(const ValueKey('thread-jump-to-latest-surface')), + ), + const Size.square(Grid.lg), + ); + expect( + find.descendant( + of: find.byKey(const ValueKey('thread-jump-to-latest')), + matching: find.byIcon(LucideIcons.arrowDown), + ), + findsOneWidget, + ); + expect(find.text('Latest'), findsNothing); + final threadLatestSwitcher = tester.widget( + find.byKey(const ValueKey('thread-jump-to-latest-switcher')), + ); + expect( + threadLatestSwitcher.duration, + const Duration(milliseconds: 180), + ); + expect( + threadLatestSwitcher.reverseDuration, + const Duration(milliseconds: 160), + ); final threadScrollable = tester.state( find.descendant(of: list, matching: find.byType(Scrollable)).first, ); + final startPixels = threadScrollable.position.pixels; + final targetPixels = threadScrollable.position.maxScrollExtent; await tester.tap(find.byKey(const ValueKey('thread-jump-to-latest'))); - await tester.pump(const Duration(milliseconds: 50)); + await tester.pump(); + + expect( + find.descendant(of: list, matching: find.byType(Scrollable)), + findsOneWidget, + reason: + 'Latest must move the active thread scroll position directly; ' + 'a second transitional list produces the visible bounce.', + ); expect( threadScrollable.position.isScrollingNotifier.value, isTrue, - reason: 'An explicit Latest tap should retain its navigation motion.', + reason: 'Latest should use the same visible glide as the channel.', ); + await tester.pump(const Duration(milliseconds: 110)); + expect(threadScrollable.position.pixels, greaterThan(startPixels)); + expect(threadScrollable.position.pixels, lessThan(targetPixels)); + expect(threadScrollable.position.isScrollingNotifier.value, isTrue); await tester.pumpAndSettle(); expect( @@ -8312,7 +8934,7 @@ void main() { ); testWidgets( - 'a newly sent thread reply follows the tail without animation', + 'a newly sent reply keeps Latest visible until the lazy tail settles', (tester) async { tester.view.physicalSize = const Size(400, 800); tester.view.devicePixelRatio = 1; @@ -8326,11 +8948,17 @@ void main() { createdAt: 1000, ); final replies = [ - for (var i = 0; i < 20; i++) + for (var i = 0; i < 160; i++) _textMsg( id: 'reply-$i', pubkey: 'bob', - content: 'Reply $i', + content: [ + 'Reply $i', + ...List.filled( + 1 + (i ~/ 6), + 'Variable-height reply line for lazy layout.', + ), + ].join('\n'), createdAt: 1100 + i, extraTags: const [ ['e', 'thread-root', '', 'reply'], @@ -8369,13 +8997,22 @@ void main() { await tester.pumpAndSettle(); final list = find.byKey(const ValueKey('thread-message-list')); + final positionedList = tester.widget(list); final threadScrollable = tester.state( find.descendant(of: list, matching: find.byType(Scrollable)).first, ); + positionedList.itemScrollController!.jumpTo(index: 5); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('thread-message-group-reply-159')), + findsNothing, + ); + final initialMaxScrollExtent = + threadScrollable.position.maxScrollExtent; final localReply = _textMsg( id: 'reply-local', pubkey: 'self', - content: 'My new reply', + content: 'My new reply\n${List.filled(30, 'Final line').join('\n')}', createdAt: 2000, extraTags: const [ ['e', 'thread-root', '', 'reply'], @@ -8386,6 +9023,23 @@ void main() { await tester.pump(); await tester.pump(); + expect( + find.byKey(const ValueKey('thread-jump-to-latest-hidden')), + findsNothing, + reason: + 'Automatic correction must not hide Latest before the lazy ' + 'tail is actually visible.', + ); + + await tester.pumpAndSettle(); + + expect( + threadScrollable.position.maxScrollExtent, + greaterThan(initialMaxScrollExtent), + reason: + 'The fixture must expand the lazy extent after automatic ' + 'correction starts.', + ); expect( find.byKey(const ValueKey('thread-message-group-reply-local')), findsOneWidget, diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 56f3145539b..9cd955ce52d 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -603,7 +603,7 @@ void main() { expect(skeletonSectionLabelX, sectionLabelX); }); - testWidgets('matches the community and profile avatar circle sizes', ( + testWidgets('centers the smaller profile avatar beside the community', ( tester, ) async { await tester.pumpWidget( @@ -626,7 +626,10 @@ void main() { ); expect(tester.getSize(communityAvatar), const Size.square(40)); - expect(tester.getSize(profileAvatar), const Size.square(40)); + expect(tester.getSize(profileAvatar), const Size.square(36)); + final communityRect = tester.getRect(communityAvatar); + final profileRect = tester.getRect(profileAvatar); + expect(profileRect.center.dy, communityRect.center.dy); }); testWidgets('reveals channel content from same-slot reconnect skeletons', ( diff --git a/mobile/test/features/channels/emoji_picker_test.dart b/mobile/test/features/channels/emoji_picker_test.dart index 9c1cf556169..8af821ba38d 100644 --- a/mobile/test/features/channels/emoji_picker_test.dart +++ b/mobile/test/features/channels/emoji_picker_test.dart @@ -372,13 +372,22 @@ void main() { expect(offset(), 0); await tester.tap(find.byTooltip('Animals & Nature')); - await tester.pumpAndSettle(); + await tester.pump(); // Same scroll view, moved — not a swapped-in second grid. expect(grid, findsOneWidget); expect(offset(), greaterThan(0)); // People has 200 emoji at 8 per row: 25 rows of 40px plus a 28px header. expect(offset(), closeTo(28 + 25 * 40, 0.5)); + expect( + tester + .widget(grid) + .controller! + .position + .isScrollingNotifier + .value, + isFalse, + ); }); testWidgets( diff --git a/mobile/test/features/channels/jump_to_latest_button_test.dart b/mobile/test/features/channels/jump_to_latest_button_test.dart index 26b50455859..67d16e85165 100644 --- a/mobile/test/features/channels/jump_to_latest_button_test.dart +++ b/mobile/test/features/channels/jump_to_latest_button_test.dart @@ -1,13 +1,15 @@ +import 'package:buzz/features/channels/message_action_backdrop_state.dart'; import 'package:buzz/features/channels/jump_to_latest_button.dart'; +import 'package:buzz/features/channels/jump_to_latest_switcher.dart'; import 'package:buzz/shared/theme/theme.dart'; 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:lucide_icons_flutter/lucide_icons.dart'; void main() { - testWidgets('keeps the native iOS glass in sync with the app theme', ( + testWidgets('uses native iOS liquid glass outside message-action backdrops', ( tester, ) async { debugDefaultTargetPlatformOverride = TargetPlatform.iOS; @@ -21,11 +23,9 @@ void main() { }); try { await tester.pumpWidget( - ProviderScope( - child: MaterialApp( - theme: AppTheme.light(), - home: Scaffold(body: JumpToLatestButton(onPressed: () {})), - ), + MaterialApp( + theme: AppTheme.light(), + home: Scaffold(body: JumpToLatestButton(onPressed: () {})), ), ); @@ -38,6 +38,12 @@ void main() { find.byKey(const ValueKey('channel-jump-to-latest-ios-glass')), findsOneWidget, ); + expect( + tester.getSize( + find.byKey(const ValueKey('channel-jump-to-latest-surface')), + ), + const Size.square(Grid.xl), + ); nativeView.onPlatformViewCreated!(41); await tester.pump(); @@ -48,29 +54,80 @@ void main() { 'light', ); - methodCalls.clear(); + messageActionBackdropActive.value = true; + await tester.pump(); + expect(find.byType(UiKitView), findsNothing); + expect(find.byType(BackdropFilter), findsOneWidget); + expect(find.byIcon(LucideIcons.arrowDown), findsOneWidget); + } finally { + messageActionBackdropActive.value = false; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ); + debugDefaultTargetPlatformOverride = null; + } + }); + + testWidgets('keeps the composable Flutter glass control on Android', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + var pressed = false; + try { await tester.pumpWidget( - ProviderScope( - child: MaterialApp( - theme: AppTheme.dark(), - home: Scaffold(body: JumpToLatestButton(onPressed: () {})), + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: Center( + child: JumpToLatestButton(onPressed: () => pressed = true), + ), ), ), ); - await tester.pumpAndSettle(); + expect(find.byType(UiKitView), findsNothing); + expect(find.byType(BackdropFilter), findsOneWidget); + expect(find.byIcon(LucideIcons.arrowDown), findsOneWidget); expect( - methodCalls - .lastWhere((call) => call.method == 'setBrightness') - .arguments, - 'dark', + tester.getSize(find.byType(JumpToLatestButton)), + const Size.square(Grid.xl), ); - } finally { - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - channel, - null, + expect( + tester.getSize( + find.byKey(const ValueKey('channel-jump-to-latest-surface')), + ), + const Size.square(Grid.lg), ); + + await tester.tap(find.byType(JumpToLatestButton)); + expect(pressed, isTrue); + } finally { debugDefaultTargetPlatformOverride = null; } }); + + testWidgets('gives thread controls thread-specific measurement keys', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.light(), + home: Scaffold( + body: JumpToLatestSwitcher( + id: 'thread', + visible: true, + onPressed: () {}, + ), + ), + ), + ); + + expect(find.byKey(const ValueKey('thread-jump-to-latest')), findsOneWidget); + expect( + find.byKey(const ValueKey('thread-jump-to-latest-surface')), + findsOneWidget, + ); + expect(find.text('Latest'), findsNothing); + }); } diff --git a/mobile/test/features/channels/sticky_date_header_test.dart b/mobile/test/features/channels/sticky_date_header_test.dart index 5f89ebb8106..7fab7eccf95 100644 --- a/mobile/test/features/channels/sticky_date_header_test.dart +++ b/mobile/test/features/channels/sticky_date_header_test.dart @@ -1,114 +1,208 @@ +import 'package:buzz/features/channels/message_action_backdrop_state.dart'; import 'package:buzz/features/channels/sticky_date_header.dart'; import 'package:buzz/shared/theme/theme.dart'; 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'; void main() { - testWidgets('updates the native iOS glass date and app theme', ( - tester, - ) async { - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - final state = ValueNotifier( - const StickyDateHeaderState(label: 'Yesterday'), - ); - const channel = MethodChannel('buzz/sticky_date_glass/42'); - final methodCalls = []; - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( - call, - ) async { - methodCalls.add(call); - return null; - }); - try { - await tester.pumpWidget( - ProviderScope( - child: MaterialApp( + testWidgets( + 'keeps the native iOS date compact and stationary during push-off', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + final state = ValueNotifier( + const StickyDateHeaderState(label: 'Yesterday'), + ); + const channel = MethodChannel('buzz/sticky_date_glass/42'); + final methodCalls = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, ( + call, + ) async { + methodCalls.add(call); + return null; + }); + try { + await tester.pumpWidget( + MaterialApp( theme: AppTheme.light(), home: Scaffold(body: StickyDateHeader(state: state)), ), - ), - ); + ); - var nativeView = tester.widget(find.byType(UiKitView)); - expect(nativeView.viewType, 'buzz/sticky_date_glass'); - expect(nativeView.creationParams, { - 'label': 'Yesterday', - 'brightness': 'light', - }); - expect(find.byType(BackdropFilter), findsNothing); + var nativeView = tester.widget(find.byType(UiKitView)); + expect(nativeView.viewType, 'buzz/sticky_date_glass'); + expect(nativeView.creationParams, { + 'label': 'Yesterday', + 'brightness': 'light', + }); + expect(find.byType(BackdropFilter), findsNothing); + final nativeSurface = find.byKey( + const ValueKey('channel-sticky-date-header-surface'), + ); + expect( + tester.getSize(nativeSurface).width, + lessThan(tester.getSize(find.byType(StickyDateHeader)).width), + ); + expect( + tester.getSize(nativeSurface).height, + StickyDateHeader.heightOf( + tester.element(find.byType(StickyDateHeader)), + ), + ); + expect( + find.byKey(const ValueKey('sticky-date-header-clip')), + findsNothing, + ); + final initialSurfaceTop = tester.getTopLeft(nativeSurface).dy; + final stickyHeight = StickyDateHeader.heightOf( + tester.element(find.byType(StickyDateHeader)), + ); - nativeView.onPlatformViewCreated!(42); - await tester.pump(); - expect( - methodCalls.lastWhere((call) => call.method == 'setLabel').arguments, - 'Yesterday', - ); - expect( - methodCalls - .lastWhere((call) => call.method == 'setBrightness') - .arguments, - 'light', - ); + state.value = StickyDateHeaderState( + label: 'Yesterday', + translateY: -(stickyHeight + 5) / 2, + ); + await tester.pump(); + expect( + tester + .widget( + find.byKey(const ValueKey('sticky-date-push-off-opacity')), + ) + .opacity, + closeTo(0.5, 0.001), + ); + expect( + tester.getTopLeft(nativeSurface).dy, + closeTo(initialSurfaceTop, 0.01), + reason: 'Native glass must not cross the app-bar compositing edge.', + ); - state.value = const StickyDateHeaderState(label: 'Today'); - await tester.pump(); + state.value = StickyDateHeaderState( + label: 'Yesterday', + translateY: -(stickyHeight + 5), + ); + await tester.pump(); + expect( + tester + .widget( + find.byKey(const ValueKey('sticky-date-push-off-opacity')), + ) + .opacity, + 0, + ); - nativeView = tester.widget(find.byType(UiKitView)); - expect(nativeView.creationParams, { - 'label': 'Today', - 'brightness': 'light', - }); - expect( - methodCalls.lastWhere((call) => call.method == 'setLabel').arguments, - 'Today', - ); + nativeView.onPlatformViewCreated!(42); + await tester.pump(); + expect( + methodCalls.lastWhere((call) => call.method == 'setLabel').arguments, + 'Yesterday', + ); - methodCalls.clear(); - await tester.pumpWidget( - ProviderScope( - child: MaterialApp( - theme: AppTheme.dark(), - home: Scaffold(body: StickyDateHeader(state: state)), - ), - ), - ); - await tester.pumpAndSettle(); + state.value = const StickyDateHeaderState(label: 'Today'); + await tester.pump(); + nativeView = tester.widget(find.byType(UiKitView)); + expect(nativeView.creationParams, { + 'label': 'Today', + 'brightness': 'light', + }); + expect( + methodCalls.lastWhere((call) => call.method == 'setLabel').arguments, + 'Today', + ); + expect( + tester + .widget( + find.byKey(const ValueKey('sticky-date-push-off-opacity')), + ) + .opacity, + 1, + ); - expect( - methodCalls - .lastWhere((call) => call.method == 'setBrightness') - .arguments, - 'dark', - ); - } finally { - tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( - channel, - null, - ); - state.dispose(); - debugDefaultTargetPlatformOverride = null; - } - }); + messageActionBackdropActive.value = true; + await tester.pump(); + expect(find.byType(UiKitView), findsNothing); + expect(find.byType(BackdropFilter), findsOneWidget); + expect( + find.byKey(const ValueKey('sticky-date-header-clip')), + findsNothing, + ); + expect( + tester + .getSize( + find.byKey( + const ValueKey('channel-sticky-date-header-surface'), + ), + ) + .width, + lessThan(tester.getSize(find.byType(StickyDateHeader)).width), + ); + } finally { + messageActionBackdropActive.value = false; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + null, + ); + state.dispose(); + debugDefaultTargetPlatformOverride = null; + } + }, + ); - testWidgets('keeps the Flutter date surface on Android', (tester) async { + testWidgets('keeps the compact Flutter date surface on Android', ( + tester, + ) async { debugDefaultTargetPlatformOverride = TargetPlatform.android; final state = ValueNotifier(const StickyDateHeaderState(label: 'Today')); try { await tester.pumpWidget( - ProviderScope( - child: MaterialApp( - theme: AppTheme.light(), - home: Scaffold(body: StickyDateHeader(state: state)), - ), + MaterialApp( + theme: AppTheme.light(), + home: Scaffold(body: StickyDateHeader(state: state)), ), ); expect(find.byType(UiKitView), findsNothing); expect(find.byType(BackdropFilter), findsOneWidget); expect(find.text('Today'), findsOneWidget); + expect( + find.byKey(const ValueKey('sticky-date-header-clip')), + findsNothing, + ); + final flutterSurface = find.byKey( + const ValueKey('channel-sticky-date-header-surface'), + ); + final initialSurfaceTop = tester.getTopLeft(flutterSurface).dy; + final stickyHeight = StickyDateHeader.heightOf( + tester.element(find.byType(StickyDateHeader)), + ); + + state.value = StickyDateHeaderState( + label: 'Today', + translateY: -(stickyHeight + 5) / 2, + ); + await tester.pump(); + expect( + tester + .widget( + find.byKey(const ValueKey('sticky-date-push-off-opacity')), + ) + .opacity, + closeTo(0.5, 0.001), + ); + expect( + tester.getTopLeft(flutterSurface).dy, + closeTo(initialSurfaceTop, 0.01), + reason: 'Android uses the same stationary date handoff as iOS.', + ); + expect( + tester + .getSize( + find.byKey(const ValueKey('channel-sticky-date-header-surface')), + ) + .width, + lessThan(tester.getSize(find.byType(StickyDateHeader)).width), + ); } finally { state.dispose(); debugDefaultTargetPlatformOverride = null;