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