Add initial configuration, tokens, and widgets for M3E components

- Introduced `.gitignore` and `.metadata` for apps and examples.
- Added Flutter/Dart analysis configurations (`analysis_options.yaml`).
- Implemented foundational tokens and themes for M3E (colors, shapes).
- Created base implementations for `IconButtonM3E` and `SplitButtonM3E`.
- Set up CI workflow (`ci.yaml`) to automate testing and analysis.
This commit is contained in:
Emily Pauli 2025-10-21 22:15:15 +02:00
commit 62ecb86b76
184 changed files with 9872 additions and 0 deletions

View file

@ -0,0 +1,7 @@
library progress_indicators_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,286 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'enums.dart';
import 'tokens_adapter.dart';
class CircularProgressM3E extends StatefulWidget {
const CircularProgressM3E({
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,
});
/// 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();
}
@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
),
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;
@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;
@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;
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)
}
}
@override
bool shouldRepaint(covariant _ArcPainter old) =>
old.color != color ||
old.stroke != stroke ||
old.value != value ||
old.clockwise != clockwise;
}
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,
});
final Color color;
final double stroke;
final double? value;
final int waves;
final double amplitude;
final double phase;
final bool clockwise;
@override
void paint(Canvas canvas, Size size) {
final rect = Offset.zero & size;
final center = rect.center;
final baseRadius = (size.shortestSide - stroke) / 2;
final paint = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = stroke
..strokeCap = StrokeCap.round;
final totalAngle = (value ?? 1.0) * 2 * math.pi * (clockwise ? 1 : -1);
final start = -math.pi / 2;
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) {
path.moveTo(p.dx, p.dy);
} else {
path.lineTo(p.dx, p.dy);
}
}
canvas.drawPath(path, paint);
}
@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;
}

View file

@ -0,0 +1,8 @@
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 }
enum LinearBarShapeM3E { flat, wavy }
enum CircularBarShapeM3E { flat, wavy }

View file

@ -0,0 +1,378 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'enums.dart';
import 'tokens_adapter.dart';
class LinearProgressM3E extends StatefulWidget {
const LinearProgressM3E({
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,
});
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();
}
@override
Widget build(BuildContext context) {
final tokens = ProgressTokensAdapter(context);
final m = tokens.metrics(widget.density);
final height = switch (widget.size) {
ProgressM3ESize.small => widget.strokeHeight ?? m.linearThicknessSmall,
ProgressM3ESize.medium => widget.strokeHeight ?? m.linearThicknessMedium,
ProgressM3ESize.large => widget.strokeHeight ?? m.linearThicknessLarge,
};
final track = widget.backgroundColor ?? tokens.trackColor();
final progress = widget.progressColor ?? tokens.color(widget.emphasis);
final buffer = widget.bufferColor ?? tokens.bufferColor(progress);
final borderRadius =
widget.borderRadius ?? BorderRadius.circular(height / 2);
final inset = widget.leftRightInset ?? m.horizontalInset;
final content = Padding(
padding: EdgeInsets.symmetric(horizontal: inset),
child: _buildBar(
context, height, borderRadius, track, progress, buffer, tokens),
);
final bar = ClipRRect(
borderRadius: borderRadius,
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,
),
);
} 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({
required this.value,
required this.height,
required this.wavelength,
required this.amplitude,
required this.track,
required this.fill,
required this.phase,
required this.indeterminate,
});
final double value;
final double height;
final double wavelength;
final double amplitude;
final Color track;
final Color fill;
final double phase;
final bool indeterminate;
@override
void paint(Canvas canvas, Size size) {
final paintTrack = Paint()..color = track;
final paintFill = Paint()..color = fill;
final r = RRect.fromRectAndRadius(
Offset.zero & Size(size.width, height), Radius.circular(height / 2));
canvas.drawRRect(r, paintTrack);
final w = size.width;
final progressW = indeterminate ? w : (w * value.clamp(0.0, 1.0));
final centerY = height / 2;
final path = Path()..moveTo(0, height);
path.lineTo(0, centerY);
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;
}
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;
}
}

View file

@ -0,0 +1,74 @@
import 'package:flutter/material.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,
this.textStyle,
});
final LinearProgressM3E progress;
final ProgressLabelPosition position;
final Widget? label;
final double? spacing;
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 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

@ -0,0 +1,94 @@
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;
}