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,21 +0,0 @@
MIT License
Copyright (c) ...
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,65 +1,14 @@
# progress_indicators_m3e
Material 3 **Expressive** progress indicators for Flutter:
# progress_indicator_m3e (spec build)
- `LinearProgressM3E` — determinate, indeterminate, **buffer**, **query**, with **flat** or **wavy** shape
- `CircularProgressM3E` — determinate & indeterminate, **flat** or **wavy** stroke (animated for indeterminate)
- `ProgressWithLabelM3E` — compose a linear bar with inline/top/bottom/center labels
**Visual rules implemented**
- Active and track never overlap.
- Circular ring is *broken* around the active sweep.
- Squiggle variants (48/52) draw a sine-like stroke inside the ring with 2dp clearance.
- Linear shows two lanes (active above, track below) with fixed gap and end-dot, per table.
All widgets read **M3E tokens** (`m3e_design`) for color, sizing, and typography.
## Defaults (from the spec illustrations)
- Linear: default thickness 4dp; configurable via `size` or `strokeHeight`
- Linear (wavy): `wavelength=40dp`, `amplitude≈height/3`, **4dp** left/right inset
- Circular: small/medium/large diameters ≈ 24/32/48 with stroke ≈ 3/4/6
- Circular (wavy): default **10 waves** around the circle, amplitude ≈ 35% of stroke
## Quick start
```dart
import 'package:progress_indicators_m3e/progress_indicators_m3e.dart';
// Linear (wavy, determinate)
LinearProgressM3E(
value: 0.62,
shape: LinearBarShapeM3E.wavy,
);
// Circular (wavy, indeterminate)
const CircularProgressM3E(
shape: CircularBarShapeM3E.wavy,
);
// Linear (buffer) flat
LinearProgressM3E(
variant: LinearProgressM3EVariant.buffer,
value: 0.3,
bufferValue: 0.6,
);
// Circular (flat) with center label
CircularProgressM3E(
value: 0.5,
showCenterLabel: true,
);
```
## Monorepo layout
```
packages/
m3e_design/
progress_indicators_m3e/
```
`pubspec.yaml` references `../m3e_design`.
## Accessibility
- Provide `semanticLabel` and (for determinate) the widgets expose a numeric **value** for screen readers.
- Indeterminate wavy animations use modest motion; gate the speed with a future `m3e_design.motion` flag if you support "reduce motion".
## License
MIT
**Linear variants**
- `flatXS` — track 4, gap 4, dot Ø4, dotOffset 4, trailing 4
- `flatS` — track 8, gap 4, dot Ø4, dotOffset 2, trailing 8
- `wavyM` — track 4, wave amp 3, period 40, gap 4, dot Ø4, dotOffset 2, trailing 10
- `wavyL` — track 8, wave amp 3, period 40, gap 4, dot Ø4, dotOffset 2, trailing 14

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;
}

View file

@ -1,11 +1,11 @@
name: progress_indicator_m3e
description: Material 3 Expressive Progress Indicator for Flutter (linear + circular; flat + wavy) using M3E tokens.
version: 0.1.0
description: "Material 3 Expressive progress indicators."
version: 0.3.0
publish_to: none
environment:
sdk: ">=3.5.0 <4.0.0"
flutter: ">=3.22.0"
sdk: ">=3.3.0 <4.0.0"
flutter: ">=3.19.0"
dependencies:
flutter:

View file

@ -1,4 +0,0 @@
# melos_managed_dependency_overrides: m3e_design
dependency_overrides:
m3e_design:
path: ..\\m3e_design

View file

@ -1,7 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
void main() {
test('placeholder', () {
expect(1 + 2, 3);
});
}