Skip to content

Commit 0c628e3

Browse files
committed
Addressed TODOs added tests.
1 parent c2609d0 commit 0c628e3

File tree

9 files changed

+920
-60
lines changed

9 files changed

+920
-60
lines changed

packages/spikes/gulf_client/gulf_schema.json

Lines changed: 682 additions & 0 deletions
Large diffs are not rendered by default.

packages/spikes/gulf_client/lib/src/models/component.dart

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
// Use of this source code is governed by a BSD-style license that can be
33
// found in the LICENSE file.
44

5+
import '../utils/json_utils.dart';
6+
57
/// An exception that is thrown when an unknown component type is encountered.
68
class UnknownComponentException implements Exception {
79
UnknownComponentException(this.type);
@@ -25,7 +27,7 @@ class Component {
2527
factory Component.fromJson(Map<String, dynamic> json) {
2628
return Component(
2729
id: json['id'] as String,
28-
weight: (json['weight'] as num?)?.toDouble(),
30+
weight: JsonUtils.parseDouble(json['weight']),
2931
componentProperties: ComponentProperties.fromJson(
3032
json['componentProperties'] as Map<String, dynamic>,
3133
),
@@ -291,7 +293,7 @@ class DividerProperties implements ComponentProperties {
291293
return DividerProperties(
292294
axis: json['axis'] as String?,
293295
color: json['color'] as String?,
294-
thickness: (json['thickness'] as num?)?.toDouble(),
296+
thickness: JsonUtils.parseDouble(json['thickness']),
295297
);
296298
}
297299

@@ -463,8 +465,8 @@ class SliderProperties implements ComponentProperties {
463465
factory SliderProperties.fromJson(Map<String, dynamic> json) {
464466
return SliderProperties(
465467
value: BoundValue.fromJson(json['value'] as Map<String, dynamic>),
466-
minValue: (json['minValue'] as num?)?.toDouble(),
467-
maxValue: (json['maxValue'] as num?)?.toDouble(),
468+
minValue: JsonUtils.parseDouble(json['minValue']),
469+
maxValue: JsonUtils.parseDouble(json['maxValue']),
468470
);
469471
}
470472

@@ -491,7 +493,7 @@ class BoundValue {
491493
return BoundValue(
492494
path: json['path'] as String?,
493495
literalString: json['literalString'] as String?,
494-
literalNumber: (json['literalNumber'] as num?)?.toDouble(),
496+
literalNumber: JsonUtils.parseDouble(json['literalNumber']),
495497
literalBoolean: json['literalBoolean'] as bool?,
496498
);
497499
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Copyright 2025 The Flutter Authors.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
/// A utility class for handling JSON parsing.
6+
class JsonUtils {
7+
/// Safely parses a [value] to a [double].
8+
static double? parseDouble(Object? value) {
9+
if (value is num) {
10+
return value.toDouble();
11+
}
12+
return null;
13+
}
14+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
// Copyright 2025 The Flutter Authors.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import '../models/component.dart';
6+
import '../core/interpreter.dart';
7+
8+
/// A visitor that resolves the properties of a [Component].
9+
class ComponentPropertiesVisitor {
10+
/// Creates a new [ComponentPropertiesVisitor].
11+
const ComponentPropertiesVisitor(this.interpreter);
12+
13+
/// The interpreter to use for resolving data bindings.
14+
final GulfInterpreter interpreter;
15+
16+
/// Resolves the properties of a [component].
17+
Map<String, Object?> visit(
18+
ComponentProperties properties,
19+
Map<String, dynamic>? itemData,
20+
) {
21+
return switch (properties) {
22+
TextProperties() => {'text': _resolveValue(properties.text, itemData)},
23+
HeadingProperties() => {
24+
'text': _resolveValue(properties.text, itemData),
25+
'level': properties.level,
26+
},
27+
ImageProperties() => {'url': _resolveValue(properties.url, itemData)},
28+
VideoProperties() => {'url': _resolveValue(properties.url, itemData)},
29+
AudioPlayerProperties() => {
30+
'url': _resolveValue(properties.url, itemData),
31+
'description': _resolveValue(properties.description, itemData),
32+
},
33+
ButtonProperties() => {
34+
'label': _resolveValue(properties.label, itemData),
35+
'action': properties.action,
36+
},
37+
CheckBoxProperties() => {
38+
'label': _resolveValue(properties.label, itemData),
39+
'value': _resolveValue(properties.value, itemData),
40+
},
41+
TextFieldProperties() => {
42+
'text': _resolveValue(properties.text, itemData),
43+
'label': _resolveValue(properties.label, itemData),
44+
'type': properties.type,
45+
'validationRegexp': properties.validationRegexp,
46+
},
47+
DateTimeInputProperties() => {
48+
'value': _resolveValue(properties.value, itemData),
49+
'enableDate': properties.enableDate,
50+
'enableTime': properties.enableTime,
51+
'outputFormat': properties.outputFormat,
52+
},
53+
MultipleChoiceProperties() => {
54+
'selections': _resolveValue(properties.selections, itemData),
55+
'options': properties.options,
56+
'maxAllowedSelections': properties.maxAllowedSelections,
57+
},
58+
SliderProperties() => {
59+
'value': _resolveValue(properties.value, itemData),
60+
'minValue': properties.minValue,
61+
'maxValue': properties.maxValue,
62+
},
63+
RowProperties() => {},
64+
ColumnProperties() => {},
65+
ListProperties() => {},
66+
CardProperties() => {},
67+
TabsProperties() => {},
68+
DividerProperties() => {},
69+
ModalProperties() => {},
70+
_ => {},
71+
};
72+
}
73+
74+
Object? _resolveValue(BoundValue? value, Map<String, dynamic>? itemData) {
75+
if (value == null) {
76+
return null;
77+
}
78+
if (value.literalString != null) {
79+
return value.literalString;
80+
} else if (value.literalNumber != null) {
81+
return value.literalNumber;
82+
} else if (value.literalBoolean != null) {
83+
return value.literalBoolean;
84+
} else if (value.path != null) {
85+
if (itemData != null) {
86+
return itemData[value.path!];
87+
} else {
88+
return interpreter.resolveDataBinding(value.path!);
89+
}
90+
}
91+
return null;
92+
}
93+
}

packages/spikes/gulf_client/lib/src/widgets/gulf_view.dart

Lines changed: 8 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
66
import '../core/interpreter.dart';
77
import '../core/widget_registry.dart';
88
import '../models/component.dart';
9+
import 'component_properties_visitor.dart';
910
import 'gulf_provider.dart';
1011

1112
/// The main entry point for rendering a UI from the GULF Streaming Protocol.
@@ -136,52 +137,8 @@ class _LayoutEngine extends StatelessWidget {
136137
children['child'] = [_buildNode(context, properties.child, newVisited)];
137138
}
138139

139-
final resolvedProperties = <String, Object?>{};
140-
// TODO(gspencergoog): find a more generic way to do this.
141-
if (properties is TextProperties) {
142-
resolvedProperties['text'] = _resolveValue(properties.text, null);
143-
} else if (properties is HeadingProperties) {
144-
resolvedProperties['text'] = _resolveValue(properties.text, null);
145-
resolvedProperties['level'] = properties.level;
146-
} else if (properties is ImageProperties) {
147-
resolvedProperties['url'] = _resolveValue(properties.url, null);
148-
} else if (properties is VideoProperties) {
149-
resolvedProperties['url'] = _resolveValue(properties.url, null);
150-
} else if (properties is AudioPlayerProperties) {
151-
resolvedProperties['url'] = _resolveValue(properties.url, null);
152-
resolvedProperties['description'] = _resolveValue(
153-
properties.description,
154-
null,
155-
);
156-
} else if (properties is ButtonProperties) {
157-
resolvedProperties['label'] = _resolveValue(properties.label, null);
158-
resolvedProperties['action'] = properties.action;
159-
} else if (properties is CheckBoxProperties) {
160-
resolvedProperties['label'] = _resolveValue(properties.label, null);
161-
resolvedProperties['value'] = _resolveValue(properties.value, null);
162-
} else if (properties is TextFieldProperties) {
163-
resolvedProperties['text'] = _resolveValue(properties.text, null);
164-
resolvedProperties['label'] = _resolveValue(properties.label, null);
165-
resolvedProperties['type'] = properties.type;
166-
resolvedProperties['validationRegexp'] = properties.validationRegexp;
167-
} else if (properties is DateTimeInputProperties) {
168-
resolvedProperties['value'] = _resolveValue(properties.value, null);
169-
resolvedProperties['enableDate'] = properties.enableDate;
170-
resolvedProperties['enableTime'] = properties.enableTime;
171-
resolvedProperties['outputFormat'] = properties.outputFormat;
172-
} else if (properties is MultipleChoiceProperties) {
173-
resolvedProperties['selections'] = _resolveValue(
174-
properties.selections,
175-
null,
176-
);
177-
resolvedProperties['options'] = properties.options;
178-
resolvedProperties['maxAllowedSelections'] =
179-
properties.maxAllowedSelections;
180-
} else if (properties is SliderProperties) {
181-
resolvedProperties['value'] = _resolveValue(properties.value, null);
182-
resolvedProperties['minValue'] = properties.minValue;
183-
resolvedProperties['maxValue'] = properties.maxValue;
184-
}
140+
final visitor = ComponentPropertiesVisitor(interpreter);
141+
final resolvedProperties = visitor.visit(properties, null);
185142

186143
return builder(context, component, resolvedProperties, children);
187144
}
@@ -212,15 +169,11 @@ class _LayoutEngine extends StatelessWidget {
212169
);
213170
}
214171
final children = data.map((Object? itemData) {
215-
final resolvedProperties = <String, Object?>{};
216-
final templateProperties = templateComponent.componentProperties;
217-
if (templateProperties is TextProperties) {
218-
resolvedProperties['text'] = _resolveValue(
219-
templateProperties.text,
220-
itemData as Map<String, Object?>,
221-
);
222-
}
223-
// TODO(gspencer): Add all other properties types here.
172+
final visitor = ComponentPropertiesVisitor(interpreter);
173+
final resolvedProperties = visitor.visit(
174+
templateComponent.componentProperties,
175+
itemData as Map<String, Object?>,
176+
);
224177
final itemChildren = <String, List<Widget>>{};
225178
final itemBuilder = registry.getBuilder(
226179
templateComponent.componentProperties.runtimeType.toString(),

packages/spikes/gulf_client/pubspec.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,6 @@ dev_dependencies:
2424
sdk: flutter
2525
freezed: ^3.2.3
2626
json_serializable: ^6.11.1
27+
mockito: ^5.5.1
2728

2829
flutter:
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// Copyright 2025 The Flutter Authors.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter_test/flutter_test.dart';
6+
import 'package:gulf_client/src/models/component.dart';
7+
import 'package:gulf_client/src/widgets/component_properties_visitor.dart';
8+
9+
import 'fakes.dart';
10+
11+
void main() {
12+
group('ComponentPropertiesVisitor', () {
13+
late FakeGulfInterpreter fakeInterpreter;
14+
late ComponentPropertiesVisitor visitor;
15+
16+
setUp(() {
17+
fakeInterpreter = FakeGulfInterpreter();
18+
visitor = ComponentPropertiesVisitor(fakeInterpreter);
19+
});
20+
21+
test('visit TextProperties with literal value', () {
22+
const properties = TextProperties(
23+
text: BoundValue(literalString: 'Hello'),
24+
);
25+
final result = visitor.visit(properties, null);
26+
expect(result, {'text': 'Hello'});
27+
});
28+
29+
test('visit TextProperties with bound value', () {
30+
fakeInterpreter.onResolveDataBinding('path.to.text', 'World');
31+
const properties = TextProperties(
32+
text: BoundValue(path: 'path.to.text'),
33+
);
34+
final result = visitor.visit(properties, null);
35+
expect(result, {'text': 'World'});
36+
});
37+
38+
test('visit HeadingProperties', () {
39+
const properties = HeadingProperties(
40+
text: BoundValue(literalString: 'Title'),
41+
level: 'h1',
42+
);
43+
final result = visitor.visit(properties, null);
44+
expect(result, {'text': 'Title', 'level': 'h1'});
45+
});
46+
});
47+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Copyright 2025 The Flutter Authors.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter/foundation.dart';
6+
import 'package:gulf_client/src/core/interpreter.dart';
7+
import 'package:gulf_client/src/models/component.dart';
8+
9+
class FakeGulfInterpreter extends ChangeNotifier implements GulfInterpreter {
10+
final Map<String, Object?> _data = {};
11+
12+
void onResolveDataBinding(String path, Object? value) {
13+
_data[path] = value;
14+
}
15+
16+
@override
17+
Object? resolveDataBinding(String path) {
18+
return _data[path];
19+
}
20+
21+
@override
22+
String? get error => throw UnimplementedError();
23+
24+
@override
25+
bool get isReadyToRender => throw UnimplementedError();
26+
27+
@override
28+
void processMessage(String jsonl) {
29+
throw UnimplementedError();
30+
}
31+
32+
@override
33+
String? get rootComponentId => throw UnimplementedError();
34+
35+
@override
36+
Stream<String> get stream => throw UnimplementedError();
37+
38+
@override
39+
Component? getComponent(String id) {
40+
throw UnimplementedError();
41+
}
42+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Copyright 2025 The Flutter Authors.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
import 'package:flutter_test/flutter_test.dart';
6+
import 'package:gulf_client/src/utils/json_utils.dart';
7+
8+
void main() {
9+
group('JsonUtils', () {
10+
test('parseDouble returns double for int', () {
11+
expect(JsonUtils.parseDouble(1), 1.0);
12+
});
13+
14+
test('parseDouble returns double for double', () {
15+
expect(JsonUtils.parseDouble(1.5), 1.5);
16+
});
17+
18+
test('parseDouble returns null for non-num', () {
19+
expect(JsonUtils.parseDouble('a'), isNull);
20+
});
21+
22+
test('parseDouble returns null for null', () {
23+
expect(JsonUtils.parseDouble(null), isNull);
24+
});
25+
});
26+
}

0 commit comments

Comments
 (0)