Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
14 changes: 14 additions & 0 deletions packages/mix/doc/mix-scope-and-theming.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,20 @@ MixScope(
)
```

A nested `MixScope` is the *nearest* scope for its subtree, so it *replaces* the parent for token resolution. To **combine** tokens—keep the parent’s tokens and add (or override) your own—use **MixScope.inherit**. It builds a single scope whose token map is the parent’s map merged with yours: parent tokens plus your tokens, with your entries winning when the same token is defined in both. That way both upstream tokens and your local tokens resolve in the same subtree. Valid for both light and dark themes.

```dart
// Outer scope provides base tokens; inherit combines them with yours
MixScope(
colors: { ColorToken('brand.primary'): Colors.blue },
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doc: Invalid Dart — duplicate named parameter colors:

This constructor call has colors: twice, which is a compile error in Dart. The second entry should be merged into a single map:

MixScope(
  colors: {
    ColorToken('brand.primary'): Colors.blue,
    ColorToken('custom.accent'): Colors.orange,
  },
  child: MixScope.inherit(
    spaces: { SpaceToken('custom.gap'): 12.0 },
    child: MySubtree(),
  ),
)

colors: { ColorToken('custom.accent'): Colors.orange },
child: MixScope.inherit(
spaces: { SpaceToken('custom.gap'): 12.0 },
child: MySubtree(), // resolves both brand and custom tokens
),
)
```

## Hands‑On Tutorial

This tutorial creates brand color and spacing tokens, applies them, and integrates Material.
Expand Down
59 changes: 59 additions & 0 deletions packages/mix/lib/src/theme/mix_theme.dart
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,65 @@ 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._(
key: key,
tokens: childTokens,
orderOfModifiers: orderOfModifiers,
child: child,
);

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

return MixScope.combine(scopes: [?parent, nestedScope], child: child);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: key parameter is silently ignored

The key passed to inherit() goes into nestedScope, but nestedScope is never inserted into the widget tree — it's only used as a data carrier for combine(). The Builder returned here doesn't receive the key, and combine is called without forwarding it (defaults to null).

A caller passing key: ValueKey('foo') would expect it to affect widget identity, but it has zero effect.

Suggested fix — forward key to combine:

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