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
+257
View File
@@ -0,0 +1,257 @@
import "dart:math" as math;
import "package:flutter/services.dart" show LogicalKeyboardKey;
import "package:flutter/widgets.dart";
import "package:garage_ui/theme/garage_theme.dart";
// grab bag of the smaller shadcn widgets the app still reaches for. nothing
// fancy - just enough to match how they were used.
// shadcn's Scaffold gave the page a background + structure. the app only ever
// hands it a child, so thats all we do: fill with the theme background.
class Scaffold extends StatelessWidget {
const Scaffold({
super.key,
required this.child,
this.headers = const [],
this.footers = const [],
this.backgroundColor,
});
final Widget child;
final List<Widget> headers;
final List<Widget> footers;
final Color? backgroundColor;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final body = ColoredBox(
color: backgroundColor ?? theme.colorScheme.background,
child: child,
);
if (headers.isEmpty && footers.isEmpty) return body;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
...headers,
Expanded(child: body),
...footers,
],
);
}
}
/// A small indeterminate spinner with no Material dependency.
class CircularProgressIndicator extends StatefulWidget {
const CircularProgressIndicator({
super.key,
this.color,
this.strokeWidth = 4.0,
this.size = 18.0,
});
final Color? color;
final double strokeWidth;
final double size;
@override
State<CircularProgressIndicator> createState() =>
_CircularProgressIndicatorState();
}
class _CircularProgressIndicatorState extends State<CircularProgressIndicator>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
)..repeat();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
return SizedBox.square(
dimension: widget.size,
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) => CustomPaint(
painter: _CircularProgressPainter(
progress: _controller.value,
color: widget.color ?? theme.colorScheme.primary,
strokeWidth: widget.strokeWidth,
),
),
),
);
}
}
class _CircularProgressPainter extends CustomPainter {
const _CircularProgressPainter({
required this.progress,
required this.color,
required this.strokeWidth,
});
final double progress;
final Color color;
final double strokeWidth;
@override
void paint(Canvas canvas, Size size) {
final inset = strokeWidth / 2;
final rect =
Offset(inset, inset) & Size.square(size.shortestSide - strokeWidth);
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeWidth = strokeWidth;
canvas.drawArc(
rect,
-math.pi / 2 + progress * math.pi * 2,
math.pi * 1.35,
false,
paint,
);
}
@override
bool shouldRepaint(_CircularProgressPainter oldDelegate) =>
oldDelegate.progress != progress ||
oldDelegate.color != color ||
oldDelegate.strokeWidth != strokeWidth;
}
// renders a keyboard shortcut as little key caps. `keys` is usually a list of
// LogicalKeyboardKey but we tolerate anything (some call sites pass strings).
class KeyboardDisplay extends StatelessWidget {
const KeyboardDisplay({super.key, required this.keys, this.spacing = 4});
final List<dynamic> keys;
final double spacing;
String _label(dynamic k) {
if (k is LogicalKeyboardKey) {
final l = k.keyLabel;
return l.isNotEmpty ? l : k.debugName ?? "?";
}
return "$k";
}
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final scaling = theme.scaling;
final caps = <Widget>[];
for (var i = 0; i < keys.length; i++) {
if (i > 0) caps.add(SizedBox(width: spacing * scaling));
caps.add(
Container(
padding: EdgeInsets.symmetric(
horizontal: 5 * scaling,
vertical: 2 * scaling,
),
decoration: BoxDecoration(
color: theme.colorScheme.muted,
borderRadius: theme.borderRadiusSm,
border: Border.all(color: theme.colorScheme.border, width: scaling),
),
child: DefaultTextStyle.merge(
style: TextStyle(color: theme.colorScheme.mutedForeground),
child: Text(_label(keys[i])),
),
),
);
}
return Row(mainAxisSize: MainAxisSize.min, children: caps);
}
}
// shows [hoverBuilder]'s content floating near the child while hovered. shadcn's
// HoverCard, trimmed to a plain overlay follower.
class HoverCard extends StatefulWidget {
const HoverCard({
super.key,
required this.child,
required this.hoverBuilder,
this.anchorAlignment = Alignment.bottomLeft,
this.cardAlignment = Alignment.topLeft,
this.waitDuration = const Duration(milliseconds: 300),
});
final Widget child;
final WidgetBuilder hoverBuilder;
final Alignment anchorAlignment;
final Alignment cardAlignment;
final Duration waitDuration;
@override
State<HoverCard> createState() => _HoverCardState();
}
class _HoverCardState extends State<HoverCard> {
final LayerLink _link = LayerLink();
OverlayEntry? _entry;
void _show() {
if (_entry != null) return;
final overlay = Overlay.of(context);
_entry = OverlayEntry(
// needs an explicit origin: a Positioned with all-null coords is a
// non-positioned overlay child and gets laid out with TIGHT full-screen
// constraints, which blows any sized() card up to the whole screen. the
// follower does the real placement via the link regardless.
builder: (ctx) => Positioned(
left: 0,
top: 0,
child: CompositedTransformFollower(
link: _link,
showWhenUnlinked: false,
targetAnchor: widget.anchorAlignment,
followerAnchor: widget.cardAlignment,
child: MouseRegion(
onExit: (_) => _hide(),
child: widget.hoverBuilder(ctx),
),
),
),
);
overlay.insert(_entry!);
}
void _hide() {
_entry?.remove();
_entry = null;
}
@override
void dispose() {
_hide();
super.dispose();
}
@override
Widget build(BuildContext context) {
return CompositedTransformTarget(
link: _link,
child: MouseRegion(
onEnter: (_) => _show(),
onExit: (_) {
// give the pointer a beat to reach the card before we yank it.
Future.delayed(const Duration(milliseconds: 60), () {
if (mounted && _entry != null) _hide();
});
},
child: widget.child,
),
);
}
}