dtcg_theme 0.7.1
dtcg_theme: ^0.7.1 copied to clipboard
Turns W3C Design Tokens (DTCG) files into a type-safe Flutter theme: a ThemeExtension per mode, and a build that fails on a broken reference or a pair below your WCAG target.
import 'package:flutter/material.dart';
import 'theme/app_tokens.g.dart';
void main() => runApp(const ExampleApp());
/// Builds a [ThemeData] out of nothing but generated tokens.
ThemeData themeFrom(AppTokens tokens) {
return ThemeData(
scaffoldBackgroundColor: tokens.colorSurfaceSunken,
// Generated from the `color_scheme` and `text_theme` blocks of dtcg.yaml.
colorScheme: tokens.colorScheme,
textTheme: tokens.textTheme,
// The tokens travel with the theme, so any widget can read them.
extensions: <ThemeExtension<dynamic>>[tokens],
);
}
class ExampleApp extends StatefulWidget {
const ExampleApp({super.key});
@override
State<ExampleApp> createState() => _ExampleAppState();
}
class _ExampleAppState extends State<ExampleApp> {
bool _dark = false;
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'dtcg_theme example',
theme: themeFrom(AppTokens.light),
darkTheme: themeFrom(AppTokens.dark),
themeMode: _dark ? ThemeMode.dark : ThemeMode.light,
// The duration and the curve are tokens too, so the crossfade between
// light and dark is part of the design system.
// One `transition` token carries both halves, so they cannot drift.
themeAnimationDuration: AppTokens.light.motionTheme.duration,
themeAnimationCurve: AppTokens.light.motionTheme.curve,
home: HomePage(
dark: _dark,
onToggle: (bool value) => setState(() => _dark = value),
),
);
}
}
class HomePage extends StatelessWidget {
const HomePage({super.key, required this.dark, required this.onToggle});
final bool dark;
final ValueChanged<bool> onToggle;
@override
Widget build(BuildContext context) {
final AppTokens tokens = AppTokens.of(context);
return Scaffold(
appBar: AppBar(
title: Text('dtcg_theme', style: tokens.textTitle),
backgroundColor: tokens.colorSurface,
foregroundColor: tokens.colorOnSurface,
actions: <Widget>[
Switch(value: dark, onChanged: onToggle),
SizedBox(width: tokens.spaceMd),
],
),
body: ListView(
padding: EdgeInsets.all(tokens.spaceLg),
children: <Widget>[
Container(
padding: EdgeInsets.all(tokens.spaceLg),
decoration: BoxDecoration(
gradient: tokens.brandHero,
borderRadius: BorderRadius.circular(tokens.radiusMd),
),
child: Text(
'brand.hero',
style: tokens.textTitle.copyWith(color: tokens.colorOnPrimary),
),
),
SizedBox(height: tokens.spaceLg),
Text('Everything here comes from tokens', style: tokens.textTitle),
SizedBox(height: tokens.spaceSm),
Text(
'Colours, spacing, radii, the type scale, the shadow and even the '
'theme crossfade are values generated from the DTCG files in '
'tokens/. No hex codes and no magic numbers in this file.',
style: tokens.textBody.copyWith(color: tokens.colorMuted),
),
SizedBox(height: tokens.spaceLg),
const _GlowBadge(),
SizedBox(height: tokens.spaceLg),
const _TokenCard(),
SizedBox(height: tokens.spaceLg),
Text('Palette', style: tokens.textTitle),
SizedBox(height: tokens.spaceSm),
const _Swatches(),
],
),
);
}
}
class _TokenCard extends StatelessWidget {
const _TokenCard();
@override
Widget build(BuildContext context) {
final AppTokens tokens = AppTokens.of(context);
return Container(
padding: EdgeInsets.all(tokens.spaceLg),
decoration: BoxDecoration(
color: tokens.colorSurface,
borderRadius: BorderRadius.circular(tokens.radiusMd),
border: Border.fromBorderSide(tokens.strokeDivider),
boxShadow: tokens.elevationCard,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text('elevation.card + stroke.divider', style: tokens.textBody),
SizedBox(height: tokens.spaceSm),
Text(
'Colour, radius, border, shadow and spacing, all from tokens. '
'The contrast of every text pair here was checked at build time.',
style: tokens.textBody.copyWith(color: tokens.colorMuted),
),
SizedBox(height: tokens.spaceMd),
FilledButton(
onPressed: () {},
style: FilledButton.styleFrom(
backgroundColor: tokens.colorPrimary,
foregroundColor: tokens.colorOnPrimary,
padding: EdgeInsets.symmetric(
horizontal: tokens.spaceLg,
vertical: tokens.spaceSm,
),
),
child: Text('color.primary', style: tokens.textBody),
),
],
),
);
}
}
/// A radial gradient, which the tokens ask for through `$extensions`.
class _GlowBadge extends StatelessWidget {
const _GlowBadge();
@override
Widget build(BuildContext context) {
final AppTokens tokens = AppTokens.of(context);
return Container(
height: 120,
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: tokens.brandGlow,
borderRadius: BorderRadius.circular(tokens.radiusMd),
border: Border.all(color: tokens.colorOutline),
),
child: Text(
'brand.glow (radial)',
style: tokens.textBody.copyWith(color: tokens.colorOnSurface),
),
);
}
}
class _Swatches extends StatelessWidget {
const _Swatches();
@override
Widget build(BuildContext context) {
final AppTokens tokens = AppTokens.of(context);
final Map<String, Color> swatches = <String, Color>{
'surface': tokens.colorSurface,
'on-surface': tokens.colorOnSurface,
'primary': tokens.colorPrimary,
'muted': tokens.colorMuted,
'outline': tokens.colorOutline,
// Declared in oklch, converted to sRGB by the generator.
'accent (oklch)': tokens.paletteAccent,
};
return Wrap(
spacing: tokens.spaceMd,
runSpacing: tokens.spaceMd,
children: <Widget>[
for (final MapEntry<String, Color> entry in swatches.entries)
Column(
children: <Widget>[
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: entry.value,
borderRadius: BorderRadius.circular(tokens.radiusMd),
border: Border.all(color: tokens.colorOutline),
),
),
SizedBox(height: tokens.spaceSm),
Text(entry.key, style: tokens.textBody),
],
),
],
);
}
}