Duit Maintainer
Quick Start
When working on flutter_duit widgets, follow these patterns consistently across all implementations.
Create widget implementation file in lib/src/ui/widgets/ following this pattern:
dart
1import "package:flutter/material.dart";
2import "package:flutter_duit/flutter_duit.dart";
3
4class Duit[WidgetName] extends StatelessWidget {
5 final ViewAttribute attributes;
6 final Widget child; // or final List<Widget?> children;
7
8 const Duit[WidgetName]({
9 required this.attributes,
10 required this.child, // or this.children
11 super.key,
12 });
13
14 @override
15 Widget build(BuildContext context) {
16 final attrs = attributes.payload;
17 return [WidgetName](
18 key: ValueKey(attributes.id),
19 // ... widget-specific properties using attrs methods
20 child: child, // or children
21 );
22 }
23}
24
25class DuitControlled[WidgetName] extends StatefulWidget {
26 final UIElementController controller;
27 final Widget child; // or final List<Widget?> children;
28
29 const DuitControlled[WidgetName]({
30 required this.controller,
31 required this.child, // or this.children
32 super.key,
33 });
34
35 @override
36 State<DuitControlled[WidgetName]> createState() =>
37 _DuitControlled[WidgetName]State();
38}
39
40class _DuitControlled[WidgetName]State extends State<DuitControlled[WidgetName]>
41 with ViewControllerChangeListener {
42 @override
43 void initState() {
44 attachStateToController(widget.controller);
45 super.initState();
46 }
47
48 @override
49 Widget build(BuildContext context) {
50 return [WidgetName](
51 key: ValueKey(widget.controller.id),
52 // ... widget-specific properties using attributes
53 child: widget.child, // or widget.children
54 );
55 }
56}
Key Patterns:
- Always create both Duit[WidgetName] (non-controlled) and DuitControlled[WidgetName] (controlled)
- Controlled widgets must extend StatefulWidget and mix in ViewControllerChangeListener
- The ViewControllerChangeListener mixin provides the
late DuitDataSource attributes property, allowing direct access to attributes.getBool(), attributes.getDouble(), etc. in controlled widget state
- Call
attachStateToController(widget.controller) in initState
- Use
ValueKey(attributes.id) for non-controlled, ValueKey(widget.controller.id) for controlled
- Access attributes via
attributes.payload in non-controlled widgets
- Access attributes via
attributes (provided by ViewControllerChangeListener mixin) in controlled widgets
- Themes are applied automatically via ViewAttribute.from() in widget_from_element.dart - themes are merged during ViewAttribute creation before reaching the widget implementation
- Widget implementation code does NOT need to handle theme application explicitly - attributes received already have theme data merged
Add to lib/src/ui/element_type.dart:
- Add enum value:
dart
1[widgetName](
2 name: "[WidgetName]",
3 isControlledByDefault: false, // or true for interactive widgets
4 childRelation: 0, // 0=none, 1=single, 2=multiple, 3=component, 4=fragment
5),
- Add to lookup table (at end of file):
dart
1 "[WidgetName]": ElementType.[widgetName],
Child Relations:
- 0: Leaf widgets (Text, Image, TextField)
- 1: Single child wrapper (Container, Padding, Center)
- 2: Multiple children (Row, Column, Stack, Visibility)
- 3: Component content
- 4: Fragment content
Step 3: Create Build Function
Add to lib/src/ui/widget_from_element.dart:
dart
1Widget _build[WidgetName](ElementPropertyView model) {
2 return switch (model.controlled) {
3 true => DuitControlled[WidgetName](
4 controller: model.viewController,
5 child: _buildWidget(model.child), // or children
6 ),
7 false => Duit[WidgetName](
8 attributes: model.attributes,
9 child: _buildWidget(model.child), // or children
10 ),
11 };
12}
For multi-child widgets with nullable children:
dart
1Widget _build[WidgetName](ElementPropertyView model) {
2 final children = _mapToNullableWidgetList(model);
3 return switch (model.controlled) {
4 true => DuitControlled[WidgetName](
5 controller: model.viewController,
6 children: children,
7 ),
8 false => Duit[WidgetName](
9 attributes: model.attributes,
10 children: children,
11 ),
12 };
13}
Step 4: Add to Build Function Lookup
Add to lib/src/ui/build_fn_lookup.dart:
dart
1 ElementType.[widgetName]: _build[WidgetName],
Add to lib/src/ui/widgets/index.dart:
dart
1export "[widget_name].dart";
Step 6: Use AnimatedAttributes Mixin (for animatable properties)
If the widget has properties of types that support animation (e.g., width/height, Offset, and other types implementing .lerp), apply the AnimatedAttributes mixin.
The AnimatedAttributes mixin provides the mergeWithDataSource method that merges the widget's attributes with animated properties from the DuitAnimationContext if animation is active.
When to use AnimatedAttributes:
Apply this mixin when the widget has animatable properties such as:
- Numeric values:
width, height, opacity, scale
- Position/offset:
Offset, Size
- Colors and other interpolatable types
Implementation pattern:
dart
1import "package:flutter_duit/flutter_duit.dart";
2import "package:flutter_duit/src/animations/animated_props.dart";
3
4class Duit[WidgetName] extends StatelessWidget with AnimatedAttributes {
5 final ViewAttribute attributes;
6 final Widget child;
7
8 const Duit[WidgetName]({
9 required this.attributes,
10 required this.child,
11 super.key,
12 });
13
14 @override
15 Widget build(BuildContext context) {
16 final attrs = mergeWithDataSource(context, attributes.payload);
17 return [WidgetName](
18 key: ValueKey(attributes.id),
19 // ... widget-specific properties using attrs methods
20 // Properties will use animated values if animation is active
21 width: attrs.getDouble(key: "width"),
22 height: attrs.getDouble(key: "height"),
23 child: child,
24 );
25 }
26}
For controlled widgets:
dart
1class _DuitControlled[WidgetName]State extends State<DuitControlled[WidgetName]>
2 with ViewControllerChangeListener {
3 // ... existing code ...
4
5 @override
6 Widget build(BuildContext context) {
7 final attrs = widget.mergeWithDataSource(context, attributes);
8 return [WidgetName](
9 key: ValueKey(widget.controller.id),
10 // ... widget-specific properties
11 width: attrs.getDouble(key: "width"),
12 height: attrs.getDouble(key: "height"),
13 child: widget.child,
14 );
15 }
16}
How mergeWithDataSource works:
The method checks for the following conditions:
- If the dataSource has a parentBuilderId
- If a DuitAnimationContext exists in the widget tree
- If the animation context's parentId matches the dataSource's parentBuilderId
- If there are affected properties to animate
If all conditions are met, it merges the animated values from the animation context streams with the current dataSource, allowing animated values to override static ones.
Key points:
- Apply
AnimatedAttributes mixin to both non-controlled and controlled widget classes
- For non-controlled widgets: call
mergeWithDataSource(context, attributes.payload)
- For controlled widgets: call
widget.mergeWithDataSource(context, attributes) (attributes provided by ViewControllerChangeListener)
- Use the merged attributes to extract all animatable properties
- The mixin gracefully handles cases where no animation is active (returns original dataSource)
Working with Themes
Theme Overview
Duit framework provides a theming system that allows you to define reusable styling for widgets. Themes are defined globally and applied automatically when widgets specify a theme attribute in their JSON definition.
How Themes Work
- Theme Definition: Themes are defined in a JSON structure and processed by
DuitThemePreprocessor
- Theme Tokens: Each widget type has an associated
ThemeToken class that defines which attributes can be themed
- Theme Application: When a widget specifies
theme: "theme_name", the theme data is merged with the widget's attributes during ViewAttribute.from() creation
- Excluded Fields: Each ThemeToken has a list of excluded fields that should NOT be themed (e.g., callbacks, data, structural properties)
Theme System Components
Theme Tokens
Theme tokens are defined in lib/src/ui/theme/tokens.dart. Each token type excludes specific fields that should not be part of the theme:
- TextThemeToken: Excludes
data (the text content itself)
- ImageThemeToken: Excludes
src, byteData (image source data)
- AttendedWidgetThemeToken: Excludes
value (for Checkbox, Switch, etc.)
- RadioThemeToken: Excludes animation-related properties
- SliderThemeToken: Excludes callback properties (
onChanged, onChangeStart, onChangeEnd)
- ImplicitAnimatableThemeToken: Excludes
onEnd callback
- ExcludeGestureCallbacksThemeToken: Excludes all gesture callbacks (
onTap, onDoubleTap, etc.)
- ExcludeChildThemeToken: Excludes child subviews (
title, actions, body, etc. for AppBar/Scaffold)
- DynamicChildHolderThemeToken: Excludes
childObjects, constructor
- AnimatedPropOwnerThemeToken: Excludes animation-related properties
- DefaultThemeToken: No exclusions (for widgets that can be fully themed)
Theme Preprocessor
The DuitThemePreprocessor class (lib/src/ui/theme/preprocessor.dart) handles theme token creation. It maps widget types to appropriate theme tokens:
dart
1final class DuitThemePreprocessor extends ThemePreprocessor {
2 const DuitThemePreprocessor({
3 super.customWidgetTokenizer,
4 super.overrideWidgetTokenizer,
5 });
6
7 @override
8 ThemeToken createToken(String widgetType, Map<String, dynamic> themeData) {
9 final type = ElementType.valueOrNull(widgetType);
10 return switch (type) {
11 ElementType.text => TextThemeToken(themeData),
12 ElementType.image => ImageThemeToken(themeData),
13 ElementType.checkbox || ElementType.switch_ || ElementType.textField || ElementType.meta =>
14 AttendedWidgetThemeToken(themeData, widgetType),
15 // ... more mappings
16 _ => customWidgetTokenizer?.call(widgetType, themeData) ?? const UnknownThemeToken(),
17 };
18 }
19}
Theme Initialization
Themes are initialized at application startup:
dart
1import 'package:duit_kernel/duit_kernel.dart';
2import 'package:flutter_duit/src/ui/theme/preprocessor.dart';
3
4// Define your themes
5final themePreprocessor = const DuitThemePreprocessor().tokenize({
6 "primary_text": {
7 "type": "Text",
8 "data": {
9 "style": {
10 "fontSize": 16.0,
11 "fontWeight": "w600",
12 "color": "#333333",
13 },
14 "textAlign": "left",
15 },
16 },
17 "secondary_text": {
18 "type": "Text",
19 "data": {
20 "style": {
21 "fontSize": 14.0,
22 "color": "#666666",
23 },
24 },
25 },
26 "primary_button": {
27 "type": "ElevatedButton",
28 "data": {
29 "style": {
30 "backgroundColor": "#2196F3",
31 "foregroundColor": "#FFFFFF",
32 "padding": {"all": 16.0},
33 },
34 },
35 },
36});
37
38// Initialize Duit with themes
39await DuitRegistry.initialize(
40 theme: themePreprocessor,
41);
To apply a theme to a widget, use the theme attribute:
json
1{
2 "type": "Text",
3 "id": "title",
4 "controlled": false,
5 "theme": "primary_text",
6 "attributes": {
7 "data": "Hello World"
8 }
9}
The theme will be merged with the widget's attributes. If both theme and attributes specify the same property, the behavior depends on overrideRule:
themeOverlay (default): Widget attributes take precedence
themePriority: Theme values take precedence
json
1{
2 "type": "Text",
3 "id": "title",
4 "controlled": false,
5 "theme": "primary_text",
6 "overrideRule": "themePriority",
7 "attributes": {
8 "data": "Hello World",
9 "textAlign": "center" // This would be overridden by theme if themePriority
10 }
11}
Custom Theme Tokens
For custom widget types, you can create custom theme tokens:
dart
1final class CustomWidgetThemeToken extends ThemeToken {
2 CustomWidgetThemeToken(Map<String, dynamic> data)
3 : super(
4 const {}, // No excluded fields - allow all to be themed
5 data,
6 "CustomWidget",
7 );
8}
Then provide it to the preprocessor:
dart
1final preprocessor = DuitThemePreprocessor(
2 customWidgetTokenizer: (type, themeData) {
3 switch (type) {
4 case "CustomWidget":
5 return CustomWidgetThemeToken(themeData);
6 default:
7 return null;
8 }
9 },
10);
Or override existing widget tokenization:
dart
1final preprocessor = DuitThemePreprocessor(
2 overrideWidgetTokenizer: (type, themeData) {
3 switch (type) {
4 case "Text":
5 return CustomTextThemeToken(themeData); // Override default TextThemeToken
6 default:
7 return null; // Use default tokenization
8 }
9 },
10);
Theme Token Selection Guide
When implementing a new widget, select the appropriate theme token type based on the widget's characteristics:
| Widget Type | Theme Token | When to Use |
|---|
| Text widgets | TextThemeToken | Text content should be excluded from theme |
| Image widgets | ImageThemeToken | Image source (src, byteData) should be excluded |
| Interactive widgets with value (Checkbox, Switch, etc.) | AttendedWidgetThemeToken | value field should be excluded |
| Radio buttons | RadioThemeToken | Animation properties excluded |
| Sliders | SliderThemeToken | Callbacks (onChanged, etc.) excluded |
| Implicit animations | ImplicitAnimatableThemeToken | onEnd callback excluded |
| Gesture handlers | ExcludeGestureCallbacksThemeToken | All gesture callbacks excluded |
| Layout widgets with children (AppBar, Scaffold) | ExcludeChildThemeToken | Child subviews excluded |
| Lists/Grids | DynamicChildHolderThemeToken | childObjects excluded |
| Containers with animatable properties | AnimatedPropOwnerThemeToken | Animation properties excluded |
| Simple widgets | DefaultThemeToken | No exclusions needed |
Widget implementations do NOT need to handle themes explicitly. The theme merging happens in ViewAttribute.from() during widget tree construction. Your widget code receives attributes that already have theme data merged:
dart
1// In widget_from_element.dart - themes are applied here automatically
2Widget _buildText(ElementPropertyView model) {
3 // model.attributes already has theme data merged
4 return switch (model.controlled) {
5 true => DuitControlledText(controller: model.viewController),
6 false => DuitText(attributes: model.attributes),
7 };
8}
9
10// In your widget implementation - just use attributes directly
11@override
12Widget build(BuildContext context) {
13 final attrs = attributes.payload; // or mergeWithDataSource for animated props
14 return Text(
15 attrs.getString(key: "data"), // Theme already merged into attributes
16 style: attrs.textStyle(),
17 );
18}
Theme Testing
When testing widgets that support themes:
- Initialize the registry with theme preprocessor in
setUpAll
- Create tests that verify theme attributes are applied
- Test override rules (
themeOverlay vs themePriority)
dart
1import "package:duit_kernel/duit_kernel.dart";
2import "package:flutter_duit/flutter_duit.dart";
3import "package:flutter_test/flutter_test.dart";
4
5void main() {
6 setUpAll(() async {
7 final proc = const DuitThemePreprocessor().tokenize({
8 "text_theme": {
9 "type": "Text",
10 "data": {
11 "style": {
12 "fontSize": 24.0,
13 "color": "#FF0000",
14 },
15 },
16 },
17 });
18 await DuitRegistry.initialize(theme: proc);
19 });
20
21 testWidgets("must apply theme attributes", (tester) async {
22 await tester.pumpWidget(
23 Directionality(
24 textDirection: TextDirection.ltr,
25 child: DuitViewHost.withDriver(
26 driver: XDriver.static({
27 "type": "Text",
28 "id": "text_1",
29 "theme": "text_theme",
30 "attributes": {"data": "Themed Text"},
31 }),
32 ),
33 ),
34 );
35
36 final textWidget = tester.widget<Text>(find.byKey(const ValueKey("text_1")));
37 expect(textWidget.style?.fontSize, equals(24.0));
38 expect(textWidget.style?.color, equals(const Color(0xFFFF0000)));
39 });
40}
Writing Tests
Test File Pattern
Create test file in test/ following this structure:
dart
1import "package:flutter/material.dart";
2import "package:flutter_duit/flutter_duit.dart";
3import "package:flutter_test/flutter_test.dart";
4
5import "utils.dart";
6
7Map<String, dynamic> _createWidget([bool isControlled = false]) {
8 return {
9 "type": "[WidgetName]",
10 "id": "[widget_name]_test",
11 "controlled": isControlled,
12 "attributes": {
13 // required attributes
14 },
15 "child": { // or "children": [...]
16 "type": "Container",
17 "id": "con",
18 "controlled": false,
19 "attributes": {"color": "#DCDCDC", "width": 50, "height": 50},
20 },
21 };
22}
23
24void main() {
25 group("Duit[WidgetName] widget tests", () {
26 testWidgets("check widget", (tester) async {
27 await tester.pumpWidget(
28 Directionality(
29 textDirection: TextDirection.ltr,
30 child: DuitViewHost.withDriver(
31 driver: XDriver.static(
32 _createWidget(),
33 ),
34 ),
35 ),
36 );
37 await tester.pumpAndSettle();
38
39 final widget = find.byKey(const ValueKey("[widget_name]_test"));
40 expect(widget, findsOneWidget);
41 });
42
43 testWidgets(
44 "must update attributes",
45 (tester) async {
46 final driver = XDriver.static(
47 _createWidget(true),
48 );
49
50 await pumpDriver(tester, driver.asInternalDriver);
51
52 final widget = find.byKey(const ValueKey("[widget_name]_test"));
53 expect(widget, findsOneWidget);
54
55 // Verify initial state
56 final initialWidget = tester.widget<[WidgetName]>(widget);
57 expect(initialWidget.property, expectedValue);
58
59 await driver.asInternalDriver.updateAttributes(
60 "[widget_name]_test",
61 {
62 "property": newValue,
63 },
64 );
65
66 await tester.pumpAndSettle();
67
68 // Verify updated state
69 final updatedWidget = tester.widget<[WidgetName]>(widget);
70 expect(updatedWidget.property, newValue);
71 },
72 );
73 });
74}
Test Guidelines:
- Always use
XDriver.static for widget tests
- Create helper function
_createWidget([bool isControlled = false])
- Test both non-controlled and controlled versions
- Use
find.byKey(const ValueKey("...")) to locate widgets
- For controlled widgets, test
updateAttributes functionality
- Use
pumpDriver(tester, driver.asInternalDriver) for controlled widget tests
- Import
utils.dart for helper functions like pumpDriver
Attribute Access Patterns
When accessing attributes in widgets:
dart
1final attrs = attributes.payload; // or use attributes directly in controlled widgets
2
3// Double values
4width: attrs.tryGetDouble(key: "width")
5height: attrs.getDouble(key: "height") // throws if missing
6attrs.getDouble(key: "value", defaultValue: 0.0)
7
8// Boolean values
9visible: attrs.getBool("visible", defaultValue: true)
10
11// String values
12title: attrs.getString(key: "title")
13
14// Color values
15color: attrs.tryParseColor(key: "color")
16decoration: attrs.decoration()
17
18// EdgeInsets values
19padding: attrs.edgeInsets()
20margin: attrs.edgeInsets(key: "margin")
21
22// Alignment values
23alignment: attrs.alignment()
24
25// BoxConstraints values
26constraints: attrs.boxConstraints()
Code Review Checklist
When reviewing widget implementations:
Determine child relation:
Widget has no children → childRelation: 0
Widget has one child → childRelation: 1
Widget has multiple children → childRelation: 2
Widget is component → childRelation: 3
Widget is fragment → childRelation: 4
Determine isControlledByDefault:
User interaction required (Button, TextField, etc.) → true
State management needed (Animation, etc.) → true
Static wrapper only → false
Common Mistakes to Avoid
- Missing controlled version: Always create both Duit[WidgetName] and DuitControlled[WidgetName]
- Wrong key type: Use
ValueKey not just Key
- Forgetting attachStateToController: Must call this in initState for controlled widgets
- Wrong childRelation: Check widget signature for single vs multiple children
- Missing tests: Every widget must have tests covering both versions
- Not testing updates: Controlled widgets must test attribute updates
- Hard-coded values: Always use attrs methods to extract values from JSON
- Missing AnimatedAttributes for animatable properties: If a widget has properties that support animation (width, height, Offset, etc.), always apply the AnimatedAttributes mixin and use mergeWithDataSource to merge animated values