mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Refine Duo sheets, rail controls, and appearance layout
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz> Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -30,6 +30,7 @@ final class ConcentricSheetSurfaceFactory: NSObject, FlutterPlatformViewFactory
|
||||
final class ConcentricSheetSurfacePlatformView: NSObject, FlutterPlatformView {
|
||||
private let rootView: UIView
|
||||
private let surfaceView: UIView
|
||||
private let independentCorners: Bool
|
||||
private let channel: FlutterMethodChannel
|
||||
private let corners: String
|
||||
private let usesGlass: Bool
|
||||
@@ -45,9 +46,10 @@ final class ConcentricSheetSurfacePlatformView: NSObject, FlutterPlatformView {
|
||||
let backdropColorValue = (arguments?["backdropColor"] as? NSNumber)?.uint32Value
|
||||
let minimumRadius = (arguments?["minimumRadius"] as? NSNumber)?.doubleValue ?? 24
|
||||
corners = arguments?["corners"] as? String ?? "all"
|
||||
independentCorners = (arguments?["independentCorners"] as? NSNumber)?.boolValue == true
|
||||
usesGlass = (arguments?["usesGlass"] as? NSNumber)?.boolValue == true
|
||||
|
||||
rootView = UIView(frame: frame)
|
||||
rootView = ConcentricSurfaceRootView(frame: frame)
|
||||
rootView.isUserInteractionEnabled = false
|
||||
rootView.isOpaque = !usesGlass && backdropColorValue != nil
|
||||
rootView.backgroundColor = backdropColorValue.map { Self.color(from: $0) } ?? .clear
|
||||
@@ -82,12 +84,20 @@ final class ConcentricSheetSurfacePlatformView: NSObject, FlutterPlatformView {
|
||||
applyInterfaceStyle(from: arguments?["brightness"])
|
||||
applyCornerConfiguration(minimumRadius: minimumRadius)
|
||||
|
||||
(rootView as? ConcentricSurfaceRootView)?.onLayout = { [weak self] in
|
||||
guard let self else { return }
|
||||
self.channel.invokeMethod("cornerRadiiChanged", arguments: self.cornerRadii())
|
||||
}
|
||||
|
||||
channel.setMethodCallHandler { [weak self] call, result in
|
||||
guard let self else {
|
||||
result(nil)
|
||||
return
|
||||
}
|
||||
switch call.method {
|
||||
case "getCornerRadii":
|
||||
self.rootView.layoutIfNeeded()
|
||||
result(self.cornerRadii())
|
||||
case "updateColors":
|
||||
guard
|
||||
let arguments = call.arguments as? [String: Any],
|
||||
@@ -165,6 +175,15 @@ final class ConcentricSheetSurfacePlatformView: NSObject, FlutterPlatformView {
|
||||
private func applyCornerConfiguration(minimumRadius: CGFloat) {
|
||||
if #available(iOS 26.0, *) {
|
||||
let radius = UICornerRadius.containerConcentric(minimum: minimumRadius)
|
||||
if independentCorners {
|
||||
surfaceView.cornerConfiguration = .corners(
|
||||
topLeftRadius: corners == "bottom" ? nil : radius,
|
||||
topRightRadius: corners == "bottom" ? nil : radius,
|
||||
bottomLeftRadius: radius,
|
||||
bottomRightRadius: radius
|
||||
)
|
||||
return
|
||||
}
|
||||
surfaceView.cornerConfiguration =
|
||||
corners == "bottom"
|
||||
? .uniformBottomRadius(
|
||||
@@ -184,6 +203,24 @@ final class ConcentricSheetSurfacePlatformView: NSObject, FlutterPlatformView {
|
||||
}
|
||||
}
|
||||
|
||||
private func cornerRadii() -> [String: CGFloat] {
|
||||
if #available(iOS 26.0, *) {
|
||||
return [
|
||||
"topLeft": surfaceView.effectiveRadius(corner: .topLeft),
|
||||
"topRight": surfaceView.effectiveRadius(corner: .topRight),
|
||||
"bottomLeft": surfaceView.effectiveRadius(corner: .bottomLeft),
|
||||
"bottomRight": surfaceView.effectiveRadius(corner: .bottomRight),
|
||||
]
|
||||
}
|
||||
let radius = surfaceView.layer.cornerRadius
|
||||
return [
|
||||
"topLeft": corners == "bottom" ? 0 : radius,
|
||||
"topRight": corners == "bottom" ? 0 : radius,
|
||||
"bottomLeft": radius,
|
||||
"bottomRight": radius,
|
||||
]
|
||||
}
|
||||
|
||||
private func applyInterfaceStyle(from value: Any?) {
|
||||
switch value as? String {
|
||||
case "dark":
|
||||
@@ -203,3 +240,14 @@ final class ConcentricSheetSurfacePlatformView: NSObject, FlutterPlatformView {
|
||||
return UIColor(red: red, green: green, blue: blue, alpha: alpha)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reading effective radii during layout lets UIKit invalidate the geometry
|
||||
/// when the enclosing display or presentation changes.
|
||||
private final class ConcentricSurfaceRootView: UIView {
|
||||
var onLayout: (() -> Void)?
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
onLayout?()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -410,7 +410,9 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView {
|
||||
guard let surface else { continue }
|
||||
let size = surface.systemLayoutSizeFitting(UIView.layoutFittingCompressedSize)
|
||||
surface.frame = CGRect(x: trailing ? container.bounds.width - 20 - size.width : 16,
|
||||
y: max(12, (container.bounds.height - size.height) / 2),
|
||||
y: navigation.navigationBar.prefersLargeTitles
|
||||
? max(12, flutterSafeTop + 24 + (44 - size.height) / 2)
|
||||
: max(12, (container.bounds.height - size.height) / 2),
|
||||
width: size.width, height: size.height)
|
||||
surface.layoutIfNeeded()
|
||||
}
|
||||
@@ -492,7 +494,8 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView {
|
||||
headerTrailing?.removeFromSuperview()
|
||||
headerLeading = nil
|
||||
headerTrailing = nil
|
||||
navigation.view.isHidden = standaloneControl || horizontalControls
|
||||
navigation.view.isHidden = standaloneControl
|
||||
|| (horizontalControls && args["largeTitle"] as? Bool != true)
|
||||
if standaloneControl {
|
||||
material.isHidden = true
|
||||
edgeScrollView.isHidden = true
|
||||
@@ -515,12 +518,16 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView {
|
||||
titleColor = color
|
||||
largeTitleOpacity = -1
|
||||
let largeTitle = args["largeTitle"] as? Bool == true
|
||||
// Flutter owns a continuous backdrop across the pane and status rail.
|
||||
// A second native material would stop abruptly at the pane boundary.
|
||||
let paneOwnsBackdrop = alignCollapsedTitleToLeading
|
||||
|| args["centerTitleInPane"] as? Bool == true
|
||||
alwaysFrosted = args["alwaysFrosted"] as? Bool == true
|
||||
if #available(iOS 26.0, *) {
|
||||
if #available(iOS 27.0, *) { usesSystemScrollEdge = true }
|
||||
content.scrollView.topEdgeEffect.style = .soft
|
||||
content.scrollView.topEdgeEffect.isHidden = !usesSystemScrollEdge || !alwaysFrosted || largeTitle
|
||||
edgeScrollView.isHidden = alwaysFrosted && !largeTitle
|
||||
content.scrollView.topEdgeEffect.isHidden = paneOwnsBackdrop || !usesSystemScrollEdge || !alwaysFrosted || largeTitle
|
||||
edgeScrollView.isHidden = paneOwnsBackdrop || (alwaysFrosted && !largeTitle)
|
||||
}
|
||||
// Large-title pages need material across the whole header: the stationary
|
||||
// system edge only blurs the status area, leaving expanded text over rows.
|
||||
@@ -529,12 +536,12 @@ private final class IosNavigationBarView: NSObject, FlutterPlatformView {
|
||||
// Conversations use the navigation controller's native edge. Other pages
|
||||
// use a stationary native edge so UIKit's large-title inset adjustments
|
||||
// cannot hide the status-area fade when the title expands or collapses.
|
||||
material.isHidden = usesSystemScrollEdge && !largeTitle
|
||||
material.isHidden = paneOwnsBackdrop || (usesSystemScrollEdge && !largeTitle)
|
||||
// Let the native fade extend past the bar without enlarging its hit area.
|
||||
// The mirrored content is transparent and the bottom edge is hidden.
|
||||
container.clipsToBounds = !usesSystemScrollEdge
|
||||
let appearance = UINavigationBarAppearance()
|
||||
if usesSystemScrollEdge && !largeTitle {
|
||||
if usesSystemScrollEdge && !largeTitle && !paneOwnsBackdrop {
|
||||
appearance.configureWithDefaultBackground()
|
||||
} else {
|
||||
appearance.configureWithTransparentBackground()
|
||||
|
||||
@@ -427,6 +427,11 @@ final class NativeProfileTextEditorViewController:
|
||||
target: self,
|
||||
action: #selector(setTapped)
|
||||
)
|
||||
if isChatEditor && !fieldsLocked {
|
||||
navigationItem.rightBarButtonItem?.title = nil
|
||||
navigationItem.rightBarButtonItem?.image = UIImage(systemName: "checkmark")
|
||||
navigationItem.rightBarButtonItem?.accessibilityLabel = "Create"
|
||||
}
|
||||
if #available(iOS 27.1, *) {
|
||||
navigationItem.leftBarButtonItem?.axisBehavior = .verticalPreferred
|
||||
navigationItem.rightBarButtonItem?.axisBehavior = .verticalPreferred
|
||||
|
||||
@@ -8,6 +8,78 @@ import XCTest
|
||||
|
||||
class RunnerTests: XCTestCase {
|
||||
|
||||
@MainActor
|
||||
func testListHeaderActionsAlignToPaneWhileLargeTitleRemainsVisible() throws {
|
||||
let parent = UIViewController()
|
||||
let window = UIWindow(windowScene: try XCTUnwrap(
|
||||
UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first))
|
||||
window.rootViewController = parent
|
||||
window.makeKeyAndVisible()
|
||||
defer { window.isHidden = true }
|
||||
let factory = IosNavigationBarFactory(messenger: NavigationTestMessenger(), parent: parent)
|
||||
let bar = factory.create(
|
||||
withFrame: CGRect(x: 0, y: 0, width: 400, height: 164),
|
||||
viewIdentifier: 999995,
|
||||
arguments: [
|
||||
"title": "Inbox", "largeTitle": true, "horizontalControls": true,
|
||||
"alignCollapsedTitleToLeading": true,
|
||||
"actions": [
|
||||
["id": "filter", "label": "Filter inbox", "symbol": "line.3.horizontal.decrease",
|
||||
"enabled": true, "keepInHeaderOnDuo": true],
|
||||
["id": "options", "label": "Inbox options", "symbol": "ellipsis",
|
||||
"enabled": true, "keepInHeaderOnDuo": true],
|
||||
],
|
||||
])
|
||||
parent.view.addSubview(bar.view())
|
||||
let navigation = try XCTUnwrap(parent.children.first as? UINavigationController)
|
||||
for width: CGFloat in [320, 400] {
|
||||
bar.view().frame.size.width = width
|
||||
bar.view().setNeedsLayout()
|
||||
bar.view().layoutIfNeeded()
|
||||
XCTAssertFalse(navigation.view.isHidden)
|
||||
let material = try XCTUnwrap(descendants(of: bar.view()).first {
|
||||
$0.accessibilityIdentifier == "navigation-scroll-material"
|
||||
})
|
||||
XCTAssertTrue(material.isHidden, "Flutter owns the full-width pane backdrop")
|
||||
XCTAssertTrue(navigation.navigationBar.prefersLargeTitles)
|
||||
let button = try XCTUnwrap(descendants(of: bar.view()).compactMap { $0 as? UIButton }
|
||||
.first { $0.accessibilityLabel == "Inbox options" })
|
||||
let frame = button.convert(button.bounds, to: bar.view())
|
||||
XCTAssertEqual(frame.maxX, width - 20, accuracy: 1)
|
||||
XCTAssertLessThan(frame.maxY, 90)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
func testConcentricSurfaceReportsEachResolvedCornerAfterLayout() throws {
|
||||
guard #available(iOS 26.0, *) else { return }
|
||||
let messenger = NavigationTestMessenger()
|
||||
let platformView = ConcentricSheetSurfacePlatformView(
|
||||
frame: CGRect(x: 0, y: 0, width: 400, height: 300),
|
||||
viewIdentifier: 73,
|
||||
messenger: messenger,
|
||||
arguments: ["independentCorners": true]
|
||||
)
|
||||
let root = platformView.view()
|
||||
let surface = try XCTUnwrap(root.subviews.first)
|
||||
XCTAssertEqual(surface.layer.cornerCurve, .continuous)
|
||||
for radii: [CGFloat] in [[24, 24, 62, 48], [48, 62, 24, 24]] {
|
||||
surface.cornerConfiguration = .corners(
|
||||
topLeftRadius: .fixed(radii[0]), topRightRadius: .fixed(radii[1]),
|
||||
bottomLeftRadius: .fixed(radii[2]), bottomRightRadius: .fixed(radii[3])
|
||||
)
|
||||
root.setNeedsLayout()
|
||||
root.layoutIfNeeded()
|
||||
let reported = try XCTUnwrap(messenger.cornerRadii)
|
||||
XCTAssertEqual(reported["topLeft"], radii[0])
|
||||
XCTAssertEqual(reported["topRight"], radii[1])
|
||||
XCTAssertEqual(reported["bottomLeft"], radii[2])
|
||||
XCTAssertEqual(reported["bottomRight"], radii[3])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@MainActor
|
||||
func testProfileAvatarStartupFailureRestoresSheetAndAllowsRetry() async throws {
|
||||
let messenger = NavigationTestMessenger()
|
||||
@@ -2161,12 +2233,14 @@ private actor NativeEmojiDownloadProbe {
|
||||
}
|
||||
|
||||
private final class NavigationTestMessenger: NSObject, FlutterBinaryMessenger {
|
||||
var cornerRadii: [String: CGFloat]?
|
||||
var metrics: [String: Any]?
|
||||
var avatarBounds: [String: Any]?
|
||||
var actions: [String] = []
|
||||
func send(onChannel channel: String, message: Data?) {
|
||||
guard let message else { return }
|
||||
let call = FlutterStandardMethodCodec.sharedInstance().decodeMethodCall(message)
|
||||
if call.method == "cornerRadiiChanged" { cornerRadii = call.arguments as? [String: CGFloat] }
|
||||
if call.method == "metrics" { metrics = call.arguments as? [String: Any] }
|
||||
if call.method == "avatarBounds" { avatarBounds = call.arguments as? [String: Any] }
|
||||
if call.method == "action", let action = call.arguments as? String { actions.append(action) }
|
||||
|
||||
@@ -442,6 +442,7 @@ class ActivityPage extends HookConsumerWidget {
|
||||
nativeActions: [
|
||||
IosNavigationAction(
|
||||
label: 'Filter inbox',
|
||||
keepInHeaderOnDuo: true,
|
||||
icon: BuzzIcons.filter2,
|
||||
children: [
|
||||
for (final entry in _filterLabels.entries)
|
||||
@@ -454,6 +455,7 @@ class ActivityPage extends HookConsumerWidget {
|
||||
),
|
||||
IosNavigationAction(
|
||||
label: 'Inbox options',
|
||||
keepInHeaderOnDuo: true,
|
||||
symbol: 'ellipsis',
|
||||
children: [
|
||||
IosNavigationAction(
|
||||
|
||||
@@ -10,6 +10,8 @@ import 'dart:math' show max, min, pi;
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:duo_kit/duo_kit.dart';
|
||||
import '../../shared/widgets/duo_toolbar.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart' show ScrollCacheExtent;
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -434,16 +436,24 @@ class ChannelsPage extends HookConsumerWidget {
|
||||
final role = ref.read(currentCommunityRoleProvider).unwrapPrevious();
|
||||
final canInvite = role.hasError || canManageCommunityInvites(role.value);
|
||||
unawaited(HapticFeedback.selectionClick());
|
||||
Widget menu(BuildContext _) => _CommunityMenuSheet(
|
||||
canInvite: canInvite,
|
||||
onSwitchCommunity: openCommunityGrid,
|
||||
invitePageBuilder: communityInvitePageBuilder,
|
||||
appearancePageBuilder: communityAppearancePageBuilder,
|
||||
);
|
||||
final window = MediaQueryData.fromView(View.of(context));
|
||||
final bounded =
|
||||
defaultTargetPlatform == TargetPlatform.iOS &&
|
||||
window.size.shortestSide >= 600;
|
||||
showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
useDuoFullWidth: !bounded,
|
||||
constraints: bounded ? const BoxConstraints(maxWidth: 480) : null,
|
||||
showCloseButton: false,
|
||||
showDragHandle: true,
|
||||
builder: (_) => _CommunityMenuSheet(
|
||||
canInvite: canInvite,
|
||||
onSwitchCommunity: openCommunityGrid,
|
||||
invitePageBuilder: communityInvitePageBuilder,
|
||||
appearancePageBuilder: communityAppearancePageBuilder,
|
||||
),
|
||||
builder: menu,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -256,23 +256,32 @@ class CommunitySwitcherPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
final ios = defaultTargetPlatform == TargetPlatform.iOS;
|
||||
final toolbarScope = DuoToolbarScope.maybeOf(context);
|
||||
final strip = ios
|
||||
? (toolbarScope == null
|
||||
? FoldGeometry.of(context).strip
|
||||
: toolbarScope.strip)
|
||||
: null;
|
||||
final closeAction = IosNavigationAction(
|
||||
label: 'Close community switcher',
|
||||
symbol: 'xmark',
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
);
|
||||
final editAction = IosNavigationAction(
|
||||
label: isEditing.value ? 'Done' : 'Edit',
|
||||
symbol: strip == null ? null : (isEditing.value ? 'checkmark' : 'pencil'),
|
||||
onPressed: () => isEditing.value = !isEditing.value,
|
||||
);
|
||||
final header = ios
|
||||
? SizedBox(
|
||||
height: frostedAppBarHeight(context),
|
||||
child: IosNavigationBar(
|
||||
title: '',
|
||||
leading: IosNavigationAction(
|
||||
label: 'Close community switcher',
|
||||
symbol: 'xmark',
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
actions: [
|
||||
IosNavigationAction(
|
||||
label: isEditing.value ? 'Done' : 'Edit',
|
||||
onPressed: () => isEditing.value = !isEditing.value,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: strip == null
|
||||
? IosNavigationBar(
|
||||
title: '',
|
||||
leading: closeAction,
|
||||
actions: [editAction],
|
||||
)
|
||||
: null,
|
||||
)
|
||||
: Padding(
|
||||
padding: const EdgeInsets.all(Grid.xxs),
|
||||
@@ -390,75 +399,88 @@ class CommunitySwitcherPage extends HookConsumerWidget {
|
||||
),
|
||||
);
|
||||
|
||||
return PopScope(
|
||||
canPop: flight.value == null,
|
||||
child: IgnorePointer(
|
||||
ignoring: flight.value != null,
|
||||
child: AnimatedBuilder(
|
||||
animation: Listenable.merge([centering, controller, reveal]),
|
||||
child: RepaintBoundary(child: surface),
|
||||
builder: (context, child) {
|
||||
final selected = flight.value;
|
||||
final departure = _communityDepartureCurve.transform(
|
||||
controller.value,
|
||||
);
|
||||
final surfaceExit = Curves.easeOutCubic.transform(centering.value);
|
||||
final centerProgress = _communityCenterCurve.transform(
|
||||
centering.value,
|
||||
);
|
||||
final screen = MediaQuery.sizeOf(context);
|
||||
final center = Offset(screen.width / 2, screen.height / 2);
|
||||
final destinationRect = landingBounds.value;
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (selected != null)
|
||||
return DuoToolbarPortal(
|
||||
strip: strip,
|
||||
active:
|
||||
(toolbarScope?.active ?? true) &&
|
||||
(ModalRoute.isCurrentOf(context) ?? true) &&
|
||||
flight.value == null,
|
||||
actions: [closeAction, editAction],
|
||||
child: PopScope(
|
||||
canPop: flight.value == null,
|
||||
child: IgnorePointer(
|
||||
ignoring: flight.value != null,
|
||||
child: AnimatedBuilder(
|
||||
animation: Listenable.merge([centering, controller, reveal]),
|
||||
child: RepaintBoundary(child: surface),
|
||||
builder: (context, child) {
|
||||
final selected = flight.value;
|
||||
final departure = _communityDepartureCurve.transform(
|
||||
controller.value,
|
||||
);
|
||||
final surfaceExit = Curves.easeOutCubic.transform(
|
||||
centering.value,
|
||||
);
|
||||
final centerProgress = _communityCenterCurve.transform(
|
||||
centering.value,
|
||||
);
|
||||
final screen = MediaQuery.sizeOf(context);
|
||||
final center = Offset(screen.width / 2, screen.height / 2);
|
||||
final destinationRect = landingBounds.value;
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (selected != null)
|
||||
Opacity(
|
||||
key: const Key('community-loading-backdrop'),
|
||||
opacity: 1 - Curves.easeOutCubic.transform(reveal.value),
|
||||
child: ColoredBox(color: context.colors.surface),
|
||||
),
|
||||
Opacity(
|
||||
key: const Key('community-loading-backdrop'),
|
||||
opacity: 1 - Curves.easeOutCubic.transform(reveal.value),
|
||||
child: ColoredBox(color: context.colors.surface),
|
||||
key: const Key('community-picker-opacity'),
|
||||
opacity: 1 - surfaceExit,
|
||||
child: Transform.scale(
|
||||
scale: 1 + 0.04 * surfaceExit,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
Opacity(
|
||||
key: const Key('community-picker-opacity'),
|
||||
opacity: 1 - surfaceExit,
|
||||
child: Transform.scale(
|
||||
scale: 1 + 0.04 * surfaceExit,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
if (selected != null && controller.value == 0)
|
||||
Positioned(
|
||||
top: center.dy + _communityCenteredAvatarSize / 2 + Grid.md,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: RepaintBoundary(
|
||||
child: BuzzLoadingIndicator(
|
||||
key: const Key('community-switch-loading'),
|
||||
size: 18,
|
||||
color: context.colors.onSecondaryContainer,
|
||||
semanticLabel: 'Loading ${selected.community.name}',
|
||||
if (selected != null && controller.value == 0)
|
||||
Positioned(
|
||||
top:
|
||||
center.dy +
|
||||
_communityCenteredAvatarSize / 2 +
|
||||
Grid.md,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: RepaintBoundary(
|
||||
child: BuzzLoadingIndicator(
|
||||
key: const Key('community-switch-loading'),
|
||||
size: 18,
|
||||
color: context.colors.onSecondaryContainer,
|
||||
semanticLabel: 'Loading ${selected.community.name}',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (selected != null)
|
||||
_CommunityFlyingAvatar(
|
||||
avatar: flightAvatar,
|
||||
origin: selected.origin.center,
|
||||
destination:
|
||||
destinationRect ??
|
||||
Rect.fromCenter(
|
||||
center: center,
|
||||
width: _communityCenteredAvatarSize,
|
||||
height: _communityCenteredAvatarSize,
|
||||
),
|
||||
centerProgress: centerProgress,
|
||||
departure: departure,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
if (selected != null)
|
||||
_CommunityFlyingAvatar(
|
||||
avatar: flightAvatar,
|
||||
origin: selected.origin.center,
|
||||
destination:
|
||||
destinationRect ??
|
||||
Rect.fromCenter(
|
||||
center: center,
|
||||
width: _communityCenteredAvatarSize,
|
||||
height: _communityCenteredAvatarSize,
|
||||
),
|
||||
centerProgress: centerProgress,
|
||||
departure: departure,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -137,7 +137,9 @@ class PairingPage extends HookConsumerWidget {
|
||||
}
|
||||
|
||||
Future<void> openScanner() async {
|
||||
final usesDynamicIslandPortal = await usesDynamicIslandQrScannerPortal();
|
||||
final usesDynamicIslandPortal =
|
||||
duoQrScannerCameraFrame(context) != null ||
|
||||
await usesDynamicIslandQrScannerPortal();
|
||||
if (!context.mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:ui' show lerpDouble;
|
||||
|
||||
import 'package:duo_kit/duo_kit.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/physics.dart';
|
||||
@@ -18,6 +19,25 @@ part 'pairing_qr_scanner/scanner_camera.dart';
|
||||
|
||||
const _qrScannerPlatformChannel = MethodChannel('buzz/qr_scanner');
|
||||
|
||||
/// The Duo cover camera, excluding the edge-attached status cluster.
|
||||
Rect? duoQrScannerCameraFrame(BuildContext context) {
|
||||
if (defaultTargetPlatform != TargetPlatform.iOS) return null;
|
||||
final fold = FoldGeometry.of(context);
|
||||
if (fold.strip == null) return null;
|
||||
for (final rect in fold.occlusions) {
|
||||
if (rect.width > 0 &&
|
||||
(rect.width - rect.height).abs() < 1 &&
|
||||
rect.left > 0 &&
|
||||
rect.top > 0 &&
|
||||
rect.right < fold.size.width &&
|
||||
rect.bottom < fold.size.height &&
|
||||
fold.strip!.rect.contains(rect.center)) {
|
||||
return rect;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Returns whether the current iPhone should use the Dynamic Island scanner.
|
||||
///
|
||||
/// Android, iPad, and iPhones without a Dynamic Island always use the standard
|
||||
@@ -103,6 +123,7 @@ class DynamicIslandQrScannerGeometry {
|
||||
const DynamicIslandQrScannerGeometry({
|
||||
required this.viewport,
|
||||
required this.safeAreaTop,
|
||||
this.cameraFrame,
|
||||
});
|
||||
|
||||
/// The full logical-pixel size available to the portal route.
|
||||
@@ -111,6 +132,9 @@ class DynamicIslandQrScannerGeometry {
|
||||
/// The top safe-area inset reported by iOS.
|
||||
final double safeAreaTop;
|
||||
|
||||
/// The Duo camera cutout; null retains the Dynamic Island geometry.
|
||||
final Rect? cameraFrame;
|
||||
|
||||
static const _collapsedWidth = 120.0;
|
||||
static const _collapsedHeight = 36.0;
|
||||
static const _fallbackTop = 11.0;
|
||||
@@ -125,15 +149,35 @@ class DynamicIslandQrScannerGeometry {
|
||||
_fallbackTop + math.max(0, safeAreaTop - _referenceSafeAreaTop);
|
||||
|
||||
/// The portal frame before the opening animation starts.
|
||||
Rect get collapsedFrame => Rect.fromLTWH(
|
||||
(viewport.width - _collapsedWidth) / 2,
|
||||
top,
|
||||
_collapsedWidth,
|
||||
_collapsedHeight,
|
||||
);
|
||||
Rect get collapsedFrame =>
|
||||
cameraFrame ??
|
||||
Rect.fromLTWH(
|
||||
(viewport.width - _collapsedWidth) / 2,
|
||||
top,
|
||||
_collapsedWidth,
|
||||
_collapsedHeight,
|
||||
);
|
||||
|
||||
/// The square camera frame after the opening animation completes.
|
||||
Rect get expandedFrame {
|
||||
final camera = cameraFrame;
|
||||
if (camera != null) {
|
||||
final size = math.min(
|
||||
420.0,
|
||||
math.min(viewport.width, viewport.height) - _edgeMargin * 2,
|
||||
);
|
||||
final left = camera.center.dx > viewport.width / 2
|
||||
? viewport.width - _edgeMargin - size
|
||||
: _edgeMargin;
|
||||
final desiredTop = camera.center.dy > viewport.height / 2
|
||||
? camera.bottom - size
|
||||
: camera.top;
|
||||
final top = desiredTop.clamp(
|
||||
_edgeMargin,
|
||||
viewport.height - _edgeMargin - size,
|
||||
);
|
||||
return Rect.fromLTWH(left, top, size, size);
|
||||
}
|
||||
final maxHeight = math.max(
|
||||
_collapsedHeight,
|
||||
viewport.height - top - _edgeMargin,
|
||||
@@ -149,7 +193,7 @@ class DynamicIslandQrScannerGeometry {
|
||||
final end = expandedFrame;
|
||||
return Rect.fromLTRB(
|
||||
lerpDouble(start.left, end.left, t)!,
|
||||
top,
|
||||
lerpDouble(start.top, end.top, t)!,
|
||||
lerpDouble(start.right, end.right, t)!,
|
||||
lerpDouble(start.bottom, end.bottom, t)!,
|
||||
);
|
||||
@@ -158,7 +202,7 @@ class DynamicIslandQrScannerGeometry {
|
||||
/// Returns the portal corner radius at [progress].
|
||||
double cornerRadiusAt(double progress) {
|
||||
final t = progress.clamp(0.0, 1.0);
|
||||
return lerpDouble(_collapsedHeight / 2, _expandedCornerRadius, t)!;
|
||||
return lerpDouble(collapsedFrame.height / 2, _expandedCornerRadius, t)!;
|
||||
}
|
||||
|
||||
/// Returns the camera opacity after its delayed entrance.
|
||||
|
||||
@@ -108,6 +108,7 @@ class _DynamicIslandQrScannerPortal extends HookWidget {
|
||||
final geometry = DynamicIslandQrScannerGeometry(
|
||||
viewport: constraints.biggest,
|
||||
safeAreaTop: MediaQuery.viewPaddingOf(context).top,
|
||||
cameraFrame: duoQrScannerCameraFrame(context),
|
||||
);
|
||||
|
||||
return Stack(
|
||||
@@ -133,7 +134,8 @@ class _DynamicIslandQrScannerPortal extends HookWidget {
|
||||
// Once revealed, keep the camera mounted until the route
|
||||
// closes. Native camera teardown can stall a moving frame.
|
||||
if (scannerOpacity > 0) cameraMounted.value = true;
|
||||
final introLabelOpacity = isClosing.value
|
||||
final introLabelOpacity =
|
||||
isClosing.value || geometry.cameraFrame != null
|
||||
? 0.0
|
||||
: geometry.introLabelOpacityAt(progress);
|
||||
final promptOpacity = math.max(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:duo_kit/duo_kit.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
@@ -11,6 +12,8 @@ import '../../shared/community/community_icon_provider.dart';
|
||||
import '../../shared/community/community_provider.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/widgets/duo_toolbar.dart';
|
||||
import '../../shared/widgets/ios_navigation_metrics.dart';
|
||||
import '../../shared/widgets/frosted_app_bar.dart';
|
||||
import '../../shared/widgets/frosted_scaffold.dart';
|
||||
import '../../shared/widgets/ios_glass_navigation_action.dart';
|
||||
@@ -20,13 +23,32 @@ import 'theme_option_sheets.dart';
|
||||
part 'theme_picker_page/theme_home_preview.dart';
|
||||
part 'theme_picker_page/theme_preview_sheet.dart';
|
||||
|
||||
String _themeLabelFor(ThemeColors theme, ThemeMode mode) =>
|
||||
mode == ThemeMode.system ? pairedThemeLabel(theme.name) : theme.displayName;
|
||||
|
||||
/// Appearance preview with draft mode and chat color, applied with Set.
|
||||
class ThemePickerPage extends StatelessWidget {
|
||||
const ThemePickerPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const _ThemePreviewExperience();
|
||||
Widget build(BuildContext context) {
|
||||
final parent = DuoToolbarScope.maybeOf(context);
|
||||
final strip = parent == null
|
||||
? FoldGeometry.of(context).strip
|
||||
: parent.strip;
|
||||
final insets =
|
||||
parent?.contentInsets ??
|
||||
EdgeInsets.only(
|
||||
left: strip?.side == FoldStripSide.left ? strip!.width : 0,
|
||||
right: strip?.side == FoldStripSide.right ? strip!.width : 0,
|
||||
);
|
||||
return DuoToolbarScope(
|
||||
strip: strip,
|
||||
active: parent?.active ?? true,
|
||||
contentInsets: insets,
|
||||
child: MediaQuery.removePadding(
|
||||
context: context,
|
||||
removeLeft: insets.left > 0,
|
||||
removeRight: insets.right > 0,
|
||||
child: const _ThemePreviewExperience(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ class _ThemePreviewExperience extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final inRail = DuoToolbarScope.maybeOf(context)?.strip != null;
|
||||
final stored = ref.read(communityThemeProvider);
|
||||
final draftMode = useState(stored.mode);
|
||||
final draftAccent = useState(chatColorIndex(stored.accent));
|
||||
@@ -32,6 +33,13 @@ class _ThemePreviewExperience extends HookConsumerWidget {
|
||||
draftMode.value = _appearanceOrder[(index + 1) % _appearanceOrder.length];
|
||||
}
|
||||
|
||||
void chooseColor() => showAccentColorPickerSheet(
|
||||
context: context,
|
||||
colorScheme: selectedBaseScheme,
|
||||
selectedIndex: draftAccent.value,
|
||||
onSelected: (index) => draftAccent.value = index,
|
||||
);
|
||||
|
||||
void applySelection() {
|
||||
unawaited(HapticFeedback.mediumImpact());
|
||||
ref
|
||||
@@ -53,7 +61,7 @@ class _ThemePreviewExperience extends HookConsumerWidget {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
|
||||
return FrostedScaffold(
|
||||
final scaffold = FrostedScaffold(
|
||||
useUtilitySurfaceTheme: true,
|
||||
appBar: FrostedAppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
@@ -72,6 +80,7 @@ class _ThemePreviewExperience extends HookConsumerWidget {
|
||||
actions: [_ThemePreviewSetButton(onPressed: applySelection)],
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(height: frostedAppBarHeight(context)),
|
||||
Expanded(
|
||||
@@ -80,59 +89,110 @@ class _ThemePreviewExperience extends HookConsumerWidget {
|
||||
mode: draftMode.value,
|
||||
accentIndex: draftAccent.value,
|
||||
platformBrightness: platformBrightness,
|
||||
titleHorizontalOffset: 0,
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
top: false,
|
||||
minimum: const EdgeInsets.only(bottom: Grid.xs),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
Grid.twelve,
|
||||
Grid.gutter,
|
||||
0,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 64,
|
||||
child: _PreviewSheetCircleAction(
|
||||
key: const ValueKey('theme-preview-accent-action-button'),
|
||||
semanticLabel: 'Chat color',
|
||||
color: chatColorForScheme(
|
||||
selectedBaseScheme,
|
||||
draftAccent.value,
|
||||
),
|
||||
onTap: () => showAccentColorPickerSheet(
|
||||
context: context,
|
||||
colorScheme: selectedBaseScheme,
|
||||
selectedIndex: draftAccent.value,
|
||||
onSelected: (index) => draftAccent.value = index,
|
||||
if (inRail) SizedBox(height: frostedAppBarHeight(context)),
|
||||
if (!inRail)
|
||||
SafeArea(
|
||||
top: false,
|
||||
minimum: const EdgeInsets.only(bottom: Grid.xs),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
Grid.twelve,
|
||||
Grid.gutter,
|
||||
0,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 64,
|
||||
child: _PreviewSheetCircleAction(
|
||||
key: const ValueKey(
|
||||
'theme-preview-accent-action-button',
|
||||
),
|
||||
semanticLabel: 'Chat color',
|
||||
color: chatColorForScheme(
|
||||
selectedBaseScheme,
|
||||
draftAccent.value,
|
||||
),
|
||||
onTap: chooseColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
appearanceModeLabel(draftMode.value),
|
||||
textAlign: TextAlign.center,
|
||||
Expanded(
|
||||
child: Text(
|
||||
appearanceModeLabel(draftMode.value),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 64,
|
||||
child: _AppearanceCycleAction(
|
||||
mode: draftMode.value,
|
||||
reduceMotion: reduceMotion,
|
||||
onTap: cycleAppearance,
|
||||
SizedBox(
|
||||
width: 64,
|
||||
child: _AppearanceCycleAction(
|
||||
mode: draftMode.value,
|
||||
reduceMotion: reduceMotion,
|
||||
onTap: cycleAppearance,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
final portal = useMemoized(() => OverlayPortalController()..show());
|
||||
return OverlayPortal(
|
||||
overlayLocation: OverlayChildLocation.rootOverlay,
|
||||
controller: portal,
|
||||
overlayChildBuilder: (context) {
|
||||
final scope = DuoToolbarScope.maybeOf(context);
|
||||
final strip = scope?.strip;
|
||||
if (strip == null) return const SizedBox.shrink();
|
||||
final window =
|
||||
View.of(context).physicalSize / View.of(context).devicePixelRatio;
|
||||
return Positioned(
|
||||
left:
|
||||
IosNavigationMetrics.of(
|
||||
context,
|
||||
).railCenter(window, strip.rect.center.dx) -
|
||||
32,
|
||||
top: strip.rect.bottom - strip.bottomClearance - 132,
|
||||
width: 64,
|
||||
child: Offstage(
|
||||
offstage:
|
||||
!(scope?.active ?? true) ||
|
||||
!(ModalRoute.isCurrentOf(context) ?? true),
|
||||
child: Column(
|
||||
key: const ValueKey('theme-preview-rail-bottom'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 60,
|
||||
child: _PreviewSheetCircleAction(
|
||||
key: const ValueKey('theme-preview-accent-action-button'),
|
||||
semanticLabel: 'Chat color',
|
||||
color: chatColorForScheme(
|
||||
selectedBaseScheme,
|
||||
draftAccent.value,
|
||||
),
|
||||
onTap: chooseColor,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 60,
|
||||
child: _AppearanceCycleAction(
|
||||
mode: draftMode.value,
|
||||
reduceMotion: reduceMotion,
|
||||
onTap: cycleAppearance,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: scaffold,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,85 +202,40 @@ class _ThemePreviewPage extends StatelessWidget {
|
||||
required this.mode,
|
||||
required this.accentIndex,
|
||||
required this.platformBrightness,
|
||||
required this.titleHorizontalOffset,
|
||||
});
|
||||
|
||||
final ThemeColors theme;
|
||||
final ThemeMode mode;
|
||||
final int accentIndex;
|
||||
final Brightness platformBrightness;
|
||||
final double titleHorizontalOffset;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(Grid.gutter, Grid.xxs, Grid.gutter, 0),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
Grid.xxs,
|
||||
Grid.gutter,
|
||||
DuoToolbarScope.maybeOf(context)?.strip != null ? Grid.xxs : 0,
|
||||
),
|
||||
child: DecoratedBox(
|
||||
key: ValueKey('theme-preview-page-${theme.name}'),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(Radii.container),
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
const previewWidth = 858.0;
|
||||
const previewHeight = 852.0;
|
||||
final availableWidth = constraints.maxWidth - Grid.xs * 2;
|
||||
final availableHeight = constraints.maxHeight - Grid.xs * 2;
|
||||
final widthScale = availableWidth / previewWidth;
|
||||
final heightScale = availableHeight / previewHeight;
|
||||
final scale = widthScale < heightScale ? widthScale : heightScale;
|
||||
final previewTop =
|
||||
(constraints.maxHeight - previewHeight * scale) / 2;
|
||||
final desiredTitleBottom =
|
||||
constraints.maxHeight - previewTop + Grid.md;
|
||||
final landscapeTitleBottom =
|
||||
constraints.maxHeight - Grid.xs - Grid.lg;
|
||||
final titleBottom =
|
||||
heightScale < widthScale &&
|
||||
desiredTitleBottom > landscapeTitleBottom
|
||||
? landscapeTitleBottom
|
||||
: desiredTitleBottom;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Grid.xs),
|
||||
child: FittedBox(
|
||||
key: ValueKey('theme-full-preview-${theme.name}'),
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.center,
|
||||
child: _ThemeDevicePairPreview(
|
||||
theme: theme,
|
||||
mode: mode,
|
||||
accentIndex: accentIndex,
|
||||
platformBrightness: platformBrightness,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: Grid.xs,
|
||||
right: Grid.xs,
|
||||
bottom: titleBottom,
|
||||
child: Transform.translate(
|
||||
key: ValueKey('theme-preview-name-motion-${theme.name}'),
|
||||
offset: Offset(titleHorizontalOffset, 0),
|
||||
child: Text(
|
||||
_themeLabelFor(theme, mode),
|
||||
key: ValueKey('theme-preview-name-${theme.name}'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: context.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(Grid.xs),
|
||||
child: FittedBox(
|
||||
key: ValueKey('theme-full-preview-${theme.name}'),
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.center,
|
||||
child: _ThemeDevicePairPreview(
|
||||
theme: theme,
|
||||
mode: mode,
|
||||
accentIndex: accentIndex,
|
||||
platformBrightness: platformBrightness,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -124,6 +124,58 @@ class ConcentricSheetSurface extends HookWidget {
|
||||
final surfaceColor = color ?? context.colors.surface;
|
||||
final brightness = context.theme.brightness;
|
||||
final nativeSurfaceChannel = useState<MethodChannel?>(null);
|
||||
final nativeCornerRadius = useState<BorderRadius?>(null);
|
||||
useEffect(() {
|
||||
final channel = nativeSurfaceChannel.value;
|
||||
if (channel == null) return null;
|
||||
var active = true;
|
||||
void updateRadii(dynamic value) {
|
||||
if (!active || value is! Map) return;
|
||||
double? radius(String key) {
|
||||
final number = value[key];
|
||||
return number is num && number.isFinite && number >= 0
|
||||
? number.toDouble()
|
||||
: null;
|
||||
}
|
||||
|
||||
final topLeft = radius('topLeft');
|
||||
final topRight = radius('topRight');
|
||||
final bottomLeft = radius('bottomLeft');
|
||||
final bottomRight = radius('bottomRight');
|
||||
if (topLeft == null ||
|
||||
topRight == null ||
|
||||
bottomLeft == null ||
|
||||
bottomRight == null) {
|
||||
return;
|
||||
}
|
||||
nativeCornerRadius.value = BorderRadius.only(
|
||||
topLeft: Radius.circular(topLeft),
|
||||
topRight: Radius.circular(topRight),
|
||||
bottomLeft: Radius.circular(bottomLeft),
|
||||
bottomRight: Radius.circular(bottomRight),
|
||||
);
|
||||
}
|
||||
|
||||
channel.setMethodCallHandler((call) async {
|
||||
if (call.method == 'cornerRadiiChanged') updateRadii(call.arguments);
|
||||
});
|
||||
// The platform view can lay out before Flutter installs its handler.
|
||||
Future<void> readInitialRadii() async {
|
||||
try {
|
||||
updateRadii(await channel.invokeMethod<dynamic>('getCornerRadii'));
|
||||
} on MissingPluginException {
|
||||
// Retain the fallback clip when the native view is unavailable.
|
||||
} on PlatformException {
|
||||
// A subsequent native layout can supply the geometry.
|
||||
}
|
||||
}
|
||||
|
||||
unawaited(readInitialRadii());
|
||||
return () {
|
||||
active = false;
|
||||
channel.setMethodCallHandler(null);
|
||||
};
|
||||
}, [nativeSurfaceChannel.value]);
|
||||
useEffect(
|
||||
() {
|
||||
final channel = nativeSurfaceChannel.value;
|
||||
@@ -204,6 +256,7 @@ class ConcentricSheetSurface extends HookWidget {
|
||||
'backdropColor': color.toARGB32(),
|
||||
'minimumRadius': minimumRadius,
|
||||
'corners': corners.name,
|
||||
'independentCorners': providesSheetSurface,
|
||||
'usesGlass': usesGlass,
|
||||
if (glassTintColor case final tint?)
|
||||
'glassTintColor': tint.toARGB32(),
|
||||
@@ -224,17 +277,14 @@ class ConcentricSheetSurface extends HookWidget {
|
||||
),
|
||||
ClipRSuperellipse(
|
||||
key: const ValueKey('concentric-sheet-content-clip'),
|
||||
// UIKit's container-concentric corner resolves substantially
|
||||
// larger than its minimum radius on an edge-inset iOS sheet. Keep
|
||||
// the Flutter content inside that continuous outline; a 24pt
|
||||
// circular clip still lets scrolling rows show through the native
|
||||
// corner cutouts.
|
||||
borderRadius: _borderRadius(
|
||||
contentClipRadius ??
|
||||
(nativeSurfaceSupported
|
||||
? _nativeContentClipRadius
|
||||
: minimumRadius),
|
||||
),
|
||||
// Match each resolved UIKit corner, including asymmetric display
|
||||
// corners and changes when folding or rotating the device.
|
||||
borderRadius: contentClipRadius != null
|
||||
? _borderRadius(contentClipRadius!)
|
||||
: nativeSurfaceSupported
|
||||
? (nativeCornerRadius.value ??
|
||||
_borderRadius(_nativeContentClipRadius))
|
||||
: fallbackBorderRadius,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: providesSheetSurface
|
||||
? _ConcentricSheetSurfaceScope(
|
||||
|
||||
@@ -314,6 +314,7 @@ class FrostedAppBar extends StatelessWidget {
|
||||
active: toolbarActive,
|
||||
actions: stripActions,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
// UIKit materials cannot reliably sample Flutter's scene behind
|
||||
// a platform view. Blur the scrolling pane in its own compositor
|
||||
@@ -321,19 +322,30 @@ class FrostedAppBar extends StatelessWidget {
|
||||
if (strip != null && frosted)
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
left: -(toolbarScope?.contentInsets.left ?? 0),
|
||||
right: -(toolbarScope?.contentInsets.right ?? 0),
|
||||
height: barHeight,
|
||||
child: ClipRect(
|
||||
child: BackdropFilter(
|
||||
key: const ValueKey('duo-navigation-backdrop'),
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: frostedBlurSigma,
|
||||
sigmaY: frostedBlurSigma,
|
||||
),
|
||||
child: ColoredBox(
|
||||
color: context.colors.surface.withValues(
|
||||
alpha: frostedSurfaceOpacity,
|
||||
child: IgnorePointer(
|
||||
child: ClipRect(
|
||||
child: BackdropFilter(
|
||||
key: const ValueKey('duo-navigation-backdrop'),
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: frostedBlurSigma,
|
||||
sigmaY: frostedBlurSigma,
|
||||
),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
stops: const [0.55, 1],
|
||||
colors: [
|
||||
context.colors.surfaceContainerHighest
|
||||
.withValues(alpha: frostedSurfaceOpacity),
|
||||
context.colors.surface.withValues(alpha: 0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -350,7 +362,10 @@ class FrostedAppBar extends StatelessWidget {
|
||||
strip != null && nativeLargeTitle,
|
||||
horizontalControls:
|
||||
strip != null &&
|
||||
(leadingAction?.keepInHeaderOnDuo ?? false),
|
||||
((leadingAction?.keepInHeaderOnDuo ?? false) ||
|
||||
trailingActions.any(
|
||||
(action) => action.keepInHeaderOnDuo,
|
||||
)),
|
||||
onReadyChanged: onNativeReadyChanged,
|
||||
title:
|
||||
nativeTitle ??
|
||||
|
||||
@@ -26,6 +26,7 @@ const buzzModalAnimationStyle = AnimationStyle(
|
||||
/// uses native concentric corners when available and paints a requested drag
|
||||
/// handle inside the shared header so its spacing is consistent on every
|
||||
/// platform.
|
||||
/// Set [useDuoFullWidth] to false for a bounded sheet with local controls.
|
||||
/// Set [padBottom] to false when the content owns its bottom edge spacing.
|
||||
Future<T?> showBuzzModalBottomSheet<T>({
|
||||
required BuildContext context,
|
||||
@@ -41,6 +42,7 @@ Future<T?> showBuzzModalBottomSheet<T>({
|
||||
bool isScrollControlled = false,
|
||||
double scrollControlDisabledMaxHeightRatio = 9.0 / 16.0,
|
||||
bool useRootNavigator = false,
|
||||
bool useDuoFullWidth = true,
|
||||
bool isDismissible = true,
|
||||
bool enableDrag = true,
|
||||
bool? showDragHandle,
|
||||
@@ -57,7 +59,7 @@ Future<T?> showBuzzModalBottomSheet<T>({
|
||||
final isIos = defaultTargetPlatform == TargetPlatform.iOS;
|
||||
final strip =
|
||||
DuoToolbarScope.maybeOf(context)?.strip ?? FoldGeometry.of(context).strip;
|
||||
final fullWidth = strip != null;
|
||||
final fullWidth = strip != null && useDuoFullWidth;
|
||||
final theme = Theme.of(context);
|
||||
// Explicitly colored sheets (for example the dark Huddle drawer) own their
|
||||
// complete surface treatment. Standard utility sheets use the quieter page
|
||||
@@ -74,55 +76,59 @@ Future<T?> showBuzzModalBottomSheet<T>({
|
||||
|
||||
return showModalBottomSheet<T>(
|
||||
context: context,
|
||||
builder: (sheetContext) => DuoToolbarPortal(
|
||||
strip: isIos ? strip : null,
|
||||
active: ModalRoute.isCurrentOf(sheetContext) ?? true,
|
||||
actions: [
|
||||
if (showCloseButton || showRailCloseButton)
|
||||
IosNavigationAction(
|
||||
label: 'Close ${title ?? 'sheet'}',
|
||||
symbol: 'xmark',
|
||||
onPressed: () => Navigator.of(sheetContext).pop(),
|
||||
),
|
||||
],
|
||||
child: DuoSheetCloseScope(
|
||||
inRail: isIos && fullWidth && (showCloseButton || showRailCloseButton),
|
||||
child: Theme(
|
||||
data: sheetTheme,
|
||||
child: Builder(
|
||||
builder: (themedContext) => ConcentricSheetSurface(
|
||||
enabled: isIos,
|
||||
padding: fullWidth
|
||||
? EdgeInsets.zero
|
||||
: const EdgeInsets.only(
|
||||
left: Grid.xxs,
|
||||
right: Grid.xxs,
|
||||
bottom: Grid.xxs,
|
||||
),
|
||||
color: surfaceColor,
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
bottom: false,
|
||||
left: fullWidth,
|
||||
right: fullWidth,
|
||||
child: _SheetContent(
|
||||
title: title,
|
||||
showCloseButton: showCloseButton,
|
||||
showDragHandle: showDragHandle == true,
|
||||
surfaceColor: surfaceColor,
|
||||
// Keep the entire scroll viewport above gesture navigation. The
|
||||
// inner builder sees a consumed bottom inset, so sheet-local safe
|
||||
// areas and scroll padding do not reserve that system inset twice.
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
left: false,
|
||||
right: false,
|
||||
bottom: padBottom,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: padBottom ? Grid.half : 0,
|
||||
builder: (sheetContext) => _SheetLocalSafeArea(
|
||||
localize: strip != null && !fullWidth,
|
||||
child: DuoToolbarPortal(
|
||||
strip: isIos && fullWidth ? strip : null,
|
||||
active: ModalRoute.isCurrentOf(sheetContext) ?? true,
|
||||
actions: [
|
||||
if (showCloseButton || showRailCloseButton)
|
||||
IosNavigationAction(
|
||||
label: 'Close ${title ?? 'sheet'}',
|
||||
symbol: 'xmark',
|
||||
onPressed: () => Navigator.of(sheetContext).pop(),
|
||||
),
|
||||
],
|
||||
child: DuoSheetCloseScope(
|
||||
inRail:
|
||||
isIos && fullWidth && (showCloseButton || showRailCloseButton),
|
||||
child: Theme(
|
||||
data: sheetTheme,
|
||||
child: Builder(
|
||||
builder: (themedContext) => ConcentricSheetSurface(
|
||||
enabled: isIos,
|
||||
padding: fullWidth
|
||||
? EdgeInsets.zero
|
||||
: const EdgeInsets.only(
|
||||
left: Grid.xxs,
|
||||
right: Grid.xxs,
|
||||
bottom: Grid.xxs,
|
||||
),
|
||||
color: surfaceColor,
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
bottom: false,
|
||||
left: fullWidth,
|
||||
right: fullWidth,
|
||||
child: _SheetContent(
|
||||
title: title,
|
||||
showCloseButton: showCloseButton,
|
||||
showDragHandle: showDragHandle == true,
|
||||
surfaceColor: surfaceColor,
|
||||
// Keep the entire scroll viewport above gesture navigation. The
|
||||
// inner builder sees a consumed bottom inset, so sheet-local safe
|
||||
// areas and scroll padding do not reserve that system inset twice.
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
left: false,
|
||||
right: false,
|
||||
bottom: padBottom,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: padBottom ? Grid.half : 0,
|
||||
),
|
||||
child: Builder(builder: builder),
|
||||
),
|
||||
child: Builder(builder: builder),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -143,7 +149,7 @@ Future<T?> showBuzzModalBottomSheet<T>({
|
||||
barrierColor: barrierColor,
|
||||
isScrollControlled: isScrollControlled,
|
||||
scrollControlDisabledMaxHeightRatio: scrollControlDisabledMaxHeightRatio,
|
||||
useRootNavigator: fullWidth || useRootNavigator,
|
||||
useRootNavigator: strip != null || useRootNavigator,
|
||||
isDismissible: isDismissible,
|
||||
enableDrag: enableDrag,
|
||||
// The shared header owns the handle so Android does not reserve a second
|
||||
@@ -160,6 +166,40 @@ Future<T?> showBuzzModalBottomSheet<T>({
|
||||
);
|
||||
}
|
||||
|
||||
/// Window insets only apply where they intersect a centered sheet's bounds.
|
||||
class _SheetLocalSafeArea extends StatelessWidget {
|
||||
const _SheetLocalSafeArea({required this.localize, required this.child});
|
||||
|
||||
final bool localize;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!localize) return child;
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final media = MediaQuery.of(context);
|
||||
final margin = (media.size.width - constraints.maxWidth) / 2;
|
||||
EdgeInsets localInsets(EdgeInsets insets) => insets.copyWith(
|
||||
left: (insets.left - margin).clamp(0.0, double.infinity),
|
||||
right: (insets.right - margin).clamp(0.0, double.infinity),
|
||||
);
|
||||
return DuoToolbarScope(
|
||||
strip: null,
|
||||
active: true,
|
||||
child: MediaQuery(
|
||||
data: media.copyWith(
|
||||
padding: localInsets(media.padding),
|
||||
viewPadding: localInsets(media.viewPadding),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SheetContent extends StatelessWidget {
|
||||
const _SheetContent({
|
||||
required this.child,
|
||||
|
||||
@@ -15,6 +15,7 @@ import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:buzz/shared/widgets/buzz_sheet_header.dart';
|
||||
import 'package:buzz/shared/widgets/concentric_sheet_surface.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:buzz/shared/community/community_membership_provider.dart';
|
||||
|
||||
@@ -1719,6 +1720,97 @@ void main() {
|
||||
}
|
||||
}
|
||||
|
||||
testWidgets(
|
||||
'community menu is a bounded bottom sheet when open and full width when compact',
|
||||
(tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
tester.view.padding = const FakeViewPadding(right: 76, bottom: 21);
|
||||
tester.view.viewPadding = const FakeViewPadding(right: 76, bottom: 21);
|
||||
addTearDown(tester.view.resetPadding);
|
||||
addTearDown(tester.view.resetViewPadding);
|
||||
final community = Community(
|
||||
id: 'alpha',
|
||||
name: 'Alpha',
|
||||
relayUrl: 'wss://alpha.example.com',
|
||||
addedAt: DateTime(2025),
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
buildTestable(
|
||||
overrides: [
|
||||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||||
communityListProvider.overrideWith(
|
||||
() => _FakeCommunityListNotifier([community]),
|
||||
),
|
||||
activeCommunityProvider.overrideWith((ref) async => community),
|
||||
],
|
||||
),
|
||||
);
|
||||
for (final size in [
|
||||
const Size(951, 669),
|
||||
const Size(669, 951),
|
||||
const Size(466, 678),
|
||||
]) {
|
||||
tester.view.physicalSize = size;
|
||||
await tester.pumpAndSettle();
|
||||
void openMenu() => tester
|
||||
.widget<FrostedAppBar>(find.byType(FrostedAppBar))
|
||||
.nativeLeading!
|
||||
.onPressed!();
|
||||
openMenu();
|
||||
await tester.pumpAndSettle();
|
||||
final dialog = find.byType(BottomSheet);
|
||||
if (size.shortestSide >= 600) {
|
||||
expect(dialog, findsOneWidget);
|
||||
final surface = find.descendant(
|
||||
of: dialog,
|
||||
matching: find.byType(ConcentricSheetSurface),
|
||||
);
|
||||
final rect = tester.getRect(surface);
|
||||
final menuContext = tester.element(
|
||||
find.byKey(const Key('community-switcher-sheet')),
|
||||
);
|
||||
expect(MediaQuery.paddingOf(menuContext).right, 0);
|
||||
final menu = tester.getRect(
|
||||
find.byKey(const Key('community-switcher-sheet')),
|
||||
);
|
||||
expect(menu.center.dx, closeTo(size.width / 2, 1));
|
||||
expect(rect.width, lessThanOrEqualTo(480));
|
||||
expect(rect.height, lessThan(size.height - 48));
|
||||
expect(rect.center.dx, closeTo(size.width / 2, 1));
|
||||
expect(rect.bottom, closeTo(size.height, 1));
|
||||
} else {
|
||||
expect(dialog, findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const Key('community-switcher-sheet')),
|
||||
findsOneWidget,
|
||||
);
|
||||
}
|
||||
await tester.tapAt(const Offset(5, 5));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('community-switcher-sheet')), findsNothing);
|
||||
openMenu();
|
||||
await tester.pumpAndSettle();
|
||||
await tester.drag(find.byType(BottomSheet), const Offset(0, 500));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(BottomSheet), findsNothing);
|
||||
openMenu();
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Appearance'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Appearance destination'), findsOneWidget);
|
||||
expect(dialog, findsNothing);
|
||||
Navigator.of(tester.element(find.text('Appearance destination'))).pop();
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
}
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('community settings hides invite for members', (tester) async {
|
||||
final community = Community(
|
||||
id: 'alpha',
|
||||
@@ -2240,16 +2332,31 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
for (final nativeHeader in [false, true]) {
|
||||
for (final (nativeHeader, duo) in [
|
||||
(false, false),
|
||||
(true, false),
|
||||
(true, true),
|
||||
]) {
|
||||
for (final reduceMotion in [false, true]) {
|
||||
testWidgets(
|
||||
'community selection holds enlarged until content loads (native header: $nativeHeader, reduced motion: $reduceMotion)',
|
||||
'community selection holds enlarged until content loads (native header: $nativeHeader, Duo: $duo, reduced motion: $reduceMotion)',
|
||||
(tester) async {
|
||||
debugDefaultTargetPlatformOverride = nativeHeader
|
||||
? TargetPlatform.iOS
|
||||
: TargetPlatform.android;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
tester.view.physicalSize = duo
|
||||
? const Size(466, 678)
|
||||
: const Size(390, 844);
|
||||
if (duo) {
|
||||
tester.view.viewPadding = const FakeViewPadding(
|
||||
right: 76,
|
||||
bottom: 21,
|
||||
);
|
||||
tester.view.padding = const FakeViewPadding(right: 76, bottom: 21);
|
||||
addTearDown(tester.view.resetViewPadding);
|
||||
addTearDown(tester.view.resetPadding);
|
||||
}
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
@@ -2350,7 +2457,27 @@ void main() {
|
||||
await tester.tap(find.byKey(const Key('community-menu-switch')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(progress.last, 1);
|
||||
if (nativeHeader) {
|
||||
if (duo) {
|
||||
IosNavigationAction railAction(String label) => tester
|
||||
.widget<IosNavigationBar>(
|
||||
find.descendant(
|
||||
of: find.byKey(ValueKey('duo-toolbar-$label')),
|
||||
matching: find.byType(IosNavigationBar),
|
||||
),
|
||||
)
|
||||
.leading!;
|
||||
expect(railAction('Close community switcher').symbol, 'xmark');
|
||||
expect(railAction('Edit').symbol, 'pencil');
|
||||
railAction('Edit').onPressed!();
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.byKey(const Key('community-switcher-remove-bravo')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(railAction('Done').symbol, 'checkmark');
|
||||
railAction('Done').onPressed!();
|
||||
await tester.pumpAndSettle();
|
||||
} else if (nativeHeader) {
|
||||
var navigation = tester.widget<IosNavigationBar>(
|
||||
find.byType(IosNavigationBar).last,
|
||||
);
|
||||
@@ -2385,12 +2512,24 @@ void main() {
|
||||
isTrue,
|
||||
);
|
||||
}
|
||||
if (duo) {
|
||||
expect(
|
||||
find.byKey(const ValueKey('duo-toolbar-Edit')),
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
find.byKey(
|
||||
const ValueKey('duo-toolbar-Close community switcher'),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
}
|
||||
final origin = tester.getCenter(flyingAvatar);
|
||||
await tester.pump(const Duration(milliseconds: 105));
|
||||
if (!reduceMotion) {
|
||||
expect(notifier.switchedIds, isEmpty);
|
||||
final inFlight = tester.getCenter(flyingAvatar);
|
||||
const center = Offset(195, 422);
|
||||
final center = tester.view.physicalSize.center(Offset.zero);
|
||||
expect(
|
||||
(inFlight - center).distance,
|
||||
lessThan((origin - center).distance),
|
||||
@@ -2434,7 +2573,10 @@ void main() {
|
||||
initial.text.style?.decoration,
|
||||
anyOf(isNull, TextDecoration.none),
|
||||
);
|
||||
expect(tester.getCenter(flyingAvatar), const Offset(195, 422));
|
||||
expect(
|
||||
tester.getCenter(flyingAvatar),
|
||||
tester.view.physicalSize.center(Offset.zero),
|
||||
);
|
||||
expect(
|
||||
tester.widget<Transform>(flyingAvatar).transform.storage[0],
|
||||
1.5,
|
||||
@@ -2465,7 +2607,10 @@ void main() {
|
||||
id == 'alpha' ? findsOneWidget : findsNothing,
|
||||
);
|
||||
}
|
||||
expect(tester.getCenter(flyingAvatar), const Offset(195, 422));
|
||||
expect(
|
||||
tester.getCenter(flyingAvatar),
|
||||
tester.view.physicalSize.center(Offset.zero),
|
||||
);
|
||||
expect(
|
||||
tester.widget<Transform>(flyingAvatar).transform.storage[0],
|
||||
1.5,
|
||||
@@ -2483,7 +2628,10 @@ void main() {
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
if (!reduceMotion || nativeHeader) {
|
||||
expect(tester.getCenter(flyingAvatar), const Offset(195, 422));
|
||||
expect(
|
||||
tester.getCenter(flyingAvatar),
|
||||
tester.view.physicalSize.center(Offset.zero),
|
||||
);
|
||||
expect(
|
||||
find.byKey(const Key('community-switch-loading')),
|
||||
findsOneWidget,
|
||||
@@ -2493,7 +2641,10 @@ void main() {
|
||||
if (nativeHeader) {
|
||||
// Native images/layout may finish after the Flutter body. Keep the
|
||||
// hold, and use fresh bounds rather than the old community's slot.
|
||||
expect(tester.getCenter(flyingAvatar), const Offset(195, 422));
|
||||
expect(
|
||||
tester.getCenter(flyingAvatar),
|
||||
tester.view.physicalSize.center(Offset.zero),
|
||||
);
|
||||
final header = tester.widget<FrostedAppBar>(
|
||||
find.byType(FrostedAppBar),
|
||||
);
|
||||
@@ -2508,7 +2659,10 @@ void main() {
|
||||
await tester.pump();
|
||||
// The loaded list and header are ready, but unread history and DM
|
||||
// labels must also settle before the background can be revealed.
|
||||
expect(tester.getCenter(flyingAvatar), const Offset(195, 422));
|
||||
expect(
|
||||
tester.getCenter(flyingAvatar),
|
||||
tester.view.physicalSize.center(Offset.zero),
|
||||
);
|
||||
final container = ProviderScope.containerOf(
|
||||
tester.element(find.byType(ChannelsPage)),
|
||||
);
|
||||
@@ -2517,7 +2671,10 @@ void main() {
|
||||
unreadCatchUp.complete();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
expect(tester.getCenter(flyingAvatar), const Offset(195, 422));
|
||||
expect(
|
||||
tester.getCenter(flyingAvatar),
|
||||
tester.view.physicalSize.center(Offset.zero),
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<Opacity>(
|
||||
@@ -2539,9 +2696,15 @@ void main() {
|
||||
expect(progress.last, 0);
|
||||
if (!reduceMotion) {
|
||||
await tester.pump(const Duration(milliseconds: 65));
|
||||
expect(tester.getCenter(flyingAvatar).dx, greaterThan(195));
|
||||
expect(
|
||||
tester.getCenter(flyingAvatar).dx,
|
||||
greaterThan(tester.view.physicalSize.width / 2),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 195));
|
||||
expect(tester.getCenter(flyingAvatar).dy, lessThan(422));
|
||||
expect(
|
||||
tester.getCenter(flyingAvatar).dy,
|
||||
lessThan(tester.view.physicalSize.height / 2),
|
||||
);
|
||||
expect(
|
||||
tester.getCenter(flyingAvatar).dy,
|
||||
greaterThan(destination.dy),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:buzz/features/pairing/pairing_qr_scanner.dart';
|
||||
import 'package:duo_kit/testing.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -57,6 +58,81 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
for (final pose in [
|
||||
FoldTestPose.duoCoverWithOcclusions,
|
||||
FoldTestPose.duoCoverLandscapeLeft,
|
||||
FoldTestPose.duoCoverLandscapeRight,
|
||||
]) {
|
||||
for (final reducedMotion in [false, true]) {
|
||||
testWidgets(
|
||||
'Duo camera portal ${pose.name}, reduced motion $reducedMotion',
|
||||
(tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
final media = pose.data;
|
||||
await tester.binding.setSurfaceSize(media.size);
|
||||
addTearDown(() => tester.binding.setSurfaceSize(null));
|
||||
final camera = media.displayFeatures.first.bounds;
|
||||
Rect? detectedCamera;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
builder: (_, child) => MediaQuery(
|
||||
data: media.copyWith(disableAnimations: reducedMotion),
|
||||
child: child!,
|
||||
),
|
||||
home: Builder(
|
||||
builder: (context) => TextButton(
|
||||
onPressed: () {
|
||||
detectedCamera = duoQrScannerCameraFrame(context);
|
||||
showDynamicIslandPairingQrScanner(context);
|
||||
},
|
||||
child: const Text('Open scanner'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Open scanner'));
|
||||
await tester.pump();
|
||||
expect(detectedCamera, camera);
|
||||
final portal = find.byKey(
|
||||
const ValueKey('dynamic-island-qr-scanner-portal'),
|
||||
);
|
||||
if (!reducedMotion) {
|
||||
expect(tester.getRect(portal), camera);
|
||||
expect(
|
||||
tester.widget<ClipRSuperellipse>(portal).borderRadius,
|
||||
BorderRadius.circular(camera.height / 2),
|
||||
);
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
final expanded = tester.getRect(portal);
|
||||
expect(expanded.contains(camera.center), isTrue);
|
||||
expect((Offset.zero & media.size).contains(expanded.topLeft), isTrue);
|
||||
expect(
|
||||
(Offset.zero & media.size).contains(expanded.bottomRight),
|
||||
isTrue,
|
||||
);
|
||||
expect(expanded.width, expanded.height);
|
||||
await tester.tapAt(expanded.center);
|
||||
await tester.pump();
|
||||
if (!reducedMotion) {
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
final closing = tester.getRect(portal);
|
||||
expect(
|
||||
(closing.center - camera.center).distance,
|
||||
lessThan((expanded.center - camera.center).distance),
|
||||
);
|
||||
expect(closing.width, lessThan(expanded.width));
|
||||
}
|
||||
await tester.pumpAndSettle();
|
||||
expect(portal, findsNothing);
|
||||
expect(fakeScannerPlatform._isDisposed, isTrue);
|
||||
expect(tester.takeException(), isNull);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
group('usesDynamicIslandQrScannerPortal', () {
|
||||
test('asks the native bridge on iOS', () async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import 'package:buzz/features/settings/theme_picker_page.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:buzz/shared/widgets/ios_navigation_bar.dart';
|
||||
import 'package:buzz/shared/widgets/ios_glass_navigation_button.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@@ -36,6 +39,139 @@ Future<SharedPreferences> _pumpPicker(
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'Appearance preserves its draft inside Duo content bounds across folds',
|
||||
(tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final instance = await _prefs({});
|
||||
final notifier = _RecordingCommunityThemeNotifier();
|
||||
try {
|
||||
tester.view.physicalSize = const Size(466, 678);
|
||||
tester.view.viewPadding = const FakeViewPadding(right: 76, bottom: 21);
|
||||
tester.view.padding = const FakeViewPadding(right: 76, bottom: 21);
|
||||
await tester.pumpWidget(
|
||||
WidgetHelpers.testable(
|
||||
child: const ThemePickerPage(),
|
||||
overrides: [
|
||||
savedPrefsProvider.overrideWithValue(instance),
|
||||
communityThemeProvider.overrideWith(() => notifier),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
// The OS button is the only mocked boundary.
|
||||
IosNavigationAction railAction(String label) => tester
|
||||
.widget<IosNavigationBar>(
|
||||
find.descendant(
|
||||
of: find.byKey(ValueKey('duo-toolbar-$label')),
|
||||
matching: find.byType(IosNavigationBar),
|
||||
),
|
||||
)
|
||||
.leading!;
|
||||
IosGlassNavigationButton modeButton() =>
|
||||
tester.widget<IosGlassNavigationButton>(
|
||||
find.byKey(
|
||||
const ValueKey('theme-preview-appearance-action-button'),
|
||||
),
|
||||
);
|
||||
modeButton().onPressed!();
|
||||
await tester.pumpAndSettle();
|
||||
tester
|
||||
.widget<IosGlassNavigationButton>(
|
||||
find.descendant(
|
||||
of: find.byKey(
|
||||
const ValueKey('theme-preview-accent-action-button'),
|
||||
),
|
||||
matching: find.byType(IosGlassNavigationButton),
|
||||
),
|
||||
)
|
||||
.onPressed!();
|
||||
await tester.pumpAndSettle();
|
||||
final jade = chatColorChoices.indexWhere(
|
||||
(color) => color.name == 'Jade',
|
||||
);
|
||||
final option = find.byKey(ValueKey('accent-option-$jade'));
|
||||
await tester.ensureVisible(option);
|
||||
await tester.tap(option);
|
||||
await tester.pumpAndSettle();
|
||||
Navigator.of(tester.element(option)).pop();
|
||||
await tester.pumpAndSettle();
|
||||
for (final (size, left) in [
|
||||
(const Size(466, 678), false),
|
||||
(const Size(951, 669), false),
|
||||
(const Size(669, 951), false),
|
||||
(const Size(678, 466), true),
|
||||
]) {
|
||||
tester.view.physicalSize = size;
|
||||
final padding = FakeViewPadding(
|
||||
left: left ? 76 : 0,
|
||||
right: left ? 0 : 76,
|
||||
bottom: 21,
|
||||
);
|
||||
tester.view.viewPadding = padding;
|
||||
tester.view.padding = padding;
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
expect(modeButton().icon, IosGlassNavigationIcon.sun);
|
||||
expect(notifier.preferenceWrites, 0);
|
||||
final preview = tester.getRect(
|
||||
find.byKey(const ValueKey('theme-preview-page-$buzzThemeName')),
|
||||
);
|
||||
expect(preview.left, greaterThanOrEqualTo(left ? 76 : 0));
|
||||
expect(
|
||||
preview.right,
|
||||
lessThanOrEqualTo(size.width - (left ? 0 : 76)),
|
||||
);
|
||||
final images = tester.getCenter(
|
||||
find.byKey(
|
||||
const ValueKey('theme-device-pair-preview-$buzzThemeName'),
|
||||
),
|
||||
);
|
||||
expect(images.dy, closeTo(size.height / 2, 1));
|
||||
expect(images.dx, closeTo((size.width + (left ? 76 : -76)) / 2, 1));
|
||||
final controls = tester.getRect(
|
||||
find.byKey(const ValueKey('theme-preview-rail-bottom')),
|
||||
);
|
||||
final top = tester.getRect(
|
||||
find.byKey(const ValueKey('duo-toolbar-Set')),
|
||||
);
|
||||
expect(controls.top, greaterThan(top.bottom));
|
||||
expect(controls.bottom, lessThanOrEqualTo(size.height));
|
||||
expect(controls.center.dx, closeTo(top.center.dx, 1));
|
||||
final swatch = tester.widget<IosGlassNavigationButton>(
|
||||
find.descendant(
|
||||
of: find.byKey(
|
||||
const ValueKey('theme-preview-accent-action-button'),
|
||||
),
|
||||
matching: find.byType(IosGlassNavigationButton),
|
||||
),
|
||||
);
|
||||
expect(swatch.icon, IosGlassNavigationIcon.colorSwatch);
|
||||
expect(
|
||||
swatch.swatchColor,
|
||||
chatColorForScheme(lightColorScheme, jade),
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('theme-preview-name-$buzzThemeName')),
|
||||
findsNothing,
|
||||
);
|
||||
}
|
||||
railAction('Set').onPressed!();
|
||||
await tester.pumpAndSettle();
|
||||
expect(notifier.preferenceWrites, 1);
|
||||
expect(notifier.lastPreference?.mode, ThemeMode.light);
|
||||
expect(
|
||||
notifier.lastPreference?.accent,
|
||||
chatColorChoices[jade].wireValue,
|
||||
);
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('one Home and Chat pair replaces the theme carousel', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:duo_kit/duo_kit.dart';
|
||||
import 'package:buzz/shared/widgets/duo_toolbar.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
|
||||
import 'package:buzz/shared/widgets/frosted_scaffold.dart';
|
||||
import 'package:buzz/shared/widgets/frosted_scroll_under_scope.dart';
|
||||
import 'package:buzz/shared/widgets/ios_glass_navigation_button.dart';
|
||||
import 'package:buzz/shared/widgets/ios_navigation_bar.dart';
|
||||
@@ -13,6 +14,56 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Duo backdrop spans the rail while title stays in content', (
|
||||
tester,
|
||||
) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
try {
|
||||
for (final left in [false, true]) {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: DuoToolbarScope(
|
||||
active: true,
|
||||
strip: FoldStrip(
|
||||
side: left ? FoldStripSide.left : FoldStripSide.right,
|
||||
rect: Rect.fromLTWH(left ? 0 : 700, 0, 100, 600),
|
||||
topClearance: 170,
|
||||
bottomClearance: 0,
|
||||
isClearanceEstimated: true,
|
||||
),
|
||||
contentInsets: EdgeInsets.only(
|
||||
left: left ? 100 : 0,
|
||||
right: left ? 0 : 100,
|
||||
),
|
||||
child: const FrostedScaffold(
|
||||
appBar: FrostedAppBar(
|
||||
nativeTitle: 'Solo',
|
||||
nativeLargeTitle: true,
|
||||
),
|
||||
body: SizedBox.expand(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
final backdrop = tester.getRect(
|
||||
find.byKey(const ValueKey('duo-navigation-backdrop')),
|
||||
);
|
||||
final header = tester.getRect(find.byType(IosNavigationBar));
|
||||
expect(backdrop.left, 0);
|
||||
expect(backdrop.right, 800);
|
||||
expect(header.left, left ? 100 : 0);
|
||||
expect(header.right, left ? 800 : 700);
|
||||
}
|
||||
} finally {
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
}
|
||||
});
|
||||
|
||||
testWidgets('Duo keeps list actions in header and profile on aligned rail', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -20,6 +71,8 @@ void main() {
|
||||
addTearDown(() => debugDefaultTargetPlatformOverride = null);
|
||||
final active = ValueNotifier(true);
|
||||
final hasStrip = ValueNotifier(true);
|
||||
final listHeader = ValueNotifier(false);
|
||||
addTearDown(listHeader.dispose);
|
||||
addTearDown(active.dispose);
|
||||
addTearDown(hasStrip.dispose);
|
||||
var presses = 0;
|
||||
@@ -35,7 +88,7 @@ void main() {
|
||||
),
|
||||
theme: AppTheme.light(),
|
||||
home: ListenableBuilder(
|
||||
listenable: Listenable.merge([active, hasStrip]),
|
||||
listenable: Listenable.merge([active, hasStrip, listHeader]),
|
||||
builder: (context, _) => DuoToolbarScope(
|
||||
active: active.value,
|
||||
strip: hasStrip.value
|
||||
@@ -50,9 +103,10 @@ void main() {
|
||||
child: Stack(
|
||||
children: [
|
||||
FrostedAppBar(
|
||||
nativeLargeTitle: listHeader.value,
|
||||
nativeLeading: IosNavigationAction(
|
||||
label: 'Community',
|
||||
keepInHeaderOnDuo: true,
|
||||
keepInHeaderOnDuo: !listHeader.value,
|
||||
onPressed: () => presses++,
|
||||
),
|
||||
nativeActions: [
|
||||
@@ -95,6 +149,7 @@ void main() {
|
||||
.widgetList<IosNavigationBar>(find.byType(IosNavigationBar))
|
||||
.singleWhere((bar) => !bar.standaloneControl);
|
||||
expect(header.horizontalControls, isTrue);
|
||||
|
||||
expect(header.alwaysFrosted, isTrue);
|
||||
expect(
|
||||
find.byKey(const ValueKey('duo-navigation-backdrop')),
|
||||
@@ -105,6 +160,17 @@ void main() {
|
||||
expect(header.centerTitleInPane, isTrue);
|
||||
expect(header.leading!.label, 'Community');
|
||||
expect(header.actions.map((action) => action.label), ['Search', 'Inbox']);
|
||||
listHeader.value = true;
|
||||
await tester.pump();
|
||||
final listNavigation = tester.widget<IosNavigationBar>(
|
||||
find.byType(IosNavigationBar).first,
|
||||
);
|
||||
expect(listNavigation.horizontalControls, isTrue);
|
||||
expect(listNavigation.largeTitle, isTrue);
|
||||
expect(listNavigation.leading, isNull);
|
||||
listHeader.value = false;
|
||||
await tester.pump();
|
||||
|
||||
final toolbarContext = tester.element(find.byType(FrostedAppBar));
|
||||
final view = View.of(toolbarContext);
|
||||
final windowSize = view.physicalSize / view.devicePixelRatio;
|
||||
|
||||
@@ -90,6 +90,63 @@ void main() {
|
||||
}
|
||||
}
|
||||
|
||||
for (final left in [false, true]) {
|
||||
for (final width in [480.0, 900.0]) {
|
||||
testWidgets(
|
||||
'bounded sheet localizes rail inset left=$left width=$width',
|
||||
(tester) async {
|
||||
debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
|
||||
tester.view.physicalSize = const Size(951, 669);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
final padding = FakeViewPadding(
|
||||
left: left ? 76 : 0,
|
||||
right: left ? 0 : 76,
|
||||
bottom: 21,
|
||||
);
|
||||
tester.view.padding = padding;
|
||||
tester.view.viewPadding = padding;
|
||||
addTearDown(() {
|
||||
tester.view.reset();
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
});
|
||||
EdgeInsets? local;
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
home: Builder(
|
||||
builder: (context) => Scaffold(
|
||||
body: TextButton(
|
||||
onPressed: () => showBuzzModalBottomSheet<void>(
|
||||
context: context,
|
||||
useDuoFullWidth: false,
|
||||
constraints: BoxConstraints(maxWidth: width),
|
||||
showCloseButton: false,
|
||||
builder: (context) {
|
||||
local = MediaQuery.paddingOf(context);
|
||||
return const SizedBox(
|
||||
height: 100,
|
||||
width: double.infinity,
|
||||
);
|
||||
},
|
||||
),
|
||||
child: const Text('Open'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
final overlap = (76 - (951 - width) / 2).clamp(0.0, double.infinity);
|
||||
expect(local!.left, left ? overlap : 0);
|
||||
expect(local!.right, left ? 0 : overlap);
|
||||
expect(tester.takeException(), isNull);
|
||||
debugDefaultTargetPlatformOverride = null;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Border sheetHeaderBorder(WidgetTester tester) {
|
||||
final decoration =
|
||||
tester
|
||||
@@ -157,6 +214,56 @@ void main() {
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byType(UiKitView), findsOneWidget);
|
||||
final nativeView = tester.widget<UiKitView>(find.byType(UiKitView));
|
||||
expect(
|
||||
nativeView.creationParams,
|
||||
containsPair('independentCorners', true),
|
||||
);
|
||||
const viewChannel = MethodChannel('buzz/concentric_sheet_surface/73');
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
viewChannel,
|
||||
(call) async => call.method == 'getCornerRadii'
|
||||
? {
|
||||
'topLeft': 24.0,
|
||||
'topRight': 24.0,
|
||||
'bottomLeft': 62.0,
|
||||
'bottomRight': 48.0,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
nativeView.onPlatformViewCreated!(73);
|
||||
await tester.pumpAndSettle();
|
||||
BorderRadius clipRadius() =>
|
||||
tester
|
||||
.widget<ClipRSuperellipse>(
|
||||
find.byKey(
|
||||
const ValueKey('concentric-sheet-content-clip'),
|
||||
),
|
||||
)
|
||||
.borderRadius
|
||||
as BorderRadius;
|
||||
expect(clipRadius().bottomLeft, const Radius.circular(62));
|
||||
expect(clipRadius().bottomRight, const Radius.circular(48));
|
||||
// Rotation/folding can change each corner independently.
|
||||
await tester.binding.defaultBinaryMessenger.handlePlatformMessage(
|
||||
viewChannel.name,
|
||||
const StandardMethodCodec().encodeMethodCall(
|
||||
const MethodCall('cornerRadiiChanged', {
|
||||
'topLeft': 48.0,
|
||||
'topRight': 62.0,
|
||||
'bottomLeft': 24.0,
|
||||
'bottomRight': 24.0,
|
||||
}),
|
||||
),
|
||||
(_) {},
|
||||
);
|
||||
await tester.pump();
|
||||
expect(clipRadius().topRight, const Radius.circular(62));
|
||||
expect(clipRadius().bottomLeft, const Radius.circular(24));
|
||||
tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(
|
||||
viewChannel,
|
||||
null,
|
||||
);
|
||||
expect(
|
||||
find.ancestor(
|
||||
of: find.byType(UiKitView),
|
||||
|
||||
Reference in New Issue
Block a user