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