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
258 lines
6.9 KiB
Dart
258 lines
6.9 KiB
Dart
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,
|
|
),
|
|
);
|
|
}
|
|
}
|