Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 38 additions & 9 deletions packages/mix/doc/mix-scope-and-theming.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,11 +149,32 @@ MixScope(

## Combining/Overriding Scopes

You can merge scopes or create nested scopes to override subsets of tokens for parts of the tree.
When you place a new `MixScope` inside an existing one, the inner scope becomes the *nearest* scope for its subtree and **completely replaces** the parent for token resolution. Any token that the parent defined but the child did not is no longer visible:

```dart
MixScope(
colors: { ColorToken('brand.primary'): Colors.blue },
spaces: { SpaceToken('space.md'): 16.0 },
child: FeatureShell(
child: MixScope(
// Only overrides the color, but space.md is now gone
colors: { ColorToken('brand.primary'): Colors.green },
child: FeatureWidget(), // SpaceToken('space.md') will throw an error
),
),
)
```

In the example above, `FeatureWidget` can resolve `brand.primary` (green), but `space.md` is lost because the inner scope does not carry the parent's tokens. To keep upstream tokens available while adding or overriding a subset, use `MixScope.combine` or `MixScope.inherit`.

### MixScope.combine

`MixScope.combine` takes a list of `MixScope` instances and folds them into a single scope. Token maps are merged in order: later scopes override earlier ones when the same token appears in both.

```dart
final base = MixScope(
colors: { ColorToken('brand.primary'): Colors.blue },
spaces: { SpaceToken('space.md'): 16.0 },
child: const SizedBox(),
);

Expand All @@ -162,27 +183,35 @@ final feature = MixScope(
child: const SizedBox(),
);

// Merged result: brand.primary -> green, space.md -> 16.0
final combined = MixScope.combine(
scopes: [base, feature],
child: MyApp(),
);
```

Or simply nest a child scope where needed:
This is useful when you have multiple scope definitions (e.g. a base theme and a feature-level override) and want to merge them explicitly before placing them in the tree.

### MixScope.inherit

`MixScope.inherit` is a convenience for the most common case: you already have a parent `MixScope` in the tree and want to add (or override) a few tokens without losing the rest. It reads the nearest parent scope at build time and merges it with the tokens you provide. Parent tokens come first, then yours, so your entries win when keys collide:

```dart
MixScope(
colors: { ColorToken('brand.primary'): Colors.blue },
child: FeatureShell(
child: MixScope(
colors: { ColorToken('brand.primary'): Colors.green },
child: FeatureWidget(),
),
colors: {
ColorToken('brand.primary'): Colors.blue,
ColorToken('custom.accent'): Colors.orange,
},
spaces: { SpaceToken('space.md'): 16.0 },
child: MixScope.inherit(
// Only adds a new space token; all parent tokens remain available
spaces: { SpaceToken('custom.gap'): 12.0 },
child: MySubtree(), // resolves brand.primary, custom.accent, space.md, and custom.gap
),
)
```

## HandsOn Tutorial
## Hands-On Tutorial

This tutorial creates brand color and spacing tokens, applies them, and integrates Material.

Expand Down
62 changes: 62 additions & 0 deletions packages/mix/lib/src/theme/mix_theme.dart
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,68 @@ class MixScope extends InheritedModel<String> {
: _tokens = null,
orderOfModifiers = null;

/// Creates a [MixScope] that inherits tokens from the nearest parent [MixScope]
/// and adds (or overrides with) the given tokens.
///
/// Use this when you need additional Mix tokens (e.g. [ColorToken],
/// [TextStyleToken], [SpaceToken]) inside a subtree that already has an outer
/// scope,
/// without replacing upstream tokens. The resulting scope has a single merged
/// token map: parent tokens first, then these tokens (so local tokens override
/// parent when keys collide).
///
/// Keeps the outer scope's tokens available while allowing additive custom
/// tokens in the same widget subtree. Valid for both light and dark themes
/// when the parent scope provides theme-dependent tokens.
static Widget inherit({
Map<MixToken, Object>? tokens,
Map<ColorToken, Color>? colors,
Map<TextStyleToken, TextStyle>? textStyles,
Map<SpaceToken, double>? spaces,
Map<DoubleToken, double>? doubles,
Map<RadiusToken, Radius>? radii,
Map<BreakpointToken, Breakpoint>? breakpoints,
Map<ShadowToken, List<Shadow>>? shadows,
Map<BoxShadowToken, List<BoxShadow>>? boxShadows,
Map<BorderSideToken, BorderSide>? borders,
Map<FontWeightToken, FontWeight>? fontWeights,
List<Type>? orderOfModifiers,
required Widget child,
Key? key,
}) {
final childTokens = <MixToken, Object>{
...?tokens,
...?colors?.cast<MixToken, Object>(),
...?textStyles?.cast<MixToken, Object>(),
...?spaces?.cast<MixToken, Object>(),
...?doubles?.cast<MixToken, Object>(),
...?radii?.cast<MixToken, Object>(),
...?breakpoints?.cast<MixToken, Object>(),
...?shadows?.cast<MixToken, Object>(),
...?boxShadows?.cast<MixToken, Object>(),
...?borders?.cast<MixToken, Object>(),
...?fontWeights?.cast<MixToken, Object>(),
};

final nestedScope = MixScope._(
tokens: childTokens,
orderOfModifiers: orderOfModifiers,
child: child,
);

return Builder(
builder: (context) {
final parent = MixScope.maybeOf(context);

return MixScope.combine(
key: key,
scopes: [?parent, nestedScope],
child: child,
);
},
);
}

/// Creates a widget with Material Design tokens pre-configured.
static Widget withMaterial({
Map<MixToken, Object>? tokens,
Expand Down
209 changes: 209 additions & 0 deletions packages/mix/test/src/theme/mix_scope_inherit_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mix/mix.dart';

// Tokens used to simulate "Fortal" (outer) and custom (inner) scope.
const _fortalPrimary = ColorToken('fortal.color.primary');
const _fortalSurface = ColorToken('fortal.color.surface');
const _customGap = SpaceToken('custom.space.gap');
const _customAccent = ColorToken('custom.color.accent');

void main() {
group('MixScope.inherit', () {
testWidgets(
'outer scope token and inner custom token both resolve in same subtree',
(tester) async {
await tester.pumpWidget(
MaterialApp(
home: MixScope(
colors: {
_fortalPrimary: Colors.blue,
_fortalSurface: Colors.white,
},
child: MixScope.inherit(
spaces: {_customGap: 12.0},
colors: {_customAccent: Colors.orange},
child: Builder(
builder: (context) {
final scope = MixScope.of(context);
expect(
scope.getToken(_fortalPrimary, context),
Colors.blue,
);
expect(
scope.getToken(_fortalSurface, context),
Colors.white,
);
expect(scope.getToken(_customGap, context), 12.0);
expect(
scope.getToken(_customAccent, context),
Colors.orange,
);
return const SizedBox();
},
),
),
),
),
);
},
);

testWidgets(
'Material scope + MixScope.inherit: both Material and custom tokens resolve',
(tester) async {
await tester.pumpWidget(
MaterialApp(
theme: ThemeData.light(),
home: MixScope.withMaterial(
child: MixScope.inherit(
spaces: {_customGap: 8.0},
colors: {_customAccent: Colors.purple},
child: Builder(
builder: (context) {
final scope = MixScope.of(context);
final md = const MaterialTokens();
expect(
scope.getToken(md.colorScheme.primary, context),
Theme.of(context).colorScheme.primary,
);
expect(
scope.getToken(md.colorScheme.surface, context),
Theme.of(context).colorScheme.surface,
);
expect(scope.getToken(_customGap, context), 8.0);
expect(
scope.getToken(_customAccent, context),
Colors.purple,
);
return const SizedBox();
},
),
),
),
),
);
},
);

testWidgets(
'resolution valid for .light within createFortalScope-like outer scope',
(tester) async {
const lightPrimary = Color(0xFF0D47A1);
const lightSurface = Color(0xFFFAFAFA);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData.light().copyWith(
colorScheme: ColorScheme.light(
primary: lightPrimary,
surface: lightSurface,
),
),
home: MixScope.withMaterial(
child: MixScope.inherit(
spaces: {_customGap: 16.0},
child: Builder(
builder: (context) {
final scope = MixScope.of(context);
final md = const MaterialTokens();
expect(
scope.getToken(md.colorScheme.primary, context),
lightPrimary,
);
expect(
scope.getToken(md.colorScheme.surface, context),
lightSurface,
);
expect(scope.getToken(_customGap, context), 16.0);
return const SizedBox();
},
),
),
),
),
);
},
);

testWidgets(
'resolution valid for .dark within createFortalScope-like outer scope',
(tester) async {
const darkPrimary = Color(0xFF90CAF9);
const darkSurface = Color(0xFF121212);
await tester.pumpWidget(
MaterialApp(
theme: ThemeData.dark().copyWith(
colorScheme: ColorScheme.dark(
primary: darkPrimary,
surface: darkSurface,
),
),
home: MixScope.withMaterial(
child: MixScope.inherit(
spaces: {_customGap: 24.0},
child: Builder(
builder: (context) {
final scope = MixScope.of(context);
final md = const MaterialTokens();
expect(
scope.getToken(md.colorScheme.primary, context),
darkPrimary,
);
expect(
scope.getToken(md.colorScheme.surface, context),
darkSurface,
);
expect(scope.getToken(_customGap, context), 24.0);
return const SizedBox();
},
),
),
),
),
);
},
);

testWidgets('inner token overrides parent when same key is provided', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: MixScope(
colors: {_fortalPrimary: Colors.blue},
child: MixScope.inherit(
colors: {_fortalPrimary: Colors.green},
child: Builder(
builder: (context) {
final scope = MixScope.of(context);
expect(scope.getToken(_fortalPrimary, context), Colors.green);
return const SizedBox();
},
),
),
),
),
);
});

testWidgets(
'MixScope.inherit with no parent still provides only child tokens',
(tester) async {
await tester.pumpWidget(
MaterialApp(
home: MixScope.inherit(
colors: {_customAccent: Colors.teal},
child: Builder(
builder: (context) {
final scope = MixScope.of(context);
expect(scope.getToken(_customAccent, context), Colors.teal);
return const SizedBox();
},
),
),
),
);
},
);
});
}
Loading