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:
ImBenji
2026-09-23 18:49:21 +01:00
co-authored by Claude Opus 5.5
commit b269201919
117 changed files with 26944 additions and 0 deletions
+533
View File
@@ -0,0 +1,533 @@
// hand rolled replacements for shadcn's Checkbox (tri-state) + Switch.
// started as a pixel-identical port of shadcn_flutter 0.0.52 form/checkbox.dart
// + form/switch.dart; the colours now come from the apps own control tokens
// (ColourScheme) rather than the shadcn scheme they were ported against.
import "package:flutter/services.dart";
import "package:flutter/widgets.dart";
import "package:garage_ui/semantics_scope.dart";
import "package:garage_ui/theme/garage_theme.dart";
/// how long a switch takes to slide/recolour. matches shadcn.
const kSwitchDuration = Duration(milliseconds: 100);
/// the three states a checkbox can be in. names match shadcn exactly so call
/// sites only have to swap their import.
enum CheckboxState implements Comparable<CheckboxState> {
checked,
unchecked,
indeterminate;
@override
int compareTo(CheckboxState other) {
return index.compareTo(other.index);
}
}
/// tri-state checkbox. drive it with [state] + [onChanged] — it does not hold
/// its own state, the parent does.
class Checkbox extends StatefulWidget {
final CheckboxState state;
final ValueChanged<CheckboxState>? onChanged;
final Widget? leading;
final Widget? trailing;
/// when true, tapping cycles checked -> unchecked -> indeterminate.
/// when false it just toggles checked/unchecked.
final bool tristate;
final bool? enabled;
final double? size;
final double? gap;
final Color? backgroundColor;
final Color? activeColor;
final Color? borderColor;
final BorderRadiusGeometry? borderRadius;
/// screen reader label. Needed unless [leading]/[trailing] already carry a
/// Text that names the box — the box itself paints a tick, which reads as
/// nothing at all.
final String? semanticLabel;
final FocusNode? focusNode;
const Checkbox({
super.key,
required this.state,
required this.onChanged,
this.leading,
this.trailing,
this.tristate = false,
this.enabled,
this.size,
this.gap,
this.backgroundColor,
this.activeColor,
this.borderColor,
this.borderRadius,
this.semanticLabel,
this.focusNode,
});
@override
State<Checkbox> createState() => _CheckboxState();
}
class _CheckboxState extends State<Checkbox> {
// shadcn keeps this always false (focus ring never wired), so the border
// width stays 1*scaling. we keep the same behaviour.
final bool _focusing = false;
bool _shouldAnimate = false;
void _changeTo(CheckboxState state) {
if (widget.onChanged != null) {
widget.onChanged!(state);
}
}
void _tap() {
if (widget.tristate) {
switch (widget.state) {
case CheckboxState.checked:
_changeTo(CheckboxState.unchecked);
break;
case CheckboxState.unchecked:
_changeTo(CheckboxState.indeterminate);
break;
case CheckboxState.indeterminate:
_changeTo(CheckboxState.checked);
break;
}
} else {
_changeTo(
widget.state == CheckboxState.checked
? CheckboxState.unchecked
: CheckboxState.checked,
);
}
}
@override
void didUpdateWidget(covariant Checkbox oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.state != oldWidget.state) {
_shouldAnimate = true;
}
}
bool get enabled => widget.enabled ?? widget.onChanged != null;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final scaling = theme.scaling;
final size = widget.size ?? 16 * scaling;
final gap = widget.gap ?? 8 * scaling;
// a checkbox is a control, so it takes the control tokens: the same fill
// a secondary button/field uses, and the same border every other input
// draws. (was input.scaleAlpha(0.3) over colorScheme.border - both
// shadcn-migration leftovers that left it reading as its own family.)
final backgroundColor =
widget.backgroundColor ?? theme.colorScheme.secondary;
final activeColor = widget.activeColor ?? theme.colorScheme.primary;
final borderColor = widget.borderColor ?? theme.colorScheme.controlBorder;
final borderRadius =
widget.borderRadius?.resolve(Directionality.maybeOf(context)) ??
BorderRadius.circular(theme.radiusSm);
return MergeSemantics(
child: Semantics(
container: true,
enabled: enabled,
checked: widget.state == CheckboxState.checked,
// report mixed off the actual state, not off tristate — a box can be
// handed CheckboxState.indeterminate even when tapping it only ever
// toggles, and "partly on" is still what the user needs to hear.
mixed: widget.state == CheckboxState.indeterminate ? true : null,
label: resolveSemanticLabel(context, widget.semanticLabel),
onTap: enabled ? _tap : null,
child: _buildBox(
context,
size,
gap,
backgroundColor,
activeColor,
borderColor,
borderRadius,
theme,
scaling,
),
),
);
}
Widget _buildBox(
BuildContext context,
double size,
double gap,
Color backgroundColor,
Color activeColor,
Color borderColor,
BorderRadius borderRadius,
ThemeData theme,
double scaling,
) {
return FocusableActionDetector(
enabled: enabled,
focusNode: widget.focusNode,
mouseCursor: enabled
? SystemMouseCursors.click
: SystemMouseCursors.forbidden,
shortcuts: const {
SingleActivator(LogicalKeyboardKey.space): ActivateIntent(),
SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(),
},
actions: {
ActivateIntent: CallbackAction<ActivateIntent>(
onInvoke: (_) {
_tap();
return null;
},
),
},
child: GestureDetector(
onTap: enabled ? _tap : null,
behavior: HitTestBehavior.opaque,
excludeFromSemantics: true,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (widget.leading != null) widget.leading!,
if (widget.leading != null) SizedBox(width: gap),
AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: size,
height: size,
decoration: BoxDecoration(
color: widget.state == CheckboxState.checked
? activeColor
: backgroundColor,
borderRadius: borderRadius,
border: Border.all(
color: !enabled
? theme.colorScheme.muted
: widget.state == CheckboxState.checked
? activeColor
: borderColor,
width: (_focusing ? 2 : 1) * scaling,
),
),
child: widget.state == CheckboxState.checked
? Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
child: SizedBox(
width: scaling * 9,
height: scaling * 6.5,
child: TweenAnimationBuilder<double>(
tween: Tween<double>(
begin: _shouldAnimate ? 0.0 : 1.0,
end: 1.0,
),
duration: const Duration(milliseconds: 300),
// interval maps to shadcn's IntervalDuration(start: 175ms,
// duration: 300ms) — hold, then draw the tick.
curve: const Interval(175 / 300, 1.0),
builder: (context, value, child) {
return CustomPaint(
painter: AnimatedCheckPainter(
progress: value,
color: theme.colorScheme.primaryForeground,
strokeWidth: scaling * 1,
),
);
},
),
),
),
)
: Center(
child: AnimatedContainer(
duration: const Duration(milliseconds: 100),
width: widget.state == CheckboxState.indeterminate
? scaling * 8
: 0,
height: widget.state == CheckboxState.indeterminate
? scaling * 8
: 0,
padding: EdgeInsets.zero,
decoration: BoxDecoration(
color: activeColor,
borderRadius: BorderRadius.circular(theme.radiusXs),
),
),
),
),
if (widget.trailing != null) SizedBox(width: gap),
if (widget.trailing != null) widget.trailing!,
],
),
),
);
}
}
/// draws the little tick that animates in when a checkbox goes checked.
/// copied verbatim from shadcn so the stroke geometry stays identical.
class AnimatedCheckPainter extends CustomPainter {
final double progress;
final Color color;
final double strokeWidth;
AnimatedCheckPainter({
required this.progress,
required this.color,
required this.strokeWidth,
});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = strokeWidth
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
final path = Path();
Offset firstStrokeStart = Offset(0, size.height * 0.5);
Offset firstStrokeEnd = Offset(size.width * 0.35, size.height);
Offset secondStrokeStart = firstStrokeEnd;
Offset secondStrokeEnd = Offset(size.width, 0);
double firstStrokeLength =
(firstStrokeEnd - firstStrokeStart).distanceSquared;
double secondStrokeLength =
(secondStrokeEnd - secondStrokeStart).distanceSquared;
double totalLength = firstStrokeLength + secondStrokeLength;
double normalizedFirstStrokeLength = firstStrokeLength / totalLength;
double normalizedSecondStrokeLength = secondStrokeLength / totalLength;
double firstStrokeProgress =
progress.clamp(0.0, normalizedFirstStrokeLength) /
normalizedFirstStrokeLength;
double secondStrokeProgress =
(progress - normalizedFirstStrokeLength).clamp(
0.0,
normalizedSecondStrokeLength,
) /
normalizedSecondStrokeLength;
if (firstStrokeProgress <= 0) {
return;
}
Offset currentPoint = Offset.lerp(
firstStrokeStart,
firstStrokeEnd,
firstStrokeProgress,
)!;
path.moveTo(firstStrokeStart.dx, firstStrokeStart.dy);
path.lineTo(currentPoint.dx, currentPoint.dy);
if (secondStrokeProgress <= 0) {
canvas.drawPath(path, paint);
return;
}
Offset secondPoint = Offset.lerp(
secondStrokeStart,
secondStrokeEnd,
secondStrokeProgress,
)!;
path.lineTo(secondPoint.dx, secondPoint.dy);
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant AnimatedCheckPainter oldDelegate) {
return oldDelegate.progress != progress ||
oldDelegate.color != color ||
oldDelegate.strokeWidth != strokeWidth;
}
}
/// sliding on/off toggle. controlled — [value] in, [onChanged] out.
class Switch extends StatefulWidget {
final bool value;
final ValueChanged<bool>? onChanged;
final Widget? leading;
final Widget? trailing;
final bool? enabled;
final double? gap;
final Color? activeColor;
final Color? inactiveColor;
final Color? activeThumbColor;
final Color? inactiveThumbColor;
final BorderRadiusGeometry? borderRadius;
/// screen reader label. Same story as Checkbox — the track and thumb are
/// pure paint, so without this the switch is an unnamed toggle.
final String? semanticLabel;
const Switch({
super.key,
required this.value,
required this.onChanged,
this.leading,
this.trailing,
this.enabled = true,
this.gap,
this.activeColor,
this.inactiveColor,
this.activeThumbColor,
this.inactiveThumbColor,
this.borderRadius,
this.semanticLabel,
});
@override
State<Switch> createState() => _SwitchState();
}
class _SwitchState extends State<Switch> {
bool get _enabled => widget.enabled ?? widget.onChanged != null;
void _toggle() {
widget.onChanged?.call(!widget.value);
}
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final scaling = theme.scaling;
final densityGap = theme.density.containerGap;
final gap = widget.gap ?? 8 * scaling;
final activeColor = widget.activeColor ?? theme.colorScheme.primary;
final inactiveColor =
widget.inactiveColor ?? theme.colorScheme.switchTrackInactive;
final activeThumbColor =
widget.activeThumbColor ?? theme.colorScheme.background;
final inactiveThumbColor =
widget.inactiveThumbColor ?? theme.colorScheme.foreground;
final borderRadius =
widget.borderRadius?.resolve(Directionality.maybeOf(context)) ??
BorderRadius.circular(theme.radiusXl);
return MergeSemantics(
child: Semantics(
container: true,
toggled: widget.value,
enabled: _enabled,
label: resolveSemanticLabel(context, widget.semanticLabel),
onTap: _enabled ? _toggle : null,
child: _buildTrack(
context,
theme,
scaling,
densityGap,
gap,
activeColor,
inactiveColor,
activeThumbColor,
inactiveThumbColor,
borderRadius,
),
),
);
}
Widget _buildTrack(
BuildContext context,
ThemeData theme,
double scaling,
double densityGap,
double gap,
Color activeColor,
Color inactiveColor,
Color activeThumbColor,
Color inactiveThumbColor,
BorderRadius borderRadius,
) {
return GestureDetector(
onTap: _enabled ? _toggle : null,
behavior: HitTestBehavior.opaque,
excludeFromSemantics: true,
child: FocusableActionDetector(
enabled: _enabled,
actions: {
ActivateIntent: CallbackAction<Intent>(
onInvoke: (intent) {
_toggle();
return true;
},
),
},
shortcuts: const {
SingleActivator(LogicalKeyboardKey.enter): ActivateIntent(),
SingleActivator(LogicalKeyboardKey.space): ActivateIntent(),
},
mouseCursor: _enabled
? SystemMouseCursors.click
: SystemMouseCursors.forbidden,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (widget.leading != null) widget.leading!,
if (widget.leading != null) SizedBox(width: gap),
AnimatedContainer(
duration: kSwitchDuration,
width: (32 + 4) * scaling,
height: (16 + 4) * scaling,
padding: EdgeInsets.all(densityGap * 0.25),
decoration: BoxDecoration(
borderRadius: borderRadius,
color: !_enabled
? theme.colorScheme.muted
: widget.value
? activeColor
: inactiveColor,
),
child: Stack(
children: [
AnimatedPositioned(
duration: kSwitchDuration,
curve: Curves.easeInOut,
left: widget.value ? 16 * scaling : 0,
top: 0,
bottom: 0,
child: AspectRatio(
aspectRatio: 1,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(theme.radiusLg),
color: !_enabled
? theme.colorScheme.mutedForeground
: widget.value
? activeThumbColor
: inactiveThumbColor,
),
),
),
),
],
),
),
if (widget.trailing != null) SizedBox(width: gap),
if (widget.trailing != null) widget.trailing!,
],
),
),
);
}
}