Add initial configuration, tokens, and widgets for M3E components

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

View file

@ -0,0 +1,21 @@
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

@ -0,0 +1,56 @@
# loading_indicator_m3e
Material 3 **Expressive** Loading Indicator for Flutter — a morphing polygon that continuously rotates and morphs between shapes (ported from Android's Material3 `LoadingIndicator`).
Two configurations:
- **Default** — container uses `secondaryContainer`, active indicator uses `primary`
- **Contained** — container uses `primaryContainer`, active indicator uses `onPrimaryContainer`
Token-aligned sizes:
- Container: **48 × 48dp**
- Active indicator size: **38dp**
- Container shape: **full** (pill/circular) corners
## Usage
```dart
import 'package:loading_indicator_m3e/loading_indicator_m3e.dart';
// Default
const LoadingIndicatorM3E();
// Contained
const LoadingIndicatorM3E(variant: LoadingIndicatorM3EVariant.contained);
// Custom colors, custom polygon sequence
LoadingIndicatorM3E(
color: Colors.teal,
polygons: const [
MaterialShapes.sunny,
MaterialShapes.cookie9Sided,
MaterialShapes.pill,
],
);
```
## Notes
- The inner morph sequence and animation timings match the Compose implementation:
- Morph interval ~650ms, global rotation ~4666ms
- Active size is scaled to ~38dp inside the 48dp container to avoid clipping while rotating
- Requires your monorepo `m3e_design` (for tokens) and `material_new_shapes` (for `RoundedPolygon` + `Morph` + `MaterialShapes`). The `pubspec.yaml` is set up with `path: ../...`.
## Monorepo Layout
```
packages/
m3e_design/
material_new_shapes/
loading_indicator_m3e/
```
## Accessibility
Pass `semanticLabel` and `semanticValue` to announce loading status if needed.
## License
- Android/Compose implementation © Google, Apache-2.0
- This package MIT

View file

@ -0,0 +1,6 @@
library loading_indicator_m3e;
export 'src/enums.dart';
export 'src/loading_tokens_adapter.dart' show LoadingTokensAdapter;
export 'src/expressive_loading_indicator.dart';
export 'src/loading_indicator_m3e.dart';

View file

@ -0,0 +1 @@
enum LoadingIndicatorM3EVariant { defaultStyle, contained }

View file

@ -0,0 +1,329 @@
// Port of Android's LoadingIndicator
// Source: androidx/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/LoadingIndicator.kt
// Copyright (c) 2024 The Android Open Source Project
// Licensed under the Apache License, Version 2.0
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'package:flutter/semantics.dart';
import 'package:material_new_shapes/material_new_shapes.dart';
/// A Material Design loading indicator.
///
/// This version of the loading indicator morphs between its [polygons] shapes.
/// ![Loading indicator image](https://developer.android.com/images/reference/androidx/compose/material3/loading-indicator.png)
class ExpressiveLoadingIndicator extends ProgressIndicator {
/// A list of [RoundedPolygon]s for the sequence of shapes this loading indicator
/// will morph between. The loading indicator expects at least two items in that list.
final List<RoundedPolygon>? polygons;
/// Defines minimum and maximum sizes for an [ExpressiveLoadingIndicator].
/// If null, then the [ProgressIndicatorThemeData.constraints] will be used. Otherwise, defaults to a minimum width and height of 48 pixels.
final BoxConstraints? constraints;
const ExpressiveLoadingIndicator({
super.key,
super.color,
this.polygons,
this.constraints,
super.semanticsLabel,
super.semanticsValue,
}) : assert(polygons != null ? polygons.length > 1 : true);
@override
State<ExpressiveLoadingIndicator> createState() =>
_ExpressiveLoadingIndicatorState();
}
class _ExpressiveLoadingIndicatorState extends State<ExpressiveLoadingIndicator>
with TickerProviderStateMixin {
static final List<RoundedPolygon> _defaultPolygons = [
MaterialShapes.softBurst,
MaterialShapes.cookie9Sided,
MaterialShapes.pentagon,
MaterialShapes.pill,
MaterialShapes.sunny,
MaterialShapes.cookie4Sided,
MaterialShapes.oval,
];
static final BoxConstraints _defaultConstraints = BoxConstraints(
minWidth: 48.0,
minHeight: 48.0,
maxWidth: 48.0,
maxHeight: 48.0,
); // default from kotlin source
late final List<RoundedPolygon> _polygons;
static const int _globalRotationDurationMs = 4666;
static const int _morphIntervalMs = 650;
static const double _fullRotation = 360.0;
static const double _quarterRotation = _fullRotation / 4;
static const double _activeSize = 38; // based on source spec
late final List<Morph> _morphSequence;
late final AnimationController _morphController;
late final AnimationController _globalRotationController;
int _currentMorphIndex = 0;
double _morphRotationTargetAngle = _quarterRotation;
Timer? _morphTimer;
final _morphAnimationSpec = SpringSimulation(
SpringDescription.withDampingRatio(ratio: 0.6, stiffness: 200.0, mass: 1.0),
0.0,
1.0,
5.0,
snapToEnd: true,
);
late BoxConstraints _constraints;
late Color _color;
@override
Widget build(BuildContext context) {
final indicatorTheme = ProgressIndicatorTheme.of(context);
_color =
widget.color ??
indicatorTheme.color ??
Theme.of(context).colorScheme.primary;
_constraints =
widget.constraints ?? indicatorTheme.constraints ?? _defaultConstraints;
final activeIndicatorScale =
_activeSize / math.min(_constraints.maxWidth, _constraints.maxHeight);
final shapesScaleFactor =
_calculateScaleFactor(_polygons) * activeIndicatorScale;
return Semantics.fromProperties(
properties: SemanticsProperties(
label: widget.semanticsLabel,
value: widget.semanticsValue,
),
child: RepaintBoundary(
child: ConstrainedBox(
constraints: _constraints,
child: AspectRatio(
aspectRatio: 1.0,
child: AnimatedBuilder(
animation: Listenable.merge([
_morphController,
_globalRotationController,
]),
builder: (context, child) {
final morphProgress = _morphController.value.clamp(0.0, 1.0);
final globalRotationDegrees =
_globalRotationController.value * _fullRotation;
// calculate total rotation (clockwise, matching Kotlin implementation)
final totalRotationDegrees =
morphProgress * _quarterRotation +
_morphRotationTargetAngle +
globalRotationDegrees;
final totalRotationRadians =
totalRotationDegrees * (math.pi / 180.0);
return Transform.rotate(
angle: totalRotationRadians,
child: CustomPaint(
painter: _MorphPainter(
morph: _morphSequence[_currentMorphIndex],
progress: morphProgress,
color: _color,
scaleFactor: shapesScaleFactor,
repaint: Listenable.merge([
_morphController,
_globalRotationController,
]),
),
child: const SizedBox.expand(),
),
);
},
),
),
),
),
);
}
@override
void dispose() {
_morphTimer?.cancel();
_morphController.dispose();
_globalRotationController.dispose();
super.dispose();
}
@override
void initState() {
super.initState();
_polygons = widget.polygons ?? _defaultPolygons;
_morphSequence = _createMorphSequence(_polygons, circularSequence: true);
_morphController = AnimationController.unbounded(vsync: this);
// continuous linear rotation
_globalRotationController = AnimationController(
duration: const Duration(milliseconds: _globalRotationDurationMs),
vsync: this,
);
_startAnimations();
}
List<Morph> _createMorphSequence(
List<RoundedPolygon> polygons, {
required bool circularSequence,
}) {
final morphs = <Morph>[];
for (int i = 0; i < polygons.length; i++) {
if (i + 1 < polygons.length) {
morphs.add(Morph(polygons[i], polygons[i + 1]));
} else if (circularSequence) {
// morph from last shape back to first shape
morphs.add(Morph(polygons[i], polygons[0]));
}
}
return morphs;
}
/// Calculates a scale factor that will be used when scaling the provided [RoundedPolygon]s into a
/// specified sized container.
///
/// Since the polygons may rotate, a simple [RoundedPolygon.calculateBounds] is not enough to
/// determine the size the polygon will occupy as it rotates. Using the simple bounds calculation may
/// result in a clipped shape.
///
/// This function calculates and returns a scale factor by utilizing the
/// [RoundedPolygon.calculateMaxBounds] and comparing its result to the
/// [RoundedPolygon.calculateBounds]. The scale factor can later be used when calling [processPath].
///
/// Port of Kotlin implementation.
double _calculateScaleFactor(List<RoundedPolygon> polygons) {
var scaleFactor = 1.0;
for (final polygon in polygons) {
final bounds = polygon.calculateBounds();
final maxBounds = polygon.calculateMaxBounds();
final boundsWidth = bounds[2] - bounds[0];
final boundsHeight = bounds[3] - bounds[1];
final maxBoundsWidth = maxBounds[2] - maxBounds[0];
final maxBoundsHeight = maxBounds[3] - maxBounds[1];
final scaleX = boundsWidth / maxBoundsWidth;
final scaleY = boundsHeight / maxBoundsHeight;
// We use max(scaleX, scaleY) to handle cases like a pill-shape that can throw off the
// entire calculation.
scaleFactor = math.min(scaleFactor, math.max(scaleX, scaleY));
}
return scaleFactor;
}
void _startAnimations() {
// infinite global rotation
_globalRotationController.repeat();
// periodic morph cycle
_morphTimer = Timer.periodic(
const Duration(milliseconds: _morphIntervalMs),
(_) => _startMorphCycle(),
);
_startMorphCycle();
}
void _startMorphCycle() {
if (!mounted) return;
// move to next morph in sequence
_currentMorphIndex = (_currentMorphIndex + 1) % _morphSequence.length;
// accumulate rotation target
_morphRotationTargetAngle =
(_morphRotationTargetAngle + _quarterRotation) % _fullRotation;
// Reset and start morph animation
_morphController
..value = 0.0
..animateWith(_morphAnimationSpec);
}
}
class _MorphPainter extends CustomPainter {
final Morph morph;
final double progress;
final Color color;
/// A scale factor that will be taken into account uniformly when the [path] is
/// scaled (i.e. the scaleX would be the [size] width x the scale factor, and the scaleY would be
/// the [size] height x the scale factor)
final double scaleFactor;
_MorphPainter({
required this.morph,
required this.progress,
required this.color,
this.scaleFactor = 1.0,
super.repaint,
});
@override
void paint(Canvas canvas, Size size) {
final path = morph.toPath(progress: progress);
final processedPath = _processPath(path, size);
canvas.drawPath(
processedPath,
Paint()
..style = PaintingStyle.fill
..color = color,
);
}
@override
bool shouldRepaint(_MorphPainter oldDelegate) {
return oldDelegate.morph != morph ||
oldDelegate.progress != progress ||
oldDelegate.color != color ||
oldDelegate.scaleFactor != scaleFactor;
}
/// Process a given path to scale it and center it inside the given size.
///
/// [path] takes a [Path] that was generated by a _normalized_ [Morph] or [RoundedPolygon].
/// [size] takes a [Size] that the provided [path] is going to be scaled and centered into.
Path _processPath(Path path, Size size) {
// a [Matrix] that would be used to apply the scaling. Note that any provided
// matrix will be reset in this function.
final Matrix4 scaleMatrix = Matrix4.diagonal3Values(
size.width * scaleFactor,
size.height * scaleFactor,
1,
);
final Path scaledPath = path.transform(scaleMatrix.storage);
// Translate the path so that its center aligns with the center of the container.
final Rect bounds = scaledPath.getBounds();
final Offset translation =
Offset(size.width / 2, size.height / 2) - bounds.center;
final Path finalPath = scaledPath.shift(translation);
return finalPath;
}
}

View file

@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:material_new_shapes/material_new_shapes.dart';
import 'expressive_loading_indicator.dart';
import 'loading_tokens_adapter.dart';
import 'enums.dart';
/// Material 3 Expressive Loading Indicator
/// - Default: floating morphing shape on surface
/// - Contained: icon inside colored container (primary container) using onPrimaryContainer
class LoadingIndicatorM3E extends StatelessWidget {
const LoadingIndicatorM3E({
super.key,
this.variant = LoadingIndicatorM3EVariant.defaultStyle,
this.color,
this.containerColor,
this.polygons,
this.constraints,
this.padding,
this.semanticLabel,
this.semanticValue,
});
final LoadingIndicatorM3EVariant variant;
final Color? color;
final Color? containerColor;
final List<RoundedPolygon>? polygons;
final BoxConstraints? constraints;
final EdgeInsetsGeometry? padding;
final String? semanticLabel;
final String? semanticValue;
@override
Widget build(BuildContext context) {
final tokens = LoadingTokensAdapter(context);
final size = Size(tokens.containerWidth(), tokens.containerHeight());
final cons = constraints ?? BoxConstraints.tight(size);
final activeColor = switch (variant) {
LoadingIndicatorM3EVariant.defaultStyle => color ?? tokens.activeColor(),
LoadingIndicatorM3EVariant.contained => color ?? tokens.containedActiveColor(),
};
final containerBg = switch (variant) {
LoadingIndicatorM3EVariant.defaultStyle => containerColor ?? tokens.containerColorDefault(),
LoadingIndicatorM3EVariant.contained => containerColor ?? tokens.containedContainerColor(),
};
final indicator = ExpressiveLoadingIndicator(
color: activeColor,
polygons: polygons,
semanticsLabel: semanticLabel,
semanticsValue: semanticValue,
constraints: cons,
);
if (variant == LoadingIndicatorM3EVariant.defaultStyle) {
// Default: subtle container (secondaryContainer)
return DecoratedBox(
decoration: BoxDecoration(
color: containerBg,
borderRadius: tokens.containerRadius(),
),
child: Padding(
padding: padding ?? const EdgeInsets.all(0),
child: indicator,
),
);
}
// Contained: stronger container (primaryContainer) and contrasting active indicator
return DecoratedBox(
decoration: BoxDecoration(
color: containerBg,
borderRadius: tokens.containerRadius(),
),
child: Padding(
padding: padding ?? const EdgeInsets.all(0),
child: indicator,
),
);
}
}

View file

@ -0,0 +1,31 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
@immutable
class LoadingTokensAdapter {
const LoadingTokensAdapter(this.context);
final BuildContext context;
M3ETheme get _m3e {
final t = Theme.of(context);
return t.extension<M3ETheme>() ?? M3ETheme.defaults(t.colorScheme);
}
// Active indicator color (Default variant)
Color activeColor() => _m3e.colors.primary;
// Container color (Default variant -> transparent background)
Color containerColorDefault() => Colors.transparent;
// Contained variant colors
Color containedContainerColor() => _m3e.colors.primaryContainer;
Color containedActiveColor() => _m3e.colors.onPrimaryContainer;
// Size tokens (from spec)
double containerWidth() => 48; // container height/width
double containerHeight() => 48;
double activeIndicatorSize() => 38;
// Shape: full corners
BorderRadius containerRadius() => BorderRadius.circular(999);
}

View file

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.dart_tool" />
<excludeFolder url="file://$MODULE_DIR$/.idea" />
<excludeFolder url="file://$MODULE_DIR$/.pub" />
<excludeFolder url="file://$MODULE_DIR$/build" />
<excludeFolder url="file://$MODULE_DIR$/example/.dart_tool" />
<excludeFolder url="file://$MODULE_DIR$/example/.idea" />
<excludeFolder url="file://$MODULE_DIR$/example/.pub" />
<excludeFolder url="file://$MODULE_DIR$/example/build" />
<excludeFolder url="file://$MODULE_DIR$/example/android/.gradle" />
<excludeFolder url="file://$MODULE_DIR$/example/android/.idea" />
<excludeFolder url="file://$MODULE_DIR$/example/ios/Flutter" />
<excludeFolder url="file://$MODULE_DIR$/example/ios/Pods" />
<excludeFolder url="file://$MODULE_DIR$/example/ios/.symlinks" />
<excludeFolder url="file://$MODULE_DIR$/example/macos/Flutter" />
<excludeFolder url="file://$MODULE_DIR$/example/macos/Pods" />
<excludeFolder url="file://$MODULE_DIR$/example/macos/.symlinks" />
</content>
<orderEntry type="sourceFolder" forTests="false" />
<orderEntry type="library" name="Dart SDK" level="project" />
<orderEntry type="library" name="Flutter Plugins" level="project" />
<orderEntry type="library" name="Dart Packages" level="project" />
</component>
</module>

View file

@ -0,0 +1,19 @@
name: loading_indicator_m3e
description: Material 3 Expressive Loading Indicator (morphing polygons) for Flutter, with Default and Contained variants.
version: 0.1.0
publish_to: none
environment:
sdk: ">=3.5.0 <4.0.0"
flutter: ">=3.22.0"
dependencies:
flutter:
sdk: flutter
m3e_design:
path: ../m3e_design
material_new_shapes: ^1.0.0
dev_dependencies:
flutter_test:
sdk: flutter

View file

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