import 'dart:math' as math; import 'dart:ui' show DisplayFeature, DisplayFeatureState, DisplayFeatureType; import 'package:flutter/material.dart'; /// Width classes in logical pixels. Layout never depends on a device model, /// physical resolution or devicePixelRatio, only on the space the OS gives us. enum WindowClass { compact, medium, expanded, large, extraLarge; static const mediumMin = 600.0, expandedMin = 840.0, largeMin = 1200.0, extraLargeMin = 1600.0; static WindowClass ofWidth(double width) => width >= extraLargeMin ? extraLarge : width >= largeMin ? large : width >= expandedMin ? expanded : width >= mediumMin ? medium : compact; bool operator >=(WindowClass other) => index >= other.index; } /// Below this window size the app keeps this logical canvas and scrolls it, /// instead of squeezing controls until they overlap or overflow. const minimumCanvas = Size(240, 320); /// A fold or hinge that splits the window into separately usable regions: /// a physical hinge with area, or a fold that is currently half opened. /// A flat fold (for example a fully open Galaxy/Pixel Fold) does not split. bool isSeparating(DisplayFeature f) => f.type != DisplayFeatureType.cutout && (f.bounds.shortestSide > 0 || f.state == DisplayFeatureState.postureHalfOpened); class AdaptiveLayout { final Size size; final EdgeInsets padding; final List features; const AdaptiveLayout( this.size, { this.padding = EdgeInsets.zero, this.features = const [], }); factory AdaptiveLayout.of(BuildContext context) => AdaptiveLayout( MediaQuery.sizeOf(context), padding: MediaQuery.viewPaddingOf(context), features: MediaQuery.displayFeaturesOf(context), ); WindowClass get windowClass => WindowClass.ofWidth(size.width); bool get landscape => size.width > size.height; /// System bars placed on a side edge (landscape phones, or displays whose /// status area sits beside the content) favour side navigation. bool get sideSystemBars { final side = math.max(padding.left, padding.right); return side > 0 && side > padding.top; } /// The first separating feature that crosses the whole window, if any. DisplayFeature? get separatingFeature { for (final f in features) { if (!isSeparating(f)) continue; final b = f.bounds; final vertical = b.height >= size.height - 1 && b.width < size.width; final horizontal = b.width >= size.width - 1 && b.height < size.height; if (vertical || horizontal) return f; } return null; } /// Rail instead of a bottom bar: enough width, or a short landscape window /// where a bottom bar would eat a large part of the height. /// A tabletop posture: the fold runs across the window. bool get horizontalFold { final f = separatingFeature; return f != null && f.bounds.width >= size.width - 1; } /// Rail instead of a bottom bar: enough width, or a short landscape window /// where a bottom bar would eat a large part of the height. In a tabletop /// posture the bar stays in the lower half, away from the fold. bool get useRail => !horizontalFold && (size.width >= WindowClass.mediumMin || (size.width >= 480 && (landscape || sideSystemBars))); /// List and detail side by side. bool get twoPane => windowClass >= WindowClass.expanded || separatingFeature != null; } /// Lays [primary] and an optional [secondary] pane out. A separating fold or /// hinge always gets a pane on each side and nothing under it; otherwise the /// secondary pane takes [secondaryWidth] at the start. class FoldAwareSplit extends StatefulWidget { final Widget primary; final Widget? secondary; final double secondaryWidth; const FoldAwareSplit({ required this.primary, this.secondary, this.secondaryWidth = 360, super.key, }); @override State createState() => _FoldAwareSplitState(); } class _FoldAwareSplitState extends State { // Display features are in window coordinates; this is where we are in it. Rect? global; void _measure(_) { if (!mounted) return; final box = context.findRenderObject() as RenderBox?; if (box == null || !box.hasSize) return; final next = box.localToGlobal(Offset.zero) & box.size; if (next != global) setState(() => global = next); } @override Widget build(BuildContext context) { final features = MediaQuery.displayFeaturesOf(context); final fold = features.where(isSeparating).firstOrNull; if (fold != null) WidgetsBinding.instance.addPostFrameCallback(_measure); return LayoutBuilder( builder: (context, constraints) { final size = constraints.biggest; final secondary = widget.secondary; Rect? local; if (fold != null && global != null && global!.size == size) { local = fold.bounds.shift(-global!.topLeft); } if (local != null) { final vertical = local.height >= size.height - 1; final spans = vertical ? local.top <= 1 && local.bottom >= size.height - 1 : local.left <= 1 && local.right >= size.width - 1; final a = vertical ? local.left : local.top; final b = vertical ? size.width - local.right : size.height - local.bottom; // A fold outside this box, or at its very edge, needs no split. if (spans && a >= 1 && b >= 1) { final gap = vertical ? local.width : local.height; // Vertical (book) fold: list at the start, detail at the end. // Horizontal (tabletop) fold: detail above, list below. final first = vertical ? secondary : widget.primary; final second = vertical ? widget.primary : secondary; // Insets on the fold side belong to the other pane. Widget pane(Widget? child, {required bool leading}) => MediaQuery.removePadding( context: context, removeLeft: vertical && !leading, removeRight: vertical && leading, removeTop: !vertical && !leading, removeBottom: !vertical && leading, child: child ?? const SizedBox.shrink(), ); return Flex( direction: vertical ? Axis.horizontal : Axis.vertical, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox( width: vertical ? a : null, height: vertical ? null : a, child: pane(first, leading: true), ), SizedBox( width: vertical ? gap : null, height: vertical ? null : gap, ), Expanded(child: pane(second, leading: false)), ], ); } } if (secondary == null) return widget.primary; return Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ SizedBox( width: math.min(widget.secondaryWidth, size.width / 2), child: secondary, ), const VerticalDivider(width: 1), Expanded(child: widget.primary), ], ); }, ); } } /// Centers scrolling content at a readable width while the scroll area itself /// (and its scrollbar) still spans the full width. EdgeInsets readablePadding( BoxConstraints constraints, { double maxWidth = 760, double gutter = 20, }) { // Narrow panes (split screen, one side of a fold) keep more room for content. if (constraints.maxWidth < 360) gutter = math.min(gutter, 8); final side = math.max(gutter, (constraints.maxWidth - maxWidth) / 2); return EdgeInsets.symmetric(horizontal: side, vertical: gutter); } /// Gives [child] at least [minHeight]; when the space is shorter (software /// keyboard in a landscape phone, a tiny split-screen window) the content /// scrolls instead of overflowing. class MinHeight extends StatelessWidget { final double minHeight; final Widget child; const MinHeight({required this.minHeight, required this.child, super.key}); @override Widget build(BuildContext context) => LayoutBuilder( builder: (context, constraints) => constraints.maxHeight >= minHeight ? child : SingleChildScrollView( child: SizedBox(height: minHeight, child: child), ), ); } /// The software keyboard leaves so little height that app chrome should step /// aside for the content being typed into. bool keyboardSqueezed(BuildContext context) { final keyboard = MediaQuery.viewInsetsOf(context).bottom; return keyboard > 0 && MediaQuery.sizeOf(context).height - keyboard < 360; } /// Places equal-width tiles into as many columns as fit [minTileWidth]. class AdaptiveGrid extends StatelessWidget { final List children; final double minTileWidth, spacing; final int maxColumns; const AdaptiveGrid({ required this.children, this.minTileWidth = 260, this.spacing = 10, this.maxColumns = 4, super.key, }); @override Widget build(BuildContext context) => LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth; final columns = ((width + spacing) / (minTileWidth + spacing)) .floor() .clamp(1, maxColumns); final tile = (width - spacing * (columns - 1)) / columns; return Wrap( spacing: spacing, children: [ for (final child in children) SizedBox(width: tile, child: child), ], ); }, ); } /// Keeps a minimum logical canvas for windows smaller than [minimumCanvas] /// (tiny split-screen or freeform windows) and lets the user scroll it. class MinimumCanvas extends StatelessWidget { final Widget child; const MinimumCanvas({required this.child, super.key}); @override Widget build(BuildContext context) { final media = MediaQuery.of(context); final size = media.size; if (size.width >= minimumCanvas.width && size.height >= minimumCanvas.height) { return child; } final canvas = Size( math.max(size.width, minimumCanvas.width), math.max(size.height, minimumCanvas.height), ); return Material( child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: SingleChildScrollView( child: MediaQuery( data: media.copyWith(size: canvas), child: SizedBox.fromSize(size: canvas, child: child), ), ), ), ); } } /// Navigation labels follow the system text size up to a limit, so that six /// destinations stay reachable; their semantics labels are never truncated. Widget clampNavigationText(Widget child) => MediaQuery.withClampedTextScaling(maxScaleFactor: 1.5, child: child);