From b4ccdd7750d26eefc0dfab95c39b35bf4fef830f Mon Sep 17 00:00:00 2001 From: Emily Pauli Date: Wed, 22 Oct 2025 18:12:54 +0200 Subject: [PATCH] Refactor progress indicators to use stateful widgets, enhance animation control, and remove unnecessary subtitles in section cards --- apps/gallery/lib/sections/button_section.dart | 2 - apps/gallery/lib/sections/fab_section.dart | 2 - .../lib/sections/icon_button_section.dart | 4 +- .../sections/loading_indicator_section.dart | 1 - .../lib/sections/navigation_section.dart | 50 +++++-- .../lib/sections/progress_section.dart | 56 ++++++-- apps/gallery/lib/sections/slider_section.dart | 64 +++++---- .../lib/sections/split_button_section.dart | 4 +- .../gallery/lib/sections/toolbar_section.dart | 13 +- .../lib/src/circular_progress_m3e.dart | 128 +++++++++++++----- .../lib/src/linear_progress_m3e.dart | 122 +++++++++++++---- 11 files changed, 316 insertions(+), 130 deletions(-) diff --git a/apps/gallery/lib/sections/button_section.dart b/apps/gallery/lib/sections/button_section.dart index c26056f..897c2e5 100644 --- a/apps/gallery/lib/sections/button_section.dart +++ b/apps/gallery/lib/sections/button_section.dart @@ -10,8 +10,6 @@ class ButtonSection extends StatelessWidget { Widget build(BuildContext context) { return SectionCard( title: 'ButtonM3E', - subtitle: - 'Generated from enums: variant × size; grouped by shape family.', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/apps/gallery/lib/sections/fab_section.dart b/apps/gallery/lib/sections/fab_section.dart index ebfaf76..554a579 100644 --- a/apps/gallery/lib/sections/fab_section.dart +++ b/apps/gallery/lib/sections/fab_section.dart @@ -14,8 +14,6 @@ class FabSection extends StatelessWidget { return SectionCard( title: 'FabM3E', - subtitle: - 'Generated from enums: kind × size. Includes Extended FAB examples.', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/apps/gallery/lib/sections/icon_button_section.dart b/apps/gallery/lib/sections/icon_button_section.dart index 991abad..59b37c6 100644 --- a/apps/gallery/lib/sections/icon_button_section.dart +++ b/apps/gallery/lib/sections/icon_button_section.dart @@ -10,14 +10,14 @@ class IconButtonSection extends StatelessWidget { Widget build(BuildContext context) { return SectionCard( title: 'IconButtonM3E', - subtitle: 'Generated from enums: variant × size (round shape, default width).', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final variant in IconButtonM3EVariant.values) ...[ Padding( padding: const EdgeInsets.symmetric(vertical: 8), - child: Text(variant.name, style: Theme.of(context).textTheme.titleMedium), + child: Text(variant.name, + style: Theme.of(context).textTheme.titleMedium), ), Wrap( spacing: 12, diff --git a/apps/gallery/lib/sections/loading_indicator_section.dart b/apps/gallery/lib/sections/loading_indicator_section.dart index 78a6102..a0ad8f7 100644 --- a/apps/gallery/lib/sections/loading_indicator_section.dart +++ b/apps/gallery/lib/sections/loading_indicator_section.dart @@ -10,7 +10,6 @@ class LoadingIndicatorSection extends StatelessWidget { Widget build(BuildContext context) { return SectionCard( title: 'LoadingIndicatorM3E', - subtitle: 'Generated from enums: variant values.', child: Wrap( spacing: 16, runSpacing: 16, diff --git a/apps/gallery/lib/sections/navigation_section.dart b/apps/gallery/lib/sections/navigation_section.dart index 1498f1a..043c806 100644 --- a/apps/gallery/lib/sections/navigation_section.dart +++ b/apps/gallery/lib/sections/navigation_section.dart @@ -17,11 +17,11 @@ class _NavigationSectionState extends State { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final m3e = theme.extension() ?? M3ETheme.defaults(theme.colorScheme); + final m3e = + theme.extension() ?? M3ETheme.defaults(theme.colorScheme); return SectionCard( title: 'Navigation', - subtitle: 'Generated from enums: NavBar indicator styles and Rail indicator styles.', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -38,17 +38,33 @@ class _NavigationSectionState extends State { children: [ Padding( padding: const EdgeInsets.only(bottom: 4), - child: Text('indicator: ${style.name}', style: theme.textTheme.labelLarge), + child: Text('indicator: ${style.name}', + style: theme.textTheme.labelLarge), ), NavigationBarM3E( selectedIndex: _barIndex, - onDestinationSelected: (i) => setState(() => _barIndex = i), + onDestinationSelected: (i) => + setState(() => _barIndex = i), indicatorStyle: style, destinations: const [ - NavigationDestinationM3E(icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home), label: 'Home'), - NavigationDestinationM3E(icon: Icon(Icons.search_outlined), selectedIcon: Icon(Icons.search), label: 'Search', badgeDot: true), - NavigationDestinationM3E(icon: Icon(Icons.favorite_outline), selectedIcon: Icon(Icons.favorite), label: 'Favorites', badgeCount: 2), - NavigationDestinationM3E(icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), label: 'Profile'), + NavigationDestinationM3E( + icon: Icon(Icons.home_outlined), + selectedIcon: Icon(Icons.home), + label: 'Home'), + NavigationDestinationM3E( + icon: Icon(Icons.search_outlined), + selectedIcon: Icon(Icons.search), + label: 'Search', + badgeDot: true), + NavigationDestinationM3E( + icon: Icon(Icons.favorite_outline), + selectedIcon: Icon(Icons.favorite), + label: 'Favorites', + badgeCount: 2), + NavigationDestinationM3E( + icon: Icon(Icons.person_outline), + selectedIcon: Icon(Icons.person), + label: 'Profile'), ], ), const SizedBox(height: 8), @@ -72,12 +88,22 @@ class _NavigationSectionState extends State { for (final style in RailIndicatorStyle.values) ...[ NavigationRailM3E( selectedIndex: _railIndex, - onDestinationSelected: (i) => setState(() => _railIndex = i), + onDestinationSelected: (i) => + setState(() => _railIndex = i), indicatorStyle: style, destinations: const [ - RailDestinationM3E(icon: Icon(Icons.dashboard_outlined), selectedIcon: Icon(Icons.dashboard), label: 'Dash'), - RailDestinationM3E(icon: Icon(Icons.analytics_outlined), selectedIcon: Icon(Icons.analytics), label: 'Reports'), - RailDestinationM3E(icon: Icon(Icons.settings_outlined), selectedIcon: Icon(Icons.settings), label: 'Settings'), + RailDestinationM3E( + icon: Icon(Icons.dashboard_outlined), + selectedIcon: Icon(Icons.dashboard), + label: 'Dash'), + RailDestinationM3E( + icon: Icon(Icons.analytics_outlined), + selectedIcon: Icon(Icons.analytics), + label: 'Reports'), + RailDestinationM3E( + icon: Icon(Icons.settings_outlined), + selectedIcon: Icon(Icons.settings), + label: 'Settings'), ], ), const VerticalDivider(width: 1), diff --git a/apps/gallery/lib/sections/progress_section.dart b/apps/gallery/lib/sections/progress_section.dart index 31c5dd1..5001bb4 100644 --- a/apps/gallery/lib/sections/progress_section.dart +++ b/apps/gallery/lib/sections/progress_section.dart @@ -3,17 +3,49 @@ import 'package:m3e_collection/m3e_collection.dart'; import 'section_card.dart'; -class ProgressSection extends StatelessWidget { +class ProgressSection extends StatefulWidget { const ProgressSection({super.key}); + @override + State createState() => _ProgressSectionState(); +} + +class _ProgressSectionState extends State + with SingleTickerProviderStateMixin { + double? _value = 0.6; + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, duration: const Duration(milliseconds: 2400)) + ..repeat(); + _controller.addListener(() { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return SectionCard( title: 'ProgressIndicatorM3E', - subtitle: 'Generated from enums: circular sizes and linear variants.', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('Value: $_value (set 0 for null)'), + SliderM3E( + value: _value ?? 0, + onChanged: (v) => setState( + () => _value = v == 0 ? null : v, + ), + ), Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Text('Circular - Wavy', @@ -26,12 +58,12 @@ class ProgressSection extends StatelessWidget { for (final s in CircularProgressM3ESize.values) ...[ CircularProgressIndicatorM3E( size: s, - value: 0.4, + value: _value, shape: ProgressM3EShape.wavy, ), CircularProgressIndicatorM3E( size: s, - value: 0.6, + value: _value, shape: ProgressM3EShape.wavy, ), ], @@ -50,13 +82,13 @@ class ProgressSection extends StatelessWidget { for (final s in CircularProgressM3ESize.values) ...[ CircularProgressIndicatorM3E( size: s, - value: 0.4, + value: _value, shape: ProgressM3EShape.flat, ), CircularProgressIndicatorM3E( size: s, shape: ProgressM3EShape.flat, - value: 0.6, + value: _value, ), ], ], @@ -74,14 +106,16 @@ class ProgressSection extends StatelessWidget { SizedBox( width: 250, child: LinearProgressIndicatorM3E( - value: null, + value: _value, + size: LinearProgressM3ESize.s, shape: ProgressM3EShape.wavy, ), ), SizedBox( width: 250, child: LinearProgressIndicatorM3E( - value: 0.6, + value: _value, + size: LinearProgressM3ESize.m, shape: ProgressM3EShape.wavy, ), ), @@ -100,14 +134,16 @@ class ProgressSection extends StatelessWidget { SizedBox( width: 250, child: LinearProgressIndicatorM3E( - value: null, + value: _value, + size: LinearProgressM3ESize.s, shape: ProgressM3EShape.flat, ), ), SizedBox( width: 250, child: LinearProgressIndicatorM3E( - value: 0.6, + value: _value, + size: LinearProgressM3ESize.m, shape: ProgressM3EShape.flat, ), ), diff --git a/apps/gallery/lib/sections/slider_section.dart b/apps/gallery/lib/sections/slider_section.dart index ee8e1d7..46ea20d 100644 --- a/apps/gallery/lib/sections/slider_section.dart +++ b/apps/gallery/lib/sections/slider_section.dart @@ -18,51 +18,49 @@ class _SliderSectionState extends State { Widget build(BuildContext context) { return SectionCard( title: 'SliderM3E', - subtitle: 'Generated from enums: size × emphasis (round shape).', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final size in SliderM3ESize.values) ...[ Padding( padding: const EdgeInsets.symmetric(vertical: 8), - child: Text('size: ${size.name}', style: Theme.of(context).textTheme.titleMedium), + child: Text('size: ${size.name}', + style: Theme.of(context).textTheme.titleMedium), ), Wrap( runSpacing: 12, children: [ - for (final emp in SliderM3EEmphasis.values) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('emphasis: ${emp.name}', style: Theme.of(context).textTheme.labelLarge), - SliderM3E( - value: _value, - onChanged: (v) => setState(() => _value = v), - min: 0, - max: 100, - label: _value.toStringAsFixed(0), - size: size, - emphasis: emp, - startIcon: const Icon(Icons.volume_mute), - endIcon: const Icon(Icons.volume_up), + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SliderM3E( + value: _value, + onChanged: (v) => setState(() => _value = v), + min: 0, + max: 100, + label: _value.toStringAsFixed(0), + size: size, + emphasis: SliderM3EEmphasis.primary, + startIcon: const Icon(Icons.volume_mute), + endIcon: const Icon(Icons.volume_up), + ), + RangeSliderM3E( + values: _range, + onChanged: (v) => setState(() => _range = v), + min: 0, + max: 100, + size: size, + emphasis: SliderM3EEmphasis.primary, + labels: RangeLabels( + _range.start.toStringAsFixed(0), + _range.end.toStringAsFixed(0), ), - RangeSliderM3E( - values: _range, - onChanged: (v) => setState(() => _range = v), - min: 0, - max: 100, - size: size, - emphasis: emp, - labels: RangeLabels( - _range.start.toStringAsFixed(0), - _range.end.toStringAsFixed(0), - ), - ), - ], - ), + ), + ], ), + ), ], ), ], diff --git a/apps/gallery/lib/sections/split_button_section.dart b/apps/gallery/lib/sections/split_button_section.dart index 829a713..84e40e6 100644 --- a/apps/gallery/lib/sections/split_button_section.dart +++ b/apps/gallery/lib/sections/split_button_section.dart @@ -16,14 +16,14 @@ class SplitButtonSection extends StatelessWidget { return SectionCard( title: 'SplitButtonM3E', - subtitle: 'Generated from enums: emphasis × size (round shape).', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final emphasis in SplitButtonM3EEmphasis.values) ...[ Padding( padding: const EdgeInsets.symmetric(vertical: 8), - child: Text(emphasis.name, style: Theme.of(context).textTheme.titleMedium), + child: Text(emphasis.name, + style: Theme.of(context).textTheme.titleMedium), ), Wrap( spacing: 12, diff --git a/apps/gallery/lib/sections/toolbar_section.dart b/apps/gallery/lib/sections/toolbar_section.dart index 582459b..b557172 100644 --- a/apps/gallery/lib/sections/toolbar_section.dart +++ b/apps/gallery/lib/sections/toolbar_section.dart @@ -11,20 +11,25 @@ class ToolbarSection extends StatelessWidget { final actions = [ ToolbarActionM3E(icon: Icons.search, onPressed: () {}), ToolbarActionM3E(icon: Icons.share, onPressed: () {}), - ToolbarActionM3E(icon: Icons.delete, onPressed: () {}, isDestructive: true, label: 'Delete'), - ToolbarActionM3E(icon: Icons.settings, onPressed: () {}, label: 'Settings'), + ToolbarActionM3E( + icon: Icons.delete, + onPressed: () {}, + isDestructive: true, + label: 'Delete'), + ToolbarActionM3E( + icon: Icons.settings, onPressed: () {}, label: 'Settings'), ]; return SectionCard( title: 'ToolbarM3E', - subtitle: 'Generated from enums: variant × size (round shape).', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (final variant in ToolbarM3EVariant.values) ...[ Padding( padding: const EdgeInsets.symmetric(vertical: 8), - child: Text(variant.name, style: Theme.of(context).textTheme.titleMedium), + child: Text(variant.name, + style: Theme.of(context).textTheme.titleMedium), ), Wrap( runSpacing: 12, diff --git a/packages/progress_indicator_m3e/lib/src/circular_progress_m3e.dart b/packages/progress_indicator_m3e/lib/src/circular_progress_m3e.dart index 57f7b1f..e67fa17 100644 --- a/packages/progress_indicator_m3e/lib/src/circular_progress_m3e.dart +++ b/packages/progress_indicator_m3e/lib/src/circular_progress_m3e.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'enums.dart'; -class CircularProgressIndicatorM3E extends StatelessWidget { +class CircularProgressIndicatorM3E extends StatefulWidget { const CircularProgressIndicatorM3E({ super.key, this.value, @@ -22,13 +22,66 @@ class CircularProgressIndicatorM3E extends StatelessWidget { final Color? trackColor; final double rotation; + @override + State createState() => + _CircularProgressIndicatorM3EState(); +} + +class _CircularProgressIndicatorM3EState + extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + bool get _shouldAnimate { + final v = widget.value; + return widget.shape == ProgressM3EShape.wavy && + (v == null || (v >= 1.0)) && + widget.rotation == 0.0; + } + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 3600), + )..addListener(() { + if (mounted && _shouldAnimate) setState(() {}); + }); + if (_shouldAnimate) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant CircularProgressIndicatorM3E oldWidget) { + super.didUpdateWidget(oldWidget); + if (_shouldAnimate) { + if (!_controller.isAnimating) _controller.repeat(); + } else { + if (_controller.isAnimating) _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; - final active = activeColor ?? cs.primary; - final track = trackColor ?? cs.onSurfaceVariant.withOpacity(0.24); - final wantsWavy = shape == ProgressM3EShape.wavy; - final diameter = wantsWavy ? size.diameterWavy : size.diameterFlat; + final active = widget.activeColor ?? cs.primary; + final track = widget.trackColor ?? cs.onSurfaceVariant.withOpacity(0.24); + final wantsWavy = widget.shape == ProgressM3EShape.wavy; + final diameter = + wantsWavy ? widget.size.diameterWavy : widget.size.diameterFlat; + + final double rot = widget.rotation != 0.0 + ? widget.rotation + : (_shouldAnimate ? _controller.value * 2 * math.pi : 0.0); + return RepaintBoundary( child: SizedBox( width: diameter, @@ -36,16 +89,16 @@ class CircularProgressIndicatorM3E extends StatelessWidget { child: CustomPaint( painter: wantsWavy ? _CircularWavyPainter( - value: value, + value: widget.value, active: active, track: track, - rotation: rotation) + rotation: rot) : _CircularFlatPainter( - value: value, + value: widget.value, active: active, track: track, - rotation: rotation, - size: size), + rotation: rot, + size: widget.size), ), ), ); @@ -137,35 +190,36 @@ class _CircularWavyPainter extends CustomPainter { final center = s.center(Offset.zero); final baseRadius = (math.min(s.width, s.height) - stroke) / 2; - // Squiggle clearance: 2dp (edge-to-edge). Approximate by insetting the squiggle centerline by 6dp. - final clearance = 2.0; - final squiggleRadius = - baseRadius - (stroke / 2 + clearance + stroke / 2); // baseRadius - 6 final amp = 2.0; // radial amplitude of squiggle final scallopLen = 18.0; // along-arc wavelength proxy (dp) + // Taper length to fade the wave amplitude to zero near the end so the line ends "closed". + final taperLen = scallopLen / 2; // Active sweep final activeSweep = - value == null ? math.pi * 1.5 : (value!.clamp(0.0, 1.0) * math.pi * 2); + value == null ? math.pi * 2 : (value!.clamp(0.0, 1.0) * math.pi * 2); final start = -math.pi / 2 + rotation; final end = start + activeSweep; - // Track ring with gap around active - final trackPaint = Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = stroke - ..strokeCap = StrokeCap.round - ..isAntiAlias = true - ..color = track; + // Track ring with gap around active (skip when wave-only: indeterminate or 100%) + final bool waveOnly = value == null || (value != null && value! >= 1.0); + if (!waveOnly) { + final trackPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = stroke + ..strokeCap = StrokeCap.round + ..isAntiAlias = true + ..color = track; - final gapAngle = 2.0 / baseRadius; - final rect = Rect.fromCircle(center: center, radius: baseRadius); - final total = math.pi * 2; - final a1 = end + gapAngle; - final a2 = start - gapAngle; - double sweep1 = (a2 - a1); - while (sweep1 <= 0) sweep1 += total; - canvas.drawArc(rect, a1, sweep1, false, trackPaint); + final gapAngle = 2.0 / baseRadius; + final rect = Rect.fromCircle(center: center, radius: baseRadius); + final total = math.pi * 2; + final a1 = end + gapAngle; + final a2 = start - gapAngle; + double sweep1 = (a2 - a1); + while (sweep1 <= 0) sweep1 += total; + canvas.drawArc(rect, a1, sweep1, false, trackPaint); + } // Active squiggle path final steps = math.max(48, (s.width * 1.2).round()); @@ -173,9 +227,17 @@ class _CircularWavyPainter extends CustomPainter { for (int i = 0; i <= steps; i++) { final t = i / steps; final ang = start + (end - start) * t; - final arcLen = squiggleRadius * (ang - start); - final r = - squiggleRadius + amp * math.sin(arcLen / scallopLen * 2 * math.pi); + final arcLen = baseRadius * (ang - start); + // Fade amplitude to 0 near the end so the path ends on the base radius (closed look). + final arcToEnd = baseRadius * (end - ang); + double taperFactor = 1.0; + if (arcToEnd < taperLen) { + final tEnd = (arcToEnd / taperLen).clamp(0.0, 1.0); + // Ease-out to 0 at the very end. + taperFactor = math.sin(tEnd * math.pi / 2); + } + final r = baseRadius + + (amp * taperFactor) * math.sin(arcLen / scallopLen * 2 * math.pi); final p = Offset(center.dx + r * math.cos(ang), center.dy + r * math.sin(ang)); if (i == 0) diff --git a/packages/progress_indicator_m3e/lib/src/linear_progress_m3e.dart b/packages/progress_indicator_m3e/lib/src/linear_progress_m3e.dart index 284423f..3d3edb5 100644 --- a/packages/progress_indicator_m3e/lib/src/linear_progress_m3e.dart +++ b/packages/progress_indicator_m3e/lib/src/linear_progress_m3e.dart @@ -8,15 +8,15 @@ import 'enums.dart'; /// Linear indicator that renders two **separate lanes** (active above, track below) /// with a fixed vertical gap. Lanes never overlap. -class LinearProgressIndicatorM3E extends StatelessWidget { +class LinearProgressIndicatorM3E extends StatefulWidget { const LinearProgressIndicatorM3E({ super.key, - this.value, // null => indeterminate; animate phase externally + this.value, // null => indeterminate this.size = LinearProgressM3ESize.m, this.shape = ProgressM3EShape.wavy, this.activeColor, this.trackColor, - this.phase = 0.0, // radians for wavy animation + this.phase = 0.0, // radians for wavy animation (external override) this.inset = 4.0, // horizontal left inset }); @@ -28,6 +28,52 @@ class LinearProgressIndicatorM3E extends StatelessWidget { final double phase; final double inset; + @override + State createState() => + _LinearProgressIndicatorM3EState(); +} + +class _LinearProgressIndicatorM3EState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + bool get _shouldAnimate { + final v = widget.value; + return widget.shape == ProgressM3EShape.wavy && + (v == null || (v >= 1.0)) && + widget.phase == 0.0; + } + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..addListener(() { + if (mounted && _shouldAnimate) setState(() {}); + }); + if (_shouldAnimate) { + _controller.repeat(); + } + } + + @override + void didUpdateWidget(covariant LinearProgressIndicatorM3E oldWidget) { + super.didUpdateWidget(oldWidget); + if (_shouldAnimate) { + if (!_controller.isAnimating) _controller.repeat(); + } else { + if (_controller.isAnimating) _controller.stop(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -35,16 +81,21 @@ class LinearProgressIndicatorM3E extends StatelessWidget { theme.extension() ?? M3ETheme.defaults(theme.colorScheme); // Farben aus m3e_design beziehen (überschreibbar per Props) - final active = activeColor ?? m3e.colors.primary; - final track = trackColor ?? m3e.colors.surfaceContainerHighest; + final active = widget.activeColor ?? m3e.colors.primary; + final track = widget.trackColor ?? m3e.colors.surfaceContainerHighest; - final spec = specForLinear(size: size, shape: shape); + final spec = specForLinear(size: widget.size, shape: widget.shape); - // Total height = active lane height (trackHeight or wavyHeight) + gap + trackHeight + // Total height equals the taller of the two strokes sharing the same baseline. + // For wavy, add vertical amplitude; for flat, it's just the trackHeight. final activeHeight = spec.isWavy ? (spec.trackHeight + 2 * spec.waveAmplitude) : spec.trackHeight; - final totalHeight = activeHeight + spec.gap + spec.trackHeight; + final totalHeight = activeHeight; + + final double phaseValue = widget.phase != 0.0 + ? widget.phase + : (_shouldAnimate ? _controller.value * 2 * math.pi : 0.0); return RepaintBoundary( child: SizedBox( @@ -52,12 +103,12 @@ class LinearProgressIndicatorM3E extends StatelessWidget { width: double.infinity, child: CustomPaint( painter: _LinearPainter( - value: value, + value: widget.value, spec: spec, - active: activeColor ?? active, - track: trackColor ?? track, - phase: phase, - inset: inset, + active: widget.activeColor ?? active, + track: widget.trackColor ?? track, + phase: phaseValue, + inset: widget.inset, ), ), ), @@ -88,13 +139,10 @@ class _LinearPainter extends CustomPainter { final right = size.width - spec.trailingMargin; final width = math.max(0.0, right - left); - // lane centers: active on top, track on bottom - final trackCy = size.height - spec.trackHeight / 2; - final activeHeight = spec.isWavy - ? (spec.trackHeight + 2 * spec.waveAmplitude) - : spec.trackHeight; - final activeCy = - trackCy - (spec.trackHeight / 2 + spec.gap + activeHeight / 2); + // both strokes share the same baseline (centerline) + final cy = size.height / 2; + final trackCy = cy; + final activeCy = cy; // --- Draw track lane (flat pill) --- final base = Paint() @@ -103,11 +151,25 @@ class _LinearPainter extends CustomPainter { ..strokeCap = StrokeCap.round ..isAntiAlias = true; - canvas.drawLine( - Offset(left, trackCy), Offset(right, trackCy), base..color = track); + // compute progress fraction early for both lanes + final double p = (value ?? 0).clamp(0.0, 1.0); + + // Wave-only mode: in wavy shape, when indeterminate or full (100%), + // hide the track and end-dot; show only the wave which is animated via phase. + final bool waveOnly = spec.isWavy && (value == null || p >= 1.0); + + // Track occupies the remaining segment to the right of the active, + // leaving a fixed inter-stroke gap. For indeterminate, fill full width. + final double activeEndX = value == null ? right : (left + width * p); + final double trackStartX = + value == null ? left : math.min(right, activeEndX + spec.gap); + + if (!waveOnly) { + canvas.drawLine(Offset(trackStartX, trackCy), Offset(right, trackCy), + base..color = track); + } // --- Active lane --- - final double p = (value ?? 0).clamp(0.0, 1.0); if (spec.isWavy) { // wavy centerline final start = left; @@ -134,10 +196,12 @@ class _LinearPainter extends CustomPainter { ..color = active ..strokeWidth = spec.trackHeight); - // end dot (non-overlapping, placed slightly before end) - final dotCenterX = math.max(start, end - spec.dotOffset); - canvas.drawCircle( - Offset(dotCenterX, y), spec.dotDiameter / 2, Paint()..color = active); + // end dot: accent at far right end of the track (shared baseline) + if (!waveOnly) { + final dotCenterX = math.max(left, right - spec.dotOffset); + canvas.drawCircle(Offset(dotCenterX, trackCy), spec.dotDiameter / 2, + Paint()..color = active); + } } else { // flat active pill + end dot final start = left; @@ -148,8 +212,8 @@ class _LinearPainter extends CustomPainter { base ..color = active ..strokeWidth = spec.trackHeight); - final dotCenterX = math.max(start, end - spec.dotOffset); - canvas.drawCircle(Offset(dotCenterX, activeCy), spec.dotDiameter / 2, + final dotCenterX = math.max(left, right - spec.dotOffset); + canvas.drawCircle(Offset(dotCenterX, trackCy), spec.dotDiameter / 2, Paint()..color = active); } }