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
+167
View File
@@ -0,0 +1,167 @@
import "dart:ui" show lerpDouble;
import "package:flutter/widgets.dart";
import "package:garage_ui/theme/garage_theme.dart";
// Rest -> hover geometry. A hairline: at rest its barely a mark on the edge
// of the pane, and hover only just thickens it. The pill radius falls out of
// the thickness so the two never drift.
const double _restThickness = 2;
const double _hoverThickness = 3.5;
const double _restAlpha = 0.35;
const double _hoverAlpha = 0.55;
const double _dragAlpha = 0.7;
// how long the fatten/brighten takes. short - this is a pointer response, not
// a transition.
const Duration _grow = Duration(milliseconds: 140);
/// The Garage scroll thumb.
///
/// Every scrollable in a [GarageApp] gets one of these through
/// GarageScrollBehavior, so nothing has to wrap itself in a scrollbar by hand.
/// Flutters own default is a [RawScrollbar] in stock grey - square ended, 8px,
/// no relation to the scheme - which is what you were seeing before this
/// existed.
///
/// It floats OVER the content (no track, no gutter) so turning it on doesnt
/// reflow anything, and it holds itself visible while the pointer is anywhere
/// in the pane rather than only while youre actually scrolling.
class GarageScrollbar extends StatefulWidget {
const GarageScrollbar({super.key, required this.child, this.controller});
final Widget child;
/// The controller of the scrollable underneath. Comes straight off
/// ScrollableDetails when this is built by the scroll behaviour; null falls
/// back to the PrimaryScrollController the same way [RawScrollbar] does.
final ScrollController? controller;
@override
State<GarageScrollbar> createState() => _GarageScrollbarState();
}
class _GarageScrollbarState extends State<GarageScrollbar> {
bool _hovered = false;
bool _dragging = false;
ScrollController? get _controller =>
widget.controller ?? PrimaryScrollController.maybeOf(context);
// RawScrollbar only tolerates thumbVisibility when its controller is wired
// to exactly one live position - it asserts on everything else. We flip that
// flag on hover rather than at construction, so the same checks have to
// happen here first: a pane whose controller hasnt attached yet would
// otherwise throw the moment the pointer crossed it, and one whose content
// fits would paint a full height thumb over nothing.
bool get _canHold {
final c = _controller;
if (c == null || !c.hasClients || c.positions.length != 1) return false;
final p = c.position;
if (!p.hasContentDimensions) return false;
return p.maxScrollExtent > p.minScrollExtent;
}
void _setHovered(bool value) {
if (_hovered == value) return;
setState(() {
_hovered = value;
// a drag that ends outside the pane never reports back (the recognisers
// cancel path doesnt route through handleThumbPressEnd), so dont let the
// flag stick.
if (!value) _dragging = false;
});
}
void _setDragging(bool value) {
if (_dragging == value) return;
setState(() => _dragging = value);
}
@override
Widget build(BuildContext context) {
final scheme = GarageTheme.maybeOf(context)?.colorScheme;
// outside a GarageTheme (a bare test host, mostly) fall back to flutters
// own grey rather than asserting - a scrollbar is never worth a red screen.
final base = scheme?.mutedForeground ?? const Color(0xffbcbcbc);
final lit = _hovered || _dragging;
return MouseRegion(
opaque: false,
onEnter: (_) => _setHovered(true),
onExit: (_) => _setHovered(false),
child: Listener(
onPointerUp: (_) => _setDragging(false),
onPointerCancel: (_) => _setDragging(false),
child: TweenAnimationBuilder<double>(
tween: Tween<double>(end: lit ? 1 : 0),
duration: _grow,
curve: Curves.easeOut,
builder: (context, t, child) {
final thickness = lerpDouble(_restThickness, _hoverThickness, t)!;
final alpha = _dragging
? _dragAlpha
: lerpDouble(_restAlpha, _hoverAlpha, t)!;
return _GarageRawScrollbar(
controller: widget.controller,
thumbColor: base.withValues(alpha: alpha),
thickness: thickness,
radius: Radius.circular(thickness / 2),
// null, not false: false would mean "definitely hidden" and
// kill the fade in on scroll.
thumbVisibility: lit && _canHold ? true : null,
crossAxisMargin: 2,
mainAxisMargin: 2,
minThumbLength: 24,
onDragChanged: _setDragging,
child: child!,
);
},
child: widget.child,
),
),
);
}
}
// The only reason this subclass exists: RawScrollbar keeps its drag state to
// itself, and the thumb is meant to go full strength while youre holding it.
class _GarageRawScrollbar extends RawScrollbar {
const _GarageRawScrollbar({
required super.child,
required this.onDragChanged,
super.controller,
super.thumbVisibility,
super.thumbColor,
super.thickness,
super.radius,
super.crossAxisMargin,
super.mainAxisMargin,
super.minThumbLength,
});
final ValueChanged<bool> onDragChanged;
@override
RawScrollbarState<_GarageRawScrollbar> createState() =>
_GarageRawScrollbarState();
}
class _GarageRawScrollbarState extends RawScrollbarState<_GarageRawScrollbar> {
@override
void handleThumbPressStart(Offset localPosition) {
super.handleThumbPressStart(localPosition);
widget.onDragChanged(true);
}
@override
void handleThumbPressEnd(Offset localPosition, Velocity velocity) {
super.handleThumbPressEnd(localPosition, velocity);
widget.onDragChanged(false);
}
}