Update pubspec dependencies, refactor progress indicators, and enhance README documentation

This commit is contained in:
Emily Pauli 2025-10-22 00:58:55 +02:00
commit 687bca8817
18 changed files with 1013 additions and 916 deletions

View file

@ -1,7 +1,7 @@
library progress_indicators_m3e;
library progress_indicator_m3e;
export 'src/enums.dart';
export 'src/tokens_adapter.dart' show ProgressTokensAdapter;
export 'src/linear_progress_m3e.dart';
export 'src/circular_progress_m3e.dart';
export 'src/progress_with_label_m3e.dart';

View file

@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'enums.dart';
@immutable
class Palette {
const Palette(this.cs);
final ColorScheme cs;
// Use theme roles; callers can override colors if needed.
Color get active => cs.primary;
Color get track => cs.onSurfaceVariant.withOpacity(0.24);
Color get bg => cs.surface;
}
@immutable
class LinearSpec {
const LinearSpec({
required this.trackHeight,
required this.gap,
required this.dotDiameter,
required this.dotOffset,
required this.trailingMargin,
required this.isWavy,
this.waveAmplitude = 0,
this.wavePeriod = 40,
});
final double trackHeight;
final double gap; // vertical space between active lane and track lane
final double dotDiameter;
final double dotOffset; // center offset from end of active segment
final double trailingMargin; // empty space at the far right
final bool isWavy;
final double waveAmplitude;
final double wavePeriod;
}
LinearSpec specForLinear({
required LinearProgressM3ESize size,
required ProgressM3EShape shape,
}) => switch ((shape, size)) {
(ProgressM3EShape.flat, LinearProgressM3ESize.s) => const LinearSpec(
trackHeight: 4,
gap: 4,
dotDiameter: 4,
dotOffset: 4,
trailingMargin: 4,
isWavy: false,
),
(ProgressM3EShape.flat, LinearProgressM3ESize.m) => const LinearSpec(
trackHeight: 8,
gap: 4,
dotDiameter: 4,
dotOffset: 2,
trailingMargin: 8,
isWavy: false,
),
(ProgressM3EShape.wavy, LinearProgressM3ESize.s) => const LinearSpec(
trackHeight: 4,
gap: 4,
dotDiameter: 4,
dotOffset: 2,
trailingMargin: 10,
isWavy: true,
waveAmplitude: 3,
wavePeriod: 40,
),
(ProgressM3EShape.wavy, LinearProgressM3ESize.m) => const LinearSpec(
trackHeight: 8,
gap: 4,
dotDiameter: 4,
dotOffset: 2,
trailingMargin: 14,
isWavy: true,
waveAmplitude: 3,
wavePeriod: 40,
),
};

View file

@ -3,284 +3,199 @@ import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'enums.dart';
import 'tokens_adapter.dart';
class CircularProgressM3E extends StatefulWidget {
const CircularProgressM3E({
class CircularProgressIndicatorM3E extends StatelessWidget {
const CircularProgressIndicatorM3E({
super.key,
this.value,
this.size = ProgressM3ESize.medium,
this.emphasis = ProgressM3EEmphasis.primary,
this.density = ProgressM3EDensity.regular,
this.backgroundColor,
this.strokeWidth,
this.semanticLabel,
this.showCenterLabel = false,
this.centerLabelBuilder,
this.shape = CircularBarShapeM3E.wavy,
this.waveCount,
this.waveAmplitude,
this.rotateClockwise = true,
this.size = CircularProgressM3ESize.m,
this.shape = ProgressM3EShape.wavy,
this.activeColor,
this.trackColor,
this.rotation = 0.0, // radians, for indeterminate rotation
});
/// Determinate value (0..1). If null, renders indeterminate.
final double? value;
final ProgressM3ESize size;
final ProgressM3EEmphasis emphasis;
final ProgressM3EDensity density;
final Color? backgroundColor;
final double? strokeWidth;
/// Optional semantics label.
final String? semanticLabel;
/// Show a label centered inside (e.g., percentage).
final bool showCenterLabel;
/// Builder for custom center label; if null and showCenterLabel==true, shows percentage text.
final Widget Function(BuildContext context, double? value)?
centerLabelBuilder;
/// Expressive shape
final CircularBarShapeM3E shape;
final int? waveCount;
final double? waveAmplitude;
final bool rotateClockwise;
@override
State<CircularProgressM3E> createState() => _CircularProgressM3EState();
}
class _CircularProgressM3EState extends State<CircularProgressM3E>
with SingleTickerProviderStateMixin {
late final AnimationController _anim = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1400),
)..repeat();
@override
void dispose() {
_anim.dispose();
super.dispose();
}
final double? value; // 0..1 (null => indeterminate arc sweep)
final CircularProgressM3ESize size;
final ProgressM3EShape shape;
final Color? activeColor;
final Color? trackColor;
final double rotation;
@override
Widget build(BuildContext context) {
final tokens = ProgressTokensAdapter(context);
final m = tokens.metrics(widget.density);
final color = tokens.color(widget.emphasis);
final bg = widget.backgroundColor ?? tokens.trackColor();
final (diameter, stroke) = switch (widget.size) {
ProgressM3ESize.small => (
m.circularSmall,
widget.strokeWidth ?? m.strokeSmall
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;
return RepaintBoundary(
child: SizedBox(
width: diameter,
height: diameter,
child: CustomPaint(
painter: wantsWavy
? _CircularWavyPainter(
value: value,
active: active,
track: track,
rotation: rotation)
: _CircularFlatPainter(
value: value,
active: active,
track: track,
rotation: rotation,
size: size),
),
ProgressM3ESize.medium => (
m.circularMedium,
widget.strokeWidth ?? m.strokeMedium
),
ProgressM3ESize.large => (
m.circularLarge,
widget.strokeWidth ?? m.strokeLarge
),
};
final indicator = SizedBox(
width: diameter,
height: diameter,
child: Stack(
alignment: Alignment.center,
children: [
// Track ring
CustomPaint(
size: Size.square(diameter),
painter: _RingPainter(color: bg, stroke: stroke),
),
// Progress
if (widget.shape == CircularBarShapeM3E.flat) ...[
CustomPaint(
size: Size.square(diameter),
painter: _ArcPainter(
color: color,
stroke: stroke,
value: widget.value,
clockwise: widget.rotateClockwise,
),
),
] else ...[
AnimatedBuilder(
animation: _anim,
builder: (context, _) => CustomPaint(
size: Size.square(diameter),
painter: _WavyArcPainter(
color: color,
stroke: stroke,
value: widget.value,
waves: widget.waveCount ?? m.circularWavesPerCircle,
amplitude: (widget.waveAmplitude ??
(m.circularWaveAmplitudeFactor * stroke))
.clamp(0, stroke / 2),
phase:
(widget.value == null ? 2 * math.pi * _anim.value : 0) *
(widget.rotateClockwise ? 1 : -1),
clockwise: widget.rotateClockwise,
),
),
),
],
if (widget.showCenterLabel)
DefaultTextStyle(
style: tokens.labelStyle().copyWith(
color: Theme.of(context).colorScheme.onSurface,
),
child: widget.centerLabelBuilder?.call(context, widget.value) ??
Text(widget.value != null
? '${(widget.value! * 100).toStringAsFixed(0)}%'
: ''),
),
],
),
);
if (widget.semanticLabel == null) return indicator;
return Semantics(
label: widget.semanticLabel,
value: widget.value != null
? '${(widget.value! * 100).toStringAsFixed(0)}%'
: null,
child: indicator,
);
}
}
class _RingPainter extends CustomPainter {
_RingPainter({required this.color, required this.stroke});
final Color color;
final double stroke;
class _CircularFlatPainter extends CustomPainter {
_CircularFlatPainter(
{required this.value,
required this.active,
required this.track,
required this.rotation,
required this.size});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = stroke
..strokeCap = StrokeCap.round;
final rect = Offset.zero & size;
final center = rect.center;
final radius = (size.shortestSide - stroke) / 2;
canvas.drawCircle(center, radius, paint);
}
@override
bool shouldRepaint(covariant _RingPainter old) =>
old.color != color || old.stroke != stroke;
}
class _ArcPainter extends CustomPainter {
_ArcPainter({
required this.color,
required this.stroke,
required this.value,
required this.clockwise,
});
final Color color;
final double stroke;
final double? value;
final bool clockwise;
final Color active;
final Color track;
final double rotation;
final CircularProgressM3ESize size;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
void paint(Canvas canvas, Size s) {
final stroke = 4.0;
final center = s.center(Offset.zero);
final radius = (math.min(s.width, s.height) - stroke) / 2;
final rect = Rect.fromCircle(center: center, radius: radius);
// gap before active in dp -> angle
final gapDp = 8.0;
final gapAngle = gapDp / radius; // s = r * angle
// active sweep
final sweep =
value == null ? math.pi * 1.5 : (value!.clamp(0.0, 1.0) * math.pi * 2);
final start = -math.pi / 2 + rotation;
final activeStart = start;
final activeEnd = start + sweep;
// TRACK: draw the rest of the ring, leaving a gap ahead of the active arc and no overlap.
final trackPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = stroke
..strokeCap = StrokeCap.round;
..strokeCap = StrokeCap.round
..isAntiAlias = true
..color = track;
final rect = Offset.zero & size;
final center = rect.center;
final radius = (size.shortestSide - stroke) / 2;
final total = math.pi * 2;
final a1 = (activeEnd + gapAngle);
final a2 = (activeStart - gapAngle);
double sweep1 = (a2 - a1);
while (sweep1 <= 0) sweep1 += total;
canvas.drawArc(rect, a1, sweep1, false, trackPaint);
final start = -math.pi / 2;
final sweep = (value ?? 0.25) * 2 * math.pi * (clockwise ? 1 : -1);
canvas.drawArc(Rect.fromCircle(center: center, radius: radius), start,
sweep, false, paint);
if (value == null) {
// indeterminate - draw a moving arc; this painter is used only for determinate (flat)
}
// ACTIVE arc
final activePaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = stroke
..strokeCap = StrokeCap.round
..isAntiAlias = true
..color = active;
canvas.drawArc(rect, activeStart, sweep, false, activePaint);
}
@override
bool shouldRepaint(covariant _ArcPainter old) =>
old.color != color ||
old.stroke != stroke ||
old.value != value ||
old.clockwise != clockwise;
bool shouldRepaint(covariant _CircularFlatPainter old) =>
value != old.value ||
active != old.active ||
track != old.track ||
rotation != old.rotation ||
size != old.size;
}
class _WavyArcPainter extends CustomPainter {
_WavyArcPainter({
required this.color,
required this.stroke,
required this.value,
required this.waves,
required this.amplitude,
required this.phase,
required this.clockwise,
});
class _CircularWavyPainter extends CustomPainter {
_CircularWavyPainter(
{required this.value,
required this.active,
required this.track,
required this.rotation});
final Color color;
final double stroke;
final double? value;
final int waves;
final double amplitude;
final double phase;
final bool clockwise;
final Color active;
final Color track;
final double rotation;
@override
void paint(Canvas canvas, Size size) {
final rect = Offset.zero & size;
final center = rect.center;
final baseRadius = (size.shortestSide - stroke) / 2;
void paint(Canvas canvas, Size s) {
const stroke = 4.0;
final center = s.center(Offset.zero);
final baseRadius = (math.min(s.width, s.height) - stroke) / 2;
final paint = Paint()
..color = color
// 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)
// Active sweep
final activeSweep =
value == null ? math.pi * 1.5 : (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;
..strokeCap = StrokeCap.round
..isAntiAlias = true
..color = track;
final totalAngle = (value ?? 1.0) * 2 * math.pi * (clockwise ? 1 : -1);
final start = -math.pi / 2;
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());
final path = Path();
final steps = (200 * (value ?? 1.0)).clamp(40, 300).toInt(); // resolution
for (int i = 0; i <= steps; i++) {
final t = i / steps;
final theta = start + totalAngle * t;
final wave = math.sin((t * waves * 2 * math.pi) + phase);
final r = baseRadius + amplitude * wave;
final p = Offset(
center.dx + r * math.cos(theta), center.dy + r * math.sin(theta));
if (i == 0) {
final ang = start + (end - start) * t;
final arcLen = squiggleRadius * (ang - start);
final r =
squiggleRadius + amp * 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)
path.moveTo(p.dx, p.dy);
} else {
else
path.lineTo(p.dx, p.dy);
}
}
canvas.drawPath(path, paint);
final activePaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = stroke
..strokeCap = StrokeCap.round
..isAntiAlias = true
..color = active;
canvas.drawPath(path, activePaint);
}
@override
bool shouldRepaint(covariant _WavyArcPainter old) =>
old.color != color ||
old.stroke != stroke ||
old.value != value ||
old.waves != waves ||
old.amplitude != amplitude ||
old.phase != phase ||
old.clockwise != clockwise;
bool shouldRepaint(covariant _CircularWavyPainter old) =>
value != old.value ||
active != old.active ||
track != old.track ||
rotation != old.rotation;
}

View file

@ -1,8 +1,27 @@
enum ProgressM3ESize { small, medium, large }
enum ProgressM3EEmphasis { primary, secondary, surface }
enum ProgressM3EDensity { regular, compact }
enum LinearProgressM3EVariant { determinate, indeterminate, buffer, query }
enum ProgressLabelPosition { none, leading, trailing, top, bottom, center }
/// Circular sizes driven by outer diameter.
enum CircularProgressM3ESize { s, m }
enum LinearBarShapeM3E { flat, wavy }
enum CircularBarShapeM3E { flat, wavy }
extension CircularM3ESizeExtension on CircularProgressM3ESize {
double get diameterWavy {
switch (this) {
case CircularProgressM3ESize.s:
return 48.0; // wavy small
case CircularProgressM3ESize.m:
return 52.0; // wavy medium
}
}
double get diameterFlat {
switch (this) {
case CircularProgressM3ESize.s:
return 40.0; // flat small
case CircularProgressM3ESize.m:
return 44.0; // flat medium
}
}
}
/// Linear sizes and shapes
enum LinearProgressM3ESize { s, m }
enum ProgressM3EShape { flat, wavy }

View file

@ -1,378 +1,165 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
import '_tokens.dart';
import 'enums.dart';
import 'tokens_adapter.dart';
class LinearProgressM3E extends StatefulWidget {
const LinearProgressM3E({
/// Linear indicator that renders two **separate lanes** (active above, track below)
/// with a fixed vertical gap. Lanes never overlap.
class LinearProgressIndicatorM3E extends StatelessWidget {
const LinearProgressIndicatorM3E({
super.key,
this.value,
this.bufferValue,
this.variant = LinearProgressM3EVariant.determinate,
this.size = ProgressM3ESize.medium,
this.emphasis = ProgressM3EEmphasis.primary,
this.density = ProgressM3EDensity.regular,
this.backgroundColor,
this.progressColor,
this.bufferColor,
this.semanticLabel,
this.minWidth = double.infinity,
this.strokeHeight,
this.borderRadius,
this.shape = LinearBarShapeM3E.wavy,
this.wavelength,
this.amplitude,
this.leftRightInset,
this.value, // null => indeterminate; animate phase externally
this.size = LinearProgressM3ESize.m,
this.shape = ProgressM3EShape.wavy,
this.activeColor,
this.trackColor,
this.phase = 0.0, // radians for wavy animation
this.inset = 4.0, // horizontal left inset
});
final double? value;
final double? bufferValue;
final LinearProgressM3EVariant variant;
final ProgressM3ESize size;
final ProgressM3EEmphasis emphasis;
final ProgressM3EDensity density;
final Color? backgroundColor;
final Color? progressColor;
final Color? bufferColor;
final String? semanticLabel;
final double minWidth;
final double? strokeHeight;
final BorderRadius? borderRadius;
final LinearBarShapeM3E shape;
final double? wavelength;
final double? amplitude;
final double? leftRightInset;
@override
State<LinearProgressM3E> createState() => _LinearProgressM3EState();
}
class _LinearProgressM3EState extends State<LinearProgressM3E>
with SingleTickerProviderStateMixin {
late final AnimationController _anim = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
)..repeat();
@override
void dispose() {
_anim.dispose();
super.dispose();
}
final LinearProgressM3ESize size;
final ProgressM3EShape shape;
final Color? activeColor;
final Color? trackColor;
final double phase;
final double inset;
@override
Widget build(BuildContext context) {
final tokens = ProgressTokensAdapter(context);
final m = tokens.metrics(widget.density);
final theme = Theme.of(context);
final m3e =
theme.extension<M3ETheme>() ?? M3ETheme.defaults(theme.colorScheme);
final height = switch (widget.size) {
ProgressM3ESize.small => widget.strokeHeight ?? m.linearThicknessSmall,
ProgressM3ESize.medium => widget.strokeHeight ?? m.linearThicknessMedium,
ProgressM3ESize.large => widget.strokeHeight ?? m.linearThicknessLarge,
};
// Farben aus m3e_design beziehen (überschreibbar per Props)
final active = activeColor ?? m3e.colors.primary;
final track = trackColor ?? m3e.colors.surfaceContainerHighest;
final track = widget.backgroundColor ?? tokens.trackColor();
final progress = widget.progressColor ?? tokens.color(widget.emphasis);
final buffer = widget.bufferColor ?? tokens.bufferColor(progress);
final spec = specForLinear(size: size, shape: shape);
final borderRadius =
widget.borderRadius ?? BorderRadius.circular(height / 2);
final inset = widget.leftRightInset ?? m.horizontalInset;
// Total height = active lane height (trackHeight or wavyHeight) + gap + trackHeight
final activeHeight = spec.isWavy
? (spec.trackHeight + 2 * spec.waveAmplitude)
: spec.trackHeight;
final totalHeight = activeHeight + spec.gap + spec.trackHeight;
final content = Padding(
padding: EdgeInsets.symmetric(horizontal: inset),
child: _buildBar(
context, height, borderRadius, track, progress, buffer, tokens),
);
final bar = ClipRRect(
borderRadius: borderRadius,
return RepaintBoundary(
child: SizedBox(
height: height,
width: widget.minWidth == double.infinity ? null : widget.minWidth,
child: content,
),
);
if (widget.semanticLabel == null) return bar;
return Semantics(
label: widget.semanticLabel,
value: (widget.variant == LinearProgressM3EVariant.determinate &&
widget.value != null)
? '${(widget.value!.clamp(0.0, 1.0) * 100).toStringAsFixed(0)}%'
: null,
child: bar,
);
}
Widget _buildBar(
BuildContext context,
double height,
BorderRadius borderRadius,
Color track,
Color progress,
Color buffer,
ProgressTokensAdapter tokens,
) {
final variant = widget.variant;
final shape = widget.shape;
if (shape == LinearBarShapeM3E.flat) {
// Use standard LinearProgressIndicator behaviors.
if (variant == LinearProgressM3EVariant.indeterminate ||
(variant == LinearProgressM3EVariant.determinate &&
widget.value == null)) {
return LinearProgressIndicator(
color: progress,
backgroundColor: track,
minHeight: height,
);
} else if (variant == LinearProgressM3EVariant.query) {
return Transform(
alignment: Alignment.center,
transform: Matrix4.identity()..scale(-1.0, 1.0, 1.0),
child: LinearProgressIndicator(
color: progress,
backgroundColor: track,
minHeight: height,
height: totalHeight,
width: double.infinity,
child: CustomPaint(
painter: _LinearPainter(
value: value,
spec: spec,
active: activeColor ?? active,
track: trackColor ?? track,
phase: phase,
inset: inset,
),
);
} else if (variant == LinearProgressM3EVariant.buffer) {
return _BufferBar(
height: height,
track: track,
buffer: buffer,
progress: progress,
value: widget.value ?? 0.0,
bufferValue: widget.bufferValue ?? 0.0,
);
} else {
return LinearProgressIndicator(
value: (widget.value ?? 0.0).clamp(0.0, 1.0),
color: progress,
backgroundColor: track,
minHeight: height,
);
}
}
final wavelength =
widget.wavelength ?? tokens.metrics(widget.density).wavyWavelength;
final amplitude = widget.amplitude ??
tokens.metrics(widget.density).wavyAmplitudeFactor * height;
if (variant == LinearProgressM3EVariant.determinate &&
widget.value != null) {
return _WavyBar(
value: widget.value!.clamp(0.0, 1.0),
height: height,
wavelength: wavelength,
amplitude: amplitude.clamp(0.0, height / 2),
track: track,
fill: progress,
);
}
// Indeterminate / query / missing value animate phase
return AnimatedBuilder(
animation: _anim,
builder: (context, _) {
final phase = 2 * math.pi * _anim.value;
final reverse = widget.variant == LinearProgressM3EVariant.query;
return _WavyIndeterminateBar(
height: height,
wavelength: wavelength,
amplitude: amplitude.clamp(0.0, height / 2),
track: track,
fill: progress,
phase: reverse ? -phase : phase,
);
},
);
}
}
class _BufferBar extends StatelessWidget {
const _BufferBar({
required this.height,
required this.track,
required this.buffer,
required this.progress,
required this.value,
required this.bufferValue,
});
final double height;
final Color track;
final Color buffer;
final Color progress;
final double value;
final double bufferValue;
@override
Widget build(BuildContext context) {
return LayoutBuilder(builder: (context, constraints) {
final w = constraints.maxWidth;
final pv = (w.isFinite ? w : 0) * value.clamp(0.0, 1.0);
final bv = (w.isFinite ? w : 0) * bufferValue.clamp(0.0, 1.0);
Widget seg(double width, Color color) => Align(
alignment: Alignment.centerLeft,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
width: width,
height: height,
color: color,
),
);
return Stack(
fit: StackFit.passthrough,
children: [
ColoredBox(color: track),
seg(bv, buffer),
seg(pv, progress),
],
);
});
}
}
class _WavyBar extends StatelessWidget {
const _WavyBar({
required this.value,
required this.height,
required this.wavelength,
required this.amplitude,
required this.track,
required this.fill,
});
final double value;
final double height;
final double wavelength;
final double amplitude;
final Color track;
final Color fill;
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: _WavyPainter(
value: value,
height: height,
wavelength: wavelength,
amplitude: amplitude,
track: track,
fill: fill,
phase: 0,
indeterminate: false,
),
),
);
}
}
class _WavyIndeterminateBar extends StatelessWidget {
const _WavyIndeterminateBar({
required this.height,
required this.wavelength,
required this.amplitude,
required this.track,
required this.fill,
required this.phase,
});
final double height;
final double wavelength;
final double amplitude;
final Color track;
final Color fill;
final double phase;
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: _WavyPainter(
value: 0.6,
height: height,
wavelength: wavelength,
amplitude: amplitude,
track: track,
fill: fill,
phase: phase,
indeterminate: true,
),
);
}
}
class _WavyPainter extends CustomPainter {
_WavyPainter({
class _LinearPainter extends CustomPainter {
_LinearPainter({
required this.value,
required this.height,
required this.wavelength,
required this.amplitude,
required this.spec,
required this.active,
required this.track,
required this.fill,
required this.phase,
required this.indeterminate,
required this.inset,
});
final double value;
final double height;
final double wavelength;
final double amplitude;
final double? value;
final LinearSpec spec;
final Color active;
final Color track;
final Color fill;
final double phase;
final bool indeterminate;
final double inset;
@override
void paint(Canvas canvas, Size size) {
final paintTrack = Paint()..color = track;
final paintFill = Paint()..color = fill;
final left = inset;
final right = size.width - spec.trailingMargin;
final width = math.max(0.0, right - left);
final r = RRect.fromRectAndRadius(
Offset.zero & Size(size.width, height), Radius.circular(height / 2));
canvas.drawRRect(r, paintTrack);
// 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);
final w = size.width;
final progressW = indeterminate ? w : (w * value.clamp(0.0, 1.0));
// --- Draw track lane (flat pill) ---
final base = Paint()
..style = PaintingStyle.stroke
..strokeWidth = spec.trackHeight
..strokeCap = StrokeCap.round
..isAntiAlias = true;
final centerY = height / 2;
final path = Path()..moveTo(0, height);
path.lineTo(0, centerY);
canvas.drawLine(
Offset(left, trackCy), Offset(right, trackCy), base..color = track);
final k = 2 * math.pi / wavelength;
final step = 2.0;
double x = 0;
while (x <= progressW) {
final y = centerY - amplitude * math.sin(k * x + phase);
path.lineTo(x, y);
x += step;
// --- Active lane ---
final double p = (value ?? 0).clamp(0.0, 1.0);
if (spec.isWavy) {
// wavy centerline
final start = left;
final end = value == null ? right : (left + width * p);
final path = Path();
const step = 1.5;
final k = 2 * math.pi / spec.wavePeriod;
double x = start;
double y =
activeCy + spec.waveAmplitude * math.sin(phase + (x - start) * k);
path.moveTo(x, y);
for (x = start + step; x <= end; x += step) {
y = activeCy + spec.waveAmplitude * math.sin(phase + (x - start) * k);
path.lineTo(x, y);
}
// precise end point
y = activeCy + spec.waveAmplitude * math.sin(phase + (end - start) * k);
path.lineTo(end, y);
canvas.drawPath(
path,
base
..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);
} else {
// flat active pill + end dot
final start = left;
final end = value == null ? right : (left + width * p);
canvas.drawLine(
Offset(start, activeCy),
Offset(end, activeCy),
base
..color = active
..strokeWidth = spec.trackHeight);
final dotCenterX = math.max(start, end - spec.dotOffset);
canvas.drawCircle(Offset(dotCenterX, activeCy), spec.dotDiameter / 2,
Paint()..color = active);
}
path.lineTo(progressW, height);
path.close();
canvas.save();
final clip = Path()..addRRect(r);
canvas.clipPath(clip);
canvas.drawPath(path, paintFill);
canvas.restore();
}
@override
bool shouldRepaint(covariant _WavyPainter old) {
return old.value != value ||
old.height != height ||
old.wavelength != wavelength ||
old.amplitude != amplitude ||
old.phase != phase ||
old.track != track ||
old.fill != fill ||
old.indeterminate != indeterminate;
}
bool shouldRepaint(covariant _LinearPainter old) =>
value != old.value ||
spec != old.spec ||
active != old.active ||
track != old.track ||
phase != old.phase ||
inset != old.inset;
}

View file

@ -1,74 +1,35 @@
import 'package:flutter/material.dart';
import 'circular_progress_m3e.dart';
import 'enums.dart';
import 'tokens_adapter.dart';
import 'linear_progress_m3e.dart';
class ProgressWithLabelM3E extends StatelessWidget {
const ProgressWithLabelM3E({
super.key,
required this.progress,
this.position = ProgressLabelPosition.trailing,
this.label,
this.spacing,
required this.value,
this.size = CircularProgressM3ESize.m,
this.textStyle,
});
final LinearProgressM3E progress;
final ProgressLabelPosition position;
final Widget? label;
final double? spacing;
final double value;
final CircularProgressM3ESize size;
final TextStyle? textStyle;
@override
Widget build(BuildContext context) {
if (position == ProgressLabelPosition.none) return progress;
final tokens = ProgressTokensAdapter(context);
final style = textStyle ?? tokens.labelStyle().copyWith(
color: Theme.of(context).colorScheme.onSurface,
final d =
size.diameterWavy; // ProgressWithLabel uses wavy circular by default
return SizedBox(
width: d,
height: d,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicatorM3E(value: value, size: size),
Text('${(value * 100).round()}%',
style: textStyle ?? Theme.of(context).textTheme.labelMedium),
],
),
);
final gap = spacing ?? 8.0;
final value = progress.value;
final builtLabel = label ?? Text(
value != null ? '${(value * 100).toStringAsFixed(0)}%' : '',
style: style,
);
switch (position) {
case ProgressLabelPosition.leading:
case ProgressLabelPosition.trailing:
final children = <Widget>[
if (position == ProgressLabelPosition.leading) builtLabel,
if (position == ProgressLabelPosition.leading) SizedBox(width: gap),
Expanded(child: progress),
if (position == ProgressLabelPosition.trailing) SizedBox(width: gap),
if (position == ProgressLabelPosition.trailing) builtLabel,
];
return Row(children: children);
case ProgressLabelPosition.top:
case ProgressLabelPosition.bottom:
final children = <Widget>[
if (position == ProgressLabelPosition.top) builtLabel,
if (position == ProgressLabelPosition.top) SizedBox(height: gap),
progress,
if (position == ProgressLabelPosition.bottom) SizedBox(height: gap),
if (position == ProgressLabelPosition.bottom) builtLabel,
];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
);
case ProgressLabelPosition.center:
return Stack(
alignment: Alignment.center,
children: [
progress,
builtLabel,
],
);
case ProgressLabelPosition.none:
return progress;
}
}
}

View file

@ -1,94 +0,0 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
import 'enums.dart';
@immutable
class _ProgressMetrics {
final double circularSmall;
final double circularMedium;
final double circularLarge;
final double strokeSmall;
final double strokeMedium;
final double strokeLarge;
final double linearThicknessSmall;
final double linearThicknessMedium;
final double linearThicknessLarge;
final double wavyWavelength; // dp (linear)
final double wavyAmplitudeFactor; // fraction of bar height (linear)
final double horizontalInset; // 4dp
final double circularWaveAmplitudeFactor; // fraction of stroke
final int circularWavesPerCircle; // rough default
const _ProgressMetrics({
required this.circularSmall,
required this.circularMedium,
required this.circularLarge,
required this.strokeSmall,
required this.strokeMedium,
required this.strokeLarge,
required this.linearThicknessSmall,
required this.linearThicknessMedium,
required this.linearThicknessLarge,
required this.wavyWavelength,
required this.wavyAmplitudeFactor,
required this.horizontalInset,
required this.circularWaveAmplitudeFactor,
required this.circularWavesPerCircle,
});
}
_ProgressMetrics _metricsFor(BuildContext context, ProgressM3EDensity density) {
double cS = 24, cM = 32, cL = 48;
double sS = 3, sM = 4, sL = 6;
double ltS = 3, ltM = 4, ltL = 6;
if (density == ProgressM3EDensity.compact) {
cS -= 2; cM -= 2; cL -= 4;
sS -= 0.5; sM -= 0.5; sL -= 1;
ltS -= 0.5; ltM -= 0.5; ltL -= 1;
}
return _ProgressMetrics(
circularSmall: cS,
circularMedium: cM,
circularLarge: cL,
strokeSmall: sS,
strokeMedium: sM,
strokeLarge: sL,
linearThicknessSmall: ltS,
linearThicknessMedium: ltM,
linearThicknessLarge: ltL,
wavyWavelength: 40, // per spec illustration
wavyAmplitudeFactor: 0.33, // amplitude 1/3 of height
horizontalInset: 4, // 4dp inset L/R
circularWaveAmplitudeFactor: 0.35, // ~1/3 of stroke
circularWavesPerCircle: 10, // a nice default
);
}
class ProgressTokensAdapter {
ProgressTokensAdapter(this.context);
final BuildContext context;
M3ETheme get _m3e {
final t = Theme.of(context);
return t.extension<M3ETheme>() ?? M3ETheme.defaults(t.colorScheme);
}
_ProgressMetrics metrics(ProgressM3EDensity density) => _metricsFor(context, density);
Color color(ProgressM3EEmphasis emphasis) {
switch (emphasis) {
case ProgressM3EEmphasis.primary:
return _m3e.colors.primary;
case ProgressM3EEmphasis.secondary:
return _m3e.colors.secondary;
case ProgressM3EEmphasis.surface:
return _m3e.colors.onSurface;
}
}
Color trackColor() => _m3e.colors.onSurface.withOpacity(0.12);
Color bufferColor(Color progress) => progress.withOpacity(0.24);
TextStyle labelStyle() => _m3e.type.bodySmall;
}