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

View file

@ -1,3 +1,5 @@
# m3e_collection
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:button_group_m3e/button_group_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:icon_button_m3e/icon_button_m3e.dart';
export 'package:loading_indicator_m3e/loading_indicator_m3e.dart';

View file

@ -10,6 +10,7 @@ dependencies:
flutter:
sdk: flutter
material_new_shapes: ^1.0.0
expressive_refresh: ^0.1.2
m3e_design:
path: ../m3e_design
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';
@immutable
class M3EColors {
// --- Expressive / semantic extras ---------------------------------------
final Color emphasis;
final Color onEmphasis;
final Color info;
@ -13,7 +33,7 @@ class M3EColors {
final Color onSurfaceStrong;
final Color outlineStrong;
// New: proxy common ColorScheme fields used across packages
// --- Core ColorScheme proxies (subset used across packages) --------------
final Color primary;
final Color onPrimary;
final Color primaryContainer;
@ -41,11 +61,41 @@ class M3EColors {
final Color outline;
final Color outlineVariant;
// New: container surface tokens not always present on older ColorScheme
final Color surfaceContainerHigh;
// --- Tone-based surfaces (M3) --------------------------------------------
final Color surfaceDim;
final Color surfaceBright;
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({
// expressive
required this.emphasis,
required this.onEmphasis,
required this.info,
@ -55,7 +105,7 @@ class M3EColors {
required this.surfaceStrong,
required this.onSurfaceStrong,
required this.outlineStrong,
// New fields
// core
required this.primary,
required this.onPrimary,
required this.primaryContainer,
@ -77,34 +127,234 @@ class M3EColors {
required this.onErrorContainer,
required this.outline,
required this.outlineVariant,
required this.surfaceContainerHigh,
// tone-based surfaces
required this.surfaceDim,
required this.surfaceBright,
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) {
// Compute container surface variants if not available on the ColorScheme version in use.
// We prefer mild blends that work in both light/dark.
Color computeSurfaceContainerHigh() =>
Color.alphaBlend(s.primary.withValues(alpha: 0.12), s.surface);
Color computeSurfaceContainerLowest() =>
Color.alphaBlend(s.onSurface.withValues(alpha: 0.05), s.surface);
Color _blend(Color base, Color overlay, double a) =>
Color.alphaBlend(overlay.withValues(alpha: a), base);
// Surface tone fallbacks (approximate, spec-agnostic but pleasant)
Color _containerLow() => _blend(s.surface, s.primary, 0.04);
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(
emphasis: s.primary,
onEmphasis: s.onPrimary,
info: s.tertiary,
success: Color.alphaBlend(
Colors.green.shade400.withValues(alpha: 0.2), s.primaryContainer),
warning: Color.alphaBlend(
Colors.orange.shade400.withValues(alpha: 0.2), s.secondaryContainer),
danger: Color.alphaBlend(
Colors.red.shade400.withValues(alpha: 0.2), s.errorContainer),
surfaceStrong:
Color.alphaBlend(s.primary.withValues(alpha: 0.06), s.surface),
onSurfaceStrong: s.onSurface,
outlineStrong:
Color.alphaBlend(s.primary.withValues(alpha: 0.40), s.outlineVariant),
// New fields mapped from ColorScheme
// expressive
emphasis: emphasis,
onEmphasis: onEmphasis,
info: info,
success: success,
warning: warning,
danger: danger,
surfaceStrong: surfaceStrong,
onSurfaceStrong: onSurfaceStrong,
outlineStrong: outlineStrong,
// core
primary: s.primary,
onPrimary: s.onPrimary,
primaryContainer: s.primaryContainer,
@ -126,25 +376,168 @@ class M3EColors {
onErrorContainer: s.onErrorContainer,
outline: s.outline,
outlineVariant: s.outlineVariant,
surfaceContainerHigh: (() {
// If the ColorScheme already has a matching field, prefer that via dynamic access; otherwise compute.
try {
final dynamic dyn = s;
final c = dyn.surfaceContainerHigh as Color?;
return c ?? computeSurfaceContainerHigh();
} catch (_) {
return computeSurfaceContainerHigh();
// tone-based
surfaceDim: surfaceDim,
surfaceBright: surfaceBright,
surfaceContainerLowest: surfaceContainerLowest,
surfaceContainerLow: surfaceContainerLow,
surfaceContainer: surfaceContainer,
surfaceContainerHigh: surfaceContainerHigh,
surfaceContainerHighest: surfaceContainerHighest,
// inverse & overlays
inverseSurface: inverseSurface,
onInverseSurface: onInverseSurface,
inversePrimary: inversePrimary,
surfaceTint: surfaceTint,
shadow: shadow,
scrim: scrim,
// fixed accents
primaryFixed: primaryFixed,
primaryFixedDim: primaryFixedDim,
onPrimaryFixed: onPrimaryFixed,
onPrimaryFixedVariant: onPrimaryFixedVariant,
secondaryFixed: secondaryFixed,
secondaryFixedDim: secondaryFixedDim,
onSecondaryFixed: onSecondaryFixed,
onSecondaryFixedVariant: onSecondaryFixedVariant,
tertiaryFixed: tertiaryFixed,
tertiaryFixedDim: tertiaryFixedDim,
onTertiaryFixed: onTertiaryFixed,
onTertiaryFixedVariant: onTertiaryFixedVariant,
);
}
})(),
surfaceContainerLowest: (() {
try {
final dynamic dyn = s;
final c = dyn.surfaceContainerLowest as Color?;
return c ?? computeSurfaceContainerLowest();
} catch (_) {
return computeSurfaceContainerLowest();
/// 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)!,
onSurfaceStrong: Color.lerp(a.onSurfaceStrong, b.onSurfaceStrong, t)!,
outlineStrong: Color.lerp(a.outlineStrong, b.outlineStrong, t)!,
// New fields
primary: Color.lerp(a.primary, b.primary, t)!,
onPrimary: Color.lerp(a.onPrimary, b.onPrimary, t)!,
primaryContainer:
@ -188,9 +580,112 @@ class M3EColors {
Color.lerp(a.onErrorContainer, b.onErrorContainer, t)!,
outline: Color.lerp(a.outline, b.outline, t)!,
outlineVariant: Color.lerp(a.outlineVariant, b.outlineVariant, t)!,
surfaceContainerHigh:
Color.lerp(a.surfaceContainerHigh, b.surfaceContainerHigh, t)!,
surfaceDim: Color.lerp(a.surfaceDim, b.surfaceDim, t)!,
surfaceBright: Color.lerp(a.surfaceBright, b.surfaceBright, t)!,
surfaceContainerLowest:
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:
flutter:
sdk: flutter
dynamic_color: ^1.8.1
dev_dependencies:
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
- `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
),
ProgressM3ESize.medium => (
m.circularMedium,
widget.strokeWidth ?? m.strokeMedium
),
ProgressM3ESize.large => (
m.circularLarge,
widget.strokeWidth ?? m.strokeLarge
),
};
final indicator = SizedBox(
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: 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,
child: CustomPaint(
painter: wantsWavy
? _CircularWavyPainter(
value: value,
active: active,
track: track,
rotation: rotation)
: _CircularFlatPainter(
value: value,
active: active,
track: track,
rotation: rotation,
size: size),
),
),
] 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,
),
);
} 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(
height: totalHeight,
width: double.infinity,
child: CustomPaint(
painter: _LinearPainter(
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,
spec: spec,
active: activeColor ?? active,
track: trackColor ?? track,
phase: phase,
indeterminate: true,
inset: inset,
),
),
),
);
}
}
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);
// --- 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);
x += step;
}
path.lineTo(progressW, height);
path.close();
// precise end point
y = activeCy + spec.waveAmplitude * math.sin(phase + (end - start) * k);
path.lineTo(end, y);
canvas.save();
final clip = Path()..addRRect(r);
canvas.clipPath(clip);
canvas.drawPath(path, paintFill);
canvas.restore();
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);
}
}
@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 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(
final d =
size.diameterWavy; // ProgressWithLabel uses wavy circular by default
return SizedBox(
width: d,
height: d,
child: Stack(
alignment: Alignment.center,
children: [
progress,
builtLabel,
CircularProgressIndicatorM3E(value: value, size: size),
Text('${(value * 100).round()}%',
style: textStyle ?? Theme.of(context).textTheme.labelMedium),
],
),
);
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);
});
}