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:
kenny lopez
2026-10-24 14:12:49 -07:00
parent 373f8ea58e
commit b59d69869b
20 changed files with 1207 additions and 301 deletions

View File

@@ -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?()
}
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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) }

View File

@@ -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(

View File

@@ -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,
);
}

View File

@@ -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,
),
],
);
},
),
),
),
);

View File

@@ -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;
}

View File

@@ -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.

View File

@@ -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(

View File

@@ -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(),
),
);
}
}

View File

@@ -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,
),
),
),
),
);

View File

@@ -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(

View File

@@ -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 ??

View File

@@ -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,

View File

@@ -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),

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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),