// 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 { 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? 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 createState() => _CheckboxState(); } class _CheckboxState extends State { // 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( 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( tween: Tween( 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? 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 createState() => _SwitchState(); } class _SwitchState extends State { 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( 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!, ], ), ), ); } }