material_design 1.8.0 copy "material_design: ^1.8.0" to clipboard
material_design: ^1.8.0 copied to clipboard

A Material Design 3 contract for Flutter. Enforces M3 tokens system-wide, replacing free primitives with type-safe M3 equivalents.

example/lib/main.dart

// Material Design 3 contract — the whole public API in one file.
//
// This example is deliberately a single file so you can read it top to bottom
// and copy any section straight into your app. The sections follow the same
// order as the README's "API tour":
//
//   1. Spacing & layout        6. Interaction states & focus
//   2. Shape & borders         7. Motion
//   3. Elevation & surfaces    8. Adaptive & responsive
//   4. Typography              9. Accessibility
//   5. Color                  10. M3 Expressive (experimental)
//   5b. Schemes & contrast    11. Breaking the contract (M3Contract)
//
// Live version of everything here: https://fluttely.github.io/material_design/
//
// Section 10 uses the M3 Expressive module, whose API is marked @experimental
// because Material is still iterating on it upstream. Opting in is a decision,
// so the analyzer warns — this file acknowledges it once, deliberately:
// ignore_for_file: experimental_member_use

import 'package:flutter/material.dart';
import 'package:material_design/material_design.dart';

void main() => runApp(const ExampleApp());

class ExampleApp extends StatefulWidget {
  const ExampleApp({super.key});

  @override
  State<ExampleApp> createState() => _ExampleAppState();
}

class _ExampleAppState extends State<ExampleApp> {
  ThemeMode _mode = ThemeMode.light;

  ThemeData _theme(Brightness brightness) {
    // M3TextTheme.applyToTheme MERGES the 15 M3 text styles into the theme,
    // preserving the brightness-resolved colors the theme already computed.
    return M3TextTheme.applyToTheme(
      ThemeData(
        useMaterial3: true,
        colorScheme: ColorScheme.fromSeed(
          seedColor: const Color(0xFF6750A4),
          brightness: brightness,
        ),
        visualDensity: M3VisualDensity.standard,
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'material_design example',
      debugShowCheckedModeBanner: false,
      theme: _theme(Brightness.light),
      darkTheme: _theme(Brightness.dark),
      themeMode: _mode,
      home: ExampleHomePage(
        onToggleTheme: () => setState(() {
          _mode = _mode == ThemeMode.light ? ThemeMode.dark : ThemeMode.light;
        }),
      ),
    );
  }
}

class ExampleHomePage extends StatelessWidget {
  const ExampleHomePage({required this.onToggleTheme, super.key});

  final VoidCallback onToggleTheme;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Material Design 3 — design contract'),
        actions: [
          IconButton(
            onPressed: onToggleTheme,
            icon: const Icon(Icons.brightness_6),
            tooltip: 'Toggle light/dark',
          ),
        ],
      ),
      body: ListView(
        // The page margin is itself a token: 16dp on compact screens.
        padding: const M3EdgeInsets.all(M3Margins.compactScreen),
        children: const [
          _SpacingSection(),
          _ShapeSection(),
          _ElevationSection(),
          _TypographySection(),
          _IconSection(),
          _ColorSection(),
          _SchemeSection(),
          _InteractionSection(),
          _MotionSection(),
          _AdaptiveSection(),
          _ComponentTokensSection(),
          _AccessibilitySection(),
          _ExpressiveSection(),
          _ContractSection(),
        ],
      ),
    );
  }
}

/// Shared section scaffold — a Card shaped, padded, and spaced by tokens only.
class _Section extends StatelessWidget {
  const _Section({required this.title, required this.children});

  final String title;
  final List<Widget> children;

  @override
  Widget build(BuildContext context) {
    return Card(
      shape: M3Shape.medium, // 12dp — the M3 card shape
      margin: const M3EdgeInsets.only(bottom: M3Spacings.s16),
      child: M3Padding(
        padding: M3EdgeInsetsPatterns.card, // 16dp all around
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(title, style: M3TypeScale.titleLarge),
            const M3Gap(M3Spacings.s16),
            ...children,
          ],
        ),
      ),
    );
  }
}

// ═════════════════════════════════════════════════════════════════════════
// 1. SPACING & LAYOUT — M3Spacings, M3Margins, M3Spacers, M3EdgeInsets,
//                       M3Padding, M3Gap
// ═════════════════════════════════════════════════════════════════════════

class _SpacingSection extends StatelessWidget {
  const _SpacingSection();

  @override
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;

    return _Section(
      title: '1. Spacing & layout',
      children: [
        // Every stop of the 4dp grid, straight from the scale's `values` list.
        for (final spacing in const [
          M3Spacings.s4,
          M3Spacings.s8,
          M3Spacings.s16,
          M3Spacings.s24,
          M3Spacings.s32,
          M3Spacings.s48,
        ])
          M3Padding(
            padding: const M3EdgeInsets.only(bottom: M3Spacings.s4),
            child: Row(
              children: [
                SizedBox(
                  width: M3Spacings.s48,
                  child: Text('${spacing.toInt()}dp',
                      style: M3TypeScale.labelMedium),
                ),
                Container(
                  width: spacing, // M3SpacingValue IS a double — no unwrap
                  height: M3Spacings.s16,
                  color: colorScheme.primary,
                ),
              ],
            ),
          ),
        const M3Gap(M3Spacings.s16), // vertical inside this Column
        Text(
          'M3Gap detects its orientation from the surrounding Row/Column. '
          'M3GapUtils.addGaps interleaves a whole list:',
          style: M3TypeScale.bodyMedium,
        ),
        const M3Gap(M3Spacings.s8),
        Row(
          children: M3GapUtils.addGaps(
            [
              const Chip(label: Text('one')),
              const Chip(label: Text('two')),
              const Chip(label: Text('three')),
            ],
            M3Spacings.s8,
          ),
        ),
        const M3Gap(M3Spacings.s16),
        // Layout-level spacing is its own pair of scales: the page margin the
        // spec prescribes per window size class, and the gap between panes.
        Text(
          'Page margin: ${M3Margins.compactScreen.toInt()}dp on compact, '
          '${M3Margins.mediumScreen.toInt()}dp elsewhere. '
          'Gap between panes: ${M3Spacers.pane.toInt()}dp.',
          style: M3TypeScale.bodyMedium,
        ),
      ],
    );
  }
}

// ═════════════════════════════════════════════════════════════════════════
// 2. SHAPE & BORDERS — M3Shape, M3BorderRadius, M3Border, M3BorderWidths
// ═════════════════════════════════════════════════════════════════════════

class _ShapeSection extends StatelessWidget {
  const _ShapeSection();

  // M3Corners is the raw corner scale; M3BorderRadius applies it. Pairing them
  // here means the dp labels below are read off the token, never retyped.
  static const _corners = <(String, M3CornerValue, M3BorderRadius)>[
    ('none', M3Corners.none, M3BorderRadius.none),
    ('xs', M3Corners.extraSmall, M3BorderRadius.extraSmall),
    ('sm', M3Corners.small, M3BorderRadius.small),
    ('md', M3Corners.medium, M3BorderRadius.medium),
    ('lg', M3Corners.large, M3BorderRadius.large),
    ('xl', M3Corners.extraLarge, M3BorderRadius.extraLarge),
    ('full', M3Corners.full, M3BorderRadius.full),
  ];

  @override
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;

    return _Section(
      title: '2. Shape & borders',
      children: [
        Text('The shape scale has exactly seven stops:',
            style: M3TypeScale.bodyMedium),
        const M3Gap(M3Spacings.s8),
        Wrap(
          spacing: M3Spacings.s8,
          runSpacing: M3Spacings.s8,
          children: [
            for (final (label, corner, radius) in _corners)
              Container(
                width: M3Spacings.s64,
                height: M3Spacings.s48,
                alignment: Alignment.center,
                decoration: M3BoxDecoration(
                  color: colorScheme.surfaceContainerHighest,
                  borderRadius: radius,
                ),
                child: Text(
                  // `full` is a 9999dp sentinel, so it shows as a pill instead.
                  corner == M3Corners.full ? label : '$label ${corner.toInt()}',
                  style: M3TypeScale.labelSmall,
                ),
              ),
          ],
        ),
        const M3Gap(M3Spacings.s16),
        Text('Border widths are a scale too (0 / 1 / 2 / 4 dp):',
            style: M3TypeScale.bodyMedium),
        const M3Gap(M3Spacings.s8),
        Row(
          children: M3GapUtils.addGaps(
            [
              for (final (label, border) in <(String, M3Border)>[
                ('thin', M3Border.thin(colorScheme.outline)),
                ('thick', M3Border.thick(colorScheme.outline)),
                ('extraThick', M3Border.extraThick(colorScheme.outline)),
              ])
                Expanded(
                  child: Container(
                    padding: const M3EdgeInsets.all(M3Spacings.s12),
                    alignment: Alignment.center,
                    decoration: M3BoxDecoration(
                      border: border,
                      borderRadius: M3BorderRadius.small,
                    ),
                    child: Text(label, style: M3TypeScale.labelMedium),
                  ),
                ),
            ],
            M3Spacings.s8,
          ),
        ),
      ],
    );
  }
}

// ═════════════════════════════════════════════════════════════════════════
// 3. ELEVATION & SURFACES — M3Elevation, M3ElevationDps, M3ElevationShadows,
//                           M3ZIndexes, surface tint
// ═════════════════════════════════════════════════════════════════════════

class _ElevationSection extends StatelessWidget {
  const _ElevationSection();

  @override
  Widget build(BuildContext context) {
    return _Section(
      title: '3. Elevation & surfaces',
      children: [
        Text(
          'Each level carries its dp AND its shadows; the surface color is '
          'computed with the official tint formula.',
          style: M3TypeScale.bodyMedium,
        ),
        const M3Gap(M3Spacings.s8),
        Wrap(
          spacing: M3Spacings.s12,
          runSpacing: M3Spacings.s12,
          children: [
            for (final level in M3Elevation.values)
              Container(
                width: M3Spacings.s96,
                height: M3Spacings.s64,
                alignment: Alignment.center,
                decoration: M3BoxDecoration(
                  color: level.surfaceColor(context),
                  borderRadius: M3BorderRadius.medium,
                  boxShadow: level.shadows,
                ),
                child: Text(
                  'level ${level.index}\n${level.dp.toInt()}dp',
                  textAlign: TextAlign.center,
                  style: M3TypeScale.labelMedium,
                ),
              ),
          ],
        ),
        const M3Gap(M3Spacings.s16),
        // The two halves of M3Elevation are also available on their own:
        // M3ElevationDps when you need the number, M3ElevationShadows when you
        // need the shadow list without the composite token.
        Text(
          'Levels in dp: '
          '${M3ElevationDps.values.map((dp) => dp.toInt()).join(' · ')}. '
          'Shadow lists alone: M3ElevationShadows.level0…5.',
          style: M3TypeScale.bodyMedium,
        ),
        const M3Gap(M3Spacings.s16),
        // Elevation is depth; z-index is paint order. M3ZIndexes names the
        // stacking layers so a Stack never invents its own integers.
        Text(
          'Stacking order — M3ZIndexes: '
          'content ${M3ZIndexes.content} · '
          'floating ${M3ZIndexes.floating} · '
          'drawer ${M3ZIndexes.drawer} · '
          'modal ${M3ZIndexes.modal} · '
          'snackbar ${M3ZIndexes.snackbar} · '
          'tooltip ${M3ZIndexes.tooltip}.',
          style: M3TypeScale.bodyMedium,
        ),
      ],
    );
  }
}

// ═════════════════════════════════════════════════════════════════════════
// 4. TYPOGRAPHY — M3TypeScale (15 styles), M3TextUtils
// ═════════════════════════════════════════════════════════════════════════

class _TypographySection extends StatelessWidget {
  const _TypographySection();

  static const _styles = <(String, TextStyle)>[
    ('displaySmall 36', M3TypeScale.displaySmall),
    ('headlineSmall 24', M3TypeScale.headlineSmall),
    ('titleLarge 22', M3TypeScale.titleLarge),
    ('titleMedium 16', M3TypeScale.titleMedium),
    ('bodyLarge 16', M3TypeScale.bodyLarge),
    ('bodyMedium 14', M3TypeScale.bodyMedium),
    ('labelLarge 14', M3TypeScale.labelLarge),
    ('labelSmall 11', M3TypeScale.labelSmall),
  ];

  @override
  Widget build(BuildContext context) {
    return _Section(
      title: '4. Typography',
      children: [
        for (final (name, style) in _styles) Text(name, style: style),
        const M3Gap(M3Spacings.s16),
        Text(
          'Emphasized counterparts keep size and line height, so swapping one '
          'in never reflows the layout — only the weight changes:',
          style: M3TypeScale.bodyMedium,
        ),
        const M3Gap(M3Spacings.s8),
        for (final (name, style) in _styles.take(4))
          Row(
            children: [
              Expanded(child: Text(name, style: style)),
              Expanded(
                child: Text(
                  name,
                  style: M3EmphasizedTypeScale.of(style),
                ),
              ),
            ],
          ),
        const M3Gap(M3Spacings.s16),
        Text(
          'M3TextUtils.mono — code-friendly variant of any style',
          style: M3TextUtils.mono(M3TypeScale.bodyMedium),
        ),
        Text(
          'M3TextUtils.dyslexiaFriendly — wider tracking, taller lines',
          style: M3TextUtils.dyslexiaFriendly(M3TypeScale.bodyMedium),
        ),
      ],
    );
  }
}

// ═════════════════════════════════════════════════════════════════════════
// 4b. ICONS — M3IconSizes and the four Material Symbols axes, M3IconStyle
// ═════════════════════════════════════════════════════════════════════════

class _IconSection extends StatelessWidget {
  const _IconSection();

  @override
  Widget build(BuildContext context) {
    return _Section(
      title: '4b. Icons',
      children: [
        Text(
          'The size scale, each icon at the optical size that matches it:',
          style: M3TypeScale.bodyMedium,
        ),
        const M3Gap(M3Spacings.s8),
        Row(
          crossAxisAlignment: CrossAxisAlignment.end,
          children: [
            for (final size in M3IconSizes.values)
              M3Padding(
                padding: const M3EdgeInsets.only(right: M3Spacings.s8),
                child: Icon(
                  Icons.favorite,
                  size: size,
                  opticalSize: M3IconOpticalSizes.forIconSize(size),
                ),
              ),
          ],
        ),
        const M3Gap(M3Spacings.s16),
        Text(
          'The weight axis — stroke thickness without a change of footprint. '
          'The axes are rendered by the Material Symbols variable font; with '
          "Flutter's bundled (static) Icons font the values are inert, but "
          'they are still the values to pass:',
          style: M3TypeScale.bodyMedium,
        ),
        const M3Gap(M3Spacings.s8),
        Row(
          children: [
            for (final weight in M3IconWeights.values)
              M3Padding(
                padding: const M3EdgeInsets.only(right: M3Spacings.s8),
                child: Icon(
                  Icons.settings,
                  size: M3IconSizes.medium,
                  weight: weight,
                ),
              ),
          ],
        ),
        const M3Gap(M3Spacings.s16),
        Text(
          'Fill is a state, not a style — M3 marks the active destination by '
          'moving along the axis rather than swapping the glyph:',
          style: M3TypeScale.bodyMedium,
        ),
        const M3Gap(M3Spacings.s8),
        Row(
          children: [
            for (final fill in M3IconFills.values)
              M3Padding(
                padding: const M3EdgeInsets.only(right: M3Spacings.s8),
                child: Icon(
                  Icons.star,
                  size: M3IconSizes.medium,
                  fill: fill,
                ),
              ),
          ],
        ),
        const M3Gap(M3Spacings.s16),
        Text(
          'M3IconStyle is an IconThemeData, so a whole subtree can be typed '
          'at once:',
          style: M3TypeScale.bodyMedium,
        ),
        const M3Gap(M3Spacings.s8),
        // merge, not IconTheme: the style carries axes, and the color keeps
        // coming from the theme. A bare IconTheme would replace the inherited
        // color with null and drop every icon to Flutter's black fallback.
        IconTheme.merge(
          data: const M3IconStyle(
            size: M3IconSizes.medium,
            weight: M3IconWeights.semiBold,
            grade: M3IconGrades.emphasis,
          ),
          child: const Row(
            children: [
              Icon(Icons.check_circle),
              M3Gap(M3Spacings.s8),
              Icon(Icons.bolt),
              M3Gap(M3Spacings.s8),
              Icon(Icons.shield),
            ],
          ),
        ),
      ],
    );
  }
}

// ═════════════════════════════════════════════════════════════════════════
// 5. COLOR — M3TonalPalette, M3Tones, ColorScheme extensions, opacities
// ═════════════════════════════════════════════════════════════════════════

class _ColorSection extends StatelessWidget {
  const _ColorSection();

  @override
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;
    // Real HCT tonal palette — the same math Material uses.
    final palette = M3TonalPalette.fromSeed(const Color(0xFF6750A4));

    return _Section(
      title: '5. Color',
      children: [
        Text('Tonal palette from seed, at the 13 M3 tone stops:',
            style: M3TypeScale.bodyMedium),
        const M3Gap(M3Spacings.s8),
        SizedBox(
          height: M3Spacings.s40,
          child: Row(
            children: [
              for (final tone in M3Tones.values)
                Expanded(child: Container(color: palette[tone])),
            ],
          ),
        ),
        const M3Gap(M3Spacings.s16),
        Wrap(
          spacing: M3Spacings.s8,
          runSpacing: M3Spacings.s8,
          children: [
            _colorChip(
              'hover state layer',
              colorScheme.stateLayerColor(
                colorScheme.primary,
                M3InteractionState.hover,
              ),
            ),
            _colorChip(
              'disabled content (38%)',
              colorScheme.disabledContent(colorScheme.onSurface),
            ),
            _colorChip(
              'surface @ level3',
              colorScheme.surfaceAtElevation(M3Elevation.level3),
            ),
          ],
        ),
        const M3Gap(M3Spacings.s16),
        // The percentages behind those helpers are tokens in their own right,
        // so a one-off overlay lands on the same numbers the extensions use.
        Text(
          'M3Opacities — disabled content '
          '${(M3Opacities.disabledContent * 100).round()}% · '
          'disabled container '
          '${(M3Opacities.disabledContainer * 100).round()}%. '
          'M3StateLayerOpacities — hover '
          '${(M3StateLayerOpacities.hover * 100).round()}% · '
          'focus ${(M3StateLayerOpacities.focus * 100).round()}% · '
          'pressed ${(M3StateLayerOpacities.pressed * 100).round()}% · '
          'dragged ${(M3StateLayerOpacities.dragged * 100).round()}%.',
          style: M3TypeScale.bodyMedium,
        ),
      ],
    );
  }

  Widget _colorChip(String label, Color color) {
    return Chip(
      avatar: CircleAvatar(backgroundColor: color),
      label: Text(label, style: M3TypeScale.labelMedium),
    );
  }
}

// ═════════════════════════════════════════════════════════════════════════
// 5b. COLOR SCHEMES — M3ColorSchemes, M3SchemeVariant, M3ContrastLevels
// ═════════════════════════════════════════════════════════════════════════

class _SchemeSection extends StatefulWidget {
  const _SchemeSection();

  @override
  State<_SchemeSection> createState() => _SchemeSectionState();
}

class _SchemeSectionState extends State<_SchemeSection> {
  M3SchemeVariant _variant = M3SchemeVariant.tonalSpot;
  M3ContrastLevelValue _contrast = M3ContrastLevels.standard;

  static const _seed = Color(0xFF6750A4);

  @override
  Widget build(BuildContext context) {
    // The variant and the contrast level are tokens, not loose values: you
    // cannot pass `2.7` as a contrast level or invent a tenth variant.
    final scheme = M3ColorSchemes.fromSeed(
      seedColor: _seed,
      variant: _variant,
      contrastLevel: _contrast,
      brightness: Theme.of(context).brightness,
    );

    return _Section(
      title: '5b. Color schemes, variants & contrast',
      children: [
        Text('Variant', style: M3TypeScale.labelLarge),
        const M3Gap(M3Spacings.s8),
        Wrap(
          spacing: M3Spacings.s8,
          runSpacing: M3Spacings.s8,
          children: [
            for (final variant in M3SchemeVariant.values)
              ChoiceChip(
                label: Text(variant.name, style: M3TypeScale.labelMedium),
                selected: _variant == variant,
                onSelected: (_) => setState(() => _variant = variant),
              ),
          ],
        ),
        const M3Gap(M3Spacings.s16),
        Text('Contrast', style: M3TypeScale.labelLarge),
        const M3Gap(M3Spacings.s8),
        Wrap(
          spacing: M3Spacings.s8,
          children: [
            for (final (label, level) in const <(String, M3ContrastLevelValue)>[
              ('reduced', M3ContrastLevels.reduced),
              ('standard', M3ContrastLevels.standard),
              ('medium', M3ContrastLevels.medium),
              ('high', M3ContrastLevels.high),
            ])
              ChoiceChip(
                label: Text(label, style: M3TypeScale.labelMedium),
                selected: _contrast == level,
                onSelected: (_) => setState(() => _contrast = level),
              ),
          ],
        ),
        const M3Gap(M3Spacings.s16),
        Row(
          children: M3GapUtils.addGaps(
            [
              for (final (label, bg, fg) in <(String, Color, Color)>[
                ('primary', scheme.primary, scheme.onPrimary),
                (
                  'primaryContainer',
                  scheme.primaryContainer,
                  scheme.onPrimaryContainer
                ),
                ('tertiary', scheme.tertiary, scheme.onTertiary),
                ('surface', scheme.surface, scheme.onSurface),
              ])
                Expanded(
                  child: Container(
                    height: M3Spacings.s64,
                    alignment: Alignment.center,
                    decoration: M3BoxDecoration(
                      color: bg,
                      borderRadius: M3BorderRadius.small,
                    ),
                    child: Text(
                      label,
                      textAlign: TextAlign.center,
                      style: M3TypeScale.labelSmall.copyWith(color: fg),
                    ),
                  ),
                ),
            ],
            M3Spacings.s8,
          ),
        ),
        const M3Gap(M3Spacings.s16),
        Text(
          'onSurface/surface contrast: '
          '${M3ColorUtils.calculateContrast(scheme.onSurface, scheme.surface).toStringAsFixed(2)}:1',
          style: M3TypeScale.bodyMedium,
        ),
        const M3Gap(M3Spacings.s16),
        Text(
          'Brand colors harmonized into this scheme — a bounded HCT hue shift, '
          'so they belong without becoming a different color:',
          style: M3TypeScale.bodyMedium,
        ),
        const M3Gap(M3Spacings.s8),
        Row(
          children: M3GapUtils.addGaps(
            [
              for (final (name, source) in const <(String, Color)>[
                ('success', Color(0xFF2E7D32)),
                ('warning', Color(0xFFF9A825)),
                ('info', Color(0xFF0277BD)),
              ])
                Expanded(
                  child: _ExtendedColorTile(
                    color: M3ExtendedColor.harmonized(
                      name: name,
                      source: source,
                      harmonizeWith: scheme.primary,
                      brightness: Theme.of(context).brightness,
                    ),
                    raw: source,
                  ),
                ),
            ],
            M3Spacings.s8,
          ),
        ),
      ],
    );
  }
}

class _ExtendedColorTile extends StatelessWidget {
  const _ExtendedColorTile({required this.color, required this.raw});

  final M3ExtendedColor color;
  final Color raw;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Container(
          height: M3Spacings.s20,
          decoration: M3BoxDecoration(
            color: raw,
            borderRadius: M3BorderRadius.extraSmall,
          ),
        ),
        const M3Gap(M3Spacings.s4),
        Container(
          padding: const M3EdgeInsets.all(M3Spacings.s8),
          decoration: M3BoxDecoration(
            color: color.colorContainer,
            borderRadius: M3BorderRadius.small,
          ),
          child: Text(
            color.name,
            textAlign: TextAlign.center,
            style: M3TypeScale.labelMedium.copyWith(
              color: color.onColorContainer,
            ),
          ),
        ),
      ],
    );
  }
}

// ═════════════════════════════════════════════════════════════════════════
// 6. INTERACTION STATES & FOCUS — M3StateLayer, M3FocusRing
// ═════════════════════════════════════════════════════════════════════════

class _InteractionSection extends StatelessWidget {
  const _InteractionSection();

  @override
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;

    return _Section(
      title: '6. Interaction states & focus',
      children: [
        Text(
          'Hover / focus / press / drag overlays at official opacities '
          '(8% / 10% / 10% / 16%), with M3 precedence:',
          style: M3TypeScale.bodyMedium,
        ),
        const M3Gap(M3Spacings.s8),
        M3StateLayer(
          overlayColor: colorScheme.onSurface,
          borderRadius: M3BorderRadius.medium,
          onTap: () {},
          child: Container(
            padding: const M3EdgeInsets.all(M3Spacings.s16),
            decoration: M3BoxDecoration(
              color: colorScheme.surfaceContainerHigh,
              borderRadius: M3BorderRadius.medium,
            ),
            child:
                Text('Hover, press, or drag me', style: M3TypeScale.bodyLarge),
          ),
        ),
        const M3Gap(M3Spacings.s16),
        Text(
          'M3FocusRing reserves its 6dp inset permanently, so tabbing to the '
          'control never shifts the layout. Tab to see the 3dp ring:',
          style: M3TypeScale.bodyMedium,
        ),
        const M3Gap(M3Spacings.s8),
        M3FocusRing(
          borderRadius: M3BorderRadius.full,
          child: IconButton(
            onPressed: () {},
            icon: const Icon(Icons.star),
          ),
        ),
      ],
    );
  }
}

// ═════════════════════════════════════════════════════════════════════════
// 7. MOTION — M3Motion (duration + curve together)
// ═════════════════════════════════════════════════════════════════════════

class _MotionSection extends StatefulWidget {
  const _MotionSection();

  @override
  State<_MotionSection> createState() => _MotionSectionState();
}

class _MotionSectionState extends State<_MotionSection> {
  bool _expanded = false;

  @override
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;

    return _Section(
      title: '7. Motion',
      children: [
        Text(
          'Duration and curve travel together — you never pair them by hand. '
          'This container animates with M3Motion.emphasized (500ms):',
          style: M3TypeScale.bodyMedium,
        ),
        const M3Gap(M3Spacings.s8),
        GestureDetector(
          onTap: () => setState(() => _expanded = !_expanded),
          child: AnimatedContainer(
            duration: M3Motion.emphasized.duration,
            curve: M3Motion.emphasized.curve,
            width: _expanded ? M3Spacings.s128 * 2 : M3Spacings.s128,
            height: M3Spacings.s64,
            alignment: Alignment.center,
            decoration: M3BoxDecoration(
              color: colorScheme.primaryContainer,
              borderRadius:
                  _expanded ? M3BorderRadius.extraLarge : M3BorderRadius.medium,
            ),
            child: Text('tap me', style: M3TypeScale.labelLarge),
          ),
        ),
        const M3Gap(M3Spacings.s16),
        Text(
          'Pick by intent: M3Motion.durationFor(M3MotionDistance.long) → '
          '${M3Motion.durationFor(M3MotionDistance.long).inMilliseconds}ms',
          style: M3TypeScale.bodyMedium,
        ),
        const M3Gap(M3Spacings.s24),
        Text(
          'M3 Expressive springs are the physics-based alternative: '
          'interruptible and velocity-aware. Tap to launch both.',
          style: M3TypeScale.bodyMedium,
        ),
        const M3Gap(M3Spacings.s8),
        const _SpringDemo(),
      ],
    );
  }
}

/// Runs the same distance under a bouncy spatial spring and a critically
/// damped effects spring, so the difference is visible rather than described.
class _SpringDemo extends StatefulWidget {
  const _SpringDemo();

  @override
  State<_SpringDemo> createState() => _SpringDemoState();
}

class _SpringDemoState extends State<_SpringDemo>
    with TickerProviderStateMixin {
  late final AnimationController _spatial = AnimationController.unbounded(
    vsync: this,
  );
  late final AnimationController _effects = AnimationController.unbounded(
    vsync: this,
  );

  @override
  void dispose() {
    _spatial.dispose();
    _effects.dispose();
    super.dispose();
  }

  void _run() {
    for (final (controller, spring) in <(AnimationController, M3ESpring)>[
      // Spatial springs move things and are allowed to overshoot.
      (_spatial, M3MotionScheme.expressive.spatial(M3MotionSpeed.fast)),
      // Effects springs change color/opacity and never overshoot.
      (_effects, M3MotionScheme.expressive.effects(M3MotionSpeed.standard)),
    ]) {
      final from = controller.value > 0.5 ? 1.0 : 0.0;
      controller
        ..value = from
        ..animateWith(
          spring.simulation(start: from, end: 1 - from),
        );
    }
  }

  @override
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;

    return GestureDetector(
      onTap: _run,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          for (final (label, controller, bouncy)
              in <(String, AnimationController, bool)>[
            ('spatial · fast — overshoots', _spatial, true),
            ('effects · standard — never overshoots', _effects, false),
          ]) ...[
            Text(label, style: M3TypeScale.labelMedium),
            const M3Gap(M3Spacings.s4),
            SizedBox(
              height: M3Spacings.s40,
              child: AnimatedBuilder(
                animation: controller,
                builder: (context, child) => Align(
                  // Values outside 0..1 are exactly the overshoot.
                  alignment: Alignment(
                    (controller.value.clamp(-0.2, 1.2) * 2) - 1,
                    0,
                  ),
                  child: child,
                ),
                child: Container(
                  width: M3Spacings.s32,
                  height: M3Spacings.s32,
                  decoration: M3BoxDecoration(
                    color: bouncy
                        ? colorScheme.primary
                        : colorScheme.tertiaryContainer,
                    borderRadius: M3BorderRadius.full,
                  ),
                ),
              ),
            ),
            const M3Gap(M3Spacings.s8),
          ],
        ],
      ),
    );
  }
}

// ═════════════════════════════════════════════════════════════════════════
// 8. ADAPTIVE & RESPONSIVE — M3ScreenSize, M3ResponsiveValue, M3Adaptive
// ═════════════════════════════════════════════════════════════════════════

class _AdaptiveSection extends StatelessWidget {
  const _AdaptiveSection();

  @override
  Widget build(BuildContext context) {
    final size = M3ScreenSize.of(context);

    return _Section(
      title: '8. Adaptive & responsive',
      children: [
        Text(
          'Window class: ${size.name} — ${size.columns} columns, '
          '${size.gutterWidth.toInt()}dp gutters, '
          '${size.pageMargin.toInt()}dp margins. Resize the window.',
          style: M3TypeScale.bodyMedium,
        ),
        const M3Gap(M3Spacings.s8),
        // The numbers the window class is derived from, and the widths content
        // is measured against, are both tokens rather than literals.
        Text(
          'Breakpoints — M3Breakpoints: '
          '${M3Breakpoints.values.map((bp) => bp.toInt()).join(' / ')}. '
          'Content widths — M3LayoutWidths: '
          'pane ${M3LayoutWidths.pane.toInt()}dp · '
          'body ${M3LayoutWidths.body.toInt()}dp · '
          'ultraWide ${M3LayoutWidths.ultraWide.toInt()}dp.',
          style: M3TypeScale.bodySmall,
        ),
        const M3Gap(M3Spacings.s8),
        M3ResponsiveValue<String>(
          compact: 'compact: single column',
          medium: 'medium: rail + content',
          expanded: 'expanded: rail + content + panel',
          builder: (context, layout) =>
              Text(layout, style: M3TypeScale.labelLarge),
        ),
        const M3Gap(M3Spacings.s8),
        M3ResponsiveVisibility(
          visibleOn: const [
            M3ScreenSize.expanded,
            M3ScreenSize.large,
            M3ScreenSize.extraLarge,
          ],
          replacement: Text(
            '(a side panel would appear here on wider windows)',
            style: M3TypeScale.bodySmall,
          ),
          child: Text(
            'This side-panel text only exists on expanded+ windows.',
            style: M3TypeScale.bodySmall,
          ),
        ),
        const M3Gap(M3Spacings.s16),
        Text(
          'The three canonical layouts. Note they collapse differently on a '
          'phone: list-detail replaces, supporting-pane stacks.',
          style: M3TypeScale.bodyMedium,
        ),
        const M3Gap(M3Spacings.s8),
        SizedBox(
          height: M3Spacings.s96,
          child: M3SupportingPaneLayout(
            primary: _PaneBox(
              label: 'primary',
              color: Theme.of(context).colorScheme.primaryContainer,
            ),
            supporting: _PaneBox(
              label: 'supporting',
              color: Theme.of(context).colorScheme.tertiaryContainer,
            ),
          ),
        ),
        const M3Gap(M3Spacings.s16),
        FilledButton(
          onPressed: () => M3Adaptive.showAdaptiveDialog<void>(
            context: context,
            title: 'Adaptive dialog',
            content: const Text(
              'Fullscreen on compact windows, AlertDialog elsewhere.',
            ),
            actions: [
              TextButton(
                onPressed: () => Navigator.of(context).pop(),
                child: const Text('Close'),
              ),
            ],
          ),
          child: const Text('M3Adaptive.showAdaptiveDialog'),
        ),
      ],
    );
  }
}

/// A labelled block standing in for a real pane in the layout demo.
class _PaneBox extends StatelessWidget {
  const _PaneBox({required this.label, required this.color});

  final String label;
  final Color color;

  @override
  Widget build(BuildContext context) {
    return Container(
      alignment: Alignment.center,
      decoration: M3BoxDecoration(
        color: color,
        borderRadius: M3BorderRadius.small,
      ),
      child: Text(label, style: M3TypeScale.labelLarge),
    );
  }
}

// ═════════════════════════════════════════════════════════════════════════
// 8b. COMPONENT MEASUREMENTS — M3ButtonHeights, M3FabSizes, M3ListItemHeights
// ═════════════════════════════════════════════════════════════════════════

class _ComponentTokensSection extends StatelessWidget {
  const _ComponentTokensSection();

  @override
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;

    return _Section(
      title: '8b. Component measurements',
      children: [
        Text(
          'The spec numbers for individual components, so a control you build '
          'by hand matches the built-in one beside it. Values only — this '
          'package does not ship M3 components.',
          style: M3TypeScale.bodyMedium,
        ),
        const M3Gap(M3Spacings.s16),
        Text('M3ButtonHeights', style: M3TypeScale.labelLarge),
        const M3Gap(M3Spacings.s8),
        for (final (name, height) in const <(String, M3SpacingValue)>[
          ('extraSmall', M3ButtonHeights.extraSmall),
          ('small', M3ButtonHeights.small),
          ('medium', M3ButtonHeights.medium),
        ])
          M3Padding(
            padding: const M3EdgeInsets.only(bottom: M3Spacings.s8),
            child: Row(
              children: [
                Container(
                  height: height,
                  width: M3Spacings.s128,
                  alignment: Alignment.center,
                  decoration: M3BoxDecoration(
                    color: colorScheme.secondaryContainer,
                    borderRadius: M3BorderRadius.full,
                  ),
                  child: Text(
                    '$name ${height.toInt()}dp',
                    style: M3TypeScale.labelMedium,
                  ),
                ),
                const M3Gap(M3Spacings.s12, orientation: Axis.horizontal),
                // These are visual heights, not touch targets.
                if (height < M3Accessibility.minTouchTargetMobile)
                  Expanded(
                    child: Text(
                      'below the 48dp touch target — expand the tap area, '
                      'not the box',
                      style: M3TypeScale.bodySmall.copyWith(
                        color: colorScheme.error,
                      ),
                    ),
                  ),
              ],
            ),
          ),
        const M3Gap(M3Spacings.s8),
        Text(
          'Also: M3FabSizes.standard ${M3FabSizes.standard.toInt()}dp · '
          'M3AppBarHeights.small ${M3AppBarHeights.small.toInt()}dp · '
          'M3NavigationSizes.drawerWidth '
          '${M3NavigationSizes.drawerWidth.toInt()}dp · '
          'M3ListItemHeights.twoLine ${M3ListItemHeights.twoLine.toInt()}dp',
          style: M3TypeScale.bodySmall,
        ),
      ],
    );
  }
}

// ═════════════════════════════════════════════════════════════════════════
// 9. ACCESSIBILITY — M3Accessibility, WCAG helpers
// ═════════════════════════════════════════════════════════════════════════

class _AccessibilitySection extends StatelessWidget {
  const _AccessibilitySection();

  @override
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;
    final contrast = M3ColorUtils.calculateContrast(
      colorScheme.onSurface,
      colorScheme.surface,
    );
    final passes = M3Accessibility.meetsContrastRequirement(
      foreground: colorScheme.onSurface,
      background: colorScheme.surface,
    );

    return _Section(
      title: '9. Accessibility',
      children: [
        Text(
          'onSurface on surface: contrast ${contrast.toStringAsFixed(2)}:1 — '
          '${passes ? 'passes' : 'fails'} WCAG AA (4.5:1).',
          style: M3TypeScale.bodyMedium,
        ),
        const M3Gap(M3Spacings.s8),
        Text(
          'Minimum touch target here: '
          '${M3Accessibility.minTouchTarget(context).toInt()}dp. '
          'Reduce motion requested: '
          '${M3Accessibility.shouldReduceMotion(context)}.',
          style: M3TypeScale.bodyMedium,
        ),
      ],
    );
  }
}

// ═════════════════════════════════════════════════════════════════════════
// 10. M3 EXPRESSIVE (experimental) — loading indicator, 35-shape library
// ═════════════════════════════════════════════════════════════════════════

class _ExpressiveSection extends StatelessWidget {
  const _ExpressiveSection();

  @override
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;

    return _Section(
      title: '10. M3 Expressive (experimental)',
      children: [
        Text(
          'The morphing loading indicator replaces most indeterminate '
          'spinners, and MaterialShapes ships the official 35-shape library:',
          style: M3TypeScale.bodyMedium,
        ),
        const M3Gap(M3Spacings.s8),
        Row(
          children: M3GapUtils.addGaps(
            [
              SizedBox(
                width: M3Spacings.s64,
                height: M3Spacings.s64,
                child: M3ELoadingIndicator(),
              ),
              SizedBox(
                width: M3Spacings.s64,
                height: M3Spacings.s64,
                child: M3ELoadingIndicator.contained(),
              ),
              // M3EShapeBorder puts a polygon anywhere Flutter takes a
              // ShapeBorder — no CustomPainter needed.
              for (final shape in [
                M3EShapes.sunny,
                M3EShapes.cookie7Sided,
                M3EShapes.flower,
                M3EShapes.heart,
              ])
                SizedBox.square(
                  dimension: M3Spacings.s48,
                  child: Material(
                    shape: M3EShapeBorder(shape),
                    color: colorScheme.primary,
                  ),
                ),
            ],
            M3Spacings.s16,
          ),
        ),
      ],
    );
  }
}

// ═════════════════════════════════════════════════════════════════════════
// 11. BREAKING THE CONTRACT — M3Contract, the one greppable escape hatch
// ═════════════════════════════════════════════════════════════════════════

class _ContractSection extends StatelessWidget {
  const _ContractSection();

  @override
  Widget build(BuildContext context) {
    final colorScheme = Theme.of(context).colorScheme;

    return _Section(
      title: '11. Breaking the contract, deliberately',
      children: [
        Text(
          'A brand asset that is genuinely 18dp does not have to lie about '
          'it. M3Contract is the single sanctioned escape hatch — grep for '
          'it to audit every deviation in your codebase:',
          style: M3TypeScale.bodyMedium,
        ),
        const M3Gap(M3Spacings.s8),
        Container(
          // Off the 4dp grid, on purpose, and visibly so.
          padding: M3EdgeInsets.all(M3Contract.spacing(18)),
          decoration: M3BoxDecoration(
            color: colorScheme.tertiaryContainer,
            borderRadius: M3BorderRadius.medium,
          ),
          child: Text(
            "padding: M3EdgeInsets.all(M3Contract.spacing(18))\n"
            r"audit:   grep -rn 'M3Contract\.' lib/ | wc -l",
            style: M3TextUtils.mono(M3TypeScale.bodySmall),
          ),
        ),
      ],
    );
  }
}
30
likes
160
points
795
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A Material Design 3 contract for Flutter. Enforces M3 tokens system-wide, replacing free primitives with type-safe M3 equivalents.

Repository (GitHub)
View/report issues
Contributing

License

BSD-3-Clause (license)

Dependencies

flutter, material_color_utilities, meta, vector_math

More

Packages that depend on material_design