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
908 lines
27 KiB
Dart
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!,
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|