Skip to content

Commit 745067c

Browse files
authored
Add Icon component (#476)
1 parent 6a86aef commit 745067c

File tree

4 files changed

+249
-2
lines changed

4 files changed

+249
-2
lines changed

packages/flutter_genui/lib/src/catalog/core_catalog.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import 'core_widgets/column.dart' as column_item;
1212
import 'core_widgets/date_time_input.dart' as date_time_input_item;
1313
import 'core_widgets/divider.dart' as divider_item;
1414
import 'core_widgets/heading.dart' as heading_item;
15+
import 'core_widgets/icon.dart' as icon_item;
1516
import 'core_widgets/image.dart' as image_item;
1617
import 'core_widgets/list.dart' as list_item;
1718
import 'core_widgets/modal.dart' as modal_item;
@@ -62,6 +63,9 @@ class CoreCatalogItems {
6263
/// Supports different levels to indicate hierarchy.
6364
static final CatalogItem heading = heading_item.heading;
6465

66+
/// An icon.
67+
static final CatalogItem icon = icon_item.icon;
68+
6569
/// Represents a UI element for displaying image data from a URL or other
6670
/// source.
6771
static final CatalogItem image = image_item.image;
@@ -114,6 +118,7 @@ class CoreCatalogItems {
114118
dateTimeInput,
115119
divider,
116120
heading,
121+
icon,
117122
image,
118123
list,
119124
modal,
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
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/material.dart';
6+
import 'package:json_schema_builder/json_schema_builder.dart';
7+
8+
import '../../model/a2ui_schemas.dart';
9+
import '../../model/catalog_item.dart';
10+
import '../../model/data_model.dart';
11+
import '../../primitives/simple_items.dart';
12+
13+
final _schema = S.object(
14+
properties: {
15+
'name': A2uiSchemas.stringReference(
16+
description:
17+
'''The name of the icon to display. This can be a literal string ('literalString') or a reference to a value in the data model ('path', e.g. '/icon/name').''',
18+
enumValues: AvailableIcons.allAvailable,
19+
),
20+
},
21+
required: ['name'],
22+
);
23+
24+
extension type _IconData.fromMap(JsonMap _json) {
25+
factory _IconData({required JsonMap name}) =>
26+
_IconData.fromMap({'name': name});
27+
28+
JsonMap get nameMap => _json['name'] as JsonMap;
29+
30+
String? get literalName => nameMap['literalString'] as String?;
31+
String? get namePath => nameMap['path'] as String?;
32+
}
33+
34+
enum AvailableIcons {
35+
add(Icons.add),
36+
arrowBack(Icons.arrow_back),
37+
arrowForward(Icons.arrow_forward),
38+
build(Icons.build),
39+
cameraAlt(Icons.camera_alt),
40+
check(Icons.check),
41+
close(Icons.close),
42+
delete(Icons.delete),
43+
download(Icons.download),
44+
edit(Icons.edit),
45+
event(Icons.event),
46+
favorite(Icons.favorite),
47+
home(Icons.home),
48+
info(Icons.info),
49+
locationOn(Icons.location_on),
50+
lockOpen(Icons.lock_open),
51+
lock(Icons.lock),
52+
mail(Icons.mail),
53+
menu(Icons.menu),
54+
notificationsActive(Icons.notifications_active),
55+
notifications(Icons.notifications),
56+
payment(Icons.payment),
57+
person(Icons.person),
58+
phone(Icons.phone),
59+
photoLibrary(Icons.photo_library),
60+
search(Icons.search),
61+
settings(Icons.settings),
62+
share(Icons.share),
63+
shoppingCart(Icons.shopping_cart),
64+
upload(Icons.upload),
65+
visibilityOff(Icons.visibility_off),
66+
visibility(Icons.visibility);
67+
68+
const AvailableIcons(this.iconData);
69+
70+
final IconData iconData;
71+
72+
static List<String> get allAvailable =>
73+
values.map<String>((icon) => icon.name).toList();
74+
75+
static AvailableIcons? fromName(String name) {
76+
for (final iconName in AvailableIcons.values) {
77+
if (iconName.name == name) {
78+
return iconName;
79+
}
80+
}
81+
return null;
82+
}
83+
}
84+
85+
/// A catalog item for an icon.
86+
///
87+
/// ### Parameters:
88+
///
89+
/// - `name`: The name of the icon to display.
90+
final icon = CatalogItem(
91+
name: 'Icon',
92+
dataSchema: _schema,
93+
widgetBuilder:
94+
({
95+
required data,
96+
required id,
97+
required buildChild,
98+
required dispatchEvent,
99+
required context,
100+
required dataContext,
101+
required getComponent,
102+
}) {
103+
final iconData = _IconData.fromMap(data as JsonMap);
104+
final literalName = iconData.literalName;
105+
final namePath = iconData.namePath;
106+
107+
if (literalName != null) {
108+
final icon =
109+
AvailableIcons.fromName(literalName)?.iconData ??
110+
Icons.broken_image;
111+
return Icon(icon);
112+
}
113+
114+
if (namePath == null) {
115+
return const Icon(Icons.broken_image);
116+
}
117+
118+
final notifier = dataContext.subscribe<String>(DataPath(namePath));
119+
120+
return ValueListenableBuilder<String?>(
121+
valueListenable: notifier,
122+
builder: (context, currentValue, child) {
123+
final iconName = currentValue ?? '';
124+
final icon =
125+
AvailableIcons.fromName(iconName)?.iconData ??
126+
Icons.broken_image;
127+
return Icon(icon);
128+
},
129+
);
130+
},
131+
exampleData: [
132+
() => '''
133+
[
134+
{
135+
"id": "root",
136+
"component": {
137+
"Icon": {
138+
"name": {
139+
"literalString": "add"
140+
}
141+
}
142+
}
143+
}
144+
]
145+
''',
146+
],
147+
);

packages/flutter_genui/lib/src/model/a2ui_schemas.dart

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,20 @@ class A2uiSchemas {
1414
/// data-bound path to a string in the DataModel. If both path and
1515
/// literal are provided, the value at the path will be initialized
1616
/// with the literal.
17-
static Schema stringReference({String? description}) => S.object(
17+
///
18+
/// If `enumValues` are provided, the string value (either literal or at the
19+
/// path) must be one of the values in the enum.
20+
static Schema stringReference({
21+
String? description,
22+
List<String>? enumValues,
23+
}) => S.object(
1824
description: description,
1925
properties: {
2026
'path': S.string(
2127
description: 'A relative or absolute path in the data model.',
28+
enumValues: enumValues,
2229
),
23-
'literalString': S.string(),
30+
'literalString': S.string(enumValues: enumValues),
2431
},
2532
);
2633

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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/material.dart';
6+
import 'package:flutter_genui/flutter_genui.dart';
7+
import 'package:flutter_test/flutter_test.dart';
8+
9+
void main() {
10+
testWidgets('Icon widget renders with literal string', (
11+
WidgetTester tester,
12+
) async {
13+
final manager = GenUiManager(
14+
catalog: Catalog([CoreCatalogItems.icon]),
15+
configuration: const GenUiConfiguration(),
16+
);
17+
const surfaceId = 'testSurface';
18+
final components = [
19+
const Component(
20+
id: 'icon',
21+
componentProperties: {
22+
'Icon': {
23+
'name': {'literalString': 'add'},
24+
},
25+
},
26+
),
27+
];
28+
manager.handleMessage(
29+
SurfaceUpdate(surfaceId: surfaceId, components: components),
30+
);
31+
manager.handleMessage(
32+
const BeginRendering(surfaceId: surfaceId, root: 'icon'),
33+
);
34+
35+
await tester.pumpWidget(
36+
MaterialApp(
37+
home: Scaffold(
38+
body: GenUiSurface(host: manager, surfaceId: surfaceId),
39+
),
40+
),
41+
);
42+
43+
expect(find.byIcon(Icons.add), findsOneWidget);
44+
});
45+
46+
testWidgets('Icon widget renders with data binding', (
47+
WidgetTester tester,
48+
) async {
49+
final manager = GenUiManager(
50+
catalog: Catalog([CoreCatalogItems.icon]),
51+
configuration: const GenUiConfiguration(),
52+
);
53+
const surfaceId = 'testSurface';
54+
final components = [
55+
const Component(
56+
id: 'icon',
57+
componentProperties: {
58+
'Icon': {
59+
'name': {'path': '/iconName'},
60+
},
61+
},
62+
),
63+
];
64+
manager.handleMessage(
65+
SurfaceUpdate(surfaceId: surfaceId, components: components),
66+
);
67+
manager.handleMessage(
68+
const DataModelUpdate(
69+
surfaceId: 'testSurface',
70+
path: '/iconName',
71+
contents: 'close',
72+
),
73+
);
74+
manager.handleMessage(
75+
const BeginRendering(surfaceId: surfaceId, root: 'icon'),
76+
);
77+
78+
await tester.pumpWidget(
79+
MaterialApp(
80+
home: Scaffold(
81+
body: GenUiSurface(host: manager, surfaceId: surfaceId),
82+
),
83+
),
84+
);
85+
86+
expect(find.byIcon(Icons.close), findsOneWidget);
87+
});
88+
}

0 commit comments

Comments
 (0)