Polish mobile timeline navigation

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-08-14 19:00:36 +01:00
parent 5ddf23d700
commit d5d422d9ee
13 changed files with 1204 additions and 152 deletions
@@ -14,6 +14,8 @@
4A71C0032F40200100A17E01 /* NativeAttachmentPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */; };
4A71C0052F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */; };
4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */; };
4A71C0092F40500100A17E01 /* JumpToLatestGlassButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */; };
4A71C00B2F40600100A17E01 /* StickyDateGlassHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */; };
331C809D294A63AB00263BE5 /* UIKitEncoded.png in Resources */ = {isa = PBXBuildFile; fileRef = 331C809C294A618700263BE5 /* UIKitEncoded.png */; };
331C809F294A63AB00263BE5 /* UIKitEncoded.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 331C809E294A618700263BE5 /* UIKitEncoded.jpg */; };
33ADD70AB275E0EC81295559 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8906419FB4E98B4B12B7A56F /* Pods_Runner.framework */; };
@@ -59,6 +61,8 @@
4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAttachmentPopover.swift; sourceTree = "<group>"; };
4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeAttachmentPopoverCoordinator.swift; sourceTree = "<group>"; };
4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcentricSheetSurface.swift; sourceTree = "<group>"; };
4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JumpToLatestGlassButton.swift; sourceTree = "<group>"; };
4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StickyDateGlassHeader.swift; sourceTree = "<group>"; };
331C809C294A618700263BE5 /* UIKitEncoded.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = UIKitEncoded.png; sourceTree = "<group>"; };
331C809E294A618700263BE5 /* UIKitEncoded.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = UIKitEncoded.jpg; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -182,6 +186,8 @@
4A71C0042F40200100A17E01 /* NativeAttachmentPopover.swift */,
4A71C0062F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift */,
4A71C0082F40400100A17E01 /* ConcentricSheetSurface.swift */,
4A71C00A2F40500100A17E01 /* JumpToLatestGlassButton.swift */,
4A71C00C2F40600100A17E01 /* StickyDateGlassHeader.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
@@ -417,6 +423,8 @@
4A71C0032F40200100A17E01 /* NativeAttachmentPopover.swift in Sources */,
4A71C0052F40300100A17E01 /* NativeAttachmentPopoverCoordinator.swift in Sources */,
4A71C0072F40400100A17E01 /* ConcentricSheetSurface.swift in Sources */,
4A71C0092F40500100A17E01 /* JumpToLatestGlassButton.swift in Sources */,
4A71C00B2F40600100A17E01 /* StickyDateGlassHeader.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
);
+18
View File
@@ -88,6 +88,24 @@ import UserNotifications
}
}
if let jumpToLatestGlassRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzJumpToLatestGlassButton"
) {
jumpToLatestGlassRegistrar.register(
JumpToLatestGlassButtonFactory(messenger: messenger),
withId: "buzz/jump_to_latest_glass"
)
}
if let stickyDateGlassRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzStickyDateGlassHeader"
) {
stickyDateGlassRegistrar.register(
StickyDateGlassHeaderFactory(messenger: messenger),
withId: "buzz/sticky_date_glass"
)
}
let nativeAttachmentRegistrar = engineBridge.pluginRegistry.registrar(
forPlugin: "BuzzNativeAttachmentPopover"
)
@@ -0,0 +1,96 @@
import Flutter
import UIKit
final class JumpToLatestGlassButtonFactory: NSObject, FlutterPlatformViewFactory {
private let messenger: FlutterBinaryMessenger
init(messenger: FlutterBinaryMessenger) {
self.messenger = messenger
super.init()
}
func create(
withFrame frame: CGRect,
viewIdentifier viewId: Int64,
arguments args: Any?
) -> FlutterPlatformView {
JumpToLatestGlassButtonPlatformView(
frame: frame,
viewIdentifier: viewId,
messenger: messenger
)
}
}
private final class JumpToLatestGlassButton: UIButton {
private static let hitTargetExpansion: CGFloat = 4
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
bounds
.insetBy(
dx: -Self.hitTargetExpansion,
dy: -Self.hitTargetExpansion
)
.contains(point)
}
}
final class JumpToLatestGlassButtonPlatformView: NSObject, FlutterPlatformView {
private let containerView: UIView
private let channel: FlutterMethodChannel
private let button = JumpToLatestGlassButton(type: .system)
init(
frame: CGRect,
viewIdentifier viewId: Int64,
messenger: FlutterBinaryMessenger
) {
containerView = UIView(frame: frame)
channel = FlutterMethodChannel(
name: "buzz/jump_to_latest_glass/\(viewId)",
binaryMessenger: messenger
)
super.init()
containerView.backgroundColor = .clear
containerView.isOpaque = false
var configuration: UIButton.Configuration
if #available(iOS 26.0, *) {
configuration = .glass()
} else {
configuration = .gray()
configuration.baseBackgroundColor = UIColor.secondarySystemBackground
}
configuration.cornerStyle = .capsule
configuration.baseForegroundColor = .label
configuration.image = UIImage(
systemName: "arrow.down",
withConfiguration: UIImage.SymbolConfiguration(
pointSize: 16,
weight: .semibold
)
)
button.configuration = configuration
button.accessibilityLabel = "Jump to latest message"
button.translatesAutoresizingMaskIntoConstraints = false
button.addAction(
UIAction { [weak self] _ in
self?.channel.invokeMethod("pressed", arguments: nil)
},
for: .touchUpInside
)
containerView.addSubview(button)
NSLayoutConstraint.activate([
button.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
button.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
button.widthAnchor.constraint(equalToConstant: 40),
button.heightAnchor.constraint(equalToConstant: 40),
])
}
func view() -> UIView {
containerView
}
}
@@ -0,0 +1,117 @@
import Flutter
import UIKit
private final class StickyDateGlassView: UIVisualEffectView {
override func layoutSubviews() {
super.layoutSubviews()
layer.cornerRadius = bounds.height / 2
}
}
final class StickyDateGlassHeaderFactory: NSObject, FlutterPlatformViewFactory {
private let messenger: FlutterBinaryMessenger
init(messenger: FlutterBinaryMessenger) {
self.messenger = messenger
super.init()
}
func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol {
FlutterStandardMessageCodec.sharedInstance()
}
func create(
withFrame frame: CGRect,
viewIdentifier viewId: Int64,
arguments args: Any?
) -> FlutterPlatformView {
StickyDateGlassHeaderPlatformView(
frame: frame,
viewIdentifier: viewId,
arguments: args,
messenger: messenger
)
}
}
final class StickyDateGlassHeaderPlatformView: NSObject, FlutterPlatformView {
private let glassView: StickyDateGlassView
private let channel: FlutterMethodChannel
private let dateLabel = UILabel()
init(
frame: CGRect,
viewIdentifier viewId: Int64,
arguments args: Any?,
messenger: FlutterBinaryMessenger
) {
let arguments = args as? [String: Any]
let text = arguments?["label"] as? String ?? ""
channel = FlutterMethodChannel(
name: "buzz/sticky_date_glass/\(viewId)",
binaryMessenger: messenger
)
if #available(iOS 26.0, *) {
let glassEffect = UIGlassEffect(style: .regular)
glassEffect.isInteractive = false
glassView = StickyDateGlassView(effect: glassEffect)
} else {
glassView = StickyDateGlassView(
effect: UIBlurEffect(style: .systemMaterial)
)
}
super.init()
glassView.frame = frame
glassView.isOpaque = false
glassView.isUserInteractionEnabled = false
glassView.clipsToBounds = true
glassView.layer.cornerCurve = .continuous
dateLabel.translatesAutoresizingMaskIntoConstraints = false
dateLabel.text = text
dateLabel.textAlignment = .center
dateLabel.textColor = .secondaryLabel
dateLabel.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(
for: UIFont.systemFont(ofSize: 14, weight: .medium)
)
dateLabel.adjustsFontForContentSizeCategory = true
dateLabel.numberOfLines = 1
dateLabel.lineBreakMode = .byTruncatingTail
dateLabel.isAccessibilityElement = false
glassView.contentView.addSubview(dateLabel)
NSLayoutConstraint.activate([
dateLabel.leadingAnchor.constraint(
equalTo: glassView.contentView.leadingAnchor,
constant: 12
),
dateLabel.trailingAnchor.constraint(
equalTo: glassView.contentView.trailingAnchor,
constant: -12
),
dateLabel.centerYAnchor.constraint(
equalTo: glassView.contentView.centerYAnchor
),
])
channel.setMethodCallHandler { [weak self] call, result in
guard call.method == "setLabel", let text = call.arguments as? String else {
result(FlutterMethodNotImplemented)
return
}
self?.dateLabel.text = text
result(nil)
}
}
func view() -> UIView {
glassView
}
deinit {
channel.setMethodCallHandler(nil)
}
}
@@ -1,6 +1,5 @@
import 'dart:async';
import 'dart:math' show min;
import 'dart:ui';
import 'dart:math' show max, min;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart' show ScrollDirection;
@@ -44,6 +43,7 @@ import 'date_formatters.dart';
import 'day_divider.dart';
import 'dm_channel_labels.dart';
import 'ephemeral_channel_display.dart';
import 'jump_to_latest_button.dart';
import 'members_sheet.dart';
import 'message_actions.dart';
import 'message_long_press_region.dart';
@@ -56,6 +56,7 @@ import 'reaction_row.dart';
import 'send_message_provider.dart';
import '../profile/user_profile_sheet.dart';
import 'small_avatar.dart';
import 'sticky_date_header.dart';
import 'thread_detail_page.dart';
import 'timeline_message.dart';
@@ -38,9 +38,16 @@ class _MessageList extends HookConsumerWidget {
final displayEntries = groupMembershipTimelineEntries(entries);
final itemScrollController = useMemoized(ItemScrollController.new);
final itemPositionsListener = useMemoized(ItemPositionsListener.create);
final stickyDateHeaderState = useValueNotifier(
StickyDateHeaderState.hidden,
);
final stickyDayTimestamp = useValueNotifier<int?>(null);
final timelineViewportHeight = useRef(MediaQuery.sizeOf(context).height);
final isLoadingOlder = useState(false);
final isAtLatest = useState(true);
final isJumpToLatestVisible = useState(false);
final hasUserScrolled = useState(false);
final distanceFromLatest = useRef(0.0);
final followsLatest = useRef(
initialMessageId == null && initialThreadRootId == null,
);
@@ -58,6 +65,38 @@ class _MessageList extends HookConsumerWidget {
final hasUnreadDeepLink =
initialMessageId != null || initialThreadRootId != null;
final notifier = ref.read(channelMessagesProvider(channelId).notifier);
final dayTimestampByReversedIndex = <int, int>{};
final dayStartByReversedIndex = <int, int>{};
final dayHeaderTimestampByReversedIndex = <int, int>{};
var currentDayTimestamp =
displayEntries.firstOrNull?.first.message.createdAt;
var currentDayStartIndex = displayEntries.isEmpty
? -1
: displayEntries.length - 1;
for (
var chronologicalIndex = 0;
chronologicalIndex < displayEntries.length;
chronologicalIndex += 1
) {
final message = displayEntries[chronologicalIndex].first.message;
final previousMessage = chronologicalIndex > 0
? displayEntries[chronologicalIndex - 1].last.message
: null;
final startsDay =
previousMessage == null ||
!isSameDay(previousMessage.createdAt, message.createdAt);
final reversedIndex = displayEntries.length - 1 - chronologicalIndex;
if (startsDay) {
currentDayTimestamp = message.createdAt;
currentDayStartIndex = reversedIndex;
dayHeaderTimestampByReversedIndex[reversedIndex] = message.createdAt;
}
final dayTimestamp = currentDayTimestamp;
if (dayTimestamp != null) {
dayTimestampByReversedIndex[reversedIndex] = dayTimestamp;
dayStartByReversedIndex[reversedIndex] = currentDayStartIndex;
}
}
useEffect(
() {
@@ -156,12 +195,120 @@ class _MessageList extends HookConsumerWidget {
}
double latestAlignment() {
final viewportHeight = context.size?.height ?? 0;
final viewportHeight = timelineViewportHeight.value;
return viewportHeight > 0
? (composerBottomInset / viewportHeight).clamp(0.0, 1.0).toDouble()
: 0.0;
}
void updateStickyDateHeader(Iterable<ItemPosition> rawPositions) {
void setStickyDateHeader(
StickyDateHeaderState state, {
int? activeDayTimestamp,
}) {
stickyDateHeaderState.value = state;
stickyDayTimestamp.value = activeDayTimestamp;
}
final viewportHeight = timelineViewportHeight.value;
if (viewportHeight <= 0 || displayEntries.isEmpty) {
setStickyDateHeader(StickyDateHeaderState.hidden);
return;
}
final positions = rawPositions
.where(
(position) =>
position.index < displayEntries.length &&
position.itemLeadingEdge < 1 &&
position.itemTrailingEdge > 0,
)
.toList();
if (positions.isEmpty) {
if (!isLoadingOlder.value) {
setStickyDateHeader(StickyDateHeaderState.hidden);
}
return;
}
final stickyTop =
frostedAppBarHeight(
context,
titleContentHeight: appBarTitleContentHeight,
) +
Grid.twelve;
double physicalTop(ItemPosition position) =>
viewportHeight * (1 - position.itemTrailingEdge);
double physicalBottom(ItemPosition position) =>
viewportHeight * (1 - position.itemLeadingEdge);
final positionAtStickyTop = positions
.where(
(position) =>
physicalTop(position) <= stickyTop &&
physicalBottom(position) > stickyTop,
)
.firstOrNull;
if (positionAtStickyTop == null) {
if (!isLoadingOlder.value) {
setStickyDateHeader(StickyDateHeaderState.hidden);
}
return;
}
final activeDayTimestamp =
dayTimestampByReversedIndex[positionAtStickyTop.index];
final activeDayStartIndex =
dayStartByReversedIndex[positionAtStickyTop.index];
if (activeDayTimestamp == null || activeDayStartIndex == null) {
setStickyDateHeader(StickyDateHeaderState.hidden);
return;
}
final activeHeaderPosition = positions
.where((position) => position.index == activeDayStartIndex)
.firstOrNull;
final oldestVisibleIndex = positions
.map((position) => position.index)
.reduce((a, b) => a > b ? a : b);
final activeHeaderHasCrossed = activeHeaderPosition != null
? physicalTop(activeHeaderPosition) <= stickyTop
: activeDayStartIndex > oldestVisibleIndex;
if (!activeHeaderHasCrossed) {
setStickyDateHeader(StickyDateHeaderState.hidden);
return;
}
double? nextHeaderTop;
for (final position in positions) {
if (!dayHeaderTimestampByReversedIndex.containsKey(position.index) ||
position.index >= activeDayStartIndex) {
continue;
}
final top = physicalTop(position);
if (top <= stickyTop ||
(nextHeaderTop != null && top >= nextHeaderTop)) {
continue;
}
nextHeaderTop = top;
}
final stickyHeaderHeight = StickyDateHeader.heightOf(context);
final rawTranslateY = nextHeaderTop == null
? 0.0
: min(0.0, nextHeaderTop - stickyTop - stickyHeaderHeight - 5);
final translateY = rawTranslateY
.clamp(-(stickyHeaderHeight + 5), 0.0)
.toDouble();
setStickyDateHeader(
StickyDateHeaderState(
label: formatDayHeading(activeDayTimestamp),
translateY: (translateY * 2).round() / 2,
),
activeDayTimestamp: activeDayTimestamp,
);
}
Future<void> scrollToLatest() async {
if (!itemScrollController.isAttached || isAutoScrolling.value) return;
followsLatest.value = true;
@@ -176,6 +323,7 @@ class _MessageList extends HookConsumerWidget {
);
if (context.mounted && !hasUserScrolled.value) {
isAtLatest.value = true;
isJumpToLatestVisible.value = false;
}
} finally {
isAutoScrolling.value = false;
@@ -219,6 +367,34 @@ class _MessageList extends HookConsumerWidget {
);
}
void updateJumpToLatestVisibility(
Iterable<ItemPosition> positions, {
double? viewportDimension,
}) {
final latestIsVisible = positions.any(
(position) =>
position.index == 0 &&
position.itemLeadingEdge < 1 &&
position.itemTrailingEdge > latestAlignment(),
);
final viewportHeight = viewportDimension ?? timelineViewportHeight.value;
final visiblePageHeight = max(
0.0,
viewportHeight -
frostedAppBarHeight(
context,
titleContentHeight: appBarTitleContentHeight,
) -
composerBottomInset,
);
final shouldShow =
!latestIsAtBoundary() &&
(!latestIsVisible || distanceFromLatest.value > visiblePageHeight);
if (isJumpToLatestVisible.value != shouldShow) {
isJumpToLatestVisible.value = shouldShow;
}
}
void realignLatestAfterLayoutChange() {
if (latestRealignmentQueued.value ||
!followsLatest.value ||
@@ -242,41 +418,71 @@ class _MessageList extends HookConsumerWidget {
});
}
useEffect(
() {
void onPositionsChanged() {
final positions = itemPositionsListener.itemPositions.value;
if (positions.isEmpty) return;
updateStickyDateHeader(positions);
updateJumpToLatestVisibility(positions);
final nextIsAtLatest = latestIsAtBoundary();
if (showUnreadNavigation &&
nextIsAtLatest &&
detachedWhileUnreadShown.value) {
isUnreadNavigationDismissed.value = true;
}
if (nextIsAtLatest) {
if (!isAtLatest.value) isAtLatest.value = true;
if (isJumpToLatestVisible.value) {
isJumpToLatestVisible.value = false;
}
} else if (!followsLatest.value && isAtLatest.value) {
isAtLatest.value = false;
}
final oldestVisible = positions
.map((position) => position.index)
.reduce((a, b) => a > b ? a : b);
if (!hasUserScrolled.value ||
oldestVisible < displayEntries.length - 3 ||
isLoadingOlder.value) {
return;
}
final notifier = ref.read(
channelMessagesProvider(channelId).notifier,
);
if (notifier.reachedOldest) return;
isLoadingOlder.value = true;
notifier.fetchOlder().whenComplete(
() => isLoadingOlder.value = false,
);
}
var disposed = false;
itemPositionsListener.itemPositions.addListener(onPositionsChanged);
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!disposed && context.mounted) onPositionsChanged();
});
return () {
disposed = true;
itemPositionsListener.itemPositions.removeListener(
onPositionsChanged,
);
};
},
[
channelId,
entries.length,
itemPositionsListener,
appBarTitleContentHeight,
],
);
useEffect(() {
void onPositionsChanged() {
final positions = itemPositionsListener.itemPositions.value;
if (positions.isEmpty) return;
final nextIsAtLatest = latestIsAtBoundary();
if (showUnreadNavigation &&
nextIsAtLatest &&
detachedWhileUnreadShown.value) {
isUnreadNavigationDismissed.value = true;
}
if (nextIsAtLatest) {
if (!isAtLatest.value) isAtLatest.value = true;
} else if (!followsLatest.value && isAtLatest.value) {
isAtLatest.value = false;
}
final oldestVisible = positions
.map((position) => position.index)
.reduce((a, b) => a > b ? a : b);
if (!hasUserScrolled.value ||
oldestVisible < displayEntries.length - 3 ||
isLoadingOlder.value) {
return;
}
final notifier = ref.read(channelMessagesProvider(channelId).notifier);
if (notifier.reachedOldest) return;
isLoadingOlder.value = true;
notifier.fetchOlder().whenComplete(() => isLoadingOlder.value = false);
}
itemPositionsListener.itemPositions.addListener(onPositionsChanged);
return () => itemPositionsListener.itemPositions.removeListener(
onPositionsChanged,
);
}, [channelId, entries.length, itemPositionsListener]);
stickyDateHeaderState.value = StickyDateHeaderState.hidden;
stickyDayTimestamp.value = null;
return null;
}, [channelId]);
// Composer size changes and keyboard metrics changes arrive in separate
// layout passes. Preserve the latest-message anchor for both, but only
@@ -395,8 +601,25 @@ class _MessageList extends HookConsumerWidget {
return Stack(
children: [
NotificationListener<ScrollNotification>(
NotificationListener<Notification>(
onNotification: (notification) {
if (notification is ScrollMetricsNotification) {
timelineViewportHeight.value =
notification.metrics.viewportDimension;
return false;
}
if (notification is! ScrollNotification) return false;
timelineViewportHeight.value =
notification.metrics.viewportDimension;
distanceFromLatest.value = max(
0.0,
notification.metrics.pixels -
notification.metrics.minScrollExtent,
);
updateJumpToLatestVisibility(
itemPositionsListener.itemPositions.value,
viewportDimension: notification.metrics.viewportDimension,
);
if (notification is UserScrollNotification &&
notification.direction != ScrollDirection.idle) {
hasUserScrolled.value = true;
@@ -477,7 +700,11 @@ class _MessageList extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (showDayDivider)
DayDivider(label: formatDayHeading(message.createdAt)),
DayDivider(
label: formatDayHeading(message.createdAt),
dayTimestamp: message.createdAt,
stickyDayTimestamp: stickyDayTimestamp,
),
if (message.isSystem)
_SystemMessageRow(
message: message,
@@ -521,6 +748,21 @@ class _MessageList extends HookConsumerWidget {
),
),
),
if (!showUnreadNavigation)
Positioned(
left: 0,
right: 0,
top:
frostedAppBarHeight(
context,
titleContentHeight: appBarTitleContentHeight,
) +
Grid.twelve,
child: StickyDateHeader(
key: const ValueKey('channel-sticky-date-header'),
state: stickyDateHeaderState,
),
),
if (showUnreadNavigation)
Positioned(
left: 0,
@@ -544,15 +786,38 @@ class _MessageList extends HookConsumerWidget {
),
),
)
else if (!isAtLatest.value)
else
Positioned(
left: 0,
right: 0,
bottom: composerBottomInset + Grid.xs,
child: Center(
child: _JumpToLatestButton(
key: const ValueKey('channel-jump-to-latest'),
onPressed: scrollToLatest,
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,
),
),
),
),
@@ -561,67 +826,6 @@ class _MessageList extends HookConsumerWidget {
}
}
class _JumpToLatestButton extends StatelessWidget {
final VoidCallback onPressed;
const _JumpToLatestButton({required this.onPressed, 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: const ValueKey('channel-jump-to-latest-surface'),
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,
),
),
],
),
),
),
),
),
),
),
);
}
}
class _ChannelLatestMetricsObserver with WidgetsBindingObserver {
final VoidCallback onMetricsChanged;
@@ -630,3 +834,16 @@ class _ChannelLatestMetricsObserver with WidgetsBindingObserver {
@override
void didChangeMetrics() => onMetricsChanged();
}
class _JumpToLatestScaleAnimation extends Animation<double>
with AnimationWithParentMixin<double> {
@override
final Animation<double> parent;
_JumpToLatestScaleAnimation(this.parent);
@override
double get value => parent.status == AnimationStatus.reverse
? parent.value
: 0.92 + (0.08 * parent.value);
}
+48 -41
View File
@@ -1,54 +1,61 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../../shared/theme/theme.dart';
/// Desktop-parity day separator with a centered label over a horizontal rule.
/// In-flow date label. The active date gains a glass capsule when it sticks.
class DayDivider extends StatelessWidget {
final String label;
final int? dayTimestamp;
final ValueListenable<int?>? stickyDayTimestamp;
const DayDivider({super.key, required this.label});
const DayDivider({
super.key,
required this.label,
this.dayTimestamp,
this.stickyDayTimestamp,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
child: SizedBox(
width: double.infinity,
child: Stack(
alignment: Alignment.center,
children: [
Positioned(
left: 0,
right: 0,
child: Divider(
height: 1,
thickness: 1,
color: context.colors.outlineVariant.withValues(alpha: 0.35),
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xxs + Grid.quarter,
vertical: Grid.half,
),
decoration: BoxDecoration(
color: context.colors.surface,
borderRadius: BorderRadius.circular(Radii.dialog),
border: Border.all(
color: context.colors.outlineVariant.withValues(alpha: 0.7),
),
),
child: Text(
label,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant.withValues(alpha: 0.7),
letterSpacing: 0.22,
),
),
),
],
Widget _buildOpacity(BuildContext context, {required bool isSticky}) {
return ExcludeSemantics(
excluding: isSticky,
child: AnimatedOpacity(
key: dayTimestamp == null
? null
: ValueKey('channel-day-divider-opacity-$dayTimestamp'),
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: const Duration(milliseconds: 120),
curve: Curves.easeOutCubic,
opacity: isSticky ? 0 : 1,
child: Text(
label,
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant.withValues(alpha: 0.72),
fontWeight: FontWeight.w500,
),
),
),
);
}
@override
Widget build(BuildContext context) {
final activeTimestamp = stickyDayTimestamp;
final timestamp = dayTimestamp;
return Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs + Grid.quarter),
child: Center(
child: activeTimestamp == null || timestamp == null
? _buildOpacity(context, isSticky: false)
: ValueListenableBuilder<int?>(
valueListenable: activeTimestamp,
builder: (context, activeDayTimestamp, _) => _buildOpacity(
context,
isSticky: activeDayTimestamp == timestamp,
),
),
),
);
}
}
@@ -0,0 +1,99 @@
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/theme/theme.dart';
/// Compact conversation control that returns a detached timeline to its tail.
class JumpToLatestButton extends HookWidget {
final VoidCallback onPressed;
const JumpToLatestButton({required this.onPressed, super.key});
static const _iosViewType = 'buzz/jump_to_latest_glass';
@override
Widget build(BuildContext context) {
final nativeChannel = useState<MethodChannel?>(null);
final onPressedRef = useRef(onPressed)..value = onPressed;
useEffect(() {
final channel = nativeChannel.value;
if (channel == null) return null;
channel.setMethodCallHandler((call) async {
if (call.method == 'pressed') onPressedRef.value();
});
return () => channel.setMethodCallHandler(null);
}, [nativeChannel.value]);
final borderColor = context.colors.onSurface.withValues(alpha: 0.08);
final usesNativeIosGlass = defaultTargetPlatform == TargetPlatform.iOS;
return Semantics(
button: true,
label: 'Jump to latest message',
child: Tooltip(
excludeFromSemantics: true,
message: 'Jump to latest message',
child: SizedBox.square(
dimension: Grid.xl,
child: usesNativeIosGlass
? UiKitView(
key: const ValueKey('channel-jump-to-latest-ios-glass'),
viewType: _iosViewType,
hitTestBehavior: PlatformViewHitTestBehavior.opaque,
onPlatformViewCreated: (viewId) {
nativeChannel.value = MethodChannel(
'$_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,
),
),
),
),
),
),
),
),
),
);
}
}
@@ -0,0 +1,204 @@
import 'dart:async';
import 'dart:math';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import '../../shared/theme/theme.dart';
/// The active date and vertical push-off applied to a sticky date header.
@immutable
class StickyDateHeaderState {
final String? label;
final double translateY;
const StickyDateHeaderState({this.label, this.translateY = 0});
static const hidden = StickyDateHeaderState();
bool get isVisible => label != null;
@override
bool operator ==(Object other) {
return other is StickyDateHeaderState &&
other.label == label &&
other.translateY == translateY;
}
@override
int get hashCode => Object.hash(label, translateY);
}
/// A glass date capsule that remains below the app bar as its day scrolls.
class StickyDateHeader extends StatelessWidget {
final ValueListenable<StickyDateHeaderState> state;
const StickyDateHeader({required this.state, super.key});
static const _iosViewType = 'buzz/sticky_date_glass';
static const _minimumIosGlassHeight = 28.0;
/// Height used by the surface and the next-day push-off calculation.
static double heightOf(BuildContext context) {
final labelStyle = context.textTheme.labelMedium;
final unscaledLineHeight =
(labelStyle?.fontSize ?? 14) * (labelStyle?.height ?? 1.25);
final contentHeight =
MediaQuery.textScalerOf(context).scale(unscaledLineHeight) + Grid.xxs;
return defaultTargetPlatform == TargetPlatform.iOS
? max(_minimumIosGlassHeight, contentHeight)
: contentHeight;
}
Widget _buildIosGlass(BuildContext context, String label) {
final textStyle = context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w500,
);
final textPainter = TextPainter(
text: TextSpan(text: label, style: textStyle),
maxLines: 1,
textDirection: Directionality.of(context),
textScaler: MediaQuery.textScalerOf(context),
)..layout();
return _IosStickyDateGlass(
label: label,
width: textPainter.width + Grid.sm,
height: heightOf(context),
);
}
Widget _buildFlutterSurface(BuildContext context, String label) {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(Radii.full),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.12),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(Radii.full),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Container(
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),
),
),
child: Semantics(
header: true,
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
),
),
),
),
);
}
@override
Widget build(BuildContext context) {
final reducedMotion = MediaQuery.disableAnimationsOf(context);
return ValueListenableBuilder<StickyDateHeaderState>(
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 ?? ''),
),
),
),
),
),
);
},
);
}
}
class _IosStickyDateGlass extends HookWidget {
final String label;
final double width;
final double height;
const _IosStickyDateGlass({
required this.label,
required this.width,
required this.height,
});
@override
Widget build(BuildContext context) {
final nativeChannel = useState<MethodChannel?>(null);
useEffect(() {
final channel = nativeChannel.value;
if (channel != null) {
unawaited(channel.invokeMethod<void>('setLabel', label));
}
return null;
}, [nativeChannel.value, label]);
return Semantics(
header: true,
label: label,
child: ExcludeSemantics(
child: SizedBox(
key: const ValueKey('channel-sticky-date-header-surface'),
width: width,
height: height,
child: UiKitView(
key: const ValueKey('channel-sticky-date-header-ios-glass'),
viewType: StickyDateHeader._iosViewType,
hitTestBehavior: PlatformViewHitTestBehavior.transparent,
creationParams: <String, Object>{'label': label},
creationParamsCodec: const StandardMessageCodec(),
onPlatformViewCreated: (viewId) {
nativeChannel.value = MethodChannel(
'${StickyDateHeader._iosViewType}/$viewId',
);
},
),
),
),
);
}
}
@@ -1697,8 +1697,9 @@ void main() {
find.byKey(const ValueKey('channel-jump-to-latest')),
findsOneWidget,
);
expect(find.text('Latest'), findsOneWidget);
expect(find.text('Latest'), findsNothing);
expect(find.byIcon(LucideIcons.arrowDown), findsOneWidget);
expect(find.byTooltip('Jump to latest message'), findsOneWidget);
});
testWidgets('loads history through the oldest unread boundary', (
@@ -2440,19 +2441,83 @@ void main() {
find.byKey(const ValueKey('channel-jump-to-latest')),
findsOneWidget,
);
final latestSurface = tester.widget<Container>(
find.byKey(const ValueKey('channel-jump-to-latest-surface')),
final latestSurfaceFinder = find.byKey(
const ValueKey('channel-jump-to-latest-surface'),
);
final latestSurface = tester.widget<Container>(latestSurfaceFinder);
final latestDecoration = latestSurface.decoration! as BoxDecoration;
expect(latestDecoration.borderRadius, BorderRadius.circular(Radii.full));
expect(latestDecoration.shape, BoxShape.circle);
expect(
latestDecoration.color,
AppTheme.light().colorScheme.surface.withValues(alpha: 0.5),
AppTheme.light().colorScheme.surface.withValues(alpha: 0.72),
);
expect(
(latestDecoration.border! as Border).top.color,
Colors.black.withValues(alpha: 0.04),
AppTheme.light().colorScheme.onSurface.withValues(alpha: 0.08),
);
expect(
tester.getSize(find.byKey(const ValueKey('channel-jump-to-latest'))),
const Size.square(Grid.xl),
);
expect(
tester
.getCenter(find.byKey(const ValueKey('channel-jump-to-latest')))
.dx,
closeTo(tester.getCenter(messageList).dx, 0.1),
);
expect(
tester
.getTopLeft(find.byKey(const ValueKey('channel-composer-dock')))
.dy -
tester
.getBottomRight(
find.byKey(const ValueKey('channel-jump-to-latest')),
)
.dy,
closeTo(Grid.xs, 0.1),
);
final latestSwitcher = tester.widget<AnimatedSwitcher>(
find.byKey(const ValueKey('channel-jump-to-latest-switcher')),
);
expect(latestSwitcher.duration, const Duration(milliseconds: 180));
expect(latestSwitcher.reverseDuration, const Duration(milliseconds: 160));
expect(latestSwitcher.switchInCurve, Curves.easeOutCubic);
expect(latestSwitcher.switchOutCurve, Curves.easeInCubic);
final latestScaleTransition = tester.widget<ScaleTransition>(
find.descendant(
of: find.byKey(const ValueKey('channel-jump-to-latest-switcher')),
matching: find.byType(ScaleTransition),
),
);
expect(latestScaleTransition.alignment, Alignment.bottomCenter);
final visualAnchor = tester.widget<Align>(
find.byKey(const ValueKey('channel-jump-to-latest-visual-anchor')),
);
expect(visualAnchor.alignment, Alignment.bottomCenter);
expect(tester.getSize(latestSurfaceFinder), const Size.square(Grid.lg));
expect(
tester.getBottomRight(latestSurfaceFinder).dy,
closeTo(
tester
.getBottomRight(
find.byKey(const ValueKey('channel-jump-to-latest')),
)
.dy,
0.1,
),
);
expect(find.text('Latest'), findsNothing);
expect(find.byIcon(LucideIcons.arrowDown), findsOneWidget);
for (final container in tester.widgetList<Container>(
find.descendant(
of: find.byKey(const ValueKey('channel-jump-to-latest')),
matching: find.byType(Container),
),
)) {
if (container.decoration case final BoxDecoration decoration) {
expect(decoration.boxShadow, anyOf(isNull, isEmpty));
}
}
expect(
find.descendant(
of: find.byKey(const ValueKey('channel-jump-to-latest')),
@@ -2474,6 +2539,30 @@ void main() {
expect(findRichText('Newest live update'), findsNothing);
await tester.tap(find.byKey(const ValueKey('channel-jump-to-latest')));
await tester.pump();
ScaleTransition exitingScaleTransition() {
return tester.widget<ScaleTransition>(
find.ancestor(
of: find.byKey(const ValueKey('channel-jump-to-latest')),
matching: find.byType(ScaleTransition),
),
);
}
for (var frame = 0; frame < 60; frame += 1) {
await tester.pump(const Duration(milliseconds: 16));
if (exitingScaleTransition().scale.status == AnimationStatus.reverse) {
break;
}
}
expect(exitingScaleTransition().scale.status, AnimationStatus.reverse);
await tester.pump(const Duration(milliseconds: 120));
final collapsedScaleTransition = exitingScaleTransition();
expect(collapsedScaleTransition.alignment, Alignment.bottomCenter);
expect(collapsedScaleTransition.scale.value, lessThan(0.1));
await tester.pumpAndSettle();
expect(findRichText('Newest live update'), findsOneWidget);
@@ -2491,6 +2580,74 @@ void main() {
);
});
testWidgets(
'pins the current day below the app bar after its divider scrolls away',
(tester) async {
tester.view.physicalSize = const Size(400, 600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final firstDay =
DateTime(2025, 1, 1, 12).toUtc().millisecondsSinceEpoch ~/ 1000;
final messages = [
for (var day = 0; day < 3; day += 1)
for (var index = 0; index < 10; index += 1)
_textMsg(
id: 'day-$day-message-$index',
pubkey: 'alice',
content: 'Day $day message $index',
createdAt: firstDay + day * 86400 + index,
),
];
await tester.pumpWidget(
_buildTestable(
messages: messages,
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
),
);
await tester.pumpAndSettle();
final messageList = find.byKey(const ValueKey('channel-message-list'));
final list = tester.widget<ScrollablePositionedList>(messageList);
list.itemScrollController!.jumpTo(index: 14, alignment: 0.8);
await tester.pumpAndSettle();
final stickyHeader = find.byKey(
const ValueKey('channel-sticky-date-header'),
);
final stickySurface = find.byKey(
const ValueKey('channel-sticky-date-header-surface'),
);
expect(stickyHeader, findsOneWidget);
expect(stickySurface, findsOneWidget);
expect(
find.descendant(
of: stickyHeader,
matching: find.text(formatDayHeading(firstDay + 86400)),
),
findsOneWidget,
);
expect(
tester.getTopLeft(stickySurface).dy,
closeTo(
frostedAppBarHeight(tester.element(stickyHeader)) + Grid.twelve,
1,
),
);
expect(
find.descendant(
of: stickyHeader,
matching: find.byType(BackdropFilter),
),
findsOneWidget,
);
},
);
testWidgets(
'keeps follow mode off while a tall newest message stays visible',
(tester) async {
@@ -2539,7 +2696,7 @@ void main() {
expect(findRichText('Newest message line 0'), findsOneWidget);
expect(
find.byKey(const ValueKey('channel-jump-to-latest')),
findsOneWidget,
findsNothing,
);
messagesNotifier.setMessages([
@@ -0,0 +1,44 @@
import 'package:buzz/features/channels/day_divider.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('fades the in-flow date while that day is sticky', (
tester,
) async {
final stickyDayTimestamp = ValueNotifier<int?>(null);
addTearDown(stickyDayTimestamp.dispose);
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(
body: DayDivider(
label: 'Today',
dayTimestamp: 1000,
stickyDayTimestamp: stickyDayTimestamp,
),
),
),
);
AnimatedOpacity opacity() => tester.widget<AnimatedOpacity>(
find.byKey(const ValueKey('channel-day-divider-opacity-1000')),
);
expect(opacity().opacity, 1);
stickyDayTimestamp.value = 1000;
await tester.pump();
expect(opacity().opacity, 0);
expect(opacity().duration, const Duration(milliseconds: 120));
expect(opacity().curve, Curves.easeOutCubic);
stickyDayTimestamp.value = null;
await tester.pump();
expect(opacity().opacity, 1);
});
}
@@ -0,0 +1,28 @@
import 'package:buzz/features/channels/jump_to_latest_button.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('uses the native glass control on iOS', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
try {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(body: JumpToLatestButton(onPressed: () {})),
),
);
final nativeView = tester.widget<UiKitView>(find.byType(UiKitView));
expect(nativeView.viewType, 'buzz/jump_to_latest_glass');
expect(
find.byKey(const ValueKey('channel-jump-to-latest-ios-glass')),
findsOneWidget,
);
} finally {
debugDefaultTargetPlatformOverride = null;
}
});
}
@@ -0,0 +1,56 @@
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_test/flutter_test.dart';
void main() {
testWidgets('uses native glass and updates its date on iOS', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
final state = ValueNotifier(
const StickyDateHeaderState(label: 'Yesterday'),
);
try {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Scaffold(body: StickyDateHeader(state: state)),
),
);
var nativeView = tester.widget<UiKitView>(find.byType(UiKitView));
expect(nativeView.viewType, 'buzz/sticky_date_glass');
expect(nativeView.creationParams, <String, Object>{'label': 'Yesterday'});
expect(find.byType(BackdropFilter), findsNothing);
state.value = const StickyDateHeaderState(label: 'Today');
await tester.pump();
nativeView = tester.widget<UiKitView>(find.byType(UiKitView));
expect(nativeView.creationParams, <String, Object>{'label': 'Today'});
} finally {
state.dispose();
debugDefaultTargetPlatformOverride = null;
}
});
testWidgets('keeps the Flutter date surface on Android', (tester) async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
final state = ValueNotifier(const StickyDateHeaderState(label: 'Today'));
try {
await tester.pumpWidget(
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);
} finally {
state.dispose();
debugDefaultTargetPlatformOverride = null;
}
});
}