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
This commit is contained in:
@@ -0,0 +1,907 @@
|
||||
// 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!,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user