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,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!,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user