// 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 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 createState() => _LinearProgressIndicatorState(); } class _LinearProgressIndicatorState extends State 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( 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 children; final List header; final List 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? 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, ), ), ), ); } }