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
534 lines
17 KiB
Dart
534 lines
17 KiB
Dart
// 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!,
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|