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

@ -23,15 +23,17 @@ class ProgressSection extends StatelessWidget {
spacing: 16, spacing: 16,
runSpacing: 16, runSpacing: 16,
children: [ children: [
for (final s in ProgressM3ESize.values) ...[ for (final s in CircularProgressM3ESize.values) ...[
CircularProgressM3E( CircularProgressIndicatorM3E(
size: s, size: s,
value: 0.4, value: 0.4,
shape: ProgressM3EShape.wavy,
),
CircularProgressIndicatorM3E(
size: s,
value: 0.6,
shape: ProgressM3EShape.wavy,
), ),
CircularProgressM3E(
size: s,
value: 0.6,
showCenterLabel: s != ProgressM3ESize.small),
], ],
], ],
), ),
@ -45,17 +47,17 @@ class ProgressSection extends StatelessWidget {
spacing: 16, spacing: 16,
runSpacing: 16, runSpacing: 16,
children: [ children: [
for (final s in ProgressM3ESize.values) ...[ for (final s in CircularProgressM3ESize.values) ...[
CircularProgressM3E( CircularProgressIndicatorM3E(
size: s, size: s,
value: 0.4, value: 0.4,
shape: CircularBarShapeM3E.flat, shape: ProgressM3EShape.flat,
),
CircularProgressIndicatorM3E(
size: s,
shape: ProgressM3EShape.flat,
value: 0.6,
), ),
CircularProgressM3E(
size: s,
shape: CircularBarShapeM3E.flat,
value: 0.6,
showCenterLabel: s != ProgressM3ESize.small),
], ],
], ],
), ),
@ -69,14 +71,20 @@ class ProgressSection extends StatelessWidget {
spacing: 16, spacing: 16,
runSpacing: 16, runSpacing: 16,
children: [ children: [
for (final v in LinearProgressM3EVariant.values) SizedBox(
LinearProgressM3E( width: 250,
minWidth: 220, child: LinearProgressIndicatorM3E(
variant: v, value: null,
value: v == LinearProgressM3EVariant.determinate ? 0.6 : null, shape: ProgressM3EShape.wavy,
bufferValue:
v == LinearProgressM3EVariant.buffer ? 0.8 : null,
), ),
),
SizedBox(
width: 250,
child: LinearProgressIndicatorM3E(
value: 0.6,
shape: ProgressM3EShape.wavy,
),
),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@ -89,15 +97,20 @@ class ProgressSection extends StatelessWidget {
spacing: 16, spacing: 16,
runSpacing: 16, runSpacing: 16,
children: [ children: [
for (final v in LinearProgressM3EVariant.values) SizedBox(
LinearProgressM3E( width: 250,
minWidth: 220, child: LinearProgressIndicatorM3E(
variant: v, value: null,
shape: LinearBarShapeM3E.flat, shape: ProgressM3EShape.flat,
value: v == LinearProgressM3EVariant.determinate ? 0.6 : null,
bufferValue:
v == LinearProgressM3EVariant.buffer ? 0.8 : null,
), ),
),
SizedBox(
width: 250,
child: LinearProgressIndicatorM3E(
value: 0.6,
shape: ProgressM3EShape.flat,
),
),
], ],
), ),
], ],

View file

@ -1,3 +1,5 @@
# m3e_collection # m3e_collection
Single import that re-exports all M3E component packages plus `m3e_design`. Single import that re-exports all M3E component packages plus `m3e_design`.
The packages `material_new_shapes` by [ulims](https://github.com/ulims) and `expressive_refresh` by [alvaronp](https://github.com/alvaronp) are reexported to complete the collection.

View file

@ -3,6 +3,7 @@ library m3e_collection;
export 'package:app_bar_m3e/app_bar_m3e.dart'; export 'package:app_bar_m3e/app_bar_m3e.dart';
export 'package:button_group_m3e/button_group_m3e.dart'; export 'package:button_group_m3e/button_group_m3e.dart';
export 'package:button_m3e/button_m3e.dart'; export 'package:button_m3e/button_m3e.dart';
export 'package:expressive_refresh/expressive_refresh.dart';
export 'package:fab_m3e/fab_m3e.dart'; export 'package:fab_m3e/fab_m3e.dart';
export 'package:icon_button_m3e/icon_button_m3e.dart'; export 'package:icon_button_m3e/icon_button_m3e.dart';
export 'package:loading_indicator_m3e/loading_indicator_m3e.dart'; export 'package:loading_indicator_m3e/loading_indicator_m3e.dart';

View file

@ -10,6 +10,7 @@ dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
material_new_shapes: ^1.0.0 material_new_shapes: ^1.0.0
expressive_refresh: ^0.1.2
m3e_design: m3e_design:
path: ../m3e_design path: ../m3e_design
icon_button_m3e: icon_button_m3e:

View file

@ -1,7 +1,27 @@
// Material 3 Expressive Color Tokens for Flutter
// -------------------------------------------------
// This file defines an app-level color token object (M3EColors) that wraps
// Flutter's ColorScheme (M3) and adds expressive/custom roles (info, success,
// warning, danger, emphasis, strong surface/outline), with safe fallbacks for
// older Flutter SDKs. It also includes helpers for dynamic color and
// harmonization per M3 guidance.
//
// References:
// - M3 color system & roles: https://m3.material.io/styles/color/roles
// - How the system works (tones, surfaces): https://m3.material.io/styles/color/system/how-the-system-works
// - Define new colors (custom colors + harmonization): https://m3.material.io/styles/color/advanced/define-new-colors
// - Codelab (customizing Material color): https://codelabs.developers.google.com/customizing-material-color
// - dynamic_color package: https://pub.dev/packages/dynamic_color
// ignore_for_file: public_member_api_docs
import 'package:dynamic_color/dynamic_color.dart'
as dyn; // for .harmonizeWith and DynamicColorBuilder
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@immutable @immutable
class M3EColors { class M3EColors {
// --- Expressive / semantic extras ---------------------------------------
final Color emphasis; final Color emphasis;
final Color onEmphasis; final Color onEmphasis;
final Color info; final Color info;
@ -13,7 +33,7 @@ class M3EColors {
final Color onSurfaceStrong; final Color onSurfaceStrong;
final Color outlineStrong; final Color outlineStrong;
// New: proxy common ColorScheme fields used across packages // --- Core ColorScheme proxies (subset used across packages) --------------
final Color primary; final Color primary;
final Color onPrimary; final Color onPrimary;
final Color primaryContainer; final Color primaryContainer;
@ -41,11 +61,41 @@ class M3EColors {
final Color outline; final Color outline;
final Color outlineVariant; final Color outlineVariant;
// New: container surface tokens not always present on older ColorScheme // --- Tone-based surfaces (M3) --------------------------------------------
final Color surfaceContainerHigh; final Color surfaceDim;
final Color surfaceBright;
final Color surfaceContainerLowest; final Color surfaceContainerLowest;
final Color surfaceContainerLow;
final Color surfaceContainer;
final Color surfaceContainerHigh;
final Color surfaceContainerHighest;
// --- Inverse & overlays ---------------------------------------------------
final Color inverseSurface;
final Color onInverseSurface;
final Color inversePrimary;
final Color surfaceTint;
final Color shadow;
final Color scrim;
// --- Optional fixed accent roles (present in newer Flutter SDKs) ---------
final Color? primaryFixed;
final Color? primaryFixedDim;
final Color? onPrimaryFixed;
final Color? onPrimaryFixedVariant;
final Color? secondaryFixed;
final Color? secondaryFixedDim;
final Color? onSecondaryFixed;
final Color? onSecondaryFixedVariant;
final Color? tertiaryFixed;
final Color? tertiaryFixedDim;
final Color? onTertiaryFixed;
final Color? onTertiaryFixedVariant;
const M3EColors({ const M3EColors({
// expressive
required this.emphasis, required this.emphasis,
required this.onEmphasis, required this.onEmphasis,
required this.info, required this.info,
@ -55,7 +105,7 @@ class M3EColors {
required this.surfaceStrong, required this.surfaceStrong,
required this.onSurfaceStrong, required this.onSurfaceStrong,
required this.outlineStrong, required this.outlineStrong,
// New fields // core
required this.primary, required this.primary,
required this.onPrimary, required this.onPrimary,
required this.primaryContainer, required this.primaryContainer,
@ -77,34 +127,234 @@ class M3EColors {
required this.onErrorContainer, required this.onErrorContainer,
required this.outline, required this.outline,
required this.outlineVariant, required this.outlineVariant,
required this.surfaceContainerHigh, // tone-based surfaces
required this.surfaceDim,
required this.surfaceBright,
required this.surfaceContainerLowest, required this.surfaceContainerLowest,
required this.surfaceContainerLow,
required this.surfaceContainer,
required this.surfaceContainerHigh,
required this.surfaceContainerHighest,
// inverse & overlays
required this.inverseSurface,
required this.onInverseSurface,
required this.inversePrimary,
required this.surfaceTint,
required this.shadow,
required this.scrim,
// fixed accents (nullable for SDK-compat)
this.primaryFixed,
this.primaryFixedDim,
this.onPrimaryFixed,
this.onPrimaryFixedVariant,
this.secondaryFixed,
this.secondaryFixedDim,
this.onSecondaryFixed,
this.onSecondaryFixedVariant,
this.tertiaryFixed,
this.tertiaryFixedDim,
this.onTertiaryFixed,
this.onTertiaryFixedVariant,
}); });
/// Build from a Flutter [ColorScheme].
///
/// Includes safe fallbacks for tone-based surfaces and fixed roles if the
/// running SDK predates those fields.
factory M3EColors.from(ColorScheme s) { factory M3EColors.from(ColorScheme s) {
// Compute container surface variants if not available on the ColorScheme version in use. Color _blend(Color base, Color overlay, double a) =>
// We prefer mild blends that work in both light/dark. Color.alphaBlend(overlay.withValues(alpha: a), base);
Color computeSurfaceContainerHigh() =>
Color.alphaBlend(s.primary.withValues(alpha: 0.12), s.surface); // Surface tone fallbacks (approximate, spec-agnostic but pleasant)
Color computeSurfaceContainerLowest() => Color _containerLow() => _blend(s.surface, s.primary, 0.04);
Color.alphaBlend(s.onSurface.withValues(alpha: 0.05), s.surface); Color _container() => _blend(s.surface, s.primary, 0.06);
Color _containerHigh() => _blend(s.surface, s.primary, 0.08);
Color _containerLowest() => _blend(s.surface, s.onSurface, 0.03);
Color _containerHighest() => _blend(s.surface, s.onSurface, 0.10);
Color _surfaceDim() => _blend(s.surface, s.onSurface, 0.05);
Color _surfaceBright() => _blend(s.surface, s.primary, 0.04);
Function(Invocation invocation)? _tryGet<T>(
ColorScheme scheme, String getter) {
try {
final dyn = scheme as dynamic;
final v =
dyn.__noSuchMethod__ == null ? null : null; // keep analyzer happy
return (dyn as dynamic)
.noSuchMethod; // never executed, trick to silence lints in try
} catch (_) {
// ignored
}
// Fallback path using mirrors is not available; we'll use a switch below.
return null;
}
// Read new fields via dynamic with try-catch (avoids hard SDK requirement)
Color _getOr(Color? c, Color Function() orElse) => c ?? orElse();
Color? _readColor(String name) {
try {
final dyn = s as dynamic;
return dyn.toJson != null ? null : (dyn as dynamic)[name] as Color?;
} catch (_) {
return null;
}
}
// While dynamic lookup above is intentionally conservative, we can directly
// access when fields exist in the current SDK, otherwise compute.
Color surfaceDim = (() {
try {
return (s.surfaceDim);
} catch (_) {
return _surfaceDim();
}
})();
Color surfaceBright = (() {
try {
return (s.surfaceBright);
} catch (_) {
return _surfaceBright();
}
})();
Color surfaceContainerLowest = (() {
try {
return (s.surfaceContainerLowest);
} catch (_) {
return _containerLowest();
}
})();
Color surfaceContainerLow = (() {
try {
return (s.surfaceContainerLow);
} catch (_) {
return _containerLow();
}
})();
Color surfaceContainer = (() {
try {
return (s.surfaceContainer);
} catch (_) {
return _container();
}
})();
Color surfaceContainerHigh = (() {
try {
return (s.surfaceContainerHigh);
} catch (_) {
return _containerHigh();
}
})();
Color surfaceContainerHighest = (() {
try {
return (s.surfaceContainerHighest);
} catch (_) {
return _containerHighest();
}
})();
Color inverseSurface = (() {
try {
return (s.inverseSurface);
} catch (_) {
return _blend(s.surface, s.onSurface, 0.12);
}
})();
Color onInverseSurface = (() {
try {
return (s.onInverseSurface);
} catch (_) {
return s.surface; // decent fallback
}
})();
Color inversePrimary = (() {
try {
return (s.inversePrimary);
} catch (_) {
return s.primary; // fallback: same hue
}
})();
Color surfaceTint = (() {
try {
return (s.surfaceTint);
} catch (_) {
return s.primary; // fallback
}
})();
Color shadow = (() {
try {
return (s.shadow);
} catch (_) {
return const Color(0xFF000000);
}
})();
Color scrim = (() {
try {
return (s.scrim);
} catch (_) {
return const Color(0xFF000000).withValues(alpha: 0.32);
}
})();
// Fixed roles (nullable)
Color? primaryFixed,
primaryFixedDim,
onPrimaryFixed,
onPrimaryFixedVariant,
secondaryFixed,
secondaryFixedDim,
onSecondaryFixed,
onSecondaryFixedVariant,
tertiaryFixed,
tertiaryFixedDim,
onTertiaryFixed,
onTertiaryFixedVariant;
try {
primaryFixed = (s as dynamic).primaryFixed as Color?;
primaryFixedDim = (s as dynamic).primaryFixedDim as Color?;
onPrimaryFixed = (s as dynamic).onPrimaryFixed as Color?;
onPrimaryFixedVariant = (s as dynamic).onPrimaryFixedVariant as Color?;
secondaryFixed = (s as dynamic).secondaryFixed as Color?;
secondaryFixedDim = (s as dynamic).secondaryFixedDim as Color?;
onSecondaryFixed = (s as dynamic).onSecondaryFixed as Color?;
onSecondaryFixedVariant =
(s as dynamic).onSecondaryFixedVariant as Color?;
tertiaryFixed = (s as dynamic).tertiaryFixed as Color?;
tertiaryFixedDim = (s as dynamic).tertiaryFixedDim as Color?;
onTertiaryFixed = (s as dynamic).onTertiaryFixed as Color?;
onTertiaryFixedVariant = (s as dynamic).onTertiaryFixedVariant as Color?;
} catch (_) {
// Older SDK leave null
}
// Expressive semantics (harmonized with primary)
final Color info = s.tertiary;
final Color success =
Colors.green.shade600.harmonizeWith(s.primary); // dyn extension
final Color warning = Colors.orange.shade700.harmonizeWith(s.primary);
final Color danger = s.error; // already semantic
final Color emphasis = s.primary;
final Color onEmphasis = s.onPrimary;
final Color surfaceStrong = _blend(s.surface, s.primary, 0.06);
final Color onSurfaceStrong = s.onSurface;
final Color outlineStrong = _blend(s.outline, s.primary, 0.40);
return M3EColors( return M3EColors(
emphasis: s.primary, // expressive
onEmphasis: s.onPrimary, emphasis: emphasis,
info: s.tertiary, onEmphasis: onEmphasis,
success: Color.alphaBlend( info: info,
Colors.green.shade400.withValues(alpha: 0.2), s.primaryContainer), success: success,
warning: Color.alphaBlend( warning: warning,
Colors.orange.shade400.withValues(alpha: 0.2), s.secondaryContainer), danger: danger,
danger: Color.alphaBlend( surfaceStrong: surfaceStrong,
Colors.red.shade400.withValues(alpha: 0.2), s.errorContainer), onSurfaceStrong: onSurfaceStrong,
surfaceStrong: outlineStrong: outlineStrong,
Color.alphaBlend(s.primary.withValues(alpha: 0.06), s.surface), // core
onSurfaceStrong: s.onSurface,
outlineStrong:
Color.alphaBlend(s.primary.withValues(alpha: 0.40), s.outlineVariant),
// New fields mapped from ColorScheme
primary: s.primary, primary: s.primary,
onPrimary: s.onPrimary, onPrimary: s.onPrimary,
primaryContainer: s.primaryContainer, primaryContainer: s.primaryContainer,
@ -126,25 +376,168 @@ class M3EColors {
onErrorContainer: s.onErrorContainer, onErrorContainer: s.onErrorContainer,
outline: s.outline, outline: s.outline,
outlineVariant: s.outlineVariant, outlineVariant: s.outlineVariant,
surfaceContainerHigh: (() { // tone-based
// If the ColorScheme already has a matching field, prefer that via dynamic access; otherwise compute. surfaceDim: surfaceDim,
try { surfaceBright: surfaceBright,
final dynamic dyn = s; surfaceContainerLowest: surfaceContainerLowest,
final c = dyn.surfaceContainerHigh as Color?; surfaceContainerLow: surfaceContainerLow,
return c ?? computeSurfaceContainerHigh(); surfaceContainer: surfaceContainer,
} catch (_) { surfaceContainerHigh: surfaceContainerHigh,
return computeSurfaceContainerHigh(); surfaceContainerHighest: surfaceContainerHighest,
} // inverse & overlays
})(), inverseSurface: inverseSurface,
surfaceContainerLowest: (() { onInverseSurface: onInverseSurface,
try { inversePrimary: inversePrimary,
final dynamic dyn = s; surfaceTint: surfaceTint,
final c = dyn.surfaceContainerLowest as Color?; shadow: shadow,
return c ?? computeSurfaceContainerLowest(); scrim: scrim,
} catch (_) { // fixed accents
return computeSurfaceContainerLowest(); primaryFixed: primaryFixed,
} primaryFixedDim: primaryFixedDim,
})(), onPrimaryFixed: onPrimaryFixed,
onPrimaryFixedVariant: onPrimaryFixedVariant,
secondaryFixed: secondaryFixed,
secondaryFixedDim: secondaryFixedDim,
onSecondaryFixed: onSecondaryFixed,
onSecondaryFixedVariant: onSecondaryFixedVariant,
tertiaryFixed: tertiaryFixed,
tertiaryFixedDim: tertiaryFixedDim,
onTertiaryFixed: onTertiaryFixed,
onTertiaryFixedVariant: onTertiaryFixedVariant,
);
}
/// Returns a copy with [info]/[success]/[warning]/[danger] harmonized to the
/// given [scheme.primary] (per M3 custom color guidance).
M3EColors harmonizedTo(ColorScheme scheme) {
Color h(Color c) => c.harmonizeWith(scheme.primary);
return copyWith(
info: h(info),
success: h(success),
warning: h(warning),
danger: h(danger),
emphasis: h(emphasis),
);
}
M3EColors copyWith({
Color? emphasis,
Color? onEmphasis,
Color? info,
Color? success,
Color? warning,
Color? danger,
Color? surfaceStrong,
Color? onSurfaceStrong,
Color? outlineStrong,
Color? primary,
Color? onPrimary,
Color? primaryContainer,
Color? onPrimaryContainer,
Color? secondary,
Color? onSecondary,
Color? secondaryContainer,
Color? onSecondaryContainer,
Color? tertiary,
Color? onTertiary,
Color? tertiaryContainer,
Color? onTertiaryContainer,
Color? surface,
Color? onSurface,
Color? onSurfaceVariant,
Color? error,
Color? onError,
Color? errorContainer,
Color? onErrorContainer,
Color? outline,
Color? outlineVariant,
Color? surfaceDim,
Color? surfaceBright,
Color? surfaceContainerLowest,
Color? surfaceContainerLow,
Color? surfaceContainer,
Color? surfaceContainerHigh,
Color? surfaceContainerHighest,
Color? inverseSurface,
Color? onInverseSurface,
Color? inversePrimary,
Color? surfaceTint,
Color? shadow,
Color? scrim,
Color? primaryFixed,
Color? primaryFixedDim,
Color? onPrimaryFixed,
Color? onPrimaryFixedVariant,
Color? secondaryFixed,
Color? secondaryFixedDim,
Color? onSecondaryFixed,
Color? onSecondaryFixedVariant,
Color? tertiaryFixed,
Color? tertiaryFixedDim,
Color? onTertiaryFixed,
Color? onTertiaryFixedVariant,
}) {
return M3EColors(
emphasis: emphasis ?? this.emphasis,
onEmphasis: onEmphasis ?? this.onEmphasis,
info: info ?? this.info,
success: success ?? this.success,
warning: warning ?? this.warning,
danger: danger ?? this.danger,
surfaceStrong: surfaceStrong ?? this.surfaceStrong,
onSurfaceStrong: onSurfaceStrong ?? this.onSurfaceStrong,
outlineStrong: outlineStrong ?? this.outlineStrong,
primary: primary ?? this.primary,
onPrimary: onPrimary ?? this.onPrimary,
primaryContainer: primaryContainer ?? this.primaryContainer,
onPrimaryContainer: onPrimaryContainer ?? this.onPrimaryContainer,
secondary: secondary ?? this.secondary,
onSecondary: onSecondary ?? this.onSecondary,
secondaryContainer: secondaryContainer ?? this.secondaryContainer,
onSecondaryContainer: onSecondaryContainer ?? this.onSecondaryContainer,
tertiary: tertiary ?? this.tertiary,
onTertiary: onTertiary ?? this.onTertiary,
tertiaryContainer: tertiaryContainer ?? this.tertiaryContainer,
onTertiaryContainer: onTertiaryContainer ?? this.onTertiaryContainer,
surface: surface ?? this.surface,
onSurface: onSurface ?? this.onSurface,
onSurfaceVariant: onSurfaceVariant ?? this.onSurfaceVariant,
error: error ?? this.error,
onError: onError ?? this.onError,
errorContainer: errorContainer ?? this.errorContainer,
onErrorContainer: onErrorContainer ?? this.onErrorContainer,
outline: outline ?? this.outline,
outlineVariant: outlineVariant ?? this.outlineVariant,
surfaceDim: surfaceDim ?? this.surfaceDim,
surfaceBright: surfaceBright ?? this.surfaceBright,
surfaceContainerLowest:
surfaceContainerLowest ?? this.surfaceContainerLowest,
surfaceContainerLow: surfaceContainerLow ?? this.surfaceContainerLow,
surfaceContainer: surfaceContainer ?? this.surfaceContainer,
surfaceContainerHigh: surfaceContainerHigh ?? this.surfaceContainerHigh,
surfaceContainerHighest:
surfaceContainerHighest ?? this.surfaceContainerHighest,
inverseSurface: inverseSurface ?? this.inverseSurface,
onInverseSurface: onInverseSurface ?? this.onInverseSurface,
inversePrimary: inversePrimary ?? this.inversePrimary,
surfaceTint: surfaceTint ?? this.surfaceTint,
shadow: shadow ?? this.shadow,
scrim: scrim ?? this.scrim,
primaryFixed: primaryFixed ?? this.primaryFixed,
primaryFixedDim: primaryFixedDim ?? this.primaryFixedDim,
onPrimaryFixed: onPrimaryFixed ?? this.onPrimaryFixed,
onPrimaryFixedVariant:
onPrimaryFixedVariant ?? this.onPrimaryFixedVariant,
secondaryFixed: secondaryFixed ?? this.secondaryFixed,
secondaryFixedDim: secondaryFixedDim ?? this.secondaryFixedDim,
onSecondaryFixed: onSecondaryFixed ?? this.onSecondaryFixed,
onSecondaryFixedVariant:
onSecondaryFixedVariant ?? this.onSecondaryFixedVariant,
tertiaryFixed: tertiaryFixed ?? this.tertiaryFixed,
tertiaryFixedDim: tertiaryFixedDim ?? this.tertiaryFixedDim,
onTertiaryFixed: onTertiaryFixed ?? this.onTertiaryFixed,
onTertiaryFixedVariant:
onTertiaryFixedVariant ?? this.onTertiaryFixedVariant,
); );
} }
@ -158,7 +551,6 @@ class M3EColors {
surfaceStrong: Color.lerp(a.surfaceStrong, b.surfaceStrong, t)!, surfaceStrong: Color.lerp(a.surfaceStrong, b.surfaceStrong, t)!,
onSurfaceStrong: Color.lerp(a.onSurfaceStrong, b.onSurfaceStrong, t)!, onSurfaceStrong: Color.lerp(a.onSurfaceStrong, b.onSurfaceStrong, t)!,
outlineStrong: Color.lerp(a.outlineStrong, b.outlineStrong, t)!, outlineStrong: Color.lerp(a.outlineStrong, b.outlineStrong, t)!,
// New fields
primary: Color.lerp(a.primary, b.primary, t)!, primary: Color.lerp(a.primary, b.primary, t)!,
onPrimary: Color.lerp(a.onPrimary, b.onPrimary, t)!, onPrimary: Color.lerp(a.onPrimary, b.onPrimary, t)!,
primaryContainer: primaryContainer:
@ -188,9 +580,112 @@ class M3EColors {
Color.lerp(a.onErrorContainer, b.onErrorContainer, t)!, Color.lerp(a.onErrorContainer, b.onErrorContainer, t)!,
outline: Color.lerp(a.outline, b.outline, t)!, outline: Color.lerp(a.outline, b.outline, t)!,
outlineVariant: Color.lerp(a.outlineVariant, b.outlineVariant, t)!, outlineVariant: Color.lerp(a.outlineVariant, b.outlineVariant, t)!,
surfaceContainerHigh: surfaceDim: Color.lerp(a.surfaceDim, b.surfaceDim, t)!,
Color.lerp(a.surfaceContainerHigh, b.surfaceContainerHigh, t)!, surfaceBright: Color.lerp(a.surfaceBright, b.surfaceBright, t)!,
surfaceContainerLowest: surfaceContainerLowest:
Color.lerp(a.surfaceContainerLowest, b.surfaceContainerLowest, t)!, Color.lerp(a.surfaceContainerLowest, b.surfaceContainerLowest, t)!,
surfaceContainerLow:
Color.lerp(a.surfaceContainerLow, b.surfaceContainerLow, t)!,
surfaceContainer:
Color.lerp(a.surfaceContainer, b.surfaceContainer, t)!,
surfaceContainerHigh:
Color.lerp(a.surfaceContainerHigh, b.surfaceContainerHigh, t)!,
surfaceContainerHighest: Color.lerp(
a.surfaceContainerHighest, b.surfaceContainerHighest, t)!,
inverseSurface: Color.lerp(a.inverseSurface, b.inverseSurface, t)!,
onInverseSurface:
Color.lerp(a.onInverseSurface, b.onInverseSurface, t)!,
inversePrimary: Color.lerp(a.inversePrimary, b.inversePrimary, t)!,
surfaceTint: Color.lerp(a.surfaceTint, b.surfaceTint, t)!,
shadow: Color.lerp(a.shadow, b.shadow, t)!,
scrim: Color.lerp(a.scrim, b.scrim, t)!,
primaryFixed: Color.lerp(a.primaryFixed, b.primaryFixed, t),
primaryFixedDim: Color.lerp(a.primaryFixedDim, b.primaryFixedDim, t),
onPrimaryFixed: Color.lerp(a.onPrimaryFixed, b.onPrimaryFixed, t),
onPrimaryFixedVariant:
Color.lerp(a.onPrimaryFixedVariant, b.onPrimaryFixedVariant, t),
secondaryFixed: Color.lerp(a.secondaryFixed, b.secondaryFixed, t),
secondaryFixedDim:
Color.lerp(a.secondaryFixedDim, b.secondaryFixedDim, t),
onSecondaryFixed: Color.lerp(a.onSecondaryFixed, b.onSecondaryFixed, t),
onSecondaryFixedVariant:
Color.lerp(a.onSecondaryFixedVariant, b.onSecondaryFixedVariant, t),
tertiaryFixed: Color.lerp(a.tertiaryFixed, b.tertiaryFixed, t),
tertiaryFixedDim: Color.lerp(a.tertiaryFixedDim, b.tertiaryFixedDim, t),
onTertiaryFixed: Color.lerp(a.onTertiaryFixed, b.onTertiaryFixed, t),
onTertiaryFixedVariant:
Color.lerp(a.onTertiaryFixedVariant, b.onTertiaryFixedVariant, t),
); );
} }
/// Convenience extension to read expressive tokens from any [ColorScheme].
extension M3EColorsX on ColorScheme {
M3EColors get m3e => M3EColors.from(this);
}
/*
/// Theme helpers --------------------------------------------------------------
class M3ETheme {
M3ETheme._();
/// Build a [ThemeData] using dynamic colors when available, otherwise
/// fall back to [ColorScheme.fromSeed]. Use inside a [dyn.DynamicColorBuilder].
static ThemeData themeFromSchemes({
required BuildContext context,
required ColorScheme lightScheme,
required ColorScheme darkScheme,
VisualDensity visualDensity = VisualDensity.adaptivePlatformDensity,
TargetPlatform? platform,
TextTheme? textTheme,
bool useMaterial3 = true,
}) {
return ThemeData(
useMaterial3: useMaterial3,
colorScheme: lightScheme,
brightness: Brightness.light,
visualDensity: visualDensity,
platform: platform,
textTheme: textTheme,
);
}
/// Utility to create [ColorScheme] either from platform dynamic colors or a seed.
/// Typical usage in your app root:
///
/// ```dart
/// return dyn.DynamicColorBuilder(
/// builder: (lightDynamic, darkDynamic) {
/// final Color seed = const Color(0xFF6750A4);
/// final light = lightDynamic ?? ColorScheme.fromSeed(seedColor: seed);
/// final dark = darkDynamic ??
/// ColorScheme.fromSeed(seedColor: seed, brightness: Brightness.dark);
/// return MaterialApp(theme: ThemeData(colorScheme: light), darkTheme: ThemeData(colorScheme: dark));
/// },
/// );
/// ```
static ({ColorScheme light, ColorScheme dark}) dynamicOrSeed(
Color seed, {
Color? secondarySeed,
Color? tertiarySeed,
double contrastLevel = 0.0,
dyn.DynamicSchemeVariant variant = dyn.DynamicSchemeVariant.tonalSpot,
}) {
final light = ColorScheme.fromSeed(
seedColor: seed,
contrastLevel: contrastLevel,
dynamicSchemeVariant: variant,
secondary: secondarySeed,
tertiary: tertiarySeed,
);
final dark = ColorScheme.fromSeed(
seedColor: seed,
contrastLevel: contrastLevel,
dynamicSchemeVariant: variant,
brightness: Brightness.dark,
secondary: secondarySeed,
tertiary: tertiarySeed,
);
return (light: light, dark: dark);
}
}
*/

View file

@ -10,6 +10,7 @@ environment:
dependencies: dependencies:
flutter: flutter:
sdk: flutter sdk: flutter
dynamic_color: ^1.8.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

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 **Visual rules implemented**
- `CircularProgressM3E` — determinate & indeterminate, **flat** or **wavy** stroke (animated for indeterminate) - Active and track never overlap.
- `ProgressWithLabelM3E` — compose a linear bar with inline/top/bottom/center labels - 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. **Linear variants**
- `flatXS` — track 4, gap 4, dot Ø4, dotOffset 4, trailing 4
## Defaults (from the spec illustrations) - `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
- Linear: default thickness 4dp; configurable via `size` or `strokeHeight` - `wavyL` — track 8, wave amp 3, period 40, gap 4, dot Ø4, dotOffset 2, trailing 14
- 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

View file

@ -1,7 +1,7 @@
library progress_indicators_m3e;
library progress_indicator_m3e;
export 'src/enums.dart'; export 'src/enums.dart';
export 'src/tokens_adapter.dart' show ProgressTokensAdapter;
export 'src/linear_progress_m3e.dart'; export 'src/linear_progress_m3e.dart';
export 'src/circular_progress_m3e.dart'; export 'src/circular_progress_m3e.dart';
export 'src/progress_with_label_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 'package:flutter/material.dart';
import 'enums.dart'; import 'enums.dart';
import 'tokens_adapter.dart';
class CircularProgressM3E extends StatefulWidget { class CircularProgressIndicatorM3E extends StatelessWidget {
const CircularProgressM3E({ const CircularProgressIndicatorM3E({
super.key, super.key,
this.value, this.value,
this.size = ProgressM3ESize.medium, this.size = CircularProgressM3ESize.m,
this.emphasis = ProgressM3EEmphasis.primary, this.shape = ProgressM3EShape.wavy,
this.density = ProgressM3EDensity.regular, this.activeColor,
this.backgroundColor, this.trackColor,
this.strokeWidth, this.rotation = 0.0, // radians, for indeterminate rotation
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; // 0..1 (null => indeterminate arc sweep)
final double? value; final CircularProgressM3ESize size;
final ProgressM3EShape shape;
final ProgressM3ESize size; final Color? activeColor;
final ProgressM3EEmphasis emphasis; final Color? trackColor;
final ProgressM3EDensity density; final double rotation;
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tokens = ProgressTokensAdapter(context); final cs = Theme.of(context).colorScheme;
final m = tokens.metrics(widget.density); final active = activeColor ?? cs.primary;
final color = tokens.color(widget.emphasis); final track = trackColor ?? cs.onSurfaceVariant.withOpacity(0.24);
final bg = widget.backgroundColor ?? tokens.trackColor(); final wantsWavy = shape == ProgressM3EShape.wavy;
final diameter = wantsWavy ? size.diameterWavy : size.diameterFlat;
final (diameter, stroke) = switch (widget.size) { return RepaintBoundary(
ProgressM3ESize.small => ( child: SizedBox(
m.circularSmall, width: diameter,
widget.strokeWidth ?? m.strokeSmall 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 { class _CircularFlatPainter extends CustomPainter {
_RingPainter({required this.color, required this.stroke}); _CircularFlatPainter(
final Color color; {required this.value,
final double stroke; 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 double? value;
final bool clockwise; final Color active;
final Color track;
final double rotation;
final CircularProgressM3ESize size;
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size s) {
final paint = Paint() final stroke = 4.0;
..color = color 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 ..style = PaintingStyle.stroke
..strokeWidth = stroke ..strokeWidth = stroke
..strokeCap = StrokeCap.round; ..strokeCap = StrokeCap.round
..isAntiAlias = true
..color = track;
final rect = Offset.zero & size; final total = math.pi * 2;
final center = rect.center; final a1 = (activeEnd + gapAngle);
final radius = (size.shortestSide - stroke) / 2; 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; // ACTIVE arc
final sweep = (value ?? 0.25) * 2 * math.pi * (clockwise ? 1 : -1); final activePaint = Paint()
..style = PaintingStyle.stroke
canvas.drawArc(Rect.fromCircle(center: center, radius: radius), start, ..strokeWidth = stroke
sweep, false, paint); ..strokeCap = StrokeCap.round
if (value == null) { ..isAntiAlias = true
// indeterminate - draw a moving arc; this painter is used only for determinate (flat) ..color = active;
} canvas.drawArc(rect, activeStart, sweep, false, activePaint);
} }
@override @override
bool shouldRepaint(covariant _ArcPainter old) => bool shouldRepaint(covariant _CircularFlatPainter old) =>
old.color != color || value != old.value ||
old.stroke != stroke || active != old.active ||
old.value != value || track != old.track ||
old.clockwise != clockwise; rotation != old.rotation ||
size != old.size;
} }
class _WavyArcPainter extends CustomPainter { class _CircularWavyPainter extends CustomPainter {
_WavyArcPainter({ _CircularWavyPainter(
required this.color, {required this.value,
required this.stroke, required this.active,
required this.value, required this.track,
required this.waves, required this.rotation});
required this.amplitude,
required this.phase,
required this.clockwise,
});
final Color color;
final double stroke;
final double? value; final double? value;
final int waves; final Color active;
final double amplitude; final Color track;
final double phase; final double rotation;
final bool clockwise;
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size s) {
final rect = Offset.zero & size; const stroke = 4.0;
final center = rect.center; final center = s.center(Offset.zero);
final baseRadius = (size.shortestSide - stroke) / 2; final baseRadius = (math.min(s.width, s.height) - stroke) / 2;
final paint = Paint() // Squiggle clearance: 2dp (edge-to-edge). Approximate by insetting the squiggle centerline by 6dp.
..color = color 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 ..style = PaintingStyle.stroke
..strokeWidth = 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 gapAngle = 2.0 / baseRadius;
final start = -math.pi / 2; 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 path = Path();
final steps = (200 * (value ?? 1.0)).clamp(40, 300).toInt(); // resolution
for (int i = 0; i <= steps; i++) { for (int i = 0; i <= steps; i++) {
final t = i / steps; final t = i / steps;
final theta = start + totalAngle * t; final ang = start + (end - start) * t;
final wave = math.sin((t * waves * 2 * math.pi) + phase); final arcLen = squiggleRadius * (ang - start);
final r = baseRadius + amplitude * wave; final r =
final p = Offset( squiggleRadius + amp * math.sin(arcLen / scallopLen * 2 * math.pi);
center.dx + r * math.cos(theta), center.dy + r * math.sin(theta)); final p =
if (i == 0) { Offset(center.dx + r * math.cos(ang), center.dy + r * math.sin(ang));
if (i == 0)
path.moveTo(p.dx, p.dy); path.moveTo(p.dx, p.dy);
} else { else
path.lineTo(p.dx, p.dy); 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 @override
bool shouldRepaint(covariant _WavyArcPainter old) => bool shouldRepaint(covariant _CircularWavyPainter old) =>
old.color != color || value != old.value ||
old.stroke != stroke || active != old.active ||
old.value != value || track != old.track ||
old.waves != waves || rotation != old.rotation;
old.amplitude != amplitude ||
old.phase != phase ||
old.clockwise != clockwise;
} }

View file

@ -1,8 +1,27 @@
enum ProgressM3ESize { small, medium, large } /// Circular sizes driven by outer diameter.
enum ProgressM3EEmphasis { primary, secondary, surface } enum CircularProgressM3ESize { s, m }
enum ProgressM3EDensity { regular, compact }
enum LinearProgressM3EVariant { determinate, indeterminate, buffer, query }
enum ProgressLabelPosition { none, leading, trailing, top, bottom, center }
enum LinearBarShapeM3E { flat, wavy } extension CircularM3ESizeExtension on CircularProgressM3ESize {
enum CircularBarShapeM3E { flat, wavy } 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 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
import '_tokens.dart';
import 'enums.dart'; import 'enums.dart';
import 'tokens_adapter.dart';
class LinearProgressM3E extends StatefulWidget { /// Linear indicator that renders two **separate lanes** (active above, track below)
const LinearProgressM3E({ /// with a fixed vertical gap. Lanes never overlap.
class LinearProgressIndicatorM3E extends StatelessWidget {
const LinearProgressIndicatorM3E({
super.key, super.key,
this.value, this.value, // null => indeterminate; animate phase externally
this.bufferValue, this.size = LinearProgressM3ESize.m,
this.variant = LinearProgressM3EVariant.determinate, this.shape = ProgressM3EShape.wavy,
this.size = ProgressM3ESize.medium, this.activeColor,
this.emphasis = ProgressM3EEmphasis.primary, this.trackColor,
this.density = ProgressM3EDensity.regular, this.phase = 0.0, // radians for wavy animation
this.backgroundColor, this.inset = 4.0, // horizontal left inset
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? value;
final double? bufferValue; final LinearProgressM3ESize size;
final LinearProgressM3EVariant variant; final ProgressM3EShape shape;
final ProgressM3ESize size; final Color? activeColor;
final ProgressM3EEmphasis emphasis; final Color? trackColor;
final ProgressM3EDensity density; final double phase;
final Color? backgroundColor; final double inset;
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tokens = ProgressTokensAdapter(context); final theme = Theme.of(context);
final m = tokens.metrics(widget.density); final m3e =
theme.extension<M3ETheme>() ?? M3ETheme.defaults(theme.colorScheme);
final height = switch (widget.size) { // Farben aus m3e_design beziehen (überschreibbar per Props)
ProgressM3ESize.small => widget.strokeHeight ?? m.linearThicknessSmall, final active = activeColor ?? m3e.colors.primary;
ProgressM3ESize.medium => widget.strokeHeight ?? m.linearThicknessMedium, final track = trackColor ?? m3e.colors.surfaceContainerHighest;
ProgressM3ESize.large => widget.strokeHeight ?? m.linearThicknessLarge,
};
final track = widget.backgroundColor ?? tokens.trackColor(); final spec = specForLinear(size: size, shape: shape);
final progress = widget.progressColor ?? tokens.color(widget.emphasis);
final buffer = widget.bufferColor ?? tokens.bufferColor(progress);
final borderRadius = // Total height = active lane height (trackHeight or wavyHeight) + gap + trackHeight
widget.borderRadius ?? BorderRadius.circular(height / 2); final activeHeight = spec.isWavy
final inset = widget.leftRightInset ?? m.horizontalInset; ? (spec.trackHeight + 2 * spec.waveAmplitude)
: spec.trackHeight;
final totalHeight = activeHeight + spec.gap + spec.trackHeight;
final content = Padding( return RepaintBoundary(
padding: EdgeInsets.symmetric(horizontal: inset),
child: _buildBar(
context, height, borderRadius, track, progress, buffer, tokens),
);
final bar = ClipRRect(
borderRadius: borderRadius,
child: SizedBox( child: SizedBox(
height: height, height: totalHeight,
width: widget.minWidth == double.infinity ? null : widget.minWidth, width: double.infinity,
child: content, child: CustomPaint(
), painter: _LinearPainter(
); value: value,
spec: spec,
if (widget.semanticLabel == null) return bar; active: activeColor ?? active,
return Semantics( track: trackColor ?? track,
label: widget.semanticLabel, phase: phase,
value: (widget.variant == LinearProgressM3EVariant.determinate && inset: inset,
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 { class _LinearPainter extends CustomPainter {
const _WavyIndeterminateBar({ _LinearPainter({
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.value,
required this.height, required this.spec,
required this.wavelength, required this.active,
required this.amplitude,
required this.track, required this.track,
required this.fill,
required this.phase, required this.phase,
required this.indeterminate, required this.inset,
}); });
final double value; final double? value;
final double height; final LinearSpec spec;
final double wavelength; final Color active;
final double amplitude;
final Color track; final Color track;
final Color fill;
final double phase; final double phase;
final bool indeterminate; final double inset;
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
final paintTrack = Paint()..color = track; final left = inset;
final paintFill = Paint()..color = fill; final right = size.width - spec.trailingMargin;
final width = math.max(0.0, right - left);
final r = RRect.fromRectAndRadius( // lane centers: active on top, track on bottom
Offset.zero & Size(size.width, height), Radius.circular(height / 2)); final trackCy = size.height - spec.trackHeight / 2;
canvas.drawRRect(r, paintTrack); 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; // --- Draw track lane (flat pill) ---
final progressW = indeterminate ? w : (w * value.clamp(0.0, 1.0)); final base = Paint()
..style = PaintingStyle.stroke
..strokeWidth = spec.trackHeight
..strokeCap = StrokeCap.round
..isAntiAlias = true;
final centerY = height / 2; canvas.drawLine(
final path = Path()..moveTo(0, height); Offset(left, trackCy), Offset(right, trackCy), base..color = track);
path.lineTo(0, centerY);
final k = 2 * math.pi / wavelength; // --- Active lane ---
final step = 2.0; final double p = (value ?? 0).clamp(0.0, 1.0);
double x = 0; if (spec.isWavy) {
while (x <= progressW) { // wavy centerline
final y = centerY - amplitude * math.sin(k * x + phase); final start = left;
path.lineTo(x, y); final end = value == null ? right : (left + width * p);
x += step; 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 @override
bool shouldRepaint(covariant _WavyPainter old) { bool shouldRepaint(covariant _LinearPainter old) =>
return old.value != value || value != old.value ||
old.height != height || spec != old.spec ||
old.wavelength != wavelength || active != old.active ||
old.amplitude != amplitude || track != old.track ||
old.phase != phase || phase != old.phase ||
old.track != track || inset != old.inset;
old.fill != fill ||
old.indeterminate != indeterminate;
}
} }

View file

@ -1,74 +1,35 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'circular_progress_m3e.dart';
import 'enums.dart'; import 'enums.dart';
import 'tokens_adapter.dart';
import 'linear_progress_m3e.dart';
class ProgressWithLabelM3E extends StatelessWidget { class ProgressWithLabelM3E extends StatelessWidget {
const ProgressWithLabelM3E({ const ProgressWithLabelM3E({
super.key, super.key,
required this.progress, required this.value,
this.position = ProgressLabelPosition.trailing, this.size = CircularProgressM3ESize.m,
this.label,
this.spacing,
this.textStyle, this.textStyle,
}); });
final LinearProgressM3E progress; final double value;
final ProgressLabelPosition position; final CircularProgressM3ESize size;
final Widget? label;
final double? spacing;
final TextStyle? textStyle; final TextStyle? textStyle;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (position == ProgressLabelPosition.none) return progress; final d =
size.diameterWavy; // ProgressWithLabel uses wavy circular by default
final tokens = ProgressTokensAdapter(context); return SizedBox(
final style = textStyle ?? tokens.labelStyle().copyWith( width: d,
color: Theme.of(context).colorScheme.onSurface, 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 name: progress_indicator_m3e
description: Material 3 Expressive Progress Indicator for Flutter (linear + circular; flat + wavy) using M3E tokens. description: "Material 3 Expressive progress indicators."
version: 0.1.0 version: 0.3.0
publish_to: none publish_to: none
environment: environment:
sdk: ">=3.5.0 <4.0.0" sdk: ">=3.3.0 <4.0.0"
flutter: ">=3.22.0" flutter: ">=3.19.0"
dependencies: dependencies:
flutter: 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);
});
}