Skip to content

Commit 2c7271e

Browse files
committed
Formulas join lists (concat, list + list); icons can be bound to an icon name
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AjsYaRkTHNKszSHjd3j264
1 parent 64b29a4 commit 2c7271e

5 files changed

Lines changed: 88 additions & 4 deletions

File tree

‎lib/src/transformers/node_transformers/passive_icon_transformer.dart‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,15 +35,25 @@ class PassiveIconWidget extends StatelessWidget {
3535

3636
@override
3737
Widget build(BuildContext context) {
38+
final ScopedValues scopedValues = ScopedValues.of(context);
3839
final ColorRGBA? iconColor =
3940
PropertyValueDelegate.getPropertyValue<ColorRGBA>(
4041
node,
4142
'iconColor',
42-
scopedValues: ScopedValues.of(context),
43+
scopedValues: scopedValues,
4344
);
44-
final MultiSourceIconModel icon = iconColor == null
45-
? node.properties.icon
46-
: node.properties.icon.copyWith(color: iconColor);
45+
// An `icon` binding names the icon to show, e.g. a list item's.
46+
MultiSourceIconModel icon = iconWithName(
47+
node.properties.icon,
48+
node.variables['icon'] == null
49+
? null
50+
: PropertyValueDelegate.getPropertyValue<String>(
51+
node,
52+
'icon',
53+
scopedValues: scopedValues,
54+
),
55+
);
56+
if (iconColor != null) icon = icon.copyWith(color: iconColor);
4757
return retrieveIconWidget(icon, node.basicBoxLocal.width) ??
4858
SizedBox(
4959
width: node.basicBoxLocal.width,

‎lib/src/transformers/transformer_functions.dart‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,34 @@ String? getSliderLabel(SliderNode node, double value) {
351351
return node.properties.label.replaceAll('\${value}', valueString);
352352
}
353353

354+
/// [base] showing the Material icon named [name] instead, for an icon bound
355+
/// to data. [name] is a Flutter `Icons` name such as `kitchen` or
356+
/// `kitchen_outlined`: an `_outlined`, `_rounded` or `_sharp` suffix picks
357+
/// the style. The icon is drawn from its SVG by name, so no codepoint is
358+
/// needed. Returns [base] for an empty or malformed name.
359+
MultiSourceIconModel iconWithName(MultiSourceIconModel base, String? name) {
360+
final String key = (name ?? '').trim().toLowerCase();
361+
if (!RegExp(r'^[a-z0-9_]+$').hasMatch(key)) return base;
362+
MaterialIconStyle style = MaterialIconStyle.filled;
363+
String iconName = key;
364+
for (final (String suffix, MaterialIconStyle suffixStyle) in const [
365+
('_outlined', MaterialIconStyle.outlined),
366+
('_rounded', MaterialIconStyle.rounded),
367+
('_sharp', MaterialIconStyle.sharp),
368+
]) {
369+
if (key.endsWith(suffix)) {
370+
style = suffixStyle;
371+
iconName = key.substring(0, key.length - suffix.length);
372+
break;
373+
}
374+
}
375+
return base.copyWith(
376+
type: IconTypeEnum.icon,
377+
icon: MaterialIcon(codepoint: 0, name: iconName, style: style),
378+
forceIcon: true,
379+
);
380+
}
381+
354382
Widget? retrieveIconWidget(
355383
MultiSourceIconModel icon, [
356384
double? size,

‎lib/src/utils/formula.dart‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,11 @@ class FormulaEvaluator {
290290
FormulaFunctionDoc('take', 'take(list, count)', 'take(alerts, 3)'),
291291
FormulaFunctionDoc('skip', 'skip(list, count)', 'skip(alerts, 3)'),
292292
FormulaFunctionDoc('keys', 'keys(map)', 'keys(snoozed) → [r_black]'),
293+
FormulaFunctionDoc(
294+
'concat',
295+
'concat(list, list or value, ...)',
296+
'concat(take(history, 23), [soc]); history + [soc] does the same',
297+
),
293298
FormulaFunctionDoc(
294299
'alpha',
295300
'alpha(color, opacity)',
@@ -907,6 +912,10 @@ final Map<String, _Function> _functions = {
907912
'alpha': (a) => _alpha(_arg(a, 0), _num(_arg(a, 1))),
908913
'take': (a) => _values(_arg(a, 0), null).take(_intArg(_arg(a, 1))).toList(),
909914
'skip': (a) => _values(_arg(a, 0), null).skip(_intArg(_arg(a, 1))).toList(),
915+
'concat': (a) => [
916+
for (final Object? item in a)
917+
if (item is Iterable) ...item else item,
918+
],
910919
'keys': (a) => switch (_arg(a, 0)) {
911920
final Map m => [for (final key in m.keys) '$key'],
912921
_ => <Object?>[],
@@ -1099,6 +1108,8 @@ class _Binary extends _Node {
10991108
final Object? b = right.eval(resolve);
11001109
switch (op) {
11011110
case '+':
1111+
// Lists join, and a list plus a value appends it.
1112+
if (a is Iterable) return [...a, if (b is Iterable) ...b else b];
11021113
final num? na = a is String ? null : _toNum(a);
11031114
final num? nb = b is String ? null : _toNum(b);
11041115
if (na != null && nb != null) return _tidy(na + nb);

‎test/formula_test.dart‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ void main() {
7272
expect(eval("alpha('red', 1)"), null);
7373
expect(eval('take([1, 2, 3, 4], 3)'), [1, 2, 3]);
7474
expect(eval('take([1], 3)'), [1]);
75+
expect(eval('concat([1, 2], [3], 4)'), [1, 2, 3, 4]);
76+
expect(eval('concat(take([1, 2, 3], 2), [9])'), [1, 2, 9]);
77+
expect(eval('[1, 2] + [3]'), [1, 2, 3]);
78+
expect(eval('[1, 2] + 3'), [1, 2, 3]);
79+
expect(eval("'a' + 'b'"), 'ab');
7580
expect(eval('skip([1, 2, 3], 1)'), [2, 3]);
7681
expect(eval("keys(object('a', 1, 'b', 2))"), ['a', 'b']);
7782
expect(eval('keys(qty)'), isEmpty);

‎test/icon_binding_test.dart‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import 'package:codelessly_api/codelessly_api.dart';
2+
import 'package:codelessly_sdk/codelessly_sdk.dart';
3+
import 'package:flutter_test/flutter_test.dart';
4+
5+
void main() {
6+
const MultiSourceIconModel base = MultiSourceIconModel.icon(
7+
icon: MaterialIcon(codepoint: 0xe5e0, name: 'speed', style: MaterialIconStyle.filled),
8+
);
9+
10+
test('a bound name picks the icon and its style', () {
11+
final MaterialIcon icon =
12+
iconWithName(base, 'kitchen_outlined').icon! as MaterialIcon;
13+
expect(icon.name, 'kitchen');
14+
expect(icon.style, MaterialIconStyle.outlined);
15+
expect(icon.toSvgUrl(),
16+
'https://fonts.gstatic.com/s/i/materialiconsoutlined/kitchen/v1/24px.svg');
17+
18+
final MaterialIcon filled = iconWithName(base, 'Air').icon! as MaterialIcon;
19+
expect(filled.name, 'air');
20+
expect(filled.style, MaterialIconStyle.filled);
21+
expect((iconWithName(base, 'power_rounded').icon! as MaterialIcon).style,
22+
MaterialIconStyle.rounded);
23+
});
24+
25+
test('an empty or malformed name keeps the designed icon', () {
26+
expect(iconWithName(base, null), same(base));
27+
expect(iconWithName(base, ' '), same(base));
28+
expect(iconWithName(base, r'${item.icon}'), same(base));
29+
});
30+
}

0 commit comments

Comments
 (0)