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
601 lines
17 KiB
Dart
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,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|