Refactor progress indicators to use stateful widgets, enhance animation control, and remove unnecessary subtitles in section cards

This commit is contained in:
Emily Pauli 2025-10-22 18:12:54 +02:00
commit b4ccdd7750
11 changed files with 316 additions and 130 deletions

View file

@ -10,8 +10,6 @@ class ButtonSection extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SectionCard( return SectionCard(
title: 'ButtonM3E', title: 'ButtonM3E',
subtitle:
'Generated from enums: variant × size; grouped by shape family.',
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [

View file

@ -14,8 +14,6 @@ class FabSection extends StatelessWidget {
return SectionCard( return SectionCard(
title: 'FabM3E', title: 'FabM3E',
subtitle:
'Generated from enums: kind × size. Includes Extended FAB examples.',
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [

View file

@ -10,14 +10,14 @@ class IconButtonSection extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SectionCard( return SectionCard(
title: 'IconButtonM3E', title: 'IconButtonM3E',
subtitle: 'Generated from enums: variant × size (round shape, default width).',
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
for (final variant in IconButtonM3EVariant.values) ...[ for (final variant in IconButtonM3EVariant.values) ...[
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 8), 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( Wrap(
spacing: 12, spacing: 12,

View file

@ -10,7 +10,6 @@ class LoadingIndicatorSection extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SectionCard( return SectionCard(
title: 'LoadingIndicatorM3E', title: 'LoadingIndicatorM3E',
subtitle: 'Generated from enums: variant values.',
child: Wrap( child: Wrap(
spacing: 16, spacing: 16,
runSpacing: 16, runSpacing: 16,

View file

@ -17,11 +17,11 @@ class _NavigationSectionState extends State<NavigationSection> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final m3e = theme.extension<M3ETheme>() ?? M3ETheme.defaults(theme.colorScheme); final m3e =
theme.extension<M3ETheme>() ?? M3ETheme.defaults(theme.colorScheme);
return SectionCard( return SectionCard(
title: 'Navigation', title: 'Navigation',
subtitle: 'Generated from enums: NavBar indicator styles and Rail indicator styles.',
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -38,17 +38,33 @@ class _NavigationSectionState extends State<NavigationSection> {
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.only(bottom: 4), 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( NavigationBarM3E(
selectedIndex: _barIndex, selectedIndex: _barIndex,
onDestinationSelected: (i) => setState(() => _barIndex = i), onDestinationSelected: (i) =>
setState(() => _barIndex = i),
indicatorStyle: style, indicatorStyle: style,
destinations: const [ destinations: const [
NavigationDestinationM3E(icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home), label: 'Home'), NavigationDestinationM3E(
NavigationDestinationM3E(icon: Icon(Icons.search_outlined), selectedIcon: Icon(Icons.search), label: 'Search', badgeDot: true), icon: Icon(Icons.home_outlined),
NavigationDestinationM3E(icon: Icon(Icons.favorite_outline), selectedIcon: Icon(Icons.favorite), label: 'Favorites', badgeCount: 2), selectedIcon: Icon(Icons.home),
NavigationDestinationM3E(icon: Icon(Icons.person_outline), selectedIcon: Icon(Icons.person), label: 'Profile'), 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), const SizedBox(height: 8),
@ -72,12 +88,22 @@ class _NavigationSectionState extends State<NavigationSection> {
for (final style in RailIndicatorStyle.values) ...[ for (final style in RailIndicatorStyle.values) ...[
NavigationRailM3E( NavigationRailM3E(
selectedIndex: _railIndex, selectedIndex: _railIndex,
onDestinationSelected: (i) => setState(() => _railIndex = i), onDestinationSelected: (i) =>
setState(() => _railIndex = i),
indicatorStyle: style, indicatorStyle: style,
destinations: const [ destinations: const [
RailDestinationM3E(icon: Icon(Icons.dashboard_outlined), selectedIcon: Icon(Icons.dashboard), label: 'Dash'), RailDestinationM3E(
RailDestinationM3E(icon: Icon(Icons.analytics_outlined), selectedIcon: Icon(Icons.analytics), label: 'Reports'), icon: Icon(Icons.dashboard_outlined),
RailDestinationM3E(icon: Icon(Icons.settings_outlined), selectedIcon: Icon(Icons.settings), label: 'Settings'), 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), const VerticalDivider(width: 1),

View file

@ -3,17 +3,49 @@ import 'package:m3e_collection/m3e_collection.dart';
import 'section_card.dart'; import 'section_card.dart';
class ProgressSection extends StatelessWidget { class ProgressSection extends StatefulWidget {
const ProgressSection({super.key}); const ProgressSection({super.key});
@override
State<ProgressSection> createState() => _ProgressSectionState();
}
class _ProgressSectionState extends State<ProgressSection>
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SectionCard( return SectionCard(
title: 'ProgressIndicatorM3E', title: 'ProgressIndicatorM3E',
subtitle: 'Generated from enums: circular sizes and linear variants.',
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('Value: $_value (set 0 for null)'),
SliderM3E(
value: _value ?? 0,
onChanged: (v) => setState(
() => _value = v == 0 ? null : v,
),
),
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
child: Text('Circular - Wavy', child: Text('Circular - Wavy',
@ -26,12 +58,12 @@ class ProgressSection extends StatelessWidget {
for (final s in CircularProgressM3ESize.values) ...[ for (final s in CircularProgressM3ESize.values) ...[
CircularProgressIndicatorM3E( CircularProgressIndicatorM3E(
size: s, size: s,
value: 0.4, value: _value,
shape: ProgressM3EShape.wavy, shape: ProgressM3EShape.wavy,
), ),
CircularProgressIndicatorM3E( CircularProgressIndicatorM3E(
size: s, size: s,
value: 0.6, value: _value,
shape: ProgressM3EShape.wavy, shape: ProgressM3EShape.wavy,
), ),
], ],
@ -50,13 +82,13 @@ class ProgressSection extends StatelessWidget {
for (final s in CircularProgressM3ESize.values) ...[ for (final s in CircularProgressM3ESize.values) ...[
CircularProgressIndicatorM3E( CircularProgressIndicatorM3E(
size: s, size: s,
value: 0.4, value: _value,
shape: ProgressM3EShape.flat, shape: ProgressM3EShape.flat,
), ),
CircularProgressIndicatorM3E( CircularProgressIndicatorM3E(
size: s, size: s,
shape: ProgressM3EShape.flat, shape: ProgressM3EShape.flat,
value: 0.6, value: _value,
), ),
], ],
], ],
@ -74,14 +106,16 @@ class ProgressSection extends StatelessWidget {
SizedBox( SizedBox(
width: 250, width: 250,
child: LinearProgressIndicatorM3E( child: LinearProgressIndicatorM3E(
value: null, value: _value,
size: LinearProgressM3ESize.s,
shape: ProgressM3EShape.wavy, shape: ProgressM3EShape.wavy,
), ),
), ),
SizedBox( SizedBox(
width: 250, width: 250,
child: LinearProgressIndicatorM3E( child: LinearProgressIndicatorM3E(
value: 0.6, value: _value,
size: LinearProgressM3ESize.m,
shape: ProgressM3EShape.wavy, shape: ProgressM3EShape.wavy,
), ),
), ),
@ -100,14 +134,16 @@ class ProgressSection extends StatelessWidget {
SizedBox( SizedBox(
width: 250, width: 250,
child: LinearProgressIndicatorM3E( child: LinearProgressIndicatorM3E(
value: null, value: _value,
size: LinearProgressM3ESize.s,
shape: ProgressM3EShape.flat, shape: ProgressM3EShape.flat,
), ),
), ),
SizedBox( SizedBox(
width: 250, width: 250,
child: LinearProgressIndicatorM3E( child: LinearProgressIndicatorM3E(
value: 0.6, value: _value,
size: LinearProgressM3ESize.m,
shape: ProgressM3EShape.flat, shape: ProgressM3EShape.flat,
), ),
), ),

View file

@ -18,51 +18,49 @@ class _SliderSectionState extends State<SliderSection> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SectionCard( return SectionCard(
title: 'SliderM3E', title: 'SliderM3E',
subtitle: 'Generated from enums: size × emphasis (round shape).',
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
for (final size in SliderM3ESize.values) ...[ for (final size in SliderM3ESize.values) ...[
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 8), 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( Wrap(
runSpacing: 12, runSpacing: 12,
children: [ children: [
for (final emp in SliderM3EEmphasis.values) Padding(
Padding( padding: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.only(bottom: 8), child: Column(
child: Column( crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, children: [
children: [ SliderM3E(
Text('emphasis: ${emp.name}', style: Theme.of(context).textTheme.labelLarge), value: _value,
SliderM3E( onChanged: (v) => setState(() => _value = v),
value: _value, min: 0,
onChanged: (v) => setState(() => _value = v), max: 100,
min: 0, label: _value.toStringAsFixed(0),
max: 100, size: size,
label: _value.toStringAsFixed(0), emphasis: SliderM3EEmphasis.primary,
size: size, startIcon: const Icon(Icons.volume_mute),
emphasis: emp, endIcon: const Icon(Icons.volume_up),
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),
),
),
],
),
), ),
),
], ],
), ),
], ],

View file

@ -16,14 +16,14 @@ class SplitButtonSection extends StatelessWidget {
return SectionCard( return SectionCard(
title: 'SplitButtonM3E', title: 'SplitButtonM3E',
subtitle: 'Generated from enums: emphasis × size (round shape).',
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
for (final emphasis in SplitButtonM3EEmphasis.values) ...[ for (final emphasis in SplitButtonM3EEmphasis.values) ...[
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 8), 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( Wrap(
spacing: 12, spacing: 12,

View file

@ -11,20 +11,25 @@ class ToolbarSection extends StatelessWidget {
final actions = [ final actions = [
ToolbarActionM3E(icon: Icons.search, onPressed: () {}), ToolbarActionM3E(icon: Icons.search, onPressed: () {}),
ToolbarActionM3E(icon: Icons.share, onPressed: () {}), ToolbarActionM3E(icon: Icons.share, onPressed: () {}),
ToolbarActionM3E(icon: Icons.delete, onPressed: () {}, isDestructive: true, label: 'Delete'), ToolbarActionM3E(
ToolbarActionM3E(icon: Icons.settings, onPressed: () {}, label: 'Settings'), icon: Icons.delete,
onPressed: () {},
isDestructive: true,
label: 'Delete'),
ToolbarActionM3E(
icon: Icons.settings, onPressed: () {}, label: 'Settings'),
]; ];
return SectionCard( return SectionCard(
title: 'ToolbarM3E', title: 'ToolbarM3E',
subtitle: 'Generated from enums: variant × size (round shape).',
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
for (final variant in ToolbarM3EVariant.values) ...[ for (final variant in ToolbarM3EVariant.values) ...[
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 8), 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( Wrap(
runSpacing: 12, runSpacing: 12,

View file

@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
import 'enums.dart'; import 'enums.dart';
class CircularProgressIndicatorM3E extends StatelessWidget { class CircularProgressIndicatorM3E extends StatefulWidget {
const CircularProgressIndicatorM3E({ const CircularProgressIndicatorM3E({
super.key, super.key,
this.value, this.value,
@ -22,13 +22,66 @@ class CircularProgressIndicatorM3E extends StatelessWidget {
final Color? trackColor; final Color? trackColor;
final double rotation; final double rotation;
@override
State<CircularProgressIndicatorM3E> createState() =>
_CircularProgressIndicatorM3EState();
}
class _CircularProgressIndicatorM3EState
extends State<CircularProgressIndicatorM3E>
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme; final cs = Theme.of(context).colorScheme;
final active = activeColor ?? cs.primary; final active = widget.activeColor ?? cs.primary;
final track = trackColor ?? cs.onSurfaceVariant.withOpacity(0.24); final track = widget.trackColor ?? cs.onSurfaceVariant.withOpacity(0.24);
final wantsWavy = shape == ProgressM3EShape.wavy; final wantsWavy = widget.shape == ProgressM3EShape.wavy;
final diameter = wantsWavy ? size.diameterWavy : size.diameterFlat; 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( return RepaintBoundary(
child: SizedBox( child: SizedBox(
width: diameter, width: diameter,
@ -36,16 +89,16 @@ class CircularProgressIndicatorM3E extends StatelessWidget {
child: CustomPaint( child: CustomPaint(
painter: wantsWavy painter: wantsWavy
? _CircularWavyPainter( ? _CircularWavyPainter(
value: value, value: widget.value,
active: active, active: active,
track: track, track: track,
rotation: rotation) rotation: rot)
: _CircularFlatPainter( : _CircularFlatPainter(
value: value, value: widget.value,
active: active, active: active,
track: track, track: track,
rotation: rotation, rotation: rot,
size: size), size: widget.size),
), ),
), ),
); );
@ -137,35 +190,36 @@ class _CircularWavyPainter extends CustomPainter {
final center = s.center(Offset.zero); final center = s.center(Offset.zero);
final baseRadius = (math.min(s.width, s.height) - stroke) / 2; 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 amp = 2.0; // radial amplitude of squiggle
final scallopLen = 18.0; // along-arc wavelength proxy (dp) 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 // Active sweep
final activeSweep = 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 start = -math.pi / 2 + rotation;
final end = start + activeSweep; final end = start + activeSweep;
// Track ring with gap around active // Track ring with gap around active (skip when wave-only: indeterminate or 100%)
final trackPaint = Paint() final bool waveOnly = value == null || (value != null && value! >= 1.0);
..style = PaintingStyle.stroke if (!waveOnly) {
..strokeWidth = stroke final trackPaint = Paint()
..strokeCap = StrokeCap.round ..style = PaintingStyle.stroke
..isAntiAlias = true ..strokeWidth = stroke
..color = track; ..strokeCap = StrokeCap.round
..isAntiAlias = true
..color = track;
final gapAngle = 2.0 / baseRadius; final gapAngle = 2.0 / baseRadius;
final rect = Rect.fromCircle(center: center, radius: baseRadius); final rect = Rect.fromCircle(center: center, radius: baseRadius);
final total = math.pi * 2; final total = math.pi * 2;
final a1 = end + gapAngle; final a1 = end + gapAngle;
final a2 = start - gapAngle; final a2 = start - gapAngle;
double sweep1 = (a2 - a1); double sweep1 = (a2 - a1);
while (sweep1 <= 0) sweep1 += total; while (sweep1 <= 0) sweep1 += total;
canvas.drawArc(rect, a1, sweep1, false, trackPaint); canvas.drawArc(rect, a1, sweep1, false, trackPaint);
}
// Active squiggle path // Active squiggle path
final steps = math.max(48, (s.width * 1.2).round()); 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++) { for (int i = 0; i <= steps; i++) {
final t = i / steps; final t = i / steps;
final ang = start + (end - start) * t; final ang = start + (end - start) * t;
final arcLen = squiggleRadius * (ang - start); final arcLen = baseRadius * (ang - start);
final r = // Fade amplitude to 0 near the end so the path ends on the base radius (closed look).
squiggleRadius + amp * math.sin(arcLen / scallopLen * 2 * math.pi); 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 = final p =
Offset(center.dx + r * math.cos(ang), center.dy + r * math.sin(ang)); Offset(center.dx + r * math.cos(ang), center.dy + r * math.sin(ang));
if (i == 0) if (i == 0)

View file

@ -8,15 +8,15 @@ import 'enums.dart';
/// Linear indicator that renders two **separate lanes** (active above, track below) /// Linear indicator that renders two **separate lanes** (active above, track below)
/// with a fixed vertical gap. Lanes never overlap. /// with a fixed vertical gap. Lanes never overlap.
class LinearProgressIndicatorM3E extends StatelessWidget { class LinearProgressIndicatorM3E extends StatefulWidget {
const LinearProgressIndicatorM3E({ const LinearProgressIndicatorM3E({
super.key, super.key,
this.value, // null => indeterminate; animate phase externally this.value, // null => indeterminate
this.size = LinearProgressM3ESize.m, this.size = LinearProgressM3ESize.m,
this.shape = ProgressM3EShape.wavy, this.shape = ProgressM3EShape.wavy,
this.activeColor, this.activeColor,
this.trackColor, 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 this.inset = 4.0, // horizontal left inset
}); });
@ -28,6 +28,52 @@ class LinearProgressIndicatorM3E extends StatelessWidget {
final double phase; final double phase;
final double inset; final double inset;
@override
State<LinearProgressIndicatorM3E> createState() =>
_LinearProgressIndicatorM3EState();
}
class _LinearProgressIndicatorM3EState extends State<LinearProgressIndicatorM3E>
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@ -35,16 +81,21 @@ class LinearProgressIndicatorM3E extends StatelessWidget {
theme.extension<M3ETheme>() ?? M3ETheme.defaults(theme.colorScheme); theme.extension<M3ETheme>() ?? M3ETheme.defaults(theme.colorScheme);
// Farben aus m3e_design beziehen (überschreibbar per Props) // Farben aus m3e_design beziehen (überschreibbar per Props)
final active = activeColor ?? m3e.colors.primary; final active = widget.activeColor ?? m3e.colors.primary;
final track = trackColor ?? m3e.colors.surfaceContainerHighest; 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 final activeHeight = spec.isWavy
? (spec.trackHeight + 2 * spec.waveAmplitude) ? (spec.trackHeight + 2 * spec.waveAmplitude)
: spec.trackHeight; : 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( return RepaintBoundary(
child: SizedBox( child: SizedBox(
@ -52,12 +103,12 @@ class LinearProgressIndicatorM3E extends StatelessWidget {
width: double.infinity, width: double.infinity,
child: CustomPaint( child: CustomPaint(
painter: _LinearPainter( painter: _LinearPainter(
value: value, value: widget.value,
spec: spec, spec: spec,
active: activeColor ?? active, active: widget.activeColor ?? active,
track: trackColor ?? track, track: widget.trackColor ?? track,
phase: phase, phase: phaseValue,
inset: inset, inset: widget.inset,
), ),
), ),
), ),
@ -88,13 +139,10 @@ class _LinearPainter extends CustomPainter {
final right = size.width - spec.trailingMargin; final right = size.width - spec.trailingMargin;
final width = math.max(0.0, right - left); final width = math.max(0.0, right - left);
// lane centers: active on top, track on bottom // both strokes share the same baseline (centerline)
final trackCy = size.height - spec.trackHeight / 2; final cy = size.height / 2;
final activeHeight = spec.isWavy final trackCy = cy;
? (spec.trackHeight + 2 * spec.waveAmplitude) final activeCy = cy;
: spec.trackHeight;
final activeCy =
trackCy - (spec.trackHeight / 2 + spec.gap + activeHeight / 2);
// --- Draw track lane (flat pill) --- // --- Draw track lane (flat pill) ---
final base = Paint() final base = Paint()
@ -103,11 +151,25 @@ class _LinearPainter extends CustomPainter {
..strokeCap = StrokeCap.round ..strokeCap = StrokeCap.round
..isAntiAlias = true; ..isAntiAlias = true;
canvas.drawLine( // compute progress fraction early for both lanes
Offset(left, trackCy), Offset(right, trackCy), base..color = track); 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 --- // --- Active lane ---
final double p = (value ?? 0).clamp(0.0, 1.0);
if (spec.isWavy) { if (spec.isWavy) {
// wavy centerline // wavy centerline
final start = left; final start = left;
@ -134,10 +196,12 @@ class _LinearPainter extends CustomPainter {
..color = active ..color = active
..strokeWidth = spec.trackHeight); ..strokeWidth = spec.trackHeight);
// end dot (non-overlapping, placed slightly before end) // end dot: accent at far right end of the track (shared baseline)
final dotCenterX = math.max(start, end - spec.dotOffset); if (!waveOnly) {
canvas.drawCircle( final dotCenterX = math.max(left, right - spec.dotOffset);
Offset(dotCenterX, y), spec.dotDiameter / 2, Paint()..color = active); canvas.drawCircle(Offset(dotCenterX, trackCy), spec.dotDiameter / 2,
Paint()..color = active);
}
} else { } else {
// flat active pill + end dot // flat active pill + end dot
final start = left; final start = left;
@ -148,8 +212,8 @@ class _LinearPainter extends CustomPainter {
base base
..color = active ..color = active
..strokeWidth = spec.trackHeight); ..strokeWidth = spec.trackHeight);
final dotCenterX = math.max(start, end - spec.dotOffset); final dotCenterX = math.max(left, right - spec.dotOffset);
canvas.drawCircle(Offset(dotCenterX, activeCy), spec.dotDiameter / 2, canvas.drawCircle(Offset(dotCenterX, trackCy), spec.dotDiameter / 2,
Paint()..color = active); Paint()..color = active);
} }
} }