Add initial configuration, tokens, and widgets for M3E components

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

View file

@ -0,0 +1,7 @@
library slider_m3e;
export 'src/enums.dart';
export 'src/slider_tokens_adapter.dart' show SliderTokensAdapter;
export 'src/slider_theme_m3e.dart';
export 'src/slider_m3e.dart';
export 'src/range_slider_m3e.dart';

View file

@ -0,0 +1,4 @@
enum SliderM3ESize { small, medium, large }
enum SliderM3EEmphasis { primary, secondary, surface }
enum SliderM3EShapeFamily { round, square }
enum SliderM3EDensity { regular, compact }

View file

@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'slider_theme_m3e.dart';
import 'enums.dart';
class RangeSliderM3E extends StatelessWidget {
const RangeSliderM3E({
super.key,
required this.values,
required this.onChanged,
this.onChangeStart,
this.onChangeEnd,
this.min = 0.0,
this.max = 1.0,
this.divisions,
this.labels,
this.semanticLabel,
this.size = SliderM3ESize.medium,
this.emphasis = SliderM3EEmphasis.primary,
this.shapeFamily = SliderM3EShapeFamily.round,
this.density = SliderM3EDensity.regular,
this.showValueIndicator,
});
final RangeValues values;
final ValueChanged<RangeValues>? onChanged;
final ValueChanged<RangeValues>? onChangeStart;
final ValueChanged<RangeValues>? onChangeEnd;
final double min;
final double max;
final int? divisions;
final RangeLabels? labels;
final String? semanticLabel;
final SliderM3ESize size;
final SliderM3EEmphasis emphasis;
final SliderM3EShapeFamily shapeFamily;
final SliderM3EDensity density;
final bool? showValueIndicator;
@override
Widget build(BuildContext context) {
final theme = sliderThemeM3E(
context,
size: size,
emphasis: emphasis,
shapeFamily: shapeFamily,
density: density,
showValueIndicator: showValueIndicator ?? false,
);
return SliderTheme(
data: theme,
child: RangeSlider(
values: RangeValues(
values.start.clamp(min, max),
values.end.clamp(min, max),
),
onChanged: onChanged,
onChangeStart: onChangeStart,
onChangeEnd: onChangeEnd,
min: min,
max: max,
divisions: divisions,
labels: labels,
semanticFormatterCallback: semanticLabel != null
? (v) => '$semanticLabel ${(100 * ((v - min) / (max - min))).toStringAsFixed(0)}%'
: null,
),
);
}
}

View file

@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
import 'slider_theme_m3e.dart';
import 'enums.dart';
class SliderM3E extends StatelessWidget {
const SliderM3E({
super.key,
required this.value,
required this.onChanged,
this.onChangeStart,
this.onChangeEnd,
this.min = 0.0,
this.max = 1.0,
this.divisions,
this.label,
this.semanticLabel,
this.size = SliderM3ESize.medium,
this.emphasis = SliderM3EEmphasis.primary,
this.shapeFamily = SliderM3EShapeFamily.round,
this.density = SliderM3EDensity.regular,
this.showValueIndicator,
this.startIcon,
this.endIcon,
});
final double value;
final ValueChanged<double>? onChanged;
final ValueChanged<double>? onChangeStart;
final ValueChanged<double>? onChangeEnd;
final double min;
final double max;
final int? divisions;
final String? label;
final String? semanticLabel;
final SliderM3ESize size;
final SliderM3EEmphasis emphasis;
final SliderM3EShapeFamily shapeFamily;
final SliderM3EDensity density;
final bool? showValueIndicator;
final Widget? startIcon;
final Widget? endIcon;
@override
Widget build(BuildContext context) {
final theme = sliderThemeM3E(
context,
size: size,
emphasis: emphasis,
shapeFamily: shapeFamily,
density: density,
showValueIndicator: showValueIndicator ?? false,
);
final slider = Slider(
value: value.clamp(min, max),
onChanged: onChanged,
onChangeStart: onChangeStart,
onChangeEnd: onChangeEnd,
min: min,
max: max,
divisions: divisions,
label: label,
semanticFormatterCallback: semanticLabel != null
? (v) => '$semanticLabel ${(100 * ((v - min) / (max - min))).toStringAsFixed(0)}%'
: null,
);
if (startIcon == null && endIcon == null) {
return SliderTheme(data: theme, child: slider);
}
return SliderTheme(
data: theme,
child: Row(
children: [
if (startIcon != null) ...[startIcon!, const SizedBox(width: 8)],
Expanded(child: slider),
if (endIcon != null) ...[const SizedBox(width: 8), endIcon!],
],
),
);
}
}

View file

@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
class SliderM3EWidget extends StatelessWidget {
const SliderM3EWidget({super.key});
@override
Widget build(BuildContext context) {
final m3e = context.m3e;
return Container(
padding: EdgeInsets.all(m3e.spacing.md),
decoration: BoxDecoration(
color: m3e.colors.surfaceStrong,
borderRadius: m3e.shapes.square.md,
),
child: Text('Slider placeholder', style: m3e.typography.base.titleMedium),
);
}
}
String _pascal(String s) => s.split('_').map((p) => p.isEmpty ? '' : (p[0].toUpperCase() + p.substring(1))).join();

View file

@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'slider_tokens_adapter.dart';
import 'enums.dart';
SliderThemeData sliderThemeM3E(
BuildContext context, {
SliderM3ESize size = SliderM3ESize.medium,
SliderM3EEmphasis emphasis = SliderM3EEmphasis.primary,
SliderM3EShapeFamily shapeFamily = SliderM3EShapeFamily.round,
SliderM3EDensity density = SliderM3EDensity.regular,
bool showValueIndicator = false,
}) {
final t = SliderTokensAdapter(context);
final m = t.metrics(density);
final trackHeight = switch (size) {
SliderM3ESize.small => m.trackSmall,
SliderM3ESize.medium => m.trackMedium,
SliderM3ESize.large => m.trackLarge,
};
final thumbRadius = switch (size) {
SliderM3ESize.small => m.thumbSmall,
SliderM3ESize.medium => m.thumbMedium,
SliderM3ESize.large => m.thumbLarge,
};
final thumbShape = shapeFamily == SliderM3EShapeFamily.round
? RoundSliderThumbShape(enabledThumbRadius: thumbRadius)
: _SquareThumbShape(side: thumbRadius * 2);
return SliderTheme.of(context).copyWith(
trackHeight: trackHeight,
activeTrackColor: t.activeColor(emphasis),
inactiveTrackColor: t.inactiveColor(),
disabledActiveTrackColor: t.inactiveColor(),
disabledInactiveTrackColor: t.inactiveColor(),
activeTickMarkColor: t.tickColorActive(emphasis),
inactiveTickMarkColor: t.tickColorInactive(),
thumbColor: t.thumbColor(emphasis),
disabledThumbColor: t.inactiveColor(),
overlayColor: t.overlayColor(emphasis),
valueIndicatorColor: t.valueIndicatorColor(),
valueIndicatorTextStyle: t.valueIndicatorTextStyle(),
showValueIndicator: showValueIndicator ? ShowValueIndicator.onDrag : ShowValueIndicator.onlyForDiscrete,
thumbShape: thumbShape,
overlayShape: RoundSliderOverlayShape(overlayRadius: m.overlayRadius),
rangeThumbShape: shapeFamily == SliderM3EShapeFamily.round
? const RoundRangeSliderThumbShape()
: const _SquareRangeThumbShape(),
rangeTrackShape: const RoundedRectRangeSliderTrackShape(),
rangeValueIndicatorShape: const PaddleRangeSliderValueIndicatorShape(),
valueIndicatorShape: const PaddleSliderValueIndicatorShape(),
);
}
class _SquareThumbShape extends SliderComponentShape {
const _SquareThumbShape({required this.side});
final double side;
@override
Size getPreferredSize(bool isEnabled, bool isDiscrete) => Size.square(side);
@override
void paint(
PaintingContext context,
Offset center, {
required Animation<double> activationAnimation,
required Animation<double> enableAnimation,
required bool isDiscrete,
required TextPainter? labelPainter,
required RenderBox parentBox,
required SliderThemeData sliderTheme,
required TextDirection textDirection,
required double value,
required double textScaleFactor,
required Size sizeWithOverflow,
}) {
final canvas = context.canvas;
final rect = Rect.fromCenter(center: center, width: side, height: side);
final rrect = RRect.fromRectAndRadius(rect, const Radius.circular(4));
final paint = Paint()..color = sliderTheme.thumbColor ?? Colors.blue;
canvas.drawRRect(rrect, paint);
}
}
class _SquareRangeThumbShape extends RangeSliderThumbShape {
const _SquareRangeThumbShape();
@override
Size getPreferredSize(bool isEnabled, bool isDiscrete) => const Size(24, 24);
@override
void paint(
PaintingContext context,
Offset center, {
required Animation<double> activationAnimation,
required Animation<double> enableAnimation,
bool isDiscrete = false,
bool isEnabled = true,
bool isOnTop = false,
bool isPressed = false,
required SliderThemeData sliderTheme,
TextDirection textDirection = TextDirection.ltr,
Thumb thumb = Thumb.start,
}) {
final canvas = context.canvas;
final side = 24.0;
final rect = Rect.fromCenter(center: center, width: side, height: side);
final rrect = RRect.fromRectAndRadius(rect, const Radius.circular(4));
final paint = Paint()..color = sliderTheme.thumbColor ?? Colors.blue;
canvas.drawRRect(rrect, paint);
}
}

View file

@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:m3e_design/m3e_design.dart';
import 'enums.dart';
@immutable
class _SliderMetrics {
final double trackSmall;
final double trackMedium;
final double trackLarge;
final double thumbSmall;
final double thumbMedium;
final double thumbLarge;
final double overlayRadius;
final double tickRadius;
const _SliderMetrics({
required this.trackSmall,
required this.trackMedium,
required this.trackLarge,
required this.thumbSmall,
required this.thumbMedium,
required this.thumbLarge,
required this.overlayRadius,
required this.tickRadius,
});
}
_SliderMetrics _metricsFor(BuildContext context, SliderM3EDensity density) {
// Based on M3 defaults with a slightly more expressive large option.
double trS = 2, trM = 4, trL = 6;
double thS = 10, thM = 12, thL = 14;
double overlay = 20, tick = 2;
if (density == SliderM3EDensity.compact) {
trS -= 0.5; trM -= 0.5; trL -= 1.0;
thS -= 1; thM -= 1; thL -= 2;
overlay -= 2;
}
return _SliderMetrics(
trackSmall: trS,
trackMedium: trM,
trackLarge: trL,
thumbSmall: thS,
thumbMedium: thM,
thumbLarge: thL,
overlayRadius: overlay,
tickRadius: tick,
);
}
class SliderTokensAdapter {
SliderTokensAdapter(this.context);
final BuildContext context;
M3ETheme get _m3e {
final t = Theme.of(context);
return t.extension<M3ETheme>() ?? M3ETheme.defaults(t.colorScheme);
}
_SliderMetrics metrics(SliderM3EDensity density) => _metricsFor(context, density);
// Colors
Color activeColor(SliderM3EEmphasis e) {
switch (e) {
case SliderM3EEmphasis.primary: return _m3e.colors.primary;
case SliderM3EEmphasis.secondary: return _m3e.colors.secondary;
case SliderM3EEmphasis.surface: return _m3e.colors.onSurface;
}
}
Color inactiveColor() => _m3e.colors.onSurface.withValues(alpha: 0.24);
Color tickColorActive(SliderM3EEmphasis e) => activeColor(e).withValues(alpha: 0.9);
Color tickColorInactive() => _m3e.colors.onSurface.withValues(alpha: 0.38);
Color thumbColor(SliderM3EEmphasis e) => activeColor(e);
Color overlayColor(SliderM3EEmphasis e) => activeColor(e).withValues(alpha: 0.12);
Color valueIndicatorColor() => _m3e.colors.secondaryContainer;
TextStyle valueIndicatorTextStyle() => _m3e.type.labelSmall.copyWith(color: _m3e.colors.onSecondaryContainer);
// Shapes
OutlinedBorder containerShape(SliderM3EShapeFamily family) {
final set = family == SliderM3EShapeFamily.round ? _m3e.shapes.round : _m3e.shapes.square;
return RoundedRectangleBorder(borderRadius: set.md);
}
}