Files
ImBenjiandClaude Opus 5.5 b269201919 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
2026-09-23 18:49:21 +01:00

601 lines
17 KiB
Dart

// GarageUI — navigation groups + progress indicators.
//
// used to be a near-verbatim shadcn fork with the whole sliver/Data/overflow
// machinery bolted on. the app only ever leans on a handful of these, so this
// is the trimmed, standalone version: plain flutter widgets + GarageTheme. no
// Data lookups, no NavigationControlData plumbing, no sliver headers.
import "package:flutter/widgets.dart";
import "package:garage_ui/theme/garage_theme.dart";
import "package:garage_ui/theme/support.dart";
import "package:garage_ui/button.dart";
/// Determines when labels are shown in navigation items.
enum NavigationLabelType {
/// No labels displayed.
none,
/// Labels shown only for selected items.
selected,
/// Labels always shown for all items.
all,
/// Labels shown as tooltips on hover.
tooltip,
/// Labels shown when navigation is expanded.
expanded,
}
/// Position of navigation item labels relative to the children.
enum NavigationLabelPosition {
/// Label before the items (left in LTR).
start,
/// Label after the items (right in LTR).
end,
/// Label above the items.
top,
/// Label below the items.
bottom,
}
/// Visual divider between navigation items.
///
/// Renders a thin horizontal line separator. The old version flipped direction
/// off inherited nav data and could emit a sliver — we only ever use it inside
/// vertical rails, so it's just a padded rule now.
class NavigationDivider extends StatelessWidget {
/// Optional thickness of the divider line.
final double? thickness;
/// Optional color for the divider.
final Color? color;
/// Creates a navigation divider.
const NavigationDivider({super.key, this.thickness, this.color});
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final scaling = theme.scaling;
final line = Container(
height: thickness ?? (1 * scaling),
color: color ?? theme.colorScheme.divider,
);
return Padding(
padding: EdgeInsets.symmetric(vertical: theme.density.containerGap * 0.5),
child: line,
);
}
}
/// Groups navigation children under a label header.
///
/// Vertical column only — a padded label, a gap, then the children stretched to
/// fill. Label can sit before or after the items via [labelPosition].
class NavigationGroup extends StatelessWidget {
/// Label widget shown for the group.
final Widget label;
/// The child items within this group.
final List<Widget> children;
/// Position of the label relative to the children.
final NavigationLabelPosition labelPosition;
/// Alignment of the label content.
final AlignmentGeometry? labelAlignment;
/// Padding around the label.
final EdgeInsetsGeometry? labelPadding;
/// Creates a new navigation group.
const NavigationGroup({
super.key,
required this.label,
this.children = const [],
this.labelPosition = NavigationLabelPosition.top,
this.labelAlignment,
this.labelPadding,
});
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
// label sits at half the container padding, items get a full gap under it.
final contentPadding = theme.density.containerPadding;
final gap = theme.density.containerGap;
final paddedLabel = Container(
alignment: labelAlignment ?? Alignment.center,
padding:
labelPadding ??
EdgeInsets.symmetric(horizontal: contentPadding * 0.5),
child: DefaultTextStyle.merge(
textAlign: TextAlign.center,
maxLines: 1,
child: label,
),
);
final items = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
);
final labelFirst =
labelPosition == NavigationLabelPosition.top ||
labelPosition == NavigationLabelPosition.start;
final ordered = labelFirst
? [paddedLabel, SizedBox(height: gap), items]
: [items, SizedBox(height: gap), paddedLabel];
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: ordered,
);
}
}
/// Duration of one indeterminate sweep cycle.
const int _kIndeterminateLinearDuration = 1800;
/// A linear progress bar with determinate + indeterminate modes.
///
/// Determinate ([value] != null) fills left-to-right and eases toward each new
/// value. Indeterminate ([value] == null) runs two overlapping segments across
/// the track forever. Sparks/rtl/theme-lookup extras from the shadcn original
/// were dropped — nothing in the app used them.
class LinearProgressIndicator extends StatefulWidget {
// timing curves for the twin-line indeterminate motion, lifted straight from
// material so the sweep feels the same.
static const Curve _line1Head = Interval(
0.0,
750.0 / _kIndeterminateLinearDuration,
curve: Cubic(0.2, 0.0, 0.8, 1.0),
);
static const Curve _line1Tail = Interval(
333.0 / _kIndeterminateLinearDuration,
(333.0 + 750.0) / _kIndeterminateLinearDuration,
curve: Cubic(0.4, 0.0, 1.0, 1.0),
);
static const Curve _line2Head = Interval(
1000.0 / _kIndeterminateLinearDuration,
(1000.0 + 567.0) / _kIndeterminateLinearDuration,
curve: Cubic(0.0, 0.0, 0.65, 1.0),
);
static const Curve _line2Tail = Interval(
1267.0 / _kIndeterminateLinearDuration,
(1267.0 + 533.0) / _kIndeterminateLinearDuration,
curve: Cubic(0.10, 0.0, 0.45, 1.0),
);
/// Progress between 0.0 and 1.0. Null => indeterminate.
final double? value;
/// Background color of the track.
final Color? backgroundColor;
/// Minimum height of the bar.
final double? minHeight;
/// Primary color of the fill.
final Color? color;
/// Border radius of the container.
final BorderRadiusGeometry? borderRadius;
/// Creates a [LinearProgressIndicator].
const LinearProgressIndicator({
super.key,
this.value,
this.backgroundColor,
this.minHeight,
this.color,
this.borderRadius,
});
@override
State<LinearProgressIndicator> createState() =>
_LinearProgressIndicatorState();
}
class _LinearProgressIndicatorState extends State<LinearProgressIndicator>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: _kIndeterminateLinearDuration),
);
_syncController();
}
@override
void didUpdateWidget(covariant LinearProgressIndicator oldWidget) {
super.didUpdateWidget(oldWidget);
// only the indeterminate mode wants a ticking controller. flip it on/off
// when the value nullability changes so we're not spinning for nothing.
if ((oldWidget.value == null) != (widget.value == null)) {
_syncController();
}
}
void _syncController() {
if (widget.value == null) {
_controller.repeat();
} else {
_controller.stop();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final colorValue = widget.color ?? theme.colorScheme.primary;
final backgroundColorValue =
widget.backgroundColor ?? colorValue.scaleAlpha(0.2);
final minHeightValue = widget.minHeight ?? theme.scaling * 2;
final borderRadiusValue = widget.borderRadius ?? BorderRadius.zero;
Widget bar;
if (widget.value != null) {
// determinate — ease toward the new fill whenever value changes
bar = TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: widget.value!.clamp(0.0, 1.0)),
duration: kDefaultDuration,
curve: Curves.easeInOut,
builder: (context, v, _) {
return CustomPaint(
painter: _LinearProgressPainter(
end: v,
color: colorValue,
backgroundColor: backgroundColorValue,
),
);
},
);
} else {
// indeterminate — two overlapping segments chasing across the track
bar = AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final t = _controller.value;
return CustomPaint(
painter: _LinearProgressPainter(
start: LinearProgressIndicator._line1Tail.transform(t),
end: LinearProgressIndicator._line1Head.transform(t),
start2: LinearProgressIndicator._line2Tail.transform(t),
end2: LinearProgressIndicator._line2Head.transform(t),
color: colorValue,
backgroundColor: backgroundColorValue,
),
);
},
);
}
return RepaintBoundary(
child: SizedBox(
height: minHeightValue,
child: ClipRRect(borderRadius: borderRadiusValue, child: bar),
),
);
}
}
class _LinearProgressPainter extends CustomPainter {
final double start;
final double end;
final double? start2; // for the second indeterminate segment
final double? end2;
final Color color;
final Color backgroundColor;
_LinearProgressPainter({
this.start = 0,
required this.end,
this.start2,
this.end2,
required this.color,
required this.backgroundColor,
});
@override
void paint(Canvas canvas, Size size) {
var start = this.start;
var end = this.end;
var start2 = this.start2;
var end2 = this.end2;
// nan sneaks in from curve maths at the extremes — clamp it to nothing
if (start.isNaN) start = 0;
if (end.isNaN) end = 0;
if (start2 != null && start2.isNaN) start2 = 0;
if (end2 != null && end2.isNaN) end2 = 0;
final paint = Paint()..style = PaintingStyle.fill;
paint.color = backgroundColor;
canvas.drawRRect(
RRect.fromLTRBR(
0,
0,
size.width,
size.height,
Radius.circular(size.height / 2),
),
paint,
);
paint.color = color;
canvas.drawRect(
Rect.fromLTWH(
size.width * start,
0,
size.width * (end - start),
size.height,
),
paint,
);
if (start2 != null && end2 != null) {
canvas.drawRect(
Rect.fromLTWH(
size.width * start2,
0,
size.width * (end2 - start2),
size.height,
),
paint,
);
}
}
@override
bool shouldRepaint(covariant _LinearProgressPainter old) {
return old.start != start ||
old.end != end ||
old.start2 != start2 ||
old.end2 != end2 ||
old.color != color ||
old.backgroundColor != backgroundColor;
}
}
// ---------------------------------------------------------------------------
// NavigationRail — the little sidebar the settings panels use. collapses to
// icons, expands to icon + label. (shadcn had a much bigger rail; this is the
// slice the app actually drives.)
// ---------------------------------------------------------------------------
enum NavigationRailAlignment { start, center, end }
// carries the rail's expanded state + label side down to the items.
class _NavRailScope extends InheritedWidget {
const _NavRailScope({
required this.expanded,
required this.labelPosition,
required super.child,
});
final bool expanded;
final NavigationLabelPosition labelPosition;
static _NavRailScope? of(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<_NavRailScope>();
@override
bool updateShouldNotify(_NavRailScope old) =>
old.expanded != expanded || old.labelPosition != labelPosition;
}
class NavigationRail extends StatelessWidget {
const NavigationRail({
super.key,
this.children = const [],
this.header = const [],
this.footer = const [],
this.backgroundColor,
this.labelType = NavigationLabelType.selected,
this.labelPosition = NavigationLabelPosition.bottom,
this.alignment = NavigationRailAlignment.start,
this.expanded = false,
this.expandedSize = 150,
this.collapsedSize = 50,
this.padding,
});
final List<Widget> children;
final List<Widget> header;
final List<Widget> footer;
final Color? backgroundColor;
final NavigationLabelType labelType;
final NavigationLabelPosition labelPosition;
final NavigationRailAlignment alignment;
final bool expanded;
final double expandedSize;
final double collapsedSize;
final EdgeInsetsGeometry? padding;
MainAxisAlignment get _mainAxis => switch (alignment) {
NavigationRailAlignment.start => MainAxisAlignment.start,
NavigationRailAlignment.center => MainAxisAlignment.center,
NavigationRailAlignment.end => MainAxisAlignment.end,
};
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
return _NavRailScope(
expanded: expanded,
labelPosition: labelPosition,
child: AnimatedContainer(
duration: const Duration(milliseconds: 150),
width: (expanded ? expandedSize : collapsedSize) * 1.0,
color: backgroundColor ?? theme.colorScheme.secondary,
padding: padding ?? EdgeInsets.all(6 * theme.scaling),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: _mainAxis,
children: [
...header,
...children,
if (footer.isNotEmpty) const Spacer(),
...footer,
],
),
),
);
}
}
class NavigationItem extends StatelessWidget {
const NavigationItem({
super.key,
required this.child,
this.label,
this.selected = false,
this.onChanged,
this.selectedStyle,
});
final Widget child;
final Widget? label;
final bool selected;
final ValueChanged<bool>? onChanged;
final ButtonStyle? selectedStyle;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final expanded = _NavRailScope.of(context)?.expanded ?? false;
final fg = selected
? theme.colorScheme.primaryForeground
: theme.colorScheme.foreground;
Widget row = Row(
mainAxisAlignment: expanded
? MainAxisAlignment.start
: MainAxisAlignment.center,
children: [
IconTheme.merge(
data: IconThemeData(color: fg, size: theme.iconTheme.medium.size),
child: child,
),
if (expanded && label != null) ...[
SizedBox(width: theme.density.containerGap),
DefaultTextStyle.merge(
style: TextStyle(color: fg),
child: label!,
),
],
],
);
return Padding(
padding: EdgeInsets.symmetric(vertical: 2 * theme.scaling),
child: MergeSemantics(
child: Semantics(
container: true,
selected: selected,
inMutuallyExclusiveGroup: true,
onTap: () => onChanged?.call(!selected),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
excludeFromSemantics: true,
onTap: () => onChanged?.call(!selected),
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 8 * theme.scaling,
vertical: 8 * theme.scaling,
),
decoration: BoxDecoration(
color: selected
? theme.colorScheme.primary
: const Color(0x00000000),
borderRadius: theme.borderRadiusMd,
),
child: row,
),
),
),
),
);
}
}
// a free-form rail row (custom leading + optional title + tap). used for the
// collapse toggle.
class NavigationSlot extends StatelessWidget {
const NavigationSlot({
super.key,
this.title,
this.leading,
this.onPressed,
this.alignment = Alignment.centerLeft,
});
final Widget? title;
final Widget? leading;
final VoidCallback? onPressed;
final Alignment alignment;
@override
Widget build(BuildContext context) {
final theme = GarageTheme.of(context);
final expanded = _NavRailScope.of(context)?.expanded ?? false;
final isCenter = alignment.x == 0 && !expanded;
Widget row = Row(
mainAxisAlignment: isCenter
? MainAxisAlignment.center
: MainAxisAlignment.start,
children: [
if (leading != null) leading!,
if (expanded && title != null) ...[
SizedBox(width: theme.density.containerGap),
title!,
],
],
);
return Padding(
padding: EdgeInsets.symmetric(vertical: 2 * theme.scaling),
child: MergeSemantics(
child: Semantics(
container: true,
button: true,
onTap: onPressed,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
excludeFromSemantics: true,
onTap: onPressed,
child: row,
),
),
),
);
}
}