diff --git a/mobile/ios/Runner/ConcentricSheetSurface.swift b/mobile/ios/Runner/ConcentricSheetSurface.swift index f959c093d7..1f67193468 100644 --- a/mobile/ios/Runner/ConcentricSheetSurface.swift +++ b/mobile/ios/Runner/ConcentricSheetSurface.swift @@ -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?() + } +} diff --git a/mobile/ios/Runner/IosNavigationBar.swift b/mobile/ios/Runner/IosNavigationBar.swift index f05072c3ce..7a036785f0 100644 --- a/mobile/ios/Runner/IosNavigationBar.swift +++ b/mobile/ios/Runner/IosNavigationBar.swift @@ -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() diff --git a/mobile/ios/Runner/NativeProfileTextEditor.swift b/mobile/ios/Runner/NativeProfileTextEditor.swift index 32abe77ffc..4a8fd38da2 100644 --- a/mobile/ios/Runner/NativeProfileTextEditor.swift +++ b/mobile/ios/Runner/NativeProfileTextEditor.swift @@ -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 diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index a0611ce901..aafb7073a0 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -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) } diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index f86f3438f1..5cd58ae1ab 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -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( diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 173c7e1a49..27581362a2 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -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( 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, ); } diff --git a/mobile/lib/features/channels/channels_page/community_switcher.dart b/mobile/lib/features/channels/channels_page/community_switcher.dart index 8a258e7521..e8294c9700 100644 --- a/mobile/lib/features/channels/channels_page/community_switcher.dart +++ b/mobile/lib/features/channels/channels_page/community_switcher.dart @@ -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, + ), + ], + ); + }, + ), ), ), ); diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart index ad3a12f5f8..9969bde056 100644 --- a/mobile/lib/features/pairing/pairing_page.dart +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -137,7 +137,9 @@ class PairingPage extends HookConsumerWidget { } Future openScanner() async { - final usesDynamicIslandPortal = await usesDynamicIslandQrScannerPortal(); + final usesDynamicIslandPortal = + duoQrScannerCameraFrame(context) != null || + await usesDynamicIslandQrScannerPortal(); if (!context.mounted) { return; } diff --git a/mobile/lib/features/pairing/pairing_qr_scanner.dart b/mobile/lib/features/pairing/pairing_qr_scanner.dart index ff6c14f2f9..41aa3b4b4a 100644 --- a/mobile/lib/features/pairing/pairing_qr_scanner.dart +++ b/mobile/lib/features/pairing/pairing_qr_scanner.dart @@ -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. diff --git a/mobile/lib/features/pairing/pairing_qr_scanner/dynamic_island_portal.dart b/mobile/lib/features/pairing/pairing_qr_scanner/dynamic_island_portal.dart index 2e19c4d19f..9f0b1cf874 100644 --- a/mobile/lib/features/pairing/pairing_qr_scanner/dynamic_island_portal.dart +++ b/mobile/lib/features/pairing/pairing_qr_scanner/dynamic_island_portal.dart @@ -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( diff --git a/mobile/lib/features/settings/theme_picker_page.dart b/mobile/lib/features/settings/theme_picker_page.dart index 12664d48cf..2a417cb3b0 100644 --- a/mobile/lib/features/settings/theme_picker_page.dart +++ b/mobile/lib/features/settings/theme_picker_page.dart @@ -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(), + ), + ); + } } diff --git a/mobile/lib/features/settings/theme_picker_page/theme_preview_sheet.dart b/mobile/lib/features/settings/theme_picker_page/theme_preview_sheet.dart index 0dfe750849..74a5cf6565 100644 --- a/mobile/lib/features/settings/theme_picker_page/theme_preview_sheet.dart +++ b/mobile/lib/features/settings/theme_picker_page/theme_preview_sheet.dart @@ -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, + ), + ), ), ), ); diff --git a/mobile/lib/shared/widgets/concentric_sheet_surface.dart b/mobile/lib/shared/widgets/concentric_sheet_surface.dart index afe5fc2163..ac8ec8c11c 100644 --- a/mobile/lib/shared/widgets/concentric_sheet_surface.dart +++ b/mobile/lib/shared/widgets/concentric_sheet_surface.dart @@ -124,6 +124,58 @@ class ConcentricSheetSurface extends HookWidget { final surfaceColor = color ?? context.colors.surface; final brightness = context.theme.brightness; final nativeSurfaceChannel = useState(null); + final nativeCornerRadius = useState(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 readInitialRadii() async { + try { + updateRadii(await channel.invokeMethod('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( diff --git a/mobile/lib/shared/widgets/frosted_app_bar.dart b/mobile/lib/shared/widgets/frosted_app_bar.dart index 831daa96f4..a335584ad1 100644 --- a/mobile/lib/shared/widgets/frosted_app_bar.dart +++ b/mobile/lib/shared/widgets/frosted_app_bar.dart @@ -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 ?? diff --git a/mobile/lib/shared/widgets/modal_presentation.dart b/mobile/lib/shared/widgets/modal_presentation.dart index b0644c2a2d..ebb9c9463b 100644 --- a/mobile/lib/shared/widgets/modal_presentation.dart +++ b/mobile/lib/shared/widgets/modal_presentation.dart @@ -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 showBuzzModalBottomSheet({ required BuildContext context, @@ -41,6 +42,7 @@ Future showBuzzModalBottomSheet({ 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 showBuzzModalBottomSheet({ 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 showBuzzModalBottomSheet({ return showModalBottomSheet( 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 showBuzzModalBottomSheet({ 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 showBuzzModalBottomSheet({ ); } +/// 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, diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 50ec3b2b6f..000bebe7f2 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -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(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( + 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( 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(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(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( 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( @@ -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), diff --git a/mobile/test/features/pairing/pairing_qr_scanner_test.dart b/mobile/test/features/pairing/pairing_qr_scanner_test.dart index e47306835d..bfb8c49857 100644 --- a/mobile/test/features/pairing/pairing_qr_scanner_test.dart +++ b/mobile/test/features/pairing/pairing_qr_scanner_test.dart @@ -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(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; diff --git a/mobile/test/features/settings/theme_picker_page_test.dart b/mobile/test/features/settings/theme_picker_page_test.dart index 4e90b17b1f..937ca14f59 100644 --- a/mobile/test/features/settings/theme_picker_page_test.dart +++ b/mobile/test/features/settings/theme_picker_page_test.dart @@ -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 _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( + find.descendant( + of: find.byKey(ValueKey('duo-toolbar-$label')), + matching: find.byType(IosNavigationBar), + ), + ) + .leading!; + IosGlassNavigationButton modeButton() => + tester.widget( + find.byKey( + const ValueKey('theme-preview-appearance-action-button'), + ), + ); + modeButton().onPressed!(); + await tester.pumpAndSettle(); + tester + .widget( + 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( + 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 { diff --git a/mobile/test/shared/widgets/frosted_app_bar_test.dart b/mobile/test/shared/widgets/frosted_app_bar_test.dart index eb1847ae46..aaab74c76a 100644 --- a/mobile/test/shared/widgets/frosted_app_bar_test.dart +++ b/mobile/test/shared/widgets/frosted_app_bar_test.dart @@ -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(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( + 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; diff --git a/mobile/test/shared/widgets/modal_presentation_test.dart b/mobile/test/shared/widgets/modal_presentation_test.dart index a2f8909133..bf4d4dc5ef 100644 --- a/mobile/test/shared/widgets/modal_presentation_test.dart +++ b/mobile/test/shared/widgets/modal_presentation_test.dart @@ -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( + 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(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( + 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),