import 'dart:async'; import 'dart:math' as math; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../focus/dpad_navigator.dart'; import '../focus/input_mode_tracker.dart'; import '../focus/key_event_utils.dart'; import '../utils/platform_detector.dart'; /// Entry in the sheet page stack. class _OverlaySheetEntry { final WidgetBuilder builder; final Completer completer; final FocusNode? initialFocusNode; _OverlaySheetEntry({required this.builder, required this.completer, this.initialFocusNode}); } /// Provides [OverlaySheetController] to descendants via [of] / [maybeOf]. class _OverlaySheetScope extends InheritedWidget { final OverlaySheetController controller; const _OverlaySheetScope({required this.controller, required super.child}); @override bool updateShouldNotify(_OverlaySheetScope oldWidget) => controller != oldWidget.controller; } /// Controller for the overlay-based bottom sheet system. /// /// Use [of] or [maybeOf] to access from descendants. class OverlaySheetController { final _OverlaySheetHostState _state; OverlaySheetController._(this._state); static OverlaySheetController of(BuildContext context) { final scope = context.dependOnInheritedWidgetOfExactType<_OverlaySheetScope>(); assert(scope != null, 'No OverlaySheetHost found in context'); return scope!.controller; } static OverlaySheetController? maybeOf(BuildContext context) { return context.dependOnInheritedWidgetOfExactType<_OverlaySheetScope>()?.controller; } /// Number of sheets currently open across all hosts (and [showAdaptive] /// modal fallbacks). Sheets render inside their host's subtree, so chrome /// mounted above the navigator (the music mini-player) can never sit under /// them — such chrome listens here and hides itself while this is nonzero. static final ValueNotifier openSheetCount = ValueNotifier(0); /// Whether a sheet is currently showing (including while animating closed). bool get isOpen => _state._isOpen; /// Show a sheet with [builder] content. Returns a Future that completes /// when the sheet is closed (with an optional result). /// /// [alignment] controls where the sheet appears. Defaults to /// [Alignment.bottomCenter]. Use [Alignment.topCenter] to anchor at the top. Future show({ required WidgetBuilder builder, BoxConstraints? constraints, Color? backgroundColor, bool barrierDismissible = true, FocusNode? initialFocusNode, Alignment alignment = Alignment.bottomCenter, bool showDragHandle = false, }) { return _state._show( builder: builder, constraints: constraints, backgroundColor: backgroundColor, barrierDismissible: barrierDismissible, initialFocusNode: initialFocusNode, alignment: alignment, showDragHandle: showDragHandle, ); } /// Push a sub-page within the open sheet. Returns a Future that completes /// when the pushed page is popped (with an optional result). Future push({required WidgetBuilder builder, FocusNode? initialFocusNode}) { return _state._push(builder: builder, initialFocusNode: initialFocusNode); } /// Pop the top sub-page, or close the sheet if on the last page. void pop([dynamic result]) { _state._pop(result); } /// Force close the sheet, completing all pending completers. void close([dynamic result]) { _state._close(result); } /// Re-focus the first focusable descendant within the sheet. /// Useful after internal page changes via setState. void refocus() { _state._autoFocus(clearSelectSuppression: false); } /// Absolute height ceiling for resizable desktop windows. Without it a 4K /// window yields a 1620px sheet, which reads as a wall of list rather than a /// sheet. static const _windowedMaxHeight = 720.0; /// Sizing applied when a caller supplies no explicit constraints: three /// quarters of the viewport height everywhere, the capped width on wide /// viewports, and the absolute height ceiling on desktop windows only. /// /// Both caps require `width > 600`. The height ceiling additionally requires /// a desktop OS and not TV, because it exists for a window the user can /// resize arbitrarily tall: a portrait tablet and a 10-foot UI keep the full /// 75%, and so does a desktop window narrower than 601px, which is /// phone-shaped and where 75% is the norm. static BoxConstraints _defaultSheetConstraints(BuildContext context) { final size = MediaQuery.sizeOf(context); final isWideViewport = size.width > 600; final isDesktopWindow = isWideViewport && PlatformDetector.isDesktopOS() && !PlatformDetector.isTV(); final maxHeight = size.height * 0.75; return BoxConstraints( maxWidth: isWideViewport ? 700 : double.infinity, maxHeight: isDesktopWindow ? math.min(maxHeight, _windowedMaxHeight) : maxHeight, ); } /// Show a sheet using the overlay system if available, otherwise fall back /// to [showModalBottomSheet]. Returns the result from the sheet. static Future showAdaptive( BuildContext context, { required WidgetBuilder builder, BoxConstraints? constraints, Color? backgroundColor, bool barrierDismissible = true, bool isScrollControlled = false, FocusNode? initialFocusNode, Alignment alignment = Alignment.bottomCenter, bool showDragHandle = false, }) async { final controller = maybeOf(context); if (controller != null) { return controller.show( builder: builder, constraints: constraints, backgroundColor: backgroundColor, barrierDismissible: barrierDismissible, initialFocusNode: initialFocusNode, alignment: alignment, showDragHandle: showDragHandle, ); } // Apply the same default constraints the overlay system uses so sheets // shown without an OverlaySheetHost still have sensible sizing on desktop. final effectiveConstraints = constraints ?? _defaultSheetConstraints(context); openSheetCount.value++; try { return await showModalBottomSheet( context: context, // The host path insets its sheet by the bottom safe area; mirror that // here so the last row clears the home indicator / gesture nav bar. builder: (context) => SafeArea(top: false, child: builder(context)), constraints: effectiveConstraints, backgroundColor: backgroundColor ?? Theme.of(context).colorScheme.surface, barrierColor: Colors.black54, isDismissible: barrierDismissible, isScrollControlled: isScrollControlled, showDragHandle: showDragHandle, ); } finally { openSheetCount.value--; } } /// Push a sub-page using the overlay system if available, otherwise fall /// back to [showModalBottomSheet]. Returns the result from the page. /// /// When a hosted sheet is already open, this pushes a nested page and /// retains the root sheet's presentation. When a host is available but /// idle, this opens [builder] as its root sheet using the supplied hosted /// presentation options. Without a host, the modal fallback is used. /// /// [isScrollControlled] applies only to the modal fallback; hosted sheets /// use their explicit or default constraints. static Future pushAdaptive( BuildContext context, { required WidgetBuilder builder, FocusNode? initialFocusNode, BoxConstraints? constraints, Color? backgroundColor, bool barrierDismissible = true, bool isScrollControlled = false, bool showDragHandle = false, }) async { final controller = maybeOf(context); if (controller != null) { if (controller.isOpen) { return controller.push(builder: builder, initialFocusNode: initialFocusNode); } return controller.show( builder: builder, constraints: constraints, backgroundColor: backgroundColor, barrierDismissible: barrierDismissible, initialFocusNode: initialFocusNode, showDragHandle: showDragHandle, ); } BackKeyCoordinator.clear(); return showAdaptive( context, builder: builder, constraints: constraints, backgroundColor: backgroundColor, barrierDismissible: barrierDismissible, isScrollControlled: isScrollControlled, showDragHandle: showDragHandle, ); } /// Close the sheet entirely. Uses overlay controller if available, /// otherwise pops the route. static void closeAdaptive(BuildContext context, [dynamic result]) { final controller = maybeOf(context); if (controller != null) { controller.close(result); } else { Navigator.pop(context, result); } } /// Pop one level (sub-page or close if last page). Uses overlay controller /// if available, otherwise pops the route. static void popAdaptive(BuildContext context, [dynamic result]) { final controller = maybeOf(context); if (controller != null) { controller.pop(result); } else { Navigator.pop(context, result); } } } /// Host widget for the overlay-based bottom sheet system. /// /// Sheets are rendered as overlays within this widget's Stack instead of as /// modal routes, eliminating the route-based back-button race condition on /// Android TV and providing centralized focus management for keyboard/dpad /// navigation on all platforms. /// /// ## Back handling /// /// The host already owns the dpad/key back path (its sheet [FocusScope] closes /// the sheet on BACK when focus is inside it). For the system/route back path /// (Android gesture, iOS swipe, predictive back), opt in via [canPop]: the host /// then renders its own [PopScope] that closes an open sheet instead of popping /// the screen, so callers don't have to hand-roll it. When [canPop] is null /// (the default) the host adds no [PopScope] and behaves exactly as before. class OverlaySheetHost extends StatefulWidget { final Widget child; final ValueChanged? onOpenChanged; /// Whether the enclosing route may pop when no sheet is open (the screen's own /// business rule, mirroring [PopScope.canPop]). /// /// When non-null the host installs a [PopScope]: /// - a system back with a sheet open closes the sheet (never pops the screen); /// - otherwise, if `canPop` is true the route pops natively (preserving the /// iOS interactive swipe-back), and if false [onSystemBack] runs instead. /// /// When null (default) the host installs no [PopScope] — today's behavior. final bool? canPop; /// Called for a system/route back when no sheet is open and [canPop] is false. /// Not called when a sheet is open (the sheet is closed instead) or when /// [canPop] allows a native pop. Implementations that also have a dpad key /// handler should start with `if (BackKeyCoordinator.consumeIfHandled()) return;` /// so the system path dedups against the key path. final VoidCallback? onSystemBack; const OverlaySheetHost({super.key, required this.child, this.onOpenChanged, this.canPop, this.onSystemBack}); @override State createState() => _OverlaySheetHostState(); } class _OverlaySheetHostState extends State with SingleTickerProviderStateMixin { late final AnimationController _animationController; late final CurvedAnimation _slideCurve; late final Animation _barrierAnimation; late final OverlaySheetController _controller; final List<_OverlaySheetEntry> _pageStack = []; final _sheetFocusScopeNode = FocusScopeNode(debugLabel: 'OverlaySheetScope'); bool _isOpen = false; bool _isClosing = false; bool _barrierDismissible = true; bool _showDragHandle = false; BoxConstraints? _constraints; Color? _explicitBackgroundColor; Alignment _alignment = Alignment.bottomCenter; Offset? _lastPointerPosition; double? _sheetHorizontalAnchor; /// Bumped on every [_show]. Keys the resize animation so a freshly opened /// sheet adopts its own height immediately instead of animating down from /// the previous sheet's; nested pushes within one sheet still animate. /// /// Changing the key also remounts the sheet subtree, so a `show` that /// replaces a live sheet of the same widget type starts with fresh [State] /// rather than reconciling into the outgoing sheet's. int _sheetSession = 0; // Drag-to-dismiss state double _dragOffset = 0; bool _isDragging = false; final _sheetKey = GlobalKey(); @override void initState() { super.initState(); _controller = OverlaySheetController._(this); _animationController = AnimationController(duration: const Duration(milliseconds: 250), vsync: this); _slideCurve = CurvedAnimation( parent: _animationController, curve: Curves.easeOutCubic, reverseCurve: Curves.easeInCubic, ); _barrierAnimation = Tween( begin: 0, end: 0.5, ).animate(CurvedAnimation(parent: _animationController, curve: Curves.easeOutCubic)); } @override void dispose() { for (final entry in _pageStack) { if (!entry.completer.isCompleted) { entry.completer.complete(null); } } // A host torn down mid-sheet (or mid-close animation) never reaches the // close completion below — release its slot in the global count here. if (_isOpen) OverlaySheetController.openSheetCount.value--; _sheetFocusScopeNode.dispose(); _slideCurve.dispose(); _animationController.dispose(); super.dispose(); } Future _show({ required WidgetBuilder builder, BoxConstraints? constraints, Color? backgroundColor, bool barrierDismissible = true, FocusNode? initialFocusNode, Alignment alignment = Alignment.bottomCenter, bool showDragHandle = false, }) { BackKeyCoordinator.clear(); // If already open, close first (instant) final wasOpen = _isOpen; if (_isOpen) { for (final entry in _pageStack) { if (!entry.completer.isCompleted) { entry.completer.complete(null); } } _pageStack.clear(); _isClosing = false; } final completer = Completer(); final entry = _OverlaySheetEntry(builder: builder, completer: completer, initialFocusNode: initialFocusNode); final horizontalAnchor = _resolveSheetHorizontalAnchor(alignment); setState(() { _pageStack.add(entry); _sheetSession++; _isOpen = true; _isClosing = false; _barrierDismissible = barrierDismissible; _showDragHandle = showDragHandle; _constraints = constraints; _explicitBackgroundColor = backgroundColor; _alignment = alignment; _sheetHorizontalAnchor = horizontalAnchor; _dragOffset = 0; _isDragging = false; }); if (!wasOpen) { widget.onOpenChanged?.call(true); OverlaySheetController.openSheetCount.value++; } BackKeyUpSuppressor.clearSuppression(); _animationController.forward(from: 0); _autoFocus(); return completer.future; } Future _push({required WidgetBuilder builder, FocusNode? initialFocusNode}) { if (!_isOpen || _isClosing) { return Future.value(null); } final completer = Completer(); final entry = _OverlaySheetEntry(builder: builder, completer: completer, initialFocusNode: initialFocusNode); setState(() { _pageStack.add(entry); }); _autoFocus(); return completer.future; } void _pop([dynamic result]) { if (!_isOpen || _isClosing || _pageStack.isEmpty) return; if (_pageStack.length == 1) { _close(result); return; } final removed = _pageStack.removeLast(); if (!removed.completer.isCompleted) { removed.completer.complete(result); } setState(() {}); _autoFocus(); } void _close([dynamic result]) { if (!_isOpen || _isClosing) return; _isClosing = true; _animationController.reverse().then((_) { if (!mounted) return; setState(() { for (final entry in _pageStack) { if (!entry.completer.isCompleted) { entry.completer.complete(result); } } _pageStack.clear(); _isOpen = false; _isClosing = false; _dragOffset = 0; _isDragging = false; _sheetHorizontalAnchor = null; }); widget.onOpenChanged?.call(false); OverlaySheetController.openSheetCount.value--; }); } void _rememberPointerPosition(PointerEvent event) { if (event.kind != PointerDeviceKind.mouse) return; _lastPointerPosition = event.localPosition; } double? _resolveSheetHorizontalAnchor(Alignment alignment) { if (!PlatformDetector.isDesktopOS() || PlatformDetector.isTV()) return null; if (InputModeTracker.isKeyboardMode(context, listen: false)) return null; if (alignment.x != 0 || alignment.y <= 0) return null; return _lastPointerPosition?.dx; } void _autoFocus({bool clearSelectSuppression = true}) { final focusDescendant = InputModeTracker.isKeyboardMode(context, listen: false); // First post-frame: the FocusScope is now built and the node is attached. // Always grab scope focus so key events (especially back) are trapped, even // when a pointer opened the sheet. In keyboard mode, a second post-frame // callback focuses the first descendant for dpad navigation. WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !_isOpen) return; _sheetFocusScopeNode.requestFocus(); if (!focusDescendant) return; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !_isOpen) return; // If the current top entry has an initialFocusNode that is attached, // focus that instead of the first descendant. final topEntry = _pageStack.isNotEmpty ? _pageStack.last : null; final initialNode = topEntry?.initialFocusNode; if (initialNode != null && initialNode.context != null) { initialNode.requestFocus(); } else { _focusFirstDescendant(); } // Clear stale select suppression from the press that opened this sheet, // but only if no select key is currently held down. This handles: // - Short press: key already released → clear flag (prevents first // select inside the sheet from being eaten). // - Long press: key still held → keep flag so KeyRepeat/KeyUp events // from the long press are correctly suppressed. if (clearSelectSuppression && !HardwareKeyboard.instance.logicalKeysPressed.any((k) => k.isSelectKey)) { SelectKeyUpSuppressor.clearSuppression(); } }); }); } void _focusFirstDescendant() { final descendants = _sheetFocusScopeNode.traversalDescendants.toList(); if (descendants.isNotEmpty) { descendants.first.requestFocus(); } else { _sheetFocusScopeNode.requestFocus(); } } void _handleBack() { if (_pageStack.length > 1) { _pop(); } else { _close(); } } KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) { // Suppress stale select key-ups if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) { return KeyEventResult.handled; } // Suppress stale back key-ups if (BackKeyUpSuppressor.consumeIfSuppressed(event)) { return KeyEventResult.handled; } // Back key: pop sub-page or close sheet if (event.logicalKey.isBackKey) { if (PlatformDetector.isTV() && event is KeyDownEvent) { BackKeyCoordinator.markHandled(); } return handleBackKeyAction(event, _handleBack); } // Let all other keys pass through. Directional keys need to reach // Flutter's DirectionalFocusAction for dpad/arrow navigation, and // select/enter keys need to reach ActivateAction for item taps. // The FocusScope traps traversal within the sheet; the screen-level // Focus catches any leaked nav keys. return KeyEventResult.ignored; } @override Widget build(BuildContext context) { Widget content = Listener( behavior: HitTestBehavior.translucent, onPointerDown: _rememberPointerPosition, onPointerHover: _rememberPointerPosition, child: Stack( children: [ widget.child, // Barrier + sheet only when open if (_isOpen) ...[ Positioned.fill( // The barrier swallows every pointer event behind it, so the // screen underneath must leave the semantics tree too. Without // this, assistive tech (and UI automation) still reads rows that // cannot be activated — including through the ~250ms close, where // a tap on a "visible" row lands on the barrier instead. Flutter's // own ModalBarrier blocks semantics for the same reason. child: BlockSemantics( child: AnimatedBuilder( animation: _barrierAnimation, builder: (context, child) { return GestureDetector( onTap: _barrierDismissible ? () => _close() : null, child: ColoredBox(color: Colors.black.withValues(alpha: _barrierAnimation.value)), ); }, ), ), ), _buildSheet(context), ], ], ), ); // When a screen opts in via [canPop], the host owns the system/route back // path so callers don't hand-roll it: a back with a sheet open closes the // sheet (sub-page aware, matching the dpad path) instead of popping the // screen; otherwise the route pops natively (canPop true) or [onSystemBack] // runs (canPop false). `!_isClosing` lets a press during the ~250ms close // animation fall through instead of being swallowed. final canPop = widget.canPop; if (canPop != null) { content = PopScope( canPop: canPop && !_isOpen, onPopInvokedWithResult: (didPop, result) { if (didPop) return; if (_isOpen && !_isClosing) { // Only TV routes one Back through both the focused key path and // the platform pop, so only TV needs to dedup them. Touch // platforms never deliver Back to [_handleKeyEvent], so consulting // the global marker here could only ever consume some unrelated // widget's mark and swallow the one signal that closes the sheet. // The marker is global and one-shot, so anything left set by // another handler would strand the sheet open with no way out. if (PlatformDetector.isTV() && BackKeyCoordinator.consumeIfHandled()) return; _handleBack(); return; } widget.onSystemBack?.call(); }, child: content, ); } return _OverlaySheetScope(controller: _controller, child: content); } double _getSheetHeight() { final renderBox = _sheetKey.currentContext?.findRenderObject() as RenderBox?; return renderBox?.size.height ?? 300; } /// Minimum drag distance that dismisses a sheet, regardless of how short the /// sheet is. Content-sized sheets can be ~150px tall, where a bare 25% of the /// height is barely more than touch slop, so a slow nudge while scrolling or /// reaching would close them. Fast flicks are already handled by the velocity /// check in [_checkDismiss]. static const _minDismissDrag = 96.0; /// Ceiling on that floor, as a fraction of the sheet. A one-row menu can be /// shorter than [_minDismissDrag], and an unclamped floor would mean the only /// way to dismiss it by distance is to drag it clean off the screen. static const _maxDismissDragFraction = 0.6; void _checkDismiss(double velocity) { final sheetHeight = _getSheetHeight(); final threshold = math.min(math.max(sheetHeight * 0.25, _minDismissDrag), sheetHeight * _maxDismissDragFraction); if (_dragOffset > threshold || velocity > 500) { _close(); } else { setState(() { _dragOffset = 0; }); } } Widget _buildSheet(BuildContext context) { final size = MediaQuery.sizeOf(context); final isDesktop = size.width > 600; final isTop = _alignment.y < 0; final isTV = PlatformDetector.isTV(); final showHandle = _showDragHandle && !isTV && !isTop; final effectiveConstraints = _constraints ?? OverlaySheetController._defaultSheetConstraints(context); // Slide direction depends on alignment: bottom sheets slide up, top sheets slide down. // Use a pixel transform instead of FractionalTranslation so mouse-tracker // hit testing never depends on the sheet child's just-invalidated layout. final slideDirection = isTop ? -1.0 : 1.0; final slideDistance = size.height; final borderRadius = isTop ? const BorderRadius.vertical(bottom: Radius.circular(16)) : const BorderRadius.vertical(top: Radius.circular(16)); final colorScheme = Theme.of(context).colorScheme; Widget content = _pageStack.isNotEmpty ? Builder(builder: _pageStack.last.builder) : const SizedBox.shrink(); // Keep sheet scrollables from attaching to the route's primary controller. content = PrimaryScrollController.none(child: content); // Wrap content in NotificationListener for scroll-aware drag-to-dismiss if (showHandle) { content = NotificationListener( onNotification: (notification) { if (notification is OverscrollNotification) { // Android (ClampingScrollPhysics): overscroll fires reliably if (notification.overscroll < 0) { setState(() { _dragOffset += -notification.overscroll; }); return true; } } else if (notification is ScrollUpdateNotification) { // iOS (BouncingScrollPhysics): pixels go negative when bouncing past top if (notification.metrics.pixels < 0) { setState(() { _dragOffset = -notification.metrics.pixels; }); return true; } // If user scrolled back down from overscroll, reset drag offset if (_dragOffset > 0 && notification.metrics.pixels >= 0) { setState(() { _dragOffset = 0; }); } } else if (notification is ScrollEndNotification) { if (_dragOffset > 0) { _checkDismiss(0); return true; } } return false; }, child: content, ); } // Build the sheet content column (handle + content) Widget sheetContent; if (showHandle) { sheetContent = Column( mainAxisSize: .min, children: [ // M3 drag handle: 32x4, rounded, with 12dp top / 4dp bottom margin Container( width: 32, height: 4, margin: const EdgeInsets.only(top: 12, bottom: 4), decoration: BoxDecoration( color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: const BorderRadius.all(Radius.circular(2)), ), ), Flexible(child: content), ], ); } else { sheetContent = content; } Widget sheet = FocusScope( node: _sheetFocusScopeNode, onKeyEvent: _handleKeyEvent, child: Focus( canRequestFocus: false, skipTraversal: true, onKeyEvent: _handleKeyEvent, child: CustomSingleChildLayout( delegate: _OverlaySheetLayoutDelegate( alignment: _alignment, horizontalAnchor: _sheetHorizontalAnchor, edgePadding: isDesktop ? _OverlaySheetLayoutDelegate.desktopEdgePadding : 0, ), child: AnimatedBuilder( animation: _slideCurve, builder: (context, child) { final dy = slideDirection * slideDistance * (1 - _slideCurve.value); return Transform.translate(offset: Offset(0, dy), child: child); }, child: Transform.translate( offset: Offset(0, _dragOffset.clamp(0, double.infinity)), child: SafeArea( left: true, right: true, top: false, bottom: false, child: Material( key: _sheetKey, color: _explicitBackgroundColor ?? colorScheme.surface, borderRadius: borderRadius, clipBehavior: Clip.antiAlias, child: SafeArea( top: isTop, bottom: !isTop, left: false, right: false, // Content is sized by the sheet body, so pushing a nested // page or resolving async content changes the sheet's // height. Ease the box between those heights instead of // snapping. The child is laid out at its final size and // pinned to the anchored edge throughout, so it is revealed // rather than stretched. child: AnimatedSize( key: ValueKey(_sheetSession), duration: const Duration(milliseconds: 180), curve: Curves.easeOutCubic, alignment: isTop ? Alignment.topCenter : Alignment.bottomCenter, child: ConstrainedBox(constraints: effectiveConstraints, child: sheetContent), ), ), ), ), ), ), ), ), ); // Swipe-down-to-dismiss on non-scrollable areas (skip on TV and top-aligned) if (showHandle) { sheet = RawGestureDetector( gestures: { VerticalDragGestureRecognizer: GestureRecognizerFactoryWithHandlers( () => VerticalDragGestureRecognizer()..onlyAcceptDragOnThreshold = true, (instance) { instance ..onStart = (_) { _isDragging = true; _dragOffset = 0; } ..onUpdate = (details) { if (!_isDragging) return; setState(() { _dragOffset += details.delta.dy; }); } ..onEnd = (details) { if (!_isDragging) return; _isDragging = false; _checkDismiss(details.primaryVelocity ?? 0); }; }, ), }, child: sheet, ); } return sheet; } } class _OverlaySheetLayoutDelegate extends SingleChildLayoutDelegate { static const desktopEdgePadding = 16.0; final Alignment alignment; final double? horizontalAnchor; final double edgePadding; const _OverlaySheetLayoutDelegate({ required this.alignment, required this.horizontalAnchor, required this.edgePadding, }); @override BoxConstraints getConstraintsForChild(BoxConstraints constraints) { final size = constraints.biggest; final maxWidth = size.width > edgePadding * 2 ? size.width - edgePadding * 2 : size.width; return BoxConstraints.loose(Size(maxWidth, size.height)); } @override Offset getPositionForChild(Size size, Size childSize) { final hasHorizontalPadding = edgePadding > 0 && size.width > childSize.width + edgePadding * 2; final minLeft = hasHorizontalPadding ? edgePadding : 0.0; final maxLeft = hasHorizontalPadding ? size.width - childSize.width - edgePadding : minLeft; final left = horizontalAnchor == null ? minLeft + (maxLeft - minLeft) * (alignment.x + 1) / 2 : (horizontalAnchor! - childSize.width / 2).clamp(minLeft, maxLeft).toDouble(); final maxTop = size.height > childSize.height ? size.height - childSize.height : 0.0; final top = maxTop * (alignment.y + 1) / 2; return Offset(left, top); } @override bool shouldRelayout(_OverlaySheetLayoutDelegate oldDelegate) { return alignment != oldDelegate.alignment || horizontalAnchor != oldDelegate.horizontalAnchor || edgePadding != oldDelegate.edgePadding; } }