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
168 lines
5.7 KiB
Dart
168 lines
5.7 KiB
Dart
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);
|
|
}
|
|
}
|