mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Polish mobile timeline navigation
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -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 */,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user