Files
ImBenjiandClaude Opus 5.5 b269201919 The Garage SDKs, in the open
garage_auth, garage_entitlements, garage_iap and garage_ui, moved out of
Garage-Services and Metro-Map-Maker into one public repo. MIT, one readme,
docs under docs/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013F4NWNvYcdeSgqbWMT1VQ7
2026-09-23 18:49:21 +01:00

908 lines
27 KiB
Dart

// GarageUI — surfaces + basic layout widgets.
//
// hand rolled replacements for the shadcn surface bits we lean on:
// OutlinedContainer, Card, SurfaceCard, IconContainer, Divider,
// VerticalDivider, Gap, Basic and Label.
//
// styling here is copied straight out of shadcn_flutter 0.0.52 so the app
// keeps looking exactly the same. we still read the theme + a couple of tiny
// pure helpers (styleValue, subtractByBorder, scaleAlpha, the density padding
// resolver and the .small()/.muted() text modifiers) from shadcn during the
// migration — those imports get repointed later.
import "package:flutter/rendering.dart";
import "package:flutter/widgets.dart";
// hide the classes we redefine so our own definitions win. everything else
// (Theme, styleValue, the text extensions, SurfaceBlur, density helpers...)
// still comes through unprefixed.
import "package:garage_ui/theme/garage_theme.dart";
import "package:garage_ui/theme/support.dart";
// ---------------------------------------------------------------------------
// Gap
// ---------------------------------------------------------------------------
/// A widget that takes a fixed amount of space in the direction of its parent.
///
/// Only works as a descendant of a [Row], [Column] or [Flex] (or a
/// [Scrollable]). Reproduced from the `gap` package so we don't rely on it
/// coming in transitively through shadcn.
/// A step on the theme's gap scale. See [Density] for the values.
enum GapStep {
xxs,
xs,
sm,
md,
lg,
xl,
xxl;
double of(Density density) => switch (this) {
GapStep.xxs => density.gapXxs,
GapStep.xs => density.gapXs,
GapStep.sm => density.gapSm,
GapStep.md => density.gapMd,
GapStep.lg => density.gapLg,
GapStep.xl => density.gapXl,
GapStep.xxl => density.gapXxl,
};
}
class Gap extends StatelessWidget {
const Gap(
double this.mainAxisExtent, {
super.key,
this.crossAxisExtent,
this.color,
}) : step = null,
assert(mainAxisExtent >= 0 && mainAxisExtent < double.infinity),
assert(crossAxisExtent == null || crossAxisExtent >= 0);
// The scale constructors. These resolve their extent at build time, which is
// the whole point - a call site stays `const` and still moves when the
// density does. Handing `Gap` a number from `theme.density` at the call site
// would work too, but it costs the const and drags a GarageTheme.of() into
// every build method that happens to contain a gap.
const Gap.xxs({super.key, this.crossAxisExtent, this.color})
: mainAxisExtent = null,
step = GapStep.xxs;
const Gap.xs({super.key, this.crossAxisExtent, this.color})
: mainAxisExtent = null,
step = GapStep.xs;
const Gap.sm({super.key, this.crossAxisExtent, this.color})
: mainAxisExtent = null,
step = GapStep.sm;
const Gap.md({super.key, this.crossAxisExtent, this.color})
: mainAxisExtent = null,
step = GapStep.md;
const Gap.lg({super.key, this.crossAxisExtent, this.color})
: mainAxisExtent = null,
step = GapStep.lg;
const Gap.xl({super.key, this.crossAxisExtent, this.color})
: mainAxisExtent = null,
step = GapStep.xl;
const Gap.xxl({super.key, this.crossAxisExtent, this.color})
: mainAxisExtent = null,
step = GapStep.xxl;
const Gap.expand(double mainAxisExtent, {Key? key, Color? color})
: this(
mainAxisExtent,
key: key,
crossAxisExtent: double.infinity,
color: color,
);
/// space taken along the parent's main axis. null when [step] is set, in
/// which case the extent comes off the theme instead.
final double? mainAxisExtent;
/// which step of the density's gap scale to use. null for a literal [Gap].
final GapStep? step;
/// space taken along the cross axis. null = match the parent constraints.
final double? crossAxisExtent;
/// optional fill colour.
final Color? color;
@override
Widget build(BuildContext context) {
final scrollableState = Scrollable.maybeOf(context);
final AxisDirection? axisDirection = scrollableState?.axisDirection;
final Axis? fallbackDirection = axisDirection == null
? null
: axisDirectionToAxis(axisDirection);
final extent = mainAxisExtent ?? step!.of(GarageTheme.of(context).density);
return _RawGap(
extent,
crossAxisExtent: crossAxisExtent,
color: color,
fallbackDirection: fallbackDirection,
);
}
}
class _RawGap extends LeafRenderObjectWidget {
const _RawGap(
this.mainAxisExtent, {
this.crossAxisExtent,
this.color,
this.fallbackDirection,
}) : assert(mainAxisExtent >= 0 && mainAxisExtent < double.infinity),
assert(crossAxisExtent == null || crossAxisExtent >= 0);
final double mainAxisExtent;
final double? crossAxisExtent;
final Color? color;
final Axis? fallbackDirection;
@override
RenderObject createRenderObject(BuildContext context) {
return _RenderGap(
mainAxisExtent: mainAxisExtent,
crossAxisExtent: crossAxisExtent ?? 0,
color: color,
fallbackDirection: fallbackDirection,
);
}
@override
void updateRenderObject(BuildContext context, _RenderGap renderObject) {
renderObject
..mainAxisExtent = mainAxisExtent
..crossAxisExtent = crossAxisExtent ?? 0
..color = color
..fallbackDirection = fallbackDirection;
}
}
class _RenderGap extends RenderBox {
_RenderGap({
required double mainAxisExtent,
double? crossAxisExtent,
Axis? fallbackDirection,
Color? color,
}) : _mainAxisExtent = mainAxisExtent,
_crossAxisExtent = crossAxisExtent,
_color = color,
_fallbackDirection = fallbackDirection;
double get mainAxisExtent => _mainAxisExtent;
double _mainAxisExtent;
set mainAxisExtent(double value) {
if (_mainAxisExtent != value) {
_mainAxisExtent = value;
markNeedsLayout();
}
}
double? get crossAxisExtent => _crossAxisExtent;
double? _crossAxisExtent;
set crossAxisExtent(double? value) {
if (_crossAxisExtent != value) {
_crossAxisExtent = value;
markNeedsLayout();
}
}
Axis? get fallbackDirection => _fallbackDirection;
Axis? _fallbackDirection;
set fallbackDirection(Axis? value) {
if (_fallbackDirection != value) {
_fallbackDirection = value;
markNeedsLayout();
}
}
Axis? get _direction {
final parentNode = parent;
if (parentNode is RenderFlex) {
return parentNode.direction;
} else {
return fallbackDirection;
}
}
Color? get color => _color;
Color? _color;
set color(Color? value) {
if (_color != value) {
_color = value;
markNeedsPaint();
}
}
@override
double computeMinIntrinsicWidth(double height) {
return _computeIntrinsicExtent(
Axis.horizontal,
() => super.computeMinIntrinsicWidth(height),
)!;
}
@override
double computeMaxIntrinsicWidth(double height) {
return _computeIntrinsicExtent(
Axis.horizontal,
() => super.computeMaxIntrinsicWidth(height),
)!;
}
@override
double computeMinIntrinsicHeight(double width) {
return _computeIntrinsicExtent(
Axis.vertical,
() => super.computeMinIntrinsicHeight(width),
)!;
}
@override
double computeMaxIntrinsicHeight(double width) {
return _computeIntrinsicExtent(
Axis.vertical,
() => super.computeMaxIntrinsicHeight(width),
)!;
}
double? _computeIntrinsicExtent(Axis axis, double Function() compute) {
final Axis? direction = _direction;
if (direction == axis) {
return _mainAxisExtent;
} else {
if (_crossAxisExtent!.isFinite) {
return _crossAxisExtent;
} else {
return compute();
}
}
}
@override
Size computeDryLayout(BoxConstraints constraints) {
final Axis? direction = _direction;
if (direction != null) {
if (direction == Axis.horizontal) {
return constraints.constrain(Size(mainAxisExtent, crossAxisExtent!));
} else {
return constraints.constrain(Size(crossAxisExtent!, mainAxisExtent));
}
}
// No Flex parent and no scrollable to borrow a direction from. This used
// to throw, which took the whole enclosing subtree down at layout time
// with nothing to catch it beforehand - a Gap inside an AnimatedSize was
// enough to destroy a page. A vertical gap is the overwhelmingly common
// intent, so fall back to it and complain in debug instead of exploding
// in release.
assert(() {
FlutterError.reportError(
FlutterErrorDetails(
exception: FlutterError(
"Gap has no axis to size along.\n"
"It isn't a direct child of a Flex (Row/Column) and there's no "
"Scrollable above it, so it fell back to a vertical gap of "
"$mainAxisExtent. Give it a Flex parent, or use SizedBox if you "
"meant a fixed box.",
),
library: "garage_ui",
),
);
return true;
}());
return constraints.constrain(Size(crossAxisExtent ?? 0, mainAxisExtent));
}
@override
void performLayout() {
size = computeDryLayout(constraints);
}
@override
void paint(PaintingContext context, Offset offset) {
if (color != null) {
final Paint paint = Paint()..color = color!;
context.canvas.drawRect(offset & size, paint);
}
}
}
// ---------------------------------------------------------------------------
// OutlinedContainer
// ---------------------------------------------------------------------------
/// A container with a border + background and optional surface blur.
///
/// This is the workhorse surface the toolbar/hud/panels sit on. Defaults come
/// from the theme: xl border radius, [ColorScheme.background] fill and
/// [ColourScheme.controlBorder] border at 1px (scaled).
class OutlinedContainer extends StatefulWidget {
const OutlinedContainer({
super.key,
required this.child,
this.borderColor,
this.backgroundColor,
this.clipBehavior = Clip.antiAlias,
this.borderRadius,
this.borderStyle,
this.borderWidth,
this.boxShadow,
this.padding,
this.surfaceOpacity,
this.surfaceBlur,
this.width,
this.height,
this.duration,
});
final Widget child;
final Color? backgroundColor;
final Color? borderColor;
final Clip clipBehavior;
final BorderRadiusGeometry? borderRadius;
final BorderStyle? borderStyle;
final double? borderWidth;
final List<BoxShadow>? boxShadow;
final EdgeInsetsGeometry? padding;
final double? surfaceOpacity;
final double? surfaceBlur;
final double? width;
final double? height;
final Duration? duration;
@override
State<OutlinedContainer> createState() => _OutlinedContainerState();
}
class _OutlinedContainerState extends State<OutlinedContainer> {
final GlobalKey _mainContainerKey = GlobalKey();
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final scaling = theme.scaling;
var borderRadius = (widget.borderRadius ?? theme.borderRadiusXl).resolve(
Directionality.of(context),
);
var backgroundColor =
widget.backgroundColor ?? theme.colorScheme.background;
final surfaceOpacity = widget.surfaceOpacity;
if (surfaceOpacity != null) {
backgroundColor = backgroundColor.scaleAlpha(surfaceOpacity);
}
// controlBorder, not muted. muted is a FILL - it was doing stroke duty
// here only because it happened to be the darkest thing on offer, and
// it derives BELOW the ground, so the outline on the workhorse surface
// was reading as a shadow rather than an edge.
final borderColor = widget.borderColor ?? theme.colorScheme.controlBorder;
final borderWidth = widget.borderWidth ?? (1 * scaling);
final borderStyle = widget.borderStyle ?? BorderStyle.solid;
final boxShadow = widget.boxShadow ?? const <BoxShadow>[];
final padding = widget.padding ?? EdgeInsets.zero;
final surfaceBlur = widget.surfaceBlur;
Widget childWidget = AnimatedContainer(
duration: widget.duration ?? Duration.zero,
key: _mainContainerKey,
width: widget.width,
height: widget.height,
decoration: BoxDecoration(
color: backgroundColor,
border: Border.all(
color: borderColor,
width: borderWidth,
style: borderStyle,
),
borderRadius: borderRadius,
boxShadow: boxShadow,
),
child: AnimatedContainer(
duration: widget.duration ?? Duration.zero,
clipBehavior: widget.clipBehavior,
decoration: BoxDecoration(
borderRadius: subtractByBorder(borderRadius, borderWidth),
),
child: DensityContainerPadding(padding: padding, child: widget.child),
),
);
if (surfaceBlur != null && surfaceBlur > 0) {
childWidget = SurfaceBlur(
surfaceBlur: surfaceBlur,
borderRadius: subtractByBorder(borderRadius, borderWidth),
child: childWidget,
);
}
return childWidget;
}
}
// ---------------------------------------------------------------------------
// Card / SurfaceCard
// ---------------------------------------------------------------------------
/// A card surface — basically an [OutlinedContainer] with card colours and a
/// density aware padding. Merges a [ColourScheme.foreground] default text
/// colour over its child.
class Card extends StatelessWidget {
const Card({
super.key,
required this.child,
this.padding,
this.filled,
this.fillColor,
this.borderRadius,
this.clipBehavior,
this.borderColor,
this.borderWidth,
this.boxShadow,
this.surfaceOpacity,
this.surfaceBlur,
this.duration,
});
final Widget child;
final EdgeInsetsGeometry? padding;
final bool? filled;
final Color? fillColor;
final BorderRadiusGeometry? borderRadius;
final Color? borderColor;
final double? borderWidth;
final Clip? clipBehavior;
final List<BoxShadow>? boxShadow;
final double? surfaceOpacity;
final double? surfaceBlur;
final Duration? duration;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final densityContainerPadding = theme.density.containerPadding;
final padding = this.padding ?? EdgeInsets.all(densityContainerPadding);
final filled = this.filled ?? false;
final fillColor = this.fillColor ?? theme.colorScheme.border;
final clipBehavior = this.clipBehavior ?? Clip.none;
return OutlinedContainer(
clipBehavior: clipBehavior,
borderRadius: borderRadius,
borderWidth: borderWidth,
borderColor: borderColor,
backgroundColor: filled ? fillColor : theme.colorScheme.card,
boxShadow: boxShadow,
padding: padding,
surfaceOpacity: surfaceOpacity,
surfaceBlur: surfaceBlur,
duration: duration,
child: DefaultTextStyle.merge(
style: TextStyle(color: theme.colorScheme.foreground),
child: child,
),
);
}
}
/// [Card] variant that picks up the theme's surface blur/opacity. When drawn
/// inside a sheet overlay it collapses to just padding (no double surface).
class SurfaceCard extends StatelessWidget {
const SurfaceCard({
super.key,
required this.child,
this.padding,
this.filled,
this.fillColor,
this.borderRadius,
this.clipBehavior,
this.borderColor,
this.borderWidth,
this.boxShadow,
this.surfaceOpacity,
this.surfaceBlur,
this.duration,
});
final Widget child;
final EdgeInsetsGeometry? padding;
final bool? filled;
final Color? fillColor;
final BorderRadiusGeometry? borderRadius;
final Color? borderColor;
final double? borderWidth;
final Clip? clipBehavior;
final List<BoxShadow>? boxShadow;
final double? surfaceOpacity;
final double? surfaceBlur;
final Duration? duration;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final isSheetOverlay = SheetOverlayHandler.isSheetOverlay(context);
final densityContainerPadding = theme.density.containerPadding;
if (isSheetOverlay) {
final padding = this.padding ?? EdgeInsets.all(densityContainerPadding);
return Padding(padding: padding, child: child);
}
return Card(
clipBehavior: clipBehavior,
borderRadius: borderRadius,
borderWidth: borderWidth,
borderColor: borderColor,
filled: filled,
fillColor: fillColor,
boxShadow: boxShadow,
padding: padding,
surfaceOpacity: surfaceOpacity ?? theme.surfaceOpacity,
surfaceBlur: surfaceBlur ?? theme.surfaceBlur,
duration: duration,
child: child,
);
}
}
// ---------------------------------------------------------------------------
// IconContainer
// ---------------------------------------------------------------------------
/// A little rounded chip around an icon. Defaults to the primary colour with
/// a primaryForeground tint on the icon.
class IconContainer extends StatelessWidget {
const IconContainer({
super.key,
required this.icon,
this.padding,
this.borderRadius,
this.backgroundColor,
this.iconColor,
});
final Widget icon;
final EdgeInsetsGeometry? padding;
final BorderRadius? borderRadius;
final Color? backgroundColor;
final Color? iconColor;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
// padXs used to be a quarter-ish of the container base, resolved by density.
final xs = theme.density.containerPadding * 0.25;
return Container(
padding: padding ?? EdgeInsets.all(xs),
decoration: BoxDecoration(
color: backgroundColor ?? theme.colorScheme.primary,
borderRadius: borderRadius ?? theme.borderRadiusMd,
),
child: IconTheme(
data: IconThemeData(
color: iconColor ?? theme.colorScheme.primaryForeground,
),
child: icon,
),
);
}
}
// ---------------------------------------------------------------------------
// Divider / VerticalDivider
// ---------------------------------------------------------------------------
/// A thin horizontal rule. Colour defaults to the scheme divider, 1px thick.
///
/// note: shadcn's Divider can host a centered child label — nothing in the app
/// uses that so it's dropped here.
class Divider extends StatelessWidget implements PreferredSizeWidget {
const Divider({
super.key,
this.color,
this.height,
this.thickness,
this.indent,
this.endIndent,
});
final Color? color;
final double? height;
final double? thickness;
final double? indent;
final double? endIndent;
@override
Size get preferredSize => Size(0, height ?? 1);
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final color = this.color ?? theme.colorScheme.divider;
final thickness = this.thickness ?? 1.0;
final height = this.height ?? thickness;
final indent = this.indent ?? 0.0;
final endIndent = this.endIndent ?? 0.0;
return SizedBox(
height: height,
width: double.infinity,
child: CustomPaint(
painter: _DividerPainter(
color: color,
thickness: thickness,
indent: indent,
endIndent: endIndent,
),
),
);
}
}
/// vertical counterpart of [Divider].
class VerticalDivider extends StatelessWidget implements PreferredSizeWidget {
const VerticalDivider({
super.key,
this.color,
this.width,
this.thickness,
this.indent,
this.endIndent,
});
final Color? color;
final double? width;
final double? thickness;
final double? indent;
final double? endIndent;
@override
Size get preferredSize => Size(width ?? 1, 0);
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
return SizedBox(
width: width ?? 1,
height: double.infinity,
child: CustomPaint(
painter: _VerticalDividerPainter(
color: color ?? theme.colorScheme.divider,
thickness: thickness ?? 1,
indent: indent ?? 0,
endIndent: endIndent ?? 0,
),
),
);
}
}
class _DividerPainter extends CustomPainter {
_DividerPainter({
required this.color,
required this.thickness,
required this.indent,
required this.endIndent,
});
final Color color;
final double thickness;
final double indent;
final double endIndent;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = thickness
..strokeCap = StrokeCap.square;
final start = Offset(indent, size.height / 2);
final end = Offset(size.width - endIndent, size.height / 2);
canvas.drawLine(start, end, paint);
}
@override
bool shouldRepaint(covariant _DividerPainter oldDelegate) {
return oldDelegate.color != color ||
oldDelegate.thickness != thickness ||
oldDelegate.indent != indent ||
oldDelegate.endIndent != endIndent;
}
}
class _VerticalDividerPainter extends CustomPainter {
_VerticalDividerPainter({
required this.color,
required this.thickness,
required this.indent,
required this.endIndent,
});
final Color color;
final double thickness;
final double indent;
final double endIndent;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = thickness
..strokeCap = StrokeCap.square;
final start = Offset(size.width / 2, indent);
final end = Offset(size.width / 2, size.height - endIndent);
canvas.drawLine(start, end, paint);
}
@override
bool shouldRepaint(covariant _VerticalDividerPainter oldDelegate) {
return oldDelegate.color != color ||
oldDelegate.thickness != thickness ||
oldDelegate.indent != indent ||
oldDelegate.endIndent != endIndent;
}
}
// ---------------------------------------------------------------------------
// Basic
// ---------------------------------------------------------------------------
/// The classic list-item style layout: optional leading, a title/subtitle/
/// content column, and an optional trailing. Title gets small+medium, subtitle
/// gets xSmall+muted, content gets small — matching shadcn exactly.
class Basic extends StatelessWidget {
const Basic({
super.key,
this.leading,
this.title,
this.subtitle,
this.content,
this.trailing,
this.leadingAlignment,
this.trailingAlignment,
this.titleAlignment,
this.subtitleAlignment,
this.contentAlignment,
this.contentSpacing, // 16
this.titleSpacing, // 4
this.mainAxisAlignment,
this.padding,
});
final Widget? leading;
final Widget? title;
final Widget? subtitle;
final Widget? content;
final Widget? trailing;
final AlignmentGeometry? leadingAlignment;
final AlignmentGeometry? trailingAlignment;
final AlignmentGeometry? titleAlignment;
final AlignmentGeometry? subtitleAlignment;
final AlignmentGeometry? contentAlignment;
final double? contentSpacing;
final double? titleSpacing;
final MainAxisAlignment? mainAxisAlignment;
final EdgeInsetsGeometry? padding;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final densityGap = theme.density.containerGap;
final densityContainerPadding = theme.density.containerPadding;
final padding = this.padding ?? EdgeInsets.zero;
final resolvedPadding = resolveEdgeInsets(padding, densityContainerPadding);
final contentSpacing = this.contentSpacing ?? densityGap * 2;
final titleSpacing = this.titleSpacing ?? densityGap * 0.5;
final leadingAlignment = this.leadingAlignment ?? Alignment.topCenter;
final trailingAlignment = this.trailingAlignment ?? Alignment.topCenter;
final titleAlignment = this.titleAlignment ?? Alignment.topLeft;
final subtitleAlignment = this.subtitleAlignment ?? Alignment.topLeft;
final contentAlignment = this.contentAlignment ?? Alignment.topLeft;
final mainAxisAlignment =
this.mainAxisAlignment ?? MainAxisAlignment.center;
return Padding(
padding: resolvedPadding,
child: IntrinsicWidth(
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: mainAxisAlignment,
children: [
if (leading != null)
Align(alignment: leadingAlignment, child: leading!),
if (leading != null &&
(title != null || content != null || subtitle != null))
SizedBox(width: contentSpacing),
if (title != null || content != null || subtitle != null)
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: mainAxisAlignment,
children: [
if (title != null)
Align(
alignment: titleAlignment,
child: title!,
).small().medium(),
if (title != null && subtitle != null)
SizedBox(height: densityGap * 0.25),
if (subtitle != null)
Align(
alignment: subtitleAlignment,
child: subtitle!,
).xSmall().muted(),
if ((title != null || subtitle != null) &&
content != null)
SizedBox(height: titleSpacing),
if (content != null)
Align(
alignment: contentAlignment,
child: content!,
).small(),
],
),
),
if (trailing != null &&
(title != null ||
content != null ||
leading != null ||
subtitle != null))
SizedBox(width: contentSpacing),
if (trailing != null)
Align(alignment: trailingAlignment, child: trailing!),
],
),
),
),
);
}
}
// ---------------------------------------------------------------------------
// Label
// ---------------------------------------------------------------------------
/// A label row — the main child expanded in the middle with optional leading
/// and trailing widgets, 8px (scaled) gaps between them.
class Label extends StatelessWidget {
const Label({super.key, this.leading, required this.child, this.trailing});
final Widget? leading;
final Widget child;
final Widget? trailing;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final scaling = theme.scaling;
return IntrinsicWidth(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (leading != null) leading!,
if (leading != null) SizedBox(width: 8 * scaling),
Expanded(child: child),
if (trailing != null) SizedBox(width: 8 * scaling),
if (trailing != null) trailing!,
],
),
);
}
}