From 7f9a6cc879f17ecdbf10e1e1d10afb47b119e0a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:18:34 +0000 Subject: [PATCH 01/27] Bind a text node's color with a textColor variable or formula Found rebuilding the Tidepool reference app in CodelesslyEditor. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3B1XCZZ7QGvZLUGgSG5H4 --- lib/src/transformers/utils/text_utils.dart | 23 +++++++- test/text_color_binding_test.dart | 67 ++++++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 test/text_color_binding_test.dart diff --git a/lib/src/transformers/utils/text_utils.dart b/lib/src/transformers/utils/text_utils.dart index b677d159..0b0cf459 100644 --- a/lib/src/transformers/utils/text_utils.dart +++ b/lib/src/transformers/utils/text_utils.dart @@ -39,7 +39,8 @@ class TextUtils { context, characters, node: node, - color: retrievePropColor(prop), + color: boundTextColor(context, node, variablesOverrides) ?? + retrievePropColor(prop), fontSize: prop.fontSize, letterSpacing: prop.letterSpacing, fontName: prop.fontName, @@ -272,6 +273,26 @@ class TextUtils { ); } + /// The color bound to a text node's `textColor` property (a variable, + /// path or formula giving a color such as `#1F2B2E`), which recolors every + /// span of the text. Null when nothing is bound or there's no [context]. + static Color? boundTextColor( + BuildContext? context, + BaseNode? node, + List variablesOverrides, + ) { + if (context == null || node == null) return null; + if (node.variables['textColor'] == null) return null; + return PropertyValueDelegate.getPropertyValue( + node, + 'textColor', + scopedValues: ScopedValues.of( + context, + variablesOverrides: variablesOverrides, + ), + )?.toFlutterColor(); + } + static Color? retrievePropColor(TextProp prop) => prop.fills .firstWhereOrNull((fill) => fill.color != null) ?.toFlutterColor(); diff --git a/test/text_color_binding_test.dart b/test/text_color_binding_test.dart new file mode 100644 index 00000000..841c2016 --- /dev/null +++ b/test/text_color_binding_test.dart @@ -0,0 +1,67 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +TextNode text(Map variables) => TextNode.fromJson({ + 'id': 'text', + 'name': 'Label', + 'type': 'text', + 'basicBoxLocal': [0, 0, 120, 20], + 'characters': 'Mid zone', + 'textMixedProps': [ + { + 'start': 0, + 'end': 8, + 'fills': [ + {'id': 'f', 'type': 'solid', 'color': '#111111'}, + ], + }, + ], +})..variables.addAll(variables); + +Future> pumpText(WidgetTester tester, TextNode node) async { + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Center( + child: PassiveTextWidget( + node: node, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ); + final RichText rich = tester.widget(find.byType(RichText).first); + final List colors = []; + rich.text.visitChildren((span) { + if (span is TextSpan && span.text != null) colors.add(span.style?.color); + return true; + }); + return colors; +} + +void main() { + testWidgets('A textColor formula recolors every span of a text', ( + WidgetTester tester, + ) async { + final colors = await pumpText( + tester, + text({'textColor': r'${= 1 < 2 ? "#F3ECDD" : "#1F2B2E"}'}), + ); + expect(colors, isNotEmpty); + for (final c in colors) { + expect(c?.toARGB32(), 0xFFF3ECDD); + } + }); + + testWidgets('A text without a textColor binding keeps its span colors', ( + WidgetTester tester, + ) async { + final colors = await pumpText(tester, text({})); + expect(colors.single?.toARGB32(), isNot(0xFFF3ECDD)); + }); +} From 64f0fd4321c126c6a881bb524c84f146ac089f8c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:33:52 +0000 Subject: [PATCH 02/27] Formula series/text helpers, API results in ${result}, injectable client - Formulas: range (numbers, or dates by minutes), interpolate, runs, minBy, maxBy, obj, slice, substring, replace, split, wallTime, and a UTC offset for now()/today(). - callApi leaves its response (or error) as the chain's ${result}, so the next action can read it, e.g. inside a forEach. - Codelessly takes an optional HTTP client. Found rebuilding the Tidepool reference app in CodelesslyEditor. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3B1XCZZ7QGvZLUGgSG5H4 --- lib/src/codelessly.dart | 7 +- lib/src/functions/functions_repository.dart | 19 +- lib/src/transformers/utils/text_utils.dart | 3 +- lib/src/utils/formula.dart | 282 +++++++++++++++++++- test/api_call_result_test.dart | 93 +++++++ test/formula_series_test.dart | 185 +++++++++++++ 6 files changed, 575 insertions(+), 14 deletions(-) create mode 100644 test/api_call_result_test.dart create mode 100644 test/formula_series_test.dart diff --git a/lib/src/codelessly.dart b/lib/src/codelessly.dart index f5959501..1416b91c 100644 --- a/lib/src/codelessly.dart +++ b/lib/src/codelessly.dart @@ -46,7 +46,7 @@ class Codelessly { /// Initialization is needed before usage. static Codelessly get instance => _instance; - final Client _client = CodelesslyHttpClient(); + final Client _client; /// Returns the HTTP client used by this SDK instance. Client get client => _client; @@ -286,7 +286,10 @@ class Codelessly { DataManager? publishDataManager, DataManager? previewDataManager, DataManager? templateDataManager, - }) { + + // The HTTP client for API calls and data loading (tests pass a mock). + Client? client, + }) : _client = client ?? CodelesslyHttpClient() { _config = config; _cacheManager = cacheManager; _authManager = authManager; diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index 86de7a90..9644b6c5 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -772,10 +772,13 @@ class FunctionsRepository { printResponse(response); + final Map responseValue = ApiResponseVariableUtils.fromResponse(response); + // The next actions of the chain read the response as `${result}`, e.g. + // `${result.data}` or `${result.isError}`, even when the API has no + // variable or another call overwrites it, as in a forEach. + ActionChain.setResult(responseValue); if (variable != null) { - variable.value = variable.value.copyWith( - value: ApiResponseVariableUtils.fromResponse(response), - ); + variable.value = variable.value.copyWith(value: responseValue); DebugLogger.instance.printInfo( '${variable.value.name} updated with success state.', name: name, @@ -791,10 +794,14 @@ class FunctionsRepository { } catch (error, stackTrace) { DebugLogger.instance.printInfo(error.toString(), name: name); DebugLogger.instance.printInfo(stackTrace.toString(), name: name); + final Map errorValue = ApiResponseVariableUtils.error( + url, + error, + data: existingData, + ); + ActionChain.setResult(errorValue); if (variable != null) { - variable.value = variable.value.copyWith( - value: ApiResponseVariableUtils.error(url, error, data: existingData), - ); + variable.value = variable.value.copyWith(value: errorValue); DebugLogger.instance.printInfo( '${variable.value.name} updated with error state.', name: name, diff --git a/lib/src/transformers/utils/text_utils.dart b/lib/src/transformers/utils/text_utils.dart index 0b0cf459..9732096f 100644 --- a/lib/src/transformers/utils/text_utils.dart +++ b/lib/src/transformers/utils/text_utils.dart @@ -39,7 +39,8 @@ class TextUtils { context, characters, node: node, - color: boundTextColor(context, node, variablesOverrides) ?? + color: + boundTextColor(context, node, variablesOverrides) ?? retrievePropColor(prop), fontSize: prop.fontSize, letterSpacing: prop.letterSpacing, diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart index 99ac5af5..86c20551 100644 --- a/lib/src/utils/formula.dart +++ b/lib/src/utils/formula.dart @@ -38,7 +38,9 @@ typedef FormulaPathResolver = Object? Function(String path); /// `t.tags[0]`, `t.tags.length`) and hides a variable of the same name; /// the body can read every other variable. Lambdas are only allowed as /// function arguments. -/// - date functions: now, today, parseDate, formatDate, addMinutes, addHours, +/// - series and text helpers: range, interpolate, runs, minBy, maxBy, obj, +/// slice, substring, replace and split. +/// - date functions: now, today, wallTime, parseDate, formatDate, addMinutes, addHours, /// addDays, addMonths, diffMinutes, diffHours, diffDays, year, month, day, /// hour, minute and weekday. Dates are ISO 8601 text such as /// `2026-10-21T14:30:00.000`; a date argument can also be epoch @@ -279,8 +281,67 @@ class FormulaEvaluator { 'isNotEmpty(text or list)', 'isNotEmpty(cart) → true', ), - FormulaFunctionDoc('now', 'now()', 'now() → 2026-10-21T14:30:00.000'), - FormulaFunctionDoc('today', 'today()', 'today() → 2026-10-21T00:00:00.000'), + FormulaFunctionDoc( + 'now', + 'now(utcOffset?)', + 'now() → 2026-10-21T14:30:00.000, now("-07:00") at UTC−7', + ), + FormulaFunctionDoc( + 'today', + 'today(utcOffset?)', + 'today() → 2026-10-21T00:00:00.000', + ), + FormulaFunctionDoc( + 'wallTime', + 'wallTime(dateWithOffset)', + 'wallTime("2026-10-22T07:25:17-07:00") → 2026-10-22T07:25:17.000', + ), + FormulaFunctionDoc( + 'substring', + 'substring(text, start, end?)', + 'substring("2026-10-22 02:58", 0, 10) → 2026-10-22', + ), + FormulaFunctionDoc( + 'replace', + 'replace(text, from, to)', + 'replace("a-b", "-", " ") → a b', + ), + FormulaFunctionDoc('split', 'split(text, separator)', 'split("a,b", ",")'), + FormulaFunctionDoc( + 'slice', + 'slice(list, start, end?)', + 'slice(items, 0, 3) or slice(items, -2)', + ), + FormulaFunctionDoc( + 'range', + 'range(start, end, step) (dates: step in minutes)', + 'range(0, 6, 2) → [0, 2, 4, 6]', + ), + FormulaFunctionDoc( + 'interpolate', + 'interpolate(points, xField, yField, x)', + 'interpolate(tides, "t", "v", now())', + ), + FormulaFunctionDoc( + 'runs', + 'runs(list, item => condition)', + 'runs(steps, s => s.h <= 1) → [{first, last, items, count}]', + ), + FormulaFunctionDoc( + 'minBy', + 'minBy(list, field or item => key)', + 'minBy(lows, "v")', + ), + FormulaFunctionDoc( + 'maxBy', + 'maxBy(list, field or item => key)', + 'maxBy(highs, "v")', + ), + FormulaFunctionDoc( + 'obj', + 'obj(key, value, …)', + 'obj("from", start, "to", end) → {from, to}', + ), FormulaFunctionDoc( 'parseDate', 'parseDate(text or millis)', @@ -781,6 +842,183 @@ String? _formatDate(Object? value, Object? pattern) { return out.toString(); } +// --------------------------------------------------------------------------- +// Series, text and object helpers +// --------------------------------------------------------------------------- + +/// The most items [range] returns, so a typo can't build a huge list. +const int _maxRange = 10000; + +/// Numbers from [start] through [end] by [step], or, when [start] is a date, +/// dates from [start] through [end] every [step] minutes (default 1). +List _range(List args) { + final Object? start = _arg(args, 0), end = _arg(args, 1); + if (_isDateLike(start)) { + final DateTime? from = _toDate(start), to = _toDate(end); + final num step = _num(_arg(args, 2) ?? 1); + if (from == null || to == null || !(step > 0)) return const []; + final Duration delta = Duration(microseconds: (step * 60e6).round()); + return [ + for (DateTime t = from; !t.isAfter(to); t = t.add(delta)) _dateText(t), + ].take(_maxRange).toList(); + } + final num from = _num(start), to = _num(end); + final num step = _num(_arg(args, 2) ?? 1); + if (!from.isFinite || !to.isFinite || step == 0 || !step.isFinite) { + return const []; + } + final List out = []; + for (num v = from; step > 0 ? v <= to + 1e-9 : v >= to - 1e-9; v += step) { + out.add(_tidy(v)); + if (out.length >= _maxRange) break; + } + return out; +} + +/// A point's x as a number: dates as epoch milliseconds. +num? _xValue(Object? value) { + if (_isDateLike(value)) return _toDate(value)?.millisecondsSinceEpoch; + final num? n = value is String ? num.tryParse(value.trim()) : _toNum(value); + return n != null && n.isFinite ? n : null; +} + +/// The y of the series [list] at [x], interpolated linearly between the two +/// items whose [xField] values surround it. Items must be sorted by x. Null +/// outside the series. +num? _interpolate(List args) { + final Object? list = _arg(args, 0); + final Object? xField = _arg(args, 1), yField = _arg(args, 2); + final num? x = _xValue(_arg(args, 3)); + if (list is! List || list.isEmpty || x == null) return null; + num? xOf(int i) => _xValue(_select(list[i], xField)); + num? yOf(int i) { + final Object? y = _select(list[i], yField); + return y is String ? num.tryParse(y.trim()) : _toNum(y); + } + + int lo = 0, hi = list.length - 1; + final num? first = xOf(lo), last = xOf(hi); + if (first == null || last == null || x < first || x > last) return null; + while (hi - lo > 1) { + final int mid = (lo + hi) >> 1; + final num? m = xOf(mid); + if (m == null) return null; + if (m <= x) { + lo = mid; + } else { + hi = mid; + } + } + final num? x0 = xOf(lo), x1 = xOf(hi), y0 = yOf(lo), y1 = yOf(hi); + if (x0 == null || x1 == null || y0 == null || y1 == null) return null; + if (x1 == x0) return y0; + return y0 + (y1 - y0) * (x - x0) / (x1 - x0); +} + +/// Runs of consecutive items of `args[0]` that match the predicate in the +/// rest of [args], each as `{first, last, items, count}`. +List _runs(List args) { + final List rest = args.skip(1).toList(); + final List out = []; + List current = []; + void close() { + if (current.isEmpty) return; + out.add({ + 'first': current.first, + 'last': current.last, + 'items': current, + 'count': current.length, + }); + current = []; + } + + for (final Object? item in _values(_arg(args, 0), null)) { + if (_matches(item, rest)) { + current.add(item); + } else { + close(); + } + } + close(); + return out; +} + +/// The item of `args[0]` with the smallest ([max] false) or largest key +/// `args[1]` (a field or lambda). Items without a key are skipped. +Object? _extremeBy(List args, {required bool max}) { + Object? best; + int? bestKey; + Object? bestValue; + for (final Object? item in _values(_arg(args, 0), null)) { + final Object? key = _select(item, _arg(args, 1)); + if (key == null) continue; + if (bestKey == null) { + best = item; + bestValue = key; + bestKey = 0; + continue; + } + final int? order = _compare(key, bestValue); + if (order != null && (max ? order > 0 : order < 0)) { + best = item; + bestValue = key; + } + } + return best; +} + +/// A map from key, value pairs: `obj("from", a, "to", b)`. Formulas can't +/// contain braces, so this is how they build objects. +Map _object(List args) => { + for (int i = 0; i + 1 < args.length; i += 2) + FormulaEvaluator.stringify(args[i]): args[i + 1], +}; + +/// The UTC offset [value] gives, as hours (`-7`, `5.5`) or text (`-07:00`, +/// `+0530`, `Z`), or null. +Duration? _offset(Object? value) { + if (value == null) return null; + if (value is num) return Duration(minutes: (value * 60).round()); + final String text = value.toString().trim(); + if (text == 'Z' || text.toUpperCase() == 'UTC') return Duration.zero; + final Match? m = RegExp(r'^([+-])(\d{1,2}):?(\d{2})?$').firstMatch(text); + if (m == null) { + final num? hours = num.tryParse(text); + return hours == null ? null : Duration(minutes: (hours * 60).round()); + } + final int minutes = int.parse(m[2]!) * 60 + int.parse(m[3] ?? '0'); + return Duration(minutes: m[1] == '-' ? -minutes : minutes); +} + +/// The wall-clock time now at the UTC [offset] (see [_offset]), or local +/// time without one. Returned without a zone, like other formula dates. +DateTime _nowAt(Object? offset) { + final Duration? delta = _offset(offset); + if (delta == null) return DateTime.now(); + final DateTime utc = DateTime.now().toUtc().add(delta); + return DateTime( + utc.year, + utc.month, + utc.day, + utc.hour, + utc.minute, + utc.second, + utc.millisecond, + ); +} + +/// The wall-clock part of an ISO date-time written with an offset, e.g. +/// `2026-10-22T07:25:17-07:00` → `2026-10-22T07:25:17.000`, so a time an +/// API gives in a place's zone reads as that place's local time. +String? _wallTime(Object? value) { + final String text = FormulaEvaluator.stringify(value).trim(); + final Match? m = RegExp( + r'^(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2}(?:[.,]\d+)?)?)', + ).firstMatch(text); + if (m == null) return _dateText(_toDate(text)); + return _dateText(DateTime.tryParse(m[1]!.replaceFirst(' ', 'T'))); +} + typedef _Function = Object? Function(List args); Object? _arg(List args, int index) => @@ -854,11 +1092,45 @@ final Map _functions = { 'text': (a) => FormulaEvaluator.stringify(_arg(a, 0)), 'isEmpty': (a) => !_truthy(_functions['length']!(a)), 'isNotEmpty': (a) => _truthy(_functions['length']!(a)), - 'now': (a) => _dateText(DateTime.now()), + 'now': (a) => _dateText(_nowAt(_arg(a, 0))), 'today': (a) { - final DateTime now = DateTime.now(); + final DateTime now = _nowAt(_arg(a, 0)); return _dateText(DateTime(now.year, now.month, now.day)); }, + 'wallTime': (a) => _wallTime(_arg(a, 0)), + 'substring': (a) { + final String text = FormulaEvaluator.stringify(_arg(a, 0)); + final int start = _intArg(_arg(a, 1)).clamp(0, text.length); + final int end = _arg(a, 2) == null + ? text.length + : _intArg(_arg(a, 2)).clamp(start, text.length); + return text.substring(start, end); + }, + 'replace': (a) => FormulaEvaluator.stringify(_arg(a, 0)).replaceAll( + FormulaEvaluator.stringify(_arg(a, 1)), + FormulaEvaluator.stringify(_arg(a, 2)), + ), + 'split': (a) => + FormulaEvaluator.stringify(_arg(a, 0)) + .split(FormulaEvaluator.stringify(_arg(a, 1) ?? ',')), + 'slice': (a) { + final List items = _values(_arg(a, 0), null).toList(); + int index(Object? value, int fallback) { + if (value == null) return fallback; + final int i = _intArg(value); + return (i < 0 ? items.length + i : i).clamp(0, items.length); + } + + final int start = index(_arg(a, 1), 0); + final int end = index(_arg(a, 2), items.length); + return end <= start ? [] : items.sublist(start, end); + }, + 'range': _range, + 'interpolate': _interpolate, + 'runs': _runs, + 'minBy': (a) => _extremeBy(a, max: false), + 'maxBy': (a) => _extremeBy(a, max: true), + 'obj': _object, 'parseDate': (a) => _dateText(_toDate(_arg(a, 0))), 'formatDate': (a) => _formatDate(_arg(a, 0), _arg(a, 1)), 'addMinutes': (a) => diff --git a/test/api_call_result_test.dart b/test/api_call_result_test.dart new file mode 100644 index 00000000..2b81a04c --- /dev/null +++ b/test/api_call_result_test.dart @@ -0,0 +1,93 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:codelessly_sdk/src/functions/action_chain.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:provider/provider.dart'; + +/// Pumps a context that has a [Codelessly] whose HTTP client is [client]. +Future pumpContext( + WidgetTester tester, + http.Client client, +) async { + late BuildContext captured; + await tester.pumpWidget( + Provider.value( + value: Codelessly(client: client), + child: ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Builder( + builder: (context) { + captured = context; + return const SizedBox(); + }, + ), + ), + ), + ), + ); + return captured; +} + +void main() { + testWidgets('An API call leaves its response as the chain result', ( + WidgetTester tester, + ) async { + final context = await pumpContext( + tester, + MockClient( + (request) async => http.Response( + '{"results": {"sunrise": "7:25"}}', + 200, + request: request, + ), + ), + ); + Object? result; + await tester.runAsync( + () => ActionChain.run(() async { + await FunctionsRepository.makeApiRequest( + method: HttpMethod.get, + url: 'https://example.com/sun', + headers: const {}, + body: null, + context: context, + ); + result = ActionChain.current?.result; + }), + ); + expect(result, isA()); + final Map map = result! as Map; + expect(map['isSuccess'], true); + expect(map['statusCode'], 200); + expect((map['data'] as Map)['results'], {'sunrise': '7:25'}); + }); + + testWidgets('A failed API call leaves an error result before it throws', ( + WidgetTester tester, + ) async { + final context = await pumpContext( + tester, + MockClient((request) async => throw http.ClientException('offline')), + ); + Object? result; + await tester.runAsync( + () => ActionChain.run(() async { + try { + await FunctionsRepository.makeApiRequest( + method: HttpMethod.get, + url: 'https://example.com/sun', + headers: const {}, + body: null, + context: context, + ); + } catch (_) {} + result = ActionChain.current?.result; + }), + ); + expect((result! as Map)['isError'], true); + }); +} diff --git a/test/formula_series_test.dart b/test/formula_series_test.dart new file mode 100644 index 00000000..0a8d580f --- /dev/null +++ b/test/formula_series_test.dart @@ -0,0 +1,185 @@ +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Evaluates [expression] with [vars] as its variables. +Object? eval(String expression, [Map vars = const {}]) => + FormulaEvaluator.evaluate(expression, (path) => vars[path]); + +void main() { + // Half-hourly tide heights, NOAA style. + final List> tides = [ + {'t': '2026-10-22 14:00', 'v': '2.0'}, + {'t': '2026-10-22 14:30', 'v': '1.4'}, + {'t': '2026-10-22 15:00', 'v': '0.8'}, + {'t': '2026-10-22 15:30', 'v': '0.6'}, + {'t': '2026-10-22 16:00', 'v': '1.2'}, + {'t': '2026-10-22 16:30', 'v': '1.8'}, + ]; + + group('text', () { + test('substring clamps and defaults its end', () { + expect(eval('substring("2026-10-22 02:58", 0, 10)'), '2026-10-22'); + expect(eval('substring("abc", 1)'), 'bc'); + expect(eval('substring("abc", 2, 99)'), 'c'); + }); + + test('replace and split', () { + expect(eval('replace("a-b-c", "-", " ")'), 'a b c'); + expect(eval('split("a,b,c", ",")'), ['a', 'b', 'c']); + }); + }); + + group('lists', () { + test('slice takes a range, negative from the end', () { + expect(eval('slice([1, 2, 3, 4], 1, 3)'), [2, 3]); + expect(eval('slice([1, 2, 3, 4], -2)'), [3, 4]); + expect(eval('slice([1, 2], 5)'), isEmpty); + }); + + test('range counts numbers through the end', () { + expect(eval('range(0, 6, 2)'), [0, 2, 4, 6]); + expect(eval('range(-1, 1)'), [-1, 0, 1]); + expect(eval('range(3, 1, -1)'), [3, 2, 1]); + expect(eval('range(0, 1, 0)'), isEmpty); + }); + + test('range steps dates by minutes', () { + expect(eval('range("2026-10-22T07:25:00", "2026-10-22T07:40:00", 5)'), [ + '2026-10-22T07:25:00.000', + '2026-10-22T07:30:00.000', + '2026-10-22T07:35:00.000', + '2026-10-22T07:40:00.000', + ]); + }); + + test('runs groups consecutive matches', () { + final Object? r = eval('runs([1, 3, 4, 0, 5, 6, 7], n => n > 2)'); + expect(r, [ + { + 'first': 3, + 'last': 4, + 'items': [3, 4], + 'count': 2, + }, + { + 'first': 5, + 'last': 7, + 'items': [5, 6, 7], + 'count': 3, + }, + ]); + }); + + test('minBy and maxBy return the item', () { + expect(eval('minBy(tides, "v")', {'tides': tides}), { + 't': '2026-10-22 15:30', + 'v': '0.6', + }); + expect( + eval('maxBy(tides, t => number(t.v)).t', {'tides': tides}), + '2026-10-22 14:00', + ); + }); + + test('obj builds a map', () { + expect(eval('obj("from", 1, "to", "b")'), {'from': 1, 'to': 'b'}); + expect(eval('map([1, 2], n => obj("x", n * 2))'), [ + {'x': 2}, + {'x': 4}, + ]); + }); + }); + + group('interpolate', () { + test('reads between two points of a date series', () { + expect( + eval('interpolate(tides, "t", "v", "2026-10-22T14:15")', { + 'tides': tides, + }), + closeTo(1.7, 1e-9), + ); + expect( + eval('interpolate(tides, "t", "v", "2026-10-22 15:30")', { + 'tides': tides, + }), + closeTo(0.6, 1e-9), + ); + }); + + test('is null outside the series', () { + expect( + eval('interpolate(tides, "t", "v", "2026-10-22T13:00")', { + 'tides': tides, + }), + isNull, + ); + }); + + test('works on numbers', () { + expect( + eval( + 'interpolate([obj("x", 0, "y", 0), obj("x", 10, "y", 5)], "x", "y", 4)', + ), + closeTo(2, 1e-9), + ); + }); + }); + + group('time zones', () { + test('wallTime keeps the clock time of an offset date-time', () { + expect( + eval('wallTime("2026-10-22T07:25:17-07:00")'), + '2026-10-22T07:25:17.000', + ); + expect(eval('wallTime("2026-10-22 18:23")'), '2026-10-22T18:23:00.000'); + }); + + test('now and today take a UTC offset', () { + final DateTime utc = DateTime.now().toUtc(); + final DateTime minus7 = DateTime.parse(eval('now("-07:00")')! as String); + final DateTime expected = utc.subtract(const Duration(hours: 7)); + expect(minus7.isUtc, false); + expect( + DateTime.utc( + minus7.year, + minus7.month, + minus7.day, + minus7.hour, + minus7.minute, + ) + .difference( + DateTime.utc( + expected.year, + expected.month, + expected.day, + expected.hour, + expected.minute, + ), + ) + .inMinutes + .abs(), + lessThanOrEqualTo(1), + ); + expect(eval('now(5.5)'), isA()); + expect((eval('today(-7)')! as String).endsWith('T00:00:00.000'), true); + }); + }); + + test( + 'a daylight pooling window composes from range, interpolate and runs', + () { + // Five-minute steps from "sunrise" to "sunset", kept where the tide is at + // or below 1.0 ft, as the reference app's pooling windows. + final Object? windows = eval( + 'filter(runs(map(range("2026-10-22T14:00", "2026-10-22T16:30", 5), ' + 's => obj("t", s, "h", interpolate(tides, "t", "v", s))), ' + 's => s.h <= 1), r => diffMinutes(r.first.t, r.last.t) >= 20)', + {'tides': tides}, + ); + expect(windows, hasLength(1)); + final Map window = (windows! as List).single as Map; + expect((window['first'] as Map)['t'], '2026-10-22T14:50:00.000'); + expect((window['last'] as Map)['t'], '2026-10-22T15:50:00.000'); + }, + ); +} From 347dc4b27d9a44d3c4a5917f101443ec6236715d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:36:57 +0000 Subject: [PATCH 03/27] Give baseline-aligned rows a text baseline A row or column with crossAxisAlignment baseline built a Flex without textBaseline, which Flutter asserts against, so the whole screen failed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3B1XCZZ7QGvZLUGgSG5H4 --- .../passive_row_column_transformer.dart | 11 +++++++--- test/flex_layout_test.dart | 20 +++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/lib/src/transformers/node_transformers/passive_row_column_transformer.dart b/lib/src/transformers/node_transformers/passive_row_column_transformer.dart index ec502800..56f8f2ba 100644 --- a/lib/src/transformers/node_transformers/passive_row_column_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_row_column_transformer.dart @@ -90,12 +90,17 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { } } + final CrossAxisAlignment crossAxisAlignment = stretchFallsBack + ? CrossAxisAlignment.start + : rowColumnNode.crossAxisAlignment.flutterAxis; Widget res = CodelesslyFlex( direction: isRow ? Axis.horizontal : Axis.vertical, mainAxisAlignment: rowColumnNode.mainAxisAlignment.flutterAxis, - crossAxisAlignment: stretchFallsBack - ? CrossAxisAlignment.start - : rowColumnNode.crossAxisAlignment.flutterAxis, + crossAxisAlignment: crossAxisAlignment, + // Flex asserts a baseline to align to; texts align on the alphabetic one. + textBaseline: crossAxisAlignment == CrossAxisAlignment.baseline + ? TextBaseline.alphabetic + : null, mainAxisSize: mainFit == SizeFit.shrinkWrap ? MainAxisSize.min : MainAxisSize.max, diff --git a/test/flex_layout_test.dart b/test/flex_layout_test.dart index c8c75001..0b0eeef3 100644 --- a/test/flex_layout_test.dart +++ b/test/flex_layout_test.dart @@ -146,6 +146,26 @@ Future everywhere( } void main() { + testWidgets( + 'a row aligned to baselines lays out (it needs a text baseline)', + (tester) async { + final a = rect('a', '', width: 40, height: 30); + final b = rect('b', '', width: 40, height: 10); + final row = flex( + 'row', + kRootNode, + [a, b], + type: RowColumnType.row, + width: 200, + height: 40, + crossAxisAlignment: CrossAxisAlignmentC.baseline, + ); + await everywhere(tester, row, [a, b], (sizes, screen, host) { + expect(sizes['a'], const Size(40, 30)); + }); + }, + ); + testWidgets('a shrinkWrap row with an expanded child inside a row (E1, A7)', ( tester, ) async { From 25f044f3fa81521dad3a75588a2652e544daabc7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:38:24 +0000 Subject: [PATCH 04/27] Send an API's query parameters HttpApiData.queryParams were saved by the editor but never added to the request URL. FunctionsRepository.buildApiUrl adds the used ones, with inputs and variables substituted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3B1XCZZ7QGvZLUGgSG5H4 --- lib/src/functions/functions_repository.dart | 24 ++++++++- test/api_call_result_test.dart | 60 +++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index 9644b6c5..0f908ef2 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -363,7 +363,7 @@ class FunctionsRepository { return makeApiRequest( context: context, method: apiData.method, - url: _applyApiInputs(apiData.url, params, scopedValues), + url: buildApiUrl(apiData, params, scopedValues), headers: _generateMapFromPairs(apiData.headers, params, scopedValues), body: apiData.bodyType == RequestBodyType.form ? _generateMapFromPairs(apiData.formFields, params, scopedValues) @@ -372,6 +372,28 @@ class FunctionsRepository { ); } + /// The URL [api] requests with the inputs [params]: its URL with inputs + /// and variables substituted, plus its query parameters (used, non-empty + /// keys; same substitution), which win over ones written in the URL. + static String buildApiUrl( + HttpApiData api, + Map params, + ScopedValues scopedValues, + ) { + final String url = _applyApiInputs(api.url, params, scopedValues); + final Map query = _generateMapFromPairs( + api.queryParams, + params, + scopedValues, + ); + if (query.isEmpty) return url; + final Uri? uri = Uri.tryParse(url); + if (uri == null) return url; + return uri + .replace(queryParameters: {...uri.queryParameters, ...query}) + .toString(); + } + static Map _generateMapFromPairs( List pairs, Map parameters, diff --git a/test/api_call_result_test.dart b/test/api_call_result_test.dart index 2b81a04c..8e3419ae 100644 --- a/test/api_call_result_test.dart +++ b/test/api_call_result_test.dart @@ -33,6 +33,8 @@ Future pumpContext( } void main() { + queryParamTests(); + testWidgets('An API call leaves its response as the chain result', ( WidgetTester tester, ) async { @@ -91,3 +93,61 @@ void main() { expect((result! as Map)['isError'], true); }); } + +HttpApiData api(String url, List query) => + HttpApiData.fromJson({ + 'name': 'Tides', + 'directory': '', + 'owner': 'o', + 'teams': [], + 'users': [], + 'roles': {}, + 'public': false, + 'url': url, + 'queryParams': [for (final q in query) q.toJson()], + }); + +void queryParamTests() { + testWidgets('An API URL gets its query parameters and inputs', ( + WidgetTester tester, + ) async { + final context = await pumpContext( + tester, + MockClient((request) async => http.Response('', 200)), + ); + final String url = FunctionsRepository.buildApiUrl( + api('https://api.example.com/data?format=json', const [ + HttpKeyValuePair(key: 'station', value: r'${station}'), + HttpKeyValuePair(key: 'interval', value: 'hilo'), + HttpKeyValuePair(key: 'unused', value: 'x', isUsed: false), + HttpKeyValuePair(key: '', value: 'blank key'), + ]), + {'station': '9414131'}, + ScopedValues.of(context), + ); + final Uri uri = Uri.parse(url); + expect(uri.path, '/data'); + expect(uri.queryParameters, { + 'format': 'json', + 'station': '9414131', + 'interval': 'hilo', + }); + }); + + testWidgets('An API without query parameters keeps its URL', ( + WidgetTester tester, + ) async { + final context = await pumpContext( + tester, + MockClient((request) async => http.Response('', 200)), + ); + expect( + FunctionsRepository.buildApiUrl( + api(r'https://x.dev/a?d=${day}', const []), + {'day': '2026-10-22'}, + ScopedValues.of(context), + ), + 'https://x.dev/a?d=2026-10-22', + ); + }); +} From 32969bf7d58360d5dd0518881b62cfc55182be4e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:41:57 +0000 Subject: [PATCH 05/27] Run an API call's onSuccess/onError actions onError handles a failed request instead of stopping the chain, so a screen can show its error state and turn its loading state off. Load-time API calls take the same path. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3B1XCZZ7QGvZLUGgSG5H4 --- lib/src/functions/functions_repository.dart | 57 +++++++++- lib/src/ui/layout_builder.dart | 4 +- test/api_call_outcome_test.dart | 111 ++++++++++++++++++++ 3 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 test/api_call_outcome_test.dart diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index 0f908ef2..5f894cb0 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -109,7 +109,7 @@ class FunctionsRepository { if (result is Future) return result.then((_) => true); return true; case ActionType.callApi: - return makeApiRequestFromAction(action as ApiCallAction, context); + return callApi(context, action as ApiCallAction); case ActionType.setStorage: return setStorageFromAction(context, action as SetStorageAction); case ActionType.setCloudStorage: @@ -309,6 +309,61 @@ class FunctionsRepository { return true; } + /// Calls [action]'s API, then runs its onSuccess actions after a 2xx + /// response or its onError actions after any other response or a failed + /// request. Both read the response as `${result}`. A failed request stops + /// the chain only when there are no onError actions to handle it. + static Future callApi( + BuildContext context, + ApiCallAction action, [ + Observable? variable, + ]) async { + http.Response? response; + Object? failure; + StackTrace? failureStack; + try { + response = await makeApiRequestFromAction(action, context, variable); + } catch (error, stackTrace) { + failure = error; + failureStack = stackTrace; + } + if (!context.mounted) return response; + await handleApiOutcome( + context, + action, + response: response, + failure: failure, + failureStack: failureStack, + ); + return response; + } + + /// Runs [action]'s onSuccess actions after a 2xx [response], or its onError + /// actions otherwise. Rethrows [failure] (a request that didn't complete) + /// when there are no onError actions, so the chain stops as before. + static Future handleApiOutcome( + BuildContext context, + ApiCallAction action, { + http.Response? response, + Object? failure, + StackTrace? failureStack, + }) async { + final bool ok = + failure == null && + response != null && + response.statusCode >= 200 && + response.statusCode < 300; + if (ok) { + if (action.onSuccess.isNotEmpty) { + await runActions(context, action.onSuccess); + } + } else if (action.onError.isNotEmpty) { + await runActions(context, action.onError); + } else if (failure != null) { + Error.throwWithStackTrace(failure, failureStack ?? StackTrace.current); + } + } + static Future makeApiRequestFromAction( ApiCallAction action, BuildContext context, [ diff --git a/lib/src/ui/layout_builder.dart b/lib/src/ui/layout_builder.dart index 7c893eee..d60b64c1 100644 --- a/lib/src/ui/layout_builder.dart +++ b/lib/src/ui/layout_builder.dart @@ -344,9 +344,9 @@ class _CodelesslyLayoutBuilderState extends State { // The variable carries the error state; report the failure instead // of leaving it as an unhandled async error. unawaited( - FunctionsRepository.makeApiRequestFromAction( - canvasAction, + FunctionsRepository.callApi( context, + canvasAction, notifier, ).then( (_) {}, diff --git a/test/api_call_outcome_test.dart b/test/api_call_outcome_test.dart new file mode 100644 index 00000000..289a1e3c --- /dev/null +++ b/test/api_call_outcome_test.dart @@ -0,0 +1,111 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:provider/provider.dart'; + +void main() { + late List calls; + late BuildContext context; + + Future pump(WidgetTester tester) async { + calls = []; + final codelesslyContext = CodelesslyContext.empty() + ..functions['record'] = (context, reference, params) => + calls.add('${params['what']}'); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: codelesslyContext, + child: MaterialApp( + home: Builder( + builder: (c) { + context = c; + return const SizedBox(); + }, + ), + ), + ), + ); + } + + ApiCallAction api() => ApiCallAction( + apiId: 'tides', + onSuccess: [ + CallFunctionAction(name: 'record', params: {'what': 'success'}), + ], + onError: [ + CallFunctionAction(name: 'record', params: {'what': 'error'}), + ], + ); + + http.Response response(int status) => http.Response( + '{}', + status, + request: http.Request('GET', Uri.parse('https://x.dev')), + ); + + testWidgets('onSuccess runs after a 2xx response', (tester) async { + await pump(tester); + await tester.runAsync( + () => FunctionsRepository.handleApiOutcome( + context, + api(), + response: response(200), + ), + ); + expect(calls, ['success']); + }); + + testWidgets('onError runs after an error response', (tester) async { + await pump(tester); + await tester.runAsync( + () => FunctionsRepository.handleApiOutcome( + context, + api(), + response: response(503), + ), + ); + expect(calls, ['error']); + }); + + testWidgets( + 'onError handles a failed request instead of stopping the chain', + (tester) async { + await pump(tester); + await tester.runAsync( + () => FunctionsRepository.handleApiOutcome( + context, + api(), + failure: http.ClientException('offline'), + ), + ); + expect(calls, ['error']); + }, + ); + + testWidgets('without onError a failed request still throws', (tester) async { + await pump(tester); + Object? thrown; + await tester.runAsync(() async { + try { + await FunctionsRepository.handleApiOutcome( + context, + ApiCallAction(apiId: 'tides'), + failure: http.ClientException('offline'), + ); + } catch (e) { + thrown = e; + } + }); + expect(thrown, isA()); + }); + + test('onSuccess and onError round-trip through JSON', () { + final ApiCallAction action = ApiCallAction.fromJson(api().toJson()); + expect(action.onSuccess.single, isA()); + expect((action.onError.single as CallFunctionAction).params, { + 'what': 'error', + }); + }); +} From 35f4b91074e745f305b7e1775b3688fafc6359c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 22:42:39 +0000 Subject: [PATCH 06/27] Render LineChartNode with a CustomPainter and scrub cursor PassiveLineChartTransformer builds PassiveLineChartWidget, which resolves the node's properties and bindings (data, domain, bands, y ticks, area bands, reference lines, x ticks, markers, vertical lines, colors and the semantic label; paths or formulas) into a LineChartSpec and paints it bottom to top: bands, grid and y labels, gradient area, area bands, the polyline or Catmull-Rom curve, dashed reference lines, x axis, markers, vertical lines with tags and curve dots, and the scrub cursor with its readout card. Hover, tap, horizontal drag and the arrow keys move the cursor; Esc and mouse exit clear it. Date x values are wall-clock epoch milliseconds, and the readout formats them like the formatDate formula, which FormulaEvaluator.formatDate now exposes. LineChartGeometry holds the pure parts: date and number parsing, domain, pixel scale, linear interpolation, Catmull-Rom control points, readout text, trend and card placement. Tests cover them, a bound data list from a formula, and scrubbing with hover and keys. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3B1XCZZ7QGvZLUGgSG5H4 --- CHANGELOG.md | 10 + .../line_chart_geometry.dart | 359 ++++++++ .../node_transformers/node_transformers.dart | 2 + .../passive_line_chart_transformer.dart | 809 ++++++++++++++++++ .../passive_transformer_manager.dart | 1 + lib/src/utils/formula.dart | 6 + test/line_chart_test.dart | 376 ++++++++ test/transformers_test.dart | 1 + 8 files changed, 1564 insertions(+) create mode 100644 lib/src/transformers/node_transformers/line_chart_geometry.dart create mode 100644 lib/src/transformers/node_transformers/passive_line_chart_transformer.dart create mode 100644 test/line_chart_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index fc42932b..47f58ac1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,13 @@ +## Unreleased + +- Render `LineChartNode` with `PassiveLineChartTransformer` and + `PassiveLineChartWidget`: bands, grid, gradient area, area bands, a + polyline or Catmull-Rom curve, reference lines, x axis, markers, vertical + lines and a hover/tap/drag/arrow-key scrub cursor with a readout card. + `LineChartGeometry` holds the pure parts (dates, domain, interpolation, + Catmull-Rom control points, readout text and placement). +- `FormulaEvaluator.formatDate` exposes the `formatDate` formula function. + ## 8.2.0 ### States diff --git a/lib/src/transformers/node_transformers/line_chart_geometry.dart b/lib/src/transformers/node_transformers/line_chart_geometry.dart new file mode 100644 index 00000000..23237be8 --- /dev/null +++ b/lib/src/transformers/node_transformers/line_chart_geometry.dart @@ -0,0 +1,359 @@ +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:codelessly_api/codelessly_api.dart'; + +import '../../utils/formula.dart'; + +/// The x and y bounds of a line chart's plot, in data units. Date x values +/// are epoch milliseconds of their wall-clock time (see +/// [LineChartGeometry.parseX]). +class ChartDomain { + final double xMin; + final double xMax; + final double yMin; + final double yMax; + + const ChartDomain(this.xMin, this.xMax, this.yMin, this.yMax); + + double get xSpan => xMax - xMin; + + double get ySpan => yMax - yMin; + + @override + bool operator ==(Object other) => + other is ChartDomain && + other.xMin == xMin && + other.xMax == xMax && + other.yMin == yMin && + other.yMax == yMax; + + @override + int get hashCode => Object.hash(xMin, xMax, yMin, yMax); + + @override + String toString() => 'ChartDomain(x: $xMin..$xMax, y: $yMin..$yMax)'; +} + +/// Maps data values of a [domain] to pixels of a [plot] rectangle. +class ChartScale { + final ChartDomain domain; + final Rect plot; + + const ChartScale(this.domain, this.plot); + + /// The pixel x of data value [x]. + double px(double x) => + plot.left + (x - domain.xMin) / domain.xSpan * plot.width; + + /// The pixel y of data value [y]. + double py(double y) => + plot.bottom - (y - domain.yMin) / domain.ySpan * plot.height; + + /// The pixel position of data point [point]. + Offset toPixel(Offset point) => Offset(px(point.dx), py(point.dy)); + + /// The data x at pixel x [px], clamped to the domain. + double xAt(double px) { + if (plot.width <= 0) return domain.xMin; + final double x = domain.xMin + (px - plot.left) / plot.width * domain.xSpan; + return x.clamp(domain.xMin, domain.xMax).toDouble(); + } +} + +/// One cubic Bézier segment of a curve, from the previous segment's end. +typedef CubicSegment = ({Offset c1, Offset c2, Offset end}); + +/// Pure functions behind the line chart: reading data, the domain, +/// interpolation, the Catmull-Rom curve and readout text. +abstract final class LineChartGeometry { + /// Reads a number from a num or a numeric string. + static double? parseNum(Object? value) => switch (value) { + num() when value.isFinite => value.toDouble(), + String() => double.tryParse(value.trim()), + _ => null, + }; + + /// Reads a date from a DateTime or an ISO string such as + /// `2026-10-22 00:30` or `2026-10-22T00:30:00`, as its wall-clock time in + /// a UTC DateTime: no timezone conversion happens, so the hour read is the + /// hour written. + static DateTime? parseDate(Object? value) { + final DateTime? date = switch (value) { + DateTime() => value, + String() when double.tryParse(value.trim()) == null => DateTime.tryParse( + value.trim(), + ), + _ => null, + }; + if (date == null) return null; + return DateTime.utc( + date.year, + date.month, + date.day, + date.hour, + date.minute, + date.second, + date.millisecond, + ); + } + + /// Whether [value] is a date rather than a number. + static bool isDate(Object? value) => parseDate(value) != null; + + /// Reads an x value: a number, or a date as epoch milliseconds of its + /// wall-clock time. + static double? parseX(Object? value) => + parseNum(value) ?? parseDate(value)?.millisecondsSinceEpoch.toDouble(); + + /// The wall-clock date of a date axis x value. + static DateTime dateOf(double x) => + DateTime.fromMillisecondsSinceEpoch(x.round(), isUtc: true); + + /// Reads the points of [data], a list of maps, with x from [xField] and y + /// from [yField], sorted by x. Items without a valid x or y are skipped. + static List points(List? data, String xField, String yField) { + final List points = [ + for (final Object? item in data ?? const []) + if (item is Map) + if ((parseX(item[xField]), parseNum(item[yField])) case ( + final double x, + final double y, + )) + Offset(x, y), + ]; + points.sort((a, b) => a.dx.compareTo(b.dx)); + return points; + } + + /// Whether the x values of [data] are dates, judged by its first item + /// with an x value. + static bool hasDateX(List? data, String xField) { + for (final Object? item in data ?? const []) { + if (item is Map && item[xField] != null) return isDate(item[xField]); + } + return false; + } + + /// The domain of [points]: each bound is its override when given, else + /// the data's extent. An empty span is widened so the scale stays finite. + static ChartDomain domain( + List points, { + double? xMin, + double? xMax, + double? yMin, + double? yMax, + }) { + double extent(Iterable values, double Function(double, double) f) => + values.isEmpty ? 0 : values.reduce(f); + final Iterable xs = points.map((p) => p.dx); + final Iterable ys = points.map((p) => p.dy); + double x0 = xMin ?? extent(xs, math.min); + double x1 = xMax ?? extent(xs, math.max); + double y0 = yMin ?? extent(ys, math.min); + double y1 = yMax ?? extent(ys, math.max); + if (x1 <= x0) (x0, x1) = (x0 - 0.5, x0 + 0.5); + if (y1 <= y0) (y0, y1) = (y0 - 0.5, y0 + 0.5); + return ChartDomain(x0, x1, y0, y1); + } + + /// The plot rectangle of a chart of [size] with the given insets. + static Rect plotRect( + Size size, { + required double left, + required double top, + required double right, + required double bottom, + }) => Rect.fromLTRB( + left, + top, + math.max(left, size.width - right), + math.max(top, size.height - bottom), + ); + + /// The y of [points] (sorted by x) at [x], interpolated linearly between + /// neighbours and held at the first and last values outside them. Null + /// when there are no points. + static double? interpolate(List points, double x) { + if (points.isEmpty) return null; + if (x <= points.first.dx) return points.first.dy; + if (x >= points.last.dx) return points.last.dy; + int lo = 0, hi = points.length - 1; + while (hi - lo > 1) { + final int mid = (lo + hi) >> 1; + if (points[mid].dx <= x) { + lo = mid; + } else { + hi = mid; + } + } + final Offset a = points[lo], b = points[hi]; + if (b.dx == a.dx) return b.dy; + return a.dy + (b.dy - a.dy) * (x - a.dx) / (b.dx - a.dx); + } + + /// The control points of the Catmull-Rom segment from [p1] to [p2], with + /// [p0] before and [p3] after: `p1 + (p2 - p0) / 6` and + /// `p2 - (p3 - p1) / 6`. + static (Offset, Offset) catmullRomControls( + Offset p0, + Offset p1, + Offset p2, + Offset p3, + ) => (p1 + (p2 - p0) / 6, p2 - (p3 - p1) / 6); + + /// The cubic segments of a Catmull-Rom spline through [points], starting + /// at the first point. The neighbours of the end points are the end + /// points themselves. + static List catmullRom(List points) { + Offset at(int i) => points[i.clamp(0, points.length - 1)]; + return [ + for (int i = 0; i < points.length - 1; i++) + if (catmullRomControls(at(i - 1), at(i), at(i + 1), at(i + 2)) case ( + final Offset c1, + final Offset c2, + )) + (c1: c1, c2: c2, end: at(i + 1)), + ]; + } + + /// The curve through pixel [points]: a Catmull-Rom spline when [smooth], + /// else a polyline. + static Path curvePath(List points, {required bool smooth}) { + final Path path = Path(); + if (points.isEmpty) return path; + path.moveTo(points.first.dx, points.first.dy); + if (smooth) { + for (final CubicSegment s in catmullRom(points)) { + path.cubicTo(s.c1.dx, s.c1.dy, s.c2.dx, s.c2.dy, s.end.dx, s.end.dy); + } + } else { + for (final Offset p in points.skip(1)) { + path.lineTo(p.dx, p.dy); + } + } + return path; + } + + /// The area under [curve] (through pixel [points]) down to [bottom]. + static Path areaPath(Path curve, List points, double bottom) => + Path.from(curve) + ..lineTo(points.last.dx, bottom) + ..lineTo(points.first.dx, bottom) + ..close(); + + /// [x] as text: a date formatted with the date [pattern] (as the + /// `formatDate` formula function does) on a date axis, else the number + /// without trailing zeros. + static String formatX(double x, {required bool isDate, String? pattern}) { + if (isDate) { + return FormulaEvaluator.formatDate( + dateOf(x), + pattern == null || pattern.isEmpty ? 'h:mm a' : pattern, + ) ?? + ''; + } + return FormulaEvaluator.stringify( + x == x.roundToDouble() ? x.round() : double.parse(x.toStringAsFixed(4)), + ); + } + + /// [y] times [scale] with [decimals] decimals, followed by [suffix]. + static String formatY( + double y, { + double scale = 1, + int decimals = 1, + String suffix = '', + }) => '${(y * scale).toStringAsFixed(decimals.clamp(0, 20))}$suffix'; + + /// Whether the curve rises at [x]: compares the value at [x] with the one + /// [lookahead] further, or [lookahead] before when that is past the data. + static bool rising(List points, double x, double lookahead) { + final double here = interpolate(points, x) ?? 0; + if (points.isNotEmpty && x + lookahead > points.last.dx) { + return here > (interpolate(points, x - lookahead) ?? here); + } + return (interpolate(points, x + lookahead) ?? here) > here; + } + + /// How far ahead [rising] looks for a readout's trend: 10 minutes on a + /// date axis, else 1 % of the domain. + static double trendLookahead(ChartDomain domain, {required bool isDate}) => + isDate + ? const Duration(minutes: 10).inMilliseconds.toDouble() + : domain.xSpan / 100; + + /// The two lines of the scrub readout at [x]: the formatted x, and the + /// interpolated y with an optional trend arrow. + static (String, String) readout( + List points, + double x, { + required ChartDomain domain, + required bool isDate, + String? xFormat, + double yScale = 1, + int yDecimals = 1, + String ySuffix = '', + bool trend = false, + }) { + final double y = interpolate(points, x) ?? 0; + final String arrow = !trend + ? '' + : rising(points, x, trendLookahead(domain, isDate: isDate)) + ? ' ↑' + : ' ↓'; + return ( + formatX(x, isDate: isDate, pattern: xFormat), + '${formatY(y, scale: yScale, decimals: yDecimals, suffix: ySuffix)}' + '$arrow', + ); + } + + /// Where the readout card of [size] goes for the cursor at pixel [point]: + /// 12 px right of it, or left when it would pass the plot's right edge, + /// and above the point, clamped to the plot. + static Rect readoutRect(Offset point, Size size, Rect plot) { + double left = point.dx + 12; + if (left + size.width > plot.right) left = point.dx - 12 - size.width; + final double top = (point.dy - size.height - 12) + .clamp(plot.top, math.max(plot.top, plot.bottom - size.height)) + .toDouble(); + return Rect.fromLTWH(left, top, size.width, size.height); + } + + /// Reads a color: a hex string (`#RRGGBB` or `#AARRGGBB`), a [ColorRGBA] + /// or a [Color], with its alpha multiplied by [opacity] when given. + static Color? parseColor(Object? value, [Object? opacity]) { + final Color? color = switch (value) { + Color() => value, + ColorRGB() => Color.from( + alpha: value is ColorRGBA ? value.a : 1, + red: value.r, + green: value.g, + blue: value.b, + ), + String() => switch (ColorRGBA.fromHex(value.trim())) { + final ColorRGBA c => Color.from( + alpha: c.a, + red: c.r, + green: c.g, + blue: c.b, + ), + null => null, + }, + _ => null, + }; + final double? alpha = parseNum(opacity); + if (color == null || alpha == null) return color; + return color.withValues(alpha: color.a * alpha.clamp(0, 1)); + } + + /// Reads a dash pattern `[on, off]`, falling back to [fallback]. + static (double, double) parseDash(Object? value, (double, double) fallback) { + if (value is List && value.length >= 2) { + final double? on = parseNum(value[0]), off = parseNum(value[1]); + if (on != null && off != null && on > 0 && off >= 0) return (on, off); + } + return fallback; + } +} diff --git a/lib/src/transformers/node_transformers/node_transformers.dart b/lib/src/transformers/node_transformers/node_transformers.dart index 8421a1e2..59cbfcd0 100644 --- a/lib/src/transformers/node_transformers/node_transformers.dart +++ b/lib/src/transformers/node_transformers/node_transformers.dart @@ -15,6 +15,8 @@ export 'passive_external_component_transformer.dart'; export 'passive_floating_action_button_transformer.dart'; export 'passive_grid_view_transformer.dart'; export 'passive_icon_transformer.dart'; +export 'line_chart_geometry.dart'; +export 'passive_line_chart_transformer.dart'; export 'passive_list_tile_transformer.dart'; export 'passive_list_view_transformer.dart'; export 'passive_loading_indicator_transformer.dart'; diff --git a/lib/src/transformers/node_transformers/passive_line_chart_transformer.dart b/lib/src/transformers/node_transformers/passive_line_chart_transformer.dart new file mode 100644 index 00000000..8ddd356e --- /dev/null +++ b/lib/src/transformers/node_transformers/passive_line_chart_transformer.dart @@ -0,0 +1,809 @@ +import 'dart:math' as math; +import 'dart:ui' as ui; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../../../codelessly_sdk.dart'; + +/// Builds [LineChartNode]s as a [PassiveLineChartWidget]. +class PassiveLineChartTransformer extends NodeWidgetTransformer { + PassiveLineChartTransformer(super.getNode, super.manager); + + @override + Widget buildWidget( + LineChartNode node, + BuildContext context, + WidgetBuildSettings settings, + ) => PassiveLineChartWidget(node: node, settings: settings); + + /// Builds a preview of a chart with [properties] and no data. + Widget buildPreview({ + LineChartProperties? properties, + LineChartNode? node, + double? width, + double? height, + WidgetBuildSettings settings = const WidgetBuildSettings( + debugLabel: 'buildPreview', + ), + }) { + final LineChartNode previewNode = LineChartNode( + id: '', + name: 'Line Chart', + basicBoxLocal: NodeBox( + 0, + 0, + width ?? kLineChartDefaultWidth, + height ?? kLineChartDefaultHeight, + ), + retainedOuterBoxLocal: NodeBox( + 0, + 0, + width ?? kLineChartDefaultWidth, + height ?? kLineChartDefaultHeight, + ), + properties: properties ?? node?.properties, + variables: {...?node?.variables}, + ); + return PassiveLineChartWidget(node: previewNode, settings: settings); + } +} + +/// A full-height band of a line chart, e.g. night shading. +typedef ChartBand = ({double from, double to, Color color}); + +/// A horizontal grid line with an optional label. +typedef ChartYTick = ({double y, String? label, Color? color}); + +/// The part of the area under the curve inside [from, to] and below +/// [below], with an optional bar on the x axis. +typedef ChartAreaBand = ({ + double from, + double to, + double below, + Color color, + Color? barColor, +}); + +/// A dashed horizontal line. +typedef ChartReferenceLine = ({ + double y, + Color color, + double width, + (double, double) dash, +}); + +/// A tick and label on the x axis. +typedef ChartXTick = ({double x, String label}); + +/// A circle on a point with an optional label above or below it. +typedef ChartMarker = ({ + double x, + double y, + String? label, + bool filled, + bool above, +}); + +/// A dashed vertical line with an optional tag and dot on the curve. +typedef ChartVerticalLine = ({ + double x, + Color? color, + (double, double) dash, + String? label, + bool dotOnCurve, + Color? dotColor, +}); + +/// Everything a [LineChartPainter] draws, resolved from a [LineChartNode] +/// and its bindings. +class LineChartSpec { + final List points; + final bool isDate; + final ChartDomain domain; + final EdgeInsets insets; + final bool smooth; + final List bands; + final List yTicks; + final String yAxisTitle; + final List areaBands; + final List referenceLines; + final List xTicks; + final List markers; + final List verticalLines; + final Color lineColor; + final double lineWidth; + final Color areaTopColor; + final Color areaBottomColor; + final Color gridColor; + final Color zeroLineColor; + final Color axisColor; + final Color markerHollowColor; + final Color tagBackground; + final Color cursorColor; + final Color cursorRingFill; + final Color tooltipBackground; + final Color tooltipShadowColor; + final TextStyle axisLabelStyle; + final TextStyle axisTitleStyle; + final TextStyle markerLabelStyle; + final TextStyle tagTextStyle; + final TextStyle tooltipLabelStyle; + final TextStyle tooltipValueStyle; + final String tooltipXFormat; + final double tooltipYScale; + final int tooltipYDecimals; + final String tooltipYSuffix; + final bool tooltipTrend; + + const LineChartSpec({ + required this.points, + required this.isDate, + required this.domain, + required this.insets, + required this.smooth, + required this.bands, + required this.yTicks, + required this.yAxisTitle, + required this.areaBands, + required this.referenceLines, + required this.xTicks, + required this.markers, + required this.verticalLines, + required this.lineColor, + required this.lineWidth, + required this.areaTopColor, + required this.areaBottomColor, + required this.gridColor, + required this.zeroLineColor, + required this.axisColor, + required this.markerHollowColor, + required this.tagBackground, + required this.cursorColor, + required this.cursorRingFill, + required this.tooltipBackground, + required this.tooltipShadowColor, + required this.axisLabelStyle, + required this.axisTitleStyle, + required this.markerLabelStyle, + required this.tagTextStyle, + required this.tooltipLabelStyle, + required this.tooltipValueStyle, + required this.tooltipXFormat, + required this.tooltipYScale, + required this.tooltipYDecimals, + required this.tooltipYSuffix, + required this.tooltipTrend, + }); + + /// Resolves [node]'s properties and bindings with [scopedValues]. + factory LineChartSpec.resolve(LineChartNode node, ScopedValues scopedValues) { + final LineChartProperties props = node.properties; + + Object? bound(String name) { + final String? path = node.variables[name]; + if (path == null || path.isEmpty) return null; + return PropertyValueDelegate.getVariableValueFromPath( + path, + scopedValues: scopedValues, + ); + } + + List maps(String name) => [ + if (bound(name) case final List list) + for (final Object? item in list) + if (item is Map) item, + ]; + + Color color(String name, ColorRGBA fallback) => + PropertyValueDelegate.getPropertyValue( + node, + name, + scopedValues: scopedValues, + )?.toFlutterColor() ?? + fallback.toFlutterColor(); + + String? text(Object? value) => + value == null ? null : FormulaEvaluator.stringify(value); + + final Object? data = bound('data'); + final List? list = data is List ? data : null; + final List points = LineChartGeometry.points( + list, + props.xField, + props.yField, + ); + final Object? xMinBinding = bound('xMin'); + final bool isDate = list != null && points.isNotEmpty + ? LineChartGeometry.hasDateX(list, props.xField) + : LineChartGeometry.isDate(xMinBinding); + final ChartDomain domain = LineChartGeometry.domain( + points, + xMin: LineChartGeometry.parseX(xMinBinding) ?? props.xMin, + xMax: LineChartGeometry.parseX(bound('xMax')) ?? props.xMax, + yMin: LineChartGeometry.parseNum(bound('yMin')) ?? props.yMin, + yMax: LineChartGeometry.parseNum(bound('yMax')) ?? props.yMax, + ); + final Color lineColor = color('lineColor', props.lineColor); + double? x(Object? value) => LineChartGeometry.parseX(value); + double? n(Object? value) => LineChartGeometry.parseNum(value); + Color? c(Map item, [String key = 'color']) => + LineChartGeometry.parseColor(item[key], item['opacity']); + + return LineChartSpec( + points: points, + isDate: isDate, + domain: domain, + insets: EdgeInsets.fromLTRB( + props.padLeft, + props.padTop, + props.padRight, + props.padBottom, + ), + smooth: props.smooth, + bands: [ + for (final Map b in maps('bands')) + if ((x(b['from']), x(b['to']), c(b)) case ( + final double from, + final double to, + final Color color, + )) + (from: from, to: to, color: color), + ], + yTicks: [ + for (final Map t in maps('yTicks')) + if (n(t['y']) case final double y) + ( + y: y, + label: text(t['label']), + color: LineChartGeometry.parseColor(t['color']), + ), + ], + yAxisTitle: props.yAxisTitle, + areaBands: [ + for (final Map b in maps('areaBands')) + if ((x(b['from']), x(b['to']), n(b['below']), c(b)) case ( + final double from, + final double to, + final double below, + final Color color, + )) + ( + from: from, + to: to, + below: below, + color: color, + barColor: LineChartGeometry.parseColor(b['barColor']), + ), + ], + referenceLines: [ + for (final Map r in maps('referenceLines')) + if (n(r['y']) case final double y) + ( + y: y, + color: c(r) ?? props.axisColor.toFlutterColor(), + width: n(r['width']) ?? 1.5, + dash: LineChartGeometry.parseDash(r['dash'], (5, 5)), + ), + ], + xTicks: [ + for (final Map t in maps('xTicks')) + if (x(t['x']) case final double tx) + (x: tx, label: text(t['label']) ?? ''), + ], + markers: [ + for (final Map m in maps('markers')) + if ((x(m['x']), n(m['y'])) case (final double mx, final double my)) + ( + x: mx, + y: my, + label: text(m['label']), + filled: m['filled'] == true, + above: m['above'] == true, + ), + ], + verticalLines: [ + for (final Map v in maps('verticalLines')) + if (x(v['x']) case final double vx) + ( + x: vx, + color: c(v), + dash: LineChartGeometry.parseDash(v['dash'], (3, 3)), + label: text(v['label']), + dotOnCurve: v['dotOnCurve'] == true, + dotColor: LineChartGeometry.parseColor(v['dotColor']), + ), + ], + lineColor: lineColor, + lineWidth: props.lineWidth, + areaTopColor: color('areaTopColor', props.areaTopColor), + areaBottomColor: color('areaBottomColor', props.areaBottomColor), + gridColor: color('gridColor', props.gridColor), + zeroLineColor: color('zeroLineColor', props.zeroLineColor), + axisColor: color('axisColor', props.axisColor), + markerHollowColor: color('markerHollowColor', props.markerHollowColor), + tagBackground: color('tagBackground', props.tagBackground), + cursorColor: color('cursorColor', props.cursorColor), + cursorRingFill: color('cursorRingFill', props.cursorRingFill), + tooltipBackground: color('tooltipBackground', props.tooltipBackground), + tooltipShadowColor: color('tooltipShadowColor', props.tooltipShadowColor), + axisLabelStyle: TextUtils.retrieveTextStyleFromProp(props.axisLabelStyle), + axisTitleStyle: TextUtils.retrieveTextStyleFromProp(props.axisTitleStyle), + markerLabelStyle: TextUtils.retrieveTextStyleFromProp( + props.markerLabelStyle, + ), + tagTextStyle: TextUtils.retrieveTextStyleFromProp(props.tagTextStyle), + tooltipLabelStyle: TextUtils.retrieveTextStyleFromProp( + props.tooltipLabelStyle, + ), + tooltipValueStyle: TextUtils.retrieveTextStyleFromProp( + props.tooltipValueStyle, + ), + tooltipXFormat: props.tooltipXFormat, + tooltipYScale: props.tooltipYScale, + tooltipYDecimals: props.tooltipYDecimals, + tooltipYSuffix: props.tooltipYSuffix, + tooltipTrend: props.tooltipTrend, + ); + } + + /// The plot rectangle of a chart of [size]. + Rect plotRect(Size size) => LineChartGeometry.plotRect( + size, + left: insets.left, + top: insets.top, + right: insets.right, + bottom: insets.bottom, + ); + + /// The interpolated y of the curve at [x]. + double? valueAt(double x) => LineChartGeometry.interpolate(points, x); + + /// The scrub readout's two lines at [x]. + (String, String) readout(double x) => LineChartGeometry.readout( + points, + x, + domain: domain, + isDate: isDate, + xFormat: tooltipXFormat, + yScale: tooltipYScale, + yDecimals: tooltipYDecimals, + ySuffix: tooltipYSuffix, + trend: tooltipTrend, + ); + + /// The semantics value while the cursor is at [x], e.g. + /// `Cursor 4:30 PM, 1.9 ft`. + String cursorSemantics(double x) { + final String xText = LineChartGeometry.formatX( + x, + isDate: isDate, + pattern: tooltipXFormat, + ); + final String yText = LineChartGeometry.formatY( + valueAt(x) ?? 0, + scale: tooltipYScale, + decimals: tooltipYDecimals, + suffix: tooltipYSuffix, + ); + return 'Cursor $xText, $yText'; + } +} + +/// Renders a [LineChartNode], with a scrub cursor when +/// [LineChartProperties.scrubEnabled] is set. +class PassiveLineChartWidget extends StatefulWidget { + final LineChartNode node; + final WidgetBuildSettings settings; + final List variablesOverrides; + + const PassiveLineChartWidget({ + super.key, + required this.node, + required this.settings, + this.variablesOverrides = const [], + }); + + @override + State createState() => _PassiveLineChartWidgetState(); +} + +class _PassiveLineChartWidgetState extends State { + final FocusNode _focus = FocusNode(debugLabel: 'lineChart'); + + /// The cursor's x in data units, or null when not scrubbing. + double? _cursor; + + /// The domain [_cursor] was set in. A new domain clears the cursor. + ChartDomain? _cursorDomain; + + @override + void dispose() { + _focus.dispose(); + super.dispose(); + } + + void _setCursor(double? x, ChartDomain domain) { + if (x == _cursor) return; + setState(() { + _cursor = x; + _cursorDomain = domain; + }); + } + + @override + Widget build(BuildContext context) { + final ScopedValues scopedValues = ScopedValues.of( + context, + variablesOverrides: widget.variablesOverrides, + ); + final LineChartNode node = widget.node; + final LineChartSpec spec = LineChartSpec.resolve(node, scopedValues); + if (_cursorDomain != spec.domain) _cursor = null; + final double? cursor = node.properties.scrubEnabled ? _cursor : null; + + final String semanticLabel = + PropertyValueDelegate.getPropertyValue( + node, + 'semanticLabel', + scopedValues: scopedValues, + ) ?? + node.properties.semanticLabel; + + return AdaptiveNodeBox( + node: node, + child: LayoutBuilder( + builder: (context, constraints) { + final Size size = constraints.biggest.isFinite + ? constraints.biggest + : Size(node.basicBoxLocal.width, node.basicBoxLocal.height); + Widget chart = SizedBox.fromSize( + size: size, + child: CustomPaint( + painter: LineChartPainter(spec: spec, cursorX: cursor), + ), + ); + if (node.properties.scrubEnabled) { + chart = _interactive(chart, spec, size); + } + return Semantics( + container: true, + label: semanticLabel.isEmpty ? null : semanticLabel, + value: cursor == null ? null : spec.cursorSemantics(cursor), + child: chart, + ); + }, + ), + ); + } + + Widget _interactive(Widget chart, LineChartSpec spec, Size size) { + final ChartScale scale = ChartScale(spec.domain, spec.plotRect(size)); + void scrubTo(Offset local) => _setCursor(scale.xAt(local.dx), spec.domain); + + return Focus( + focusNode: _focus, + onKeyEvent: (node, event) { + if (event is KeyUpEvent) return KeyEventResult.ignored; + final LogicalKeyboardKey key = event.logicalKey; + if (key == LogicalKeyboardKey.arrowRight || + key == LogicalKeyboardKey.arrowLeft) { + final double step = + widget.node.properties.scrubStep * + (spec.isDate ? Duration.millisecondsPerMinute : 1); + final double start = + _cursor ?? + (spec.verticalLines.isNotEmpty + ? spec.verticalLines.first.x + : (spec.domain.xMin + spec.domain.xMax) / 2); + final double next = + start + (key == LogicalKeyboardKey.arrowRight ? step : -step); + _setCursor( + next.clamp(spec.domain.xMin, spec.domain.xMax).toDouble(), + spec.domain, + ); + return KeyEventResult.handled; + } + if (key == LogicalKeyboardKey.escape && _cursor != null) { + _setCursor(null, spec.domain); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: MouseRegion( + cursor: SystemMouseCursors.precise, + onHover: (event) => scrubTo(event.localPosition), + onExit: (_) => _setCursor(null, spec.domain), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (details) { + _focus.requestFocus(); + scrubTo(details.localPosition); + }, + onHorizontalDragStart: (details) => scrubTo(details.localPosition), + onHorizontalDragUpdate: (details) => scrubTo(details.localPosition), + child: chart, + ), + ), + ); + } +} + +/// Paints a [LineChartSpec], with the scrub cursor at [cursorX] when set. +class LineChartPainter extends CustomPainter { + final LineChartSpec spec; + final double? cursorX; + + LineChartPainter({required this.spec, this.cursorX}); + + static TextPainter _text(String text, TextStyle style) => TextPainter( + text: TextSpan(text: text, style: style), + textDirection: TextDirection.ltr, + )..layout(); + + static void _dashedLine( + Canvas canvas, + Offset from, + Offset to, + (double, double) dash, + Paint paint, + ) { + final (double on, double off) = dash; + final double length = (to - from).distance; + if (length == 0) return; + final Offset unit = (to - from) / length; + for (double d = 0; d < length; d += on + off) { + canvas.drawLine( + from + unit * d, + from + unit * math.min(d + on, length), + paint, + ); + } + } + + @override + void paint(Canvas canvas, Size size) { + final Rect plot = spec.plotRect(size); + if (plot.width <= 0 || plot.height <= 0) return; + final ChartScale scale = ChartScale(spec.domain, plot); + final double Function(double) px = scale.px, py = scale.py; + + // 1. Bands. + for (final ChartBand band in spec.bands) { + canvas.drawRect( + Rect.fromLTRB( + px(band.from), + plot.top, + px(band.to), + plot.bottom, + ).intersect(plot), + Paint()..color = band.color, + ); + } + + // 2. Horizontal grid, y labels and the y axis title. + final Paint grid = Paint()..strokeWidth = 1; + for (final ChartYTick tick in spec.yTicks) { + final double y = py(tick.y); + grid.color = + tick.color ?? (tick.y == 0 ? spec.zeroLineColor : spec.gridColor); + canvas.drawLine(Offset(plot.left, y), Offset(plot.right, y), grid); + if (tick.label case final String label when label.isNotEmpty) { + final TextPainter tp = _text(label, spec.axisLabelStyle); + tp.paint(canvas, Offset(plot.left - tp.width - 10, y - tp.height / 2)); + } + } + if (spec.yAxisTitle.isNotEmpty) { + final TextPainter tp = _text(spec.yAxisTitle, spec.axisTitleStyle); + tp.paint(canvas, Offset(plot.left - tp.width - 10, plot.top - 20)); + } + + final List points = [ + for (final Offset p in spec.points) scale.toPixel(p), + ]; + if (points.length >= 2) { + // 3. Area under the curve. + final Path curve = LineChartGeometry.curvePath( + points, + smooth: spec.smooth, + ); + final Path area = LineChartGeometry.areaPath(curve, points, plot.bottom); + canvas.drawPath( + area, + Paint() + ..shader = ui.Gradient.linear( + Offset(0, plot.top), + Offset(0, plot.bottom), + [spec.areaTopColor, spec.areaBottomColor], + ), + ); + + // 4. Area bands. + for (final ChartAreaBand band in spec.areaBands) { + canvas.save(); + canvas.clipRect( + Rect.fromLTRB( + px(band.from), + py(band.below), + px(band.to), + plot.bottom, + ), + ); + canvas.drawPath(area, Paint()..color = band.color); + canvas.restore(); + if (band.barColor case final Color barColor) { + canvas.drawRect( + Rect.fromLTRB( + px(band.from), + plot.bottom - 4, + px(band.to), + plot.bottom, + ), + Paint()..color = barColor, + ); + } + } + + // 5. The curve. + canvas.drawPath( + curve, + Paint() + ..color = spec.lineColor + ..style = PaintingStyle.stroke + ..strokeWidth = spec.lineWidth + ..strokeJoin = StrokeJoin.round, + ); + } + + // 6. Reference lines. + for (final ChartReferenceLine line in spec.referenceLines) { + final double y = py(line.y); + _dashedLine( + canvas, + Offset(plot.left, y), + Offset(plot.right, y), + line.dash, + Paint() + ..color = line.color + ..strokeWidth = line.width, + ); + } + + // 7. X axis, ticks and labels. + final Paint axis = Paint() + ..color = spec.axisColor + ..strokeWidth = 1.5; + for (int i = 0; i < spec.xTicks.length; i++) { + final ChartXTick tick = spec.xTicks[i]; + final double x = px(tick.x); + final TextPainter tp = _text(tick.label, spec.axisLabelStyle); + final double dx = i == 0 + ? 0 + : i == spec.xTicks.length - 1 + ? -tp.width + : -tp.width / 2; + tp.paint(canvas, Offset(x + dx, plot.bottom + 8)); + canvas.drawLine(Offset(x, plot.bottom), Offset(x, plot.bottom + 4), axis); + } + canvas.drawLine( + Offset(plot.left, plot.bottom), + Offset(plot.right, plot.bottom), + axis, + ); + + // 8. Markers. + final Paint outline = Paint() + ..color = spec.lineColor + ..style = PaintingStyle.stroke + ..strokeWidth = 2; + for (final ChartMarker marker in spec.markers) { + final Offset p = Offset(px(marker.x), py(marker.y)); + canvas.drawCircle( + p, + 5, + Paint() + ..color = marker.filled ? spec.lineColor : spec.markerHollowColor, + ); + canvas.drawCircle(p, 5, outline); + if (marker.label case final String label when label.isNotEmpty) { + final TextPainter tp = _text(label, spec.markerLabelStyle); + final double ly = marker.above ? p.dy - tp.height - 8 : p.dy + 8; + final double lx = (p.dx - tp.width / 2) + .clamp(plot.left, math.max(plot.left, plot.right - tp.width)) + .toDouble(); + tp.paint( + canvas, + Offset(lx, math.max(0, math.min(ly, plot.bottom - tp.height - 6))), + ); + } + } + + // 9. Vertical lines. + for (final ChartVerticalLine line in spec.verticalLines) { + final double x = px(line.x); + if (x < plot.left - 0.5 || x > plot.right + 0.5) continue; + _dashedLine( + canvas, + Offset(x, plot.top), + Offset(x, plot.bottom), + line.dash, + Paint() + ..color = line.color ?? spec.axisColor + ..strokeWidth = 1.5, + ); + if (line.label case final String label when label.isNotEmpty) { + final TextPainter tp = _text(label, spec.tagTextStyle); + final Rect box = Rect.fromCenter( + center: Offset(x, plot.top - 12), + width: tp.width + 10, + height: tp.height + 6, + ); + canvas.drawRRect( + RRect.fromRectAndRadius(box, const Radius.circular(2)), + Paint()..color = spec.tagBackground, + ); + tp.paint(canvas, Offset(box.left + 5, box.top + 3)); + } + if (line.dotOnCurve) { + if (spec.valueAt(line.x) case final double y) { + final Offset dot = Offset(x, py(y)); + canvas.drawCircle( + dot, + 6, + Paint()..color = line.dotColor ?? spec.lineColor, + ); + canvas.drawCircle(dot, 6, outline); + } + } + } + + // 10. Scrub cursor and readout. + if (cursorX case final double cx) { + final double? value = spec.valueAt(cx); + if (value == null) return; + final Offset p = Offset(px(cx), py(value)); + canvas.drawLine( + Offset(p.dx, plot.top), + Offset(p.dx, plot.bottom), + Paint() + ..color = spec.cursorColor + ..strokeWidth = 1.5, + ); + canvas.drawCircle(p, 7, Paint()..color = spec.cursorRingFill); + canvas.drawCircle( + p, + 7, + Paint() + ..color = spec.cursorColor + ..style = PaintingStyle.stroke + ..strokeWidth = 3, + ); + final (String xText, String yText) = spec.readout(cx); + final TextPainter a = _text(xText, spec.tooltipLabelStyle); + final TextPainter b = _text(yText, spec.tooltipValueStyle); + final Rect card = LineChartGeometry.readoutRect( + p, + Size(math.max(a.width, b.width) + 20, a.height + b.height + 14), + plot, + ); + canvas.drawRRect( + RRect.fromRectAndRadius( + card.shift(const Offset(3, 3)), + const Radius.circular(3), + ), + Paint()..color = spec.tooltipShadowColor, + ); + canvas.drawRRect( + RRect.fromRectAndRadius(card, const Radius.circular(3)), + Paint()..color = spec.tooltipBackground, + ); + a.paint(canvas, Offset(card.left + 10, card.top + 7)); + b.paint(canvas, Offset(card.left + 10, card.top + 7 + a.height + 2)); + } + } + + @override + bool shouldRepaint(LineChartPainter oldDelegate) => + oldDelegate.spec != spec || oldDelegate.cursorX != cursorX; +} diff --git a/lib/src/transformers/passive_transformer_manager.dart b/lib/src/transformers/passive_transformer_manager.dart index 9d6e473b..edf03174 100644 --- a/lib/src/transformers/passive_transformer_manager.dart +++ b/lib/src/transformers/passive_transformer_manager.dart @@ -53,6 +53,7 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { 'loadingIndicator': PassiveLoadingIndicatorTransformer(getNode, this), 'dropdown': PassiveDropdownTransformer(getNode, this), 'progressBar': PassiveProgressBarTransformer(getNode, this), + 'lineChart': PassiveLineChartTransformer(getNode, this), 'variance': PassiveVarianceTransformer(getNode, this), 'webView': PassiveWebViewTransformer(getNode, this), 'listView': PassiveListViewTransformer(getNode, this), diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart index 86c20551..04ae8102 100644 --- a/lib/src/utils/formula.dart +++ b/lib/src/utils/formula.dart @@ -70,6 +70,12 @@ class FormulaEvaluator { } } + /// Formats [date] (a DateTime, an ISO string, epoch milliseconds or a + /// timestamp) with a date [pattern] as the `formatDate` formula function + /// does, e.g. `h:mm a`. Returns null when [date] isn't a date. + static String? formatDate(Object? date, [String? pattern]) => + _formatDate(date, pattern); + /// The variable paths [expression] reads, e.g. `storage.cart` and `qty`. static Set referencedPaths(String expression) { final _Node? node = _parse(expression); diff --git a/test/line_chart_test.dart b/test/line_chart_test.dart new file mode 100644 index 00000000..1053419a --- /dev/null +++ b/test/line_chart_test.dart @@ -0,0 +1,376 @@ +import 'dart:convert'; +import 'dart:ui'; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +/// Half-hourly tide heights on 2026-10-22, from midnight to midnight. +final List> tides = [ + for (int i = 0; i <= 48; i++) + { + 't': + '2026-10-22 ${(i ~/ 2 % 24).toString().padLeft(2, '0')}:' + '${i.isOdd ? '30' : '00'}', + 'ft': i <= 24 ? i / 4 : (48 - i) / 4, + }, +]..last['t'] = '2026-10-23 00:00'; + +double ms(String date) => LineChartGeometry.parseX(date)!; + +LineChartNode chart({ + Map variables = const {}, + LineChartProperties? properties, +}) => LineChartNode( + id: 'chart', + name: 'Chart', + basicBoxLocal: NodeBox(0, 0, 400, 240), + properties: properties, + variables: {...variables}, +); + +VariableData listVariable(String name, List value) => VariableData( + id: name, + name: name, + value: jsonEncode(value), + type: VariableType.list, +); + +Future pumpChart( + WidgetTester tester, + LineChartNode node, { + List variables = const [], +}) async { + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: PassiveLineChartWidget( + node: node, + settings: const WidgetBuildSettings(debugLabel: 'test'), + variablesOverrides: variables, + ), + ), + ), + ), + ), + ); +} + +LineChartPainter painterOf(WidgetTester tester) => tester + .widgetList(find.byType(CustomPaint)) + .map((paint) => paint.painter) + .whereType() + .single; + +void main() { + group('LineChartGeometry', () { + test('reads numbers, numeric strings and wall-clock dates', () { + expect(LineChartGeometry.parseX(3), 3); + expect(LineChartGeometry.parseX('2.5'), 2.5); + final double space = ms('2026-10-22 00:30'); + expect(space, DateTime.utc(2026, 10, 22, 0, 30).millisecondsSinceEpoch); + expect(ms('2026-10-22T00:30:00'), space); + expect(LineChartGeometry.dateOf(space).hour, 0); + expect(LineChartGeometry.dateOf(space).minute, 30); + expect(LineChartGeometry.parseX('soon'), isNull); + expect(LineChartGeometry.isDate('2026-10-22 00:30'), isTrue); + expect(LineChartGeometry.isDate('12'), isFalse); + }); + + test('reads and sorts points, skipping invalid items', () { + final points = LineChartGeometry.points( + [ + {'x': 2, 'y': '4'}, + {'x': 1, 'y': 1}, + {'x': 'nope', 'y': 1}, + {'x': 3}, + 'junk', + ], + 'x', + 'y', + ); + expect(points, [const Offset(1, 1), const Offset(2, 4)]); + }); + + test('domain defaults to the data extent and takes overrides', () { + final points = [const Offset(0, -2), const Offset(10, 6)]; + expect(LineChartGeometry.domain(points), const ChartDomain(0, 10, -2, 6)); + expect( + LineChartGeometry.domain(points, yMin: -3, xMax: 12), + const ChartDomain(0, 12, -3, 6), + ); + expect( + LineChartGeometry.domain([const Offset(1, 1)]), + const ChartDomain(0.5, 1.5, 0.5, 1.5), + ); + }); + + test('interpolates linearly and holds the ends', () { + final points = [ + const Offset(0, 0), + const Offset(10, 10), + const Offset(20, 0), + ]; + expect(LineChartGeometry.interpolate(points, 5), 5); + expect(LineChartGeometry.interpolate(points, 15), 5); + expect(LineChartGeometry.interpolate(points, -1), 0); + expect(LineChartGeometry.interpolate(points, 25), 0); + expect(LineChartGeometry.interpolate(const [], 1), isNull); + }); + + test('Catmull-Rom control points clamp at the ends', () { + final points = [ + const Offset(0, 0), + const Offset(6, 6), + const Offset(12, 0), + ]; + final segments = LineChartGeometry.catmullRom(points); + expect(segments, hasLength(2)); + // p0 is clamped to p1 for the first segment: c1 = p1 + (p2 - p1) / 6. + expect(segments[0].c1, const Offset(1, 1)); + // c2 = p2 - (p3 - p1) / 6 = (6, 6) - (12, 0) / 6. + expect(segments[0].c2, const Offset(4, 6)); + expect(segments[0].end, const Offset(6, 6)); + // The last segment's p3 is clamped to p2. + expect(segments[1].c2, const Offset(11, 1)); + expect(segments[1].end, const Offset(12, 0)); + }); + + test('maps data to pixels inside the plot', () { + final plot = LineChartGeometry.plotRect( + const Size(400, 240), + left: 44, + top: 30, + right: 14, + bottom: 30, + ); + expect(plot, const Rect.fromLTRB(44, 30, 386, 210)); + final scale = ChartScale(const ChartDomain(0, 10, 0, 6), plot); + expect(scale.toPixel(const Offset(0, 0)), const Offset(44, 210)); + expect(scale.toPixel(const Offset(10, 6)), const Offset(386, 30)); + expect(scale.xAt(44 + 342 / 2), 5); + expect(scale.xAt(-100), 0); + }); + + test('formats the readout for a date axis', () { + final points = LineChartGeometry.points(tides, 't', 'ft'); + final domain = LineChartGeometry.domain(points); + final (String x, String y) = LineChartGeometry.readout( + points, + ms('2026-10-22 04:30'), + domain: domain, + isDate: true, + xFormat: 'h:mm a', + ySuffix: ' ft', + trend: true, + ); + expect(x, '4:30 AM'); + expect(y, '2.3 ft ↑'); + + final (String x2, String y2) = LineChartGeometry.readout( + points, + ms('2026-10-22 16:15'), + domain: domain, + isDate: true, + xFormat: 'EEE h:mm a', + yScale: 0.3048, + yDecimals: 2, + ySuffix: ' m', + trend: true, + ); + expect(x2, 'Thu 4:15 PM'); + // 16:15 is 32.5 half hours, (48 - 32.5) / 4 = 3.875 ft = 1.18 m. + expect(y2, '1.18 m ↓'); + }); + + test('formats number axes and places the readout card', () { + expect(LineChartGeometry.formatX(3, isDate: false), '3'); + expect(LineChartGeometry.formatX(2.5, isDate: false), '2.5'); + const plot = Rect.fromLTRB(44, 30, 386, 210); + expect( + LineChartGeometry.readoutRect( + const Offset(100, 150), + const Size(80, 40), + plot, + ), + const Rect.fromLTWH(112, 98, 80, 40), + ); + // Flipped near the right edge and clamped to the plot's top. + expect( + LineChartGeometry.readoutRect( + const Offset(350, 40), + const Size(80, 40), + plot, + ), + const Rect.fromLTWH(258, 30, 80, 40), + ); + }); + + test('reads colors with opacity and dash patterns', () { + final color = LineChartGeometry.parseColor('#2A3A44', 0.08)!; + expect(color.a, closeTo(0.08, 0.001)); + expect((color.r * 255).round(), 0x2A); + expect(LineChartGeometry.parseColor('#80FF0000')!.a, closeTo(0.5, 0.01)); + expect(LineChartGeometry.parseColor('nope'), isNull); + expect(LineChartGeometry.parseDash([4, 2], (5, 5)), (4.0, 2.0)); + expect(LineChartGeometry.parseDash(null, (5, 5)), (5.0, 5.0)); + }); + }); + + group('PassiveLineChartWidget', () { + testWidgets('renders bound points, lists and domain overrides', ( + tester, + ) async { + await pumpChart( + tester, + chart( + properties: LineChartProperties( + xField: 't', + yField: 'ft', + smooth: true, + ), + variables: { + 'data': r'${tides}', + 'yMin': r'${= -1}', + 'yMax': r'${= 7}', + 'xMax': r'${= "2026-10-23 00:00"}', + 'markers': r'${markers}', + 'verticalLines': r'${now}', + }, + ), + variables: [ + listVariable('tides', tides), + listVariable('markers', [ + {'x': '2026-10-22 12:00', 'y': 6, 'label': 'H 6.0', 'above': true}, + ]), + listVariable('now', [ + {'x': '2026-10-22 09:00', 'label': 'NOW', 'dotOnCurve': true}, + ]), + ], + ); + expect(tester.takeException(), isNull); + final spec = painterOf(tester).spec; + expect(spec.points, hasLength(49)); + expect(spec.isDate, isTrue); + expect( + spec.domain, + ChartDomain(ms('2026-10-22 00:00'), ms('2026-10-23 00:00'), -1, 7), + ); + expect(spec.markers.single.label, 'H 6.0'); + expect(spec.verticalLines.single.dotOnCurve, isTrue); + expect(spec.valueAt(ms('2026-10-22 09:00')), 4.5); + }); + + testWidgets('binds the data list from a formula', (tester) async { + await pumpChart( + tester, + chart( + variables: { + 'data': r'${= filter(points, p => p.y > 1)}', + 'lineColor': r'${= "#FF0000"}', + }, + ), + variables: [ + listVariable('points', [ + {'x': 1, 'y': 0}, + {'x': 2, 'y': 3}, + {'x': 3, 'y': 5}, + {'x': 4, 'y': 2}, + ]), + ], + ); + expect(tester.takeException(), isNull); + final spec = painterOf(tester).spec; + expect(spec.points, [ + const Offset(2, 3), + const Offset(3, 5), + const Offset(4, 2), + ]); + expect(spec.isDate, isFalse); + expect(spec.domain, const ChartDomain(2, 4, 2, 5)); + expect(spec.lineColor.toARGB32(), 0xFFFF0000); + }); + + testWidgets('scrubs with hover and arrow keys and reads out the value', ( + tester, + ) async { + final node = chart( + properties: LineChartProperties( + xField: 't', + yField: 'ft', + scrubEnabled: true, + scrubStep: 30, + tooltipYSuffix: ' ft', + tooltipTrend: true, + semanticLabel: 'Tide chart', + ), + variables: {'data': r'${tides}'}, + ); + await pumpChart(tester, node, variables: [listVariable('tides', tides)]); + expect(painterOf(tester).cursorX, isNull); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + // Halfway across the plot (44 to 386) is noon. + await gesture.moveTo(const Offset(44 + 342 / 2, 120)); + await tester.pump(); + expect(painterOf(tester).cursorX, ms('2026-10-22 12:00')); + expect( + tester.getSemantics(find.bySemanticsLabel('Tide chart')), + isSemantics( + label: 'Tide chart', + value: 'Cursor 12:00 PM, 6.0 ft', + ), + ); + + // Tap to focus, then step 30 minutes with the arrow keys. + await gesture.removePointer(); + await tester.tapAt(const Offset(44 + 342 / 4, 120)); + await tester.pump(); + expect(painterOf(tester).cursorX, ms('2026-10-22 06:00')); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); + await tester.pump(); + final double cursor = painterOf(tester).cursorX!; + expect(cursor, ms('2026-10-22 06:30')); + expect(painterOf(tester).spec.readout(cursor), ('6:30 AM', '3.3 ft ↑')); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pump(); + expect(painterOf(tester).cursorX, isNull); + }); + + testWidgets('builds through the passive transformer manager', ( + tester, + ) async { + final node = LineChartNode.empty(); + final registry = NodeRegistry()..setNodes({node.id: node}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Builder( + builder: (context) => manager.buildWidgetFromNode( + node, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ); + expect(tester.takeException(), isNull); + expect(find.byType(PassiveLineChartWidget), findsOneWidget); + }); + }); +} diff --git a/test/transformers_test.dart b/test/transformers_test.dart index 3b21e502..bb8f2e6d 100644 --- a/test/transformers_test.dart +++ b/test/transformers_test.dart @@ -23,6 +23,7 @@ List standaloneNodes() => [ ), ), ProgressBarNode.empty(), + LineChartNode.empty(), RadioNode.empty(), SliderNode.empty(), SwitchNode.empty(), From e5545bc34b97ef87da803c38c0fbce5646ea16eb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 23:18:52 +0000 Subject: [PATCH 07/27] Support bound semanticLabel and semanticSelected on any node Nodes can bind node.variables['semanticLabel'] and ['semanticSelected'] to give a custom accessible label and selected state. Tappable nodes merge the label with their children's semantics so the tap action is kept. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3B1XCZZ7QGvZLUGgSG5H4 --- .../widget_node_transformer_manager.dart | 59 ++++++++++++ test/semantics_test.dart | 90 +++++++++++++++++++ 2 files changed, 149 insertions(+) diff --git a/lib/src/transformers/widget_node_transformer_manager.dart b/lib/src/transformers/widget_node_transformer_manager.dart index 87c40bd2..38cbb876 100644 --- a/lib/src/transformers/widget_node_transformer_manager.dart +++ b/lib/src/transformers/widget_node_transformer_manager.dart @@ -168,6 +168,16 @@ abstract class WidgetNodeTransformerManager /// inside reads as that text; one without (an icon button) reads as its /// node name, or its icon's name when the node still has a default name. Widget wrapWithSemantics(BaseNode node, Widget widget) { + final String? customLabel = node.variables['semanticLabel']; + final String? customSelected = node.variables['semanticSelected']; + if ((customLabel?.trim().isNotEmpty ?? false) || customSelected != null) { + return _wrapWithCustomSemantics( + node, + widget, + label: customLabel, + selected: customSelected, + ); + } if (node is! ReactionMixin) return widget; if (node case CanvasNode() || SpacerNode()) return widget; if (node is CustomPropertiesMixin && @@ -203,6 +213,55 @@ abstract class WidgetNodeTransformerManager ); } + /// Semantics a node's `semanticLabel` variable gives it (a text with paths + /// or formulas): one node with that label. It replaces what the children + /// read, or, on a button (a node that reacts to taps), comes first. A `semanticSelected` + /// formula marks it selected, e.g. the chosen tab or chip. + Widget _wrapWithCustomSemantics( + BaseNode node, + Widget widget, { + String? label, + String? selected, + }) { + final bool tappable = + node is ReactionMixin && + (node as ReactionMixin).reactions.any( + (reaction) => + reaction.trigger.type == TriggerType.click && + reaction.action.enabled, + ); + return Builder( + builder: (context) { + final ScopedValues scopedValues = ScopedValues.of(context); + final String? text = label == null || label.trim().isEmpty + ? null + : PropertyValueDelegate.substituteVariables( + label, + scopedValues: scopedValues, + nullSubstitutionMode: NullSubstitutionMode.emptyString, + ); + final bool? isSelected = selected == null + ? null + : PropertyValueDelegate.getVariableValueFromPath( + selected, + scopedValues: scopedValues, + ); + final Widget child = Semantics( + container: true, + button: tappable ? true : null, + selected: isSelected, + label: text, + // A label replaces the children's text, except on a tappable node: + // its tap action lives on a child, so the children merge in after + // the label. + excludeSemantics: text != null && !tappable, + child: widget, + ); + return tappable ? MergeSemantics(child: child) : child; + }, + ); + } + /// The name a screen reader announces for [node] when it has no text. static String semanticLabelFor(BaseNode node, List descendants) { final String name = node.name.trim(); diff --git a/test/semantics_test.dart b/test/semantics_test.dart index e9432117..7595314e 100644 --- a/test/semantics_test.dart +++ b/test/semantics_test.dart @@ -42,6 +42,96 @@ Future pumpNode(WidgetTester tester, BaseNode node) async { } void main() { + testWidgets('a semanticLabel formula names a node and replaces its text', ( + tester, + ) async { + final SemanticsHandle handle = tester.ensureSemantics(); + final node = tappable('day', 'Day') + ..variables['semanticLabel'] = + r'Sat, Oct 24${= 1 < 2 ? ", good pooling" : ""}' + ..variables['semanticSelected'] = r'${= 2 > 1}'; + await pumpNode(tester, node); + expect( + tester.getSemantics(find.bySemanticsLabel('Sat, Oct 24, good pooling')), + matchesSemantics( + label: 'Sat, Oct 24, good pooling', + isButton: true, + hasTapAction: true, + hasSelectedState: true, + isSelected: true, + ), + ); + // On a button with text, the label comes first, then the text. + final withText = TextNode.fromJson({ + 'id': 'label', + 'name': 'Label', + 'type': 'text', + 'basicBoxLocal': [0, 0, 40, 20], + 'characters': '24', + 'textMixedProps': [ + {'start': 0, 'end': 2}, + ], + })..parentID = 'chip'; + final chip = + RowColumnNode( + id: 'chip', + name: 'Chip', + basicBoxLocal: NodeBox(0, 0, 60, 30), + children: ['label'], + rowColumnType: RowColumnType.row, + reactions: [ + Reaction( + action: LinkAction(url: ''), + trigger: TriggerModel(type: TriggerType.click), + ), + ], + ) + ..parentID = kRootNode + ..variables['semanticLabel'] = 'Saturday'; + final registry = NodeRegistry() + ..setNodes({'chip': chip, 'label': withText}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + chip, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ); + expect(find.bySemanticsLabel(RegExp(r'^Saturday\n24$')), findsOneWidget); + handle.dispose(); + }); + + testWidgets('a semanticLabel on a node that does not react adds a label', ( + tester, + ) async { + final SemanticsHandle handle = tester.ensureSemantics(); + final node = + RectangleNode( + id: 'reading', + name: 'Reading', + basicBoxLocal: NodeBox(0, 0, 40, 40), + ) + ..parentID = kRootNode + ..variables['semanticLabel'] = 'Tide now 2.0 ft, falling'; + await pumpNode(tester, node); + expect(find.bySemanticsLabel('Tide now 2.0 ft, falling'), findsOneWidget); + handle.dispose(); + }); + testWidgets('a tappable node without text reads as a named button', ( tester, ) async { From 71bfb8db0598f96063f1b21b767df1b038e7b348 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 02:29:59 +0000 Subject: [PATCH 08/27] Run load-time API calls in order with the canvas's other load actions LayoutBuilder used to fire a canvas's load callApi reactions itself when the layout loaded, before the rest of the load chain, so a sequence like set loading -> call API -> read result ran out of order. It now only puts those APIs' variables in the loading state and the canvas calls them in its load chain. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3B1XCZZ7QGvZLUGgSG5H4 --- .../passive_canvas_transformer.dart | 2 - lib/src/ui/layout_builder.dart | 33 +--- test/load_api_order_test.dart | 158 ++++++++++++++++++ 3 files changed, 161 insertions(+), 32 deletions(-) create mode 100644 test/load_api_order_test.dart diff --git a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart index 9fe25753..0525d1cb 100644 --- a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart @@ -787,8 +787,6 @@ class _PassiveCanvasWidgetState extends State { .where( (action) => action.enabled && - // Because calling api is handled in LayoutBuilder. - action.type != ActionType.callApi && action.type != ActionType.navigation && action.type != ActionType.submit && action.type != ActionType.link, diff --git a/lib/src/ui/layout_builder.dart b/lib/src/ui/layout_builder.dart index d60b64c1..8eb60daf 100644 --- a/lib/src/ui/layout_builder.dart +++ b/lib/src/ui/layout_builder.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'package:codelessly_api/codelessly_api.dart'; import 'package:collection/collection.dart'; @@ -292,9 +291,9 @@ class _CodelesslyLayoutBuilderState extends State { } /// This loads all the apis for the layout as variables to be available - /// in the layout. Any api that is defined on canvas' load action will be - /// called right away. Otherwise, the api will be available as a variable - /// with idle state. + /// in the layout. An api called by a canvas load action starts in the + /// loading state (the canvas calls it in order with its other load + /// actions); any other api is available as a variable with idle state. void loadApisAndItsVariables(BuildContext context) { final apiCallActions = canvasNode.reactions .whereTriggerType(TriggerType.load) @@ -334,32 +333,6 @@ class _CodelesslyLayoutBuilderState extends State { // Override default values of variables with values provided in data. final notifier = Observable(variable); codelesslyContext.variables[variable.id] = notifier; - - // Make api request right away if it is a canvas action. - if (canvasAction != null) { - DebugLogger.instance.printInfo( - 'Making api request for onLoad canvas action', - name: CodelesslyLayoutRetriever.name, - ); - // The variable carries the error state; report the failure instead - // of leaving it as an unhandled async error. - unawaited( - FunctionsRepository.callApi( - context, - canvasAction, - notifier, - ).then( - (_) {}, - onError: (Object e, StackTrace stackTrace) => - ErrorLogger.instance.captureException( - e, - message: 'onLoad API call failed', - type: 'api_call_failed', - stackTrace: stackTrace, - ), - ), - ); - } } } diff --git a/test/load_api_order_test.dart b/test/load_api_order_test.dart new file mode 100644 index 00000000..9aa3ce16 --- /dev/null +++ b/test/load_api_order_test.dart @@ -0,0 +1,158 @@ +import 'dart:convert'; + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +class _FakeCache extends Fake implements CacheManager {} + +class _FakeAuthManager extends Fake implements AuthManager { + @override + AuthData? get authData => AuthData( + authToken: 'token', + projectId: 'project', + timestamp: DateTime(2026), + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + ); +} + +class _FakeLocalRepository extends Fake implements LocalDataRepository {} + +class _FakeNetworkRepository extends Fake implements NetworkDataRepository {} + +class _FakeFirestore extends Fake implements FirebaseFirestore {} + +class _FakeLocalDatabase extends Fake implements LocalDatabase {} + +class _TestDataManager extends DataManager { + _TestDataManager(SDKPublishModel model, CodelesslyConfig config) + : super( + 'test', + config: config, + cacheManager: _FakeCache(), + authManager: _FakeAuthManager(), + networkDataRepository: _FakeNetworkRepository(), + localDataRepository: _FakeLocalRepository(), + firebaseFirestore: _FakeFirestore(), + errorLogger: ErrorLogger.instance, + publishModel: model, + ); + + @override + LocalDatabase get localDatabase => _FakeLocalDatabase(); +} + +Reaction _onLoad(ActionModel action) => Reaction( + name: action.type.name, + trigger: TriggerModel(type: TriggerType.load), + action: action, +); + +CallFunctionAction _record(String what) => + CallFunctionAction(name: 'record', params: {'what': what}); + +/// A canvas whose load actions are: record "before", call an API (recording +/// "success" when it answers), record "after". +Map _page() => { + 'canvas': CanvasNode( + id: 'canvas', + name: 'canvas', + basicBoxLocal: NodeBox(0, 0, 390, 844), + children: ['body'], + properties: CanvasProperties( + bodyId: 'body', + safeArea: const SafeAreaModel.all(false), + ), + reactions: [ + _onLoad(_record('before')), + _onLoad(ApiCallAction(apiId: 'sun', onSuccess: [_record('success')])), + _onLoad(_record('after')), + ], + )..parentID = kRootNode, + 'body': AutoPlaceholderNode( + id: 'body', + name: 'body', + basicBoxLocal: NodeBox(0, 0, 390, 844), + children: const [], + )..parentID = 'canvas', +}; + +void main() { + testWidgets('A canvas runs its load-time API call in chain order', ( + tester, + ) async { + final List calls = []; + final model = SDKPublishModel( + projectId: 'project', + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, + ); + final layout = SDKPublishLayout.private( + id: 'layout', + projectId: 'project', + pageId: 'page', + lastUpdated: DateTime(2026), + canvases: {'canvas': _page()}, + breakpoints: [const Breakpoint(nodeId: 'canvas')], + privacy: model, + ); + model.layouts[layout.id] = SDKPublishLayout.fromJson( + jsonDecode(jsonEncode(layout.toJson())) as Map, + ); + model.apis['sun'] = HttpApiData( + id: 'sun', + name: 'Sun times', + directory: '', + url: 'https://example.com/sun', + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, + ); + final config = CodelesslyConfig(authToken: 'token', isPreview: true); + final codelessly = Codelessly( + config: config, + client: MockClient((request) async { + calls.add('request'); + return http.Response('{"sunrise": "7:25"}', 200, request: request); + }), + publishDataManager: _TestDataManager(model, config) + ..status = CStatus.loaded(), + previewDataManager: _TestDataManager(model, config) + ..status = CStatus.loaded(), + )..status = CStatus.loaded(); + + await tester.runAsync(() async { + await tester.pumpWidget( + MaterialApp( + home: CodelesslyWidget( + codelessly: codelessly, + layoutID: 'layout', + functions: { + 'record': (context, reference, params) => + calls.add('${params['what']}'), + }, + ), + ), + ); + for (int i = 0; i < 10 && calls.length < 4; i++) { + await tester.pump(); + await Future.delayed(const Duration(milliseconds: 20)); + } + }); + + expect(tester.takeException(), isNull); + expect(calls, ['before', 'request', 'success', 'after']); + }); +} From 40a76ec13660e92b7bcb88cfeee113de34daa800 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 03:08:49 +0000 Subject: [PATCH 09/27] Pop to a destination when there is nothing to go back to A screen opened directly (a deep link, the first route) can't pop; a pop action with a destination now replaces it with that screen, as a back link to a parent screen should. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3B1XCZZ7QGvZLUGgSG5H4 --- lib/src/functions/functions_repository.dart | 13 ++++- test/feedback_actions_test.dart | 55 +++++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index 5f894cb0..635a0cc4 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -542,7 +542,18 @@ class FunctionsRepository { final String? myCanvasId = effectiveCodelessly.currentNavigatedCanvasId; if (action.navigationType == NavigationType.pop) { - await Navigator.maybePop(context, parsedParams); + final bool popped = await Navigator.maybePop(context, parsedParams); + // Nothing to go back to (the screen was opened directly): a pop with a + // destination goes there instead. + if (!popped && action.destinationId.isNotEmpty && context.mounted) { + return navigate( + context, + action.copyWith(navigationType: NavigationType.replace), + codelessly: codelessly, + codelesslyController: codelesslyController, + scopedValues: scopedValues, + ); + } } else { // Check if a layout exists for the action's [destinationId]. final String? layoutId = diff --git a/test/feedback_actions_test.dart b/test/feedback_actions_test.dart index 0247d15d..2ed0c177 100644 --- a/test/feedback_actions_test.dart +++ b/test/feedback_actions_test.dart @@ -197,6 +197,9 @@ Future pumpLayouts( value: CodelesslyContext.empty(layoutID: main.id), ), Provider.value(value: codelessly), + ChangeNotifierProvider.value( + value: controller, + ), ], child: MaterialApp( home: CodelesslyLayoutBuilder( @@ -222,6 +225,58 @@ TextStyle styleOfText(WidgetTester tester, String text) { } void main() { + group('pop', () { + testWidgets('with nothing to go back to goes to its destination', ( + tester, + ) async { + final guide = layoutOf('guide', [ + canvas('guideCanvas', 'guideBody'), + body('guideBody', 'guideCanvas', ['guideText']), + text('guideText', 'guideBody', 'The guide', brandRegular), + ]); + await pumpLayouts(tester, [ + layoutOf( + 'main', + mainScreen( + NavigationAction( + navigationType: NavigationType.pop, + destinationId: 'guideCanvas', + ), + ), + ), + guide, + ]); + expect(find.byType(CodelesslyWidget), findsNothing); + await tester.tap(find.text('Go')); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + // The main screen was replaced by the guide's. + expect(find.text('Your album'), findsNothing); + expect( + tester + .widget(find.byType(CodelesslyWidget)) + .controller + ?.layoutID, + 'guide', + ); + }); + + testWidgets('without a destination does nothing at the first screen', ( + tester, + ) async { + await pumpLayouts(tester, [ + layoutOf( + 'main', + mainScreen(NavigationAction(navigationType: NavigationType.pop)), + ), + ]); + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(find.text('Your album'), findsOneWidget); + }); + }); + group('FeedbackTheme', () { test('reads the font, primary, surface and text color of a layout', () { final FeedbackTheme theme = FeedbackTheme.fromNodes( From 507f019ef6d2c159bbae02853f41a764a270ef75 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 03:11:23 +0000 Subject: [PATCH 10/27] Desaturate image fills by their paint's saturation Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3B1XCZZ7QGvZLUGgSG5H4 --- .../passive_rectangle_transformer.dart | 20 +++++++++++++++ test/image_saturation_test.dart | 25 +++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 test/image_saturation_test.dart diff --git a/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart b/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart index d60a661b..9af3c5bd 100644 --- a/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart @@ -810,6 +810,12 @@ Widget buildFill( } } + if (paint.saturation != 1) { + child = ColorFiltered( + colorFilter: ColorFilter.matrix(saturationMatrix(paint.saturation)), + child: child, + ); + } if (modifiedOpacity != 1) { child = Opacity(opacity: modifiedOpacity, child: child); } @@ -822,6 +828,20 @@ Widget buildFill( } } +/// A color matrix that scales color saturation by [saturation]: 1 keeps the +/// colors, 0 leaves grey at each pixel's luminance (Rec. 709 weights). +List saturationMatrix(double saturation) { + const double r = 0.2126, g = 0.7152, b = 0.0722; + final double s = saturation; + final double i = 1 - s; + return [ + r * i + s, g * i, b * i, 0, 0, // + r * i, g * i + s, b * i, 0, 0, // + r * i, g * i, b * i + s, 0, 0, // + 0, 0, 0, 1, 0, // + ]; +} + class RelativeTransform { final List translation; final double rotation; diff --git a/test/image_saturation_test.dart b/test/image_saturation_test.dart new file mode 100644 index 00000000..ad755e5d --- /dev/null +++ b/test/image_saturation_test.dart @@ -0,0 +1,25 @@ +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Applies a 4x5 color matrix to an RGB color (alpha 1). +List apply(List m, List rgb) => [ + for (int row = 0; row < 3; row++) + m[row * 5] * rgb[0] + m[row * 5 + 1] * rgb[1] + m[row * 5 + 2] * rgb[2], +]; + +void main() { + test('saturation 0 turns colors into grey of the same luminance', () { + final grey = apply(saturationMatrix(0), [1, 0, 0]); + for (final channel in grey) { + expect(channel, closeTo(0.2126, 1e-9)); + } + final white = apply(saturationMatrix(0), [1, 1, 1]); + for (final channel in white) { + expect(channel, closeTo(1, 1e-9)); + } + }); + + test('saturation 1 keeps colors', () { + expect(apply(saturationMatrix(1), [0.2, 0.5, 0.9]), [0.2, 0.5, 0.9]); + }); +} From 4495e24e460704c71d5f6b94eb2a215043191897 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 03:18:17 +0000 Subject: [PATCH 11/27] Keep a widget's own semantics under a custom semanticLabel A label on a field, dropdown or other widget used to replace its semantics, so a screen reader lost the text field or button. Widgets now merge the label with their own semantics, as tappable nodes do. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3B1XCZZ7QGvZLUGgSG5H4 --- .../widget_node_transformer_manager.dart | 14 +++++--- test/semantics_test.dart | 34 +++++++++++++++---- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/lib/src/transformers/widget_node_transformer_manager.dart b/lib/src/transformers/widget_node_transformer_manager.dart index 38cbb876..88e943e8 100644 --- a/lib/src/transformers/widget_node_transformer_manager.dart +++ b/lib/src/transformers/widget_node_transformer_manager.dart @@ -230,6 +230,10 @@ abstract class WidgetNodeTransformerManager reaction.trigger.type == TriggerType.click && reaction.action.enabled, ); + final bool interactive = + tappable || + (node is CustomPropertiesMixin && + node.handlesDefaultReactionsInternally); return Builder( builder: (context) { final ScopedValues scopedValues = ScopedValues.of(context); @@ -251,13 +255,13 @@ abstract class WidgetNodeTransformerManager button: tappable ? true : null, selected: isSelected, label: text, - // A label replaces the children's text, except on a tappable node: - // its tap action lives on a child, so the children merge in after - // the label. - excludeSemantics: text != null && !tappable, + // A label replaces the children's text, except on a tappable node + // or a widget (a field, a dropdown): its actions and state live on + // a child, so the children merge in after the label. + excludeSemantics: text != null && !interactive, child: widget, ); - return tappable ? MergeSemantics(child: child) : child; + return interactive ? MergeSemantics(child: child) : child; }, ); } diff --git a/test/semantics_test.dart b/test/semantics_test.dart index 7595314e..ffe9b8e6 100644 --- a/test/semantics_test.dart +++ b/test/semantics_test.dart @@ -26,13 +26,15 @@ Future pumpNode(WidgetTester tester, BaseNode node) async { ChangeNotifierProvider.value( value: CodelesslyContext.empty(), child: MaterialApp( - home: Align( - alignment: Alignment.topLeft, - child: Builder( - builder: (context) => manager.buildWidgetFromNode( - node, - context, - settings: const WidgetBuildSettings(debugLabel: 'test'), + home: Material( + child: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + node, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), ), ), ), @@ -165,4 +167,22 @@ void main() { 'Add to bag', ); }); + + testWidgets('a semanticLabel on a field keeps the field', (tester) async { + final SemanticsHandle handle = tester.ensureSemantics(); + final node = TextFieldNode( + id: 'search', + name: 'Search', + basicBoxLocal: NodeBox(0, 0, 300, 48), + properties: TextFieldProperties(), + )..parentID = kRootNode; + node.variables['semanticLabel'] = 'Search creatures'; + await pumpNode(tester, node); + expect(tester.takeException(), isNull); + final data = tester + .getSemantics(find.bySemanticsLabel(RegExp('^Search creatures'))) + .getSemanticsData(); + expect(data.flagsCollection.isTextField, isTrue); + handle.dispose(); + }); } From 8d35fac7750443c90ddb3d01503a8ad19261d3ed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 03:19:36 +0000 Subject: [PATCH 12/27] Open an app at a URL location and report navigation locations CodelesslyWidgetController.initialLocation opens the canvas whose path matches it (guide/bat-star opens guide/:id) with the path and query params as route params. onLocationChanged tells the host the location of the screen a navigation action opens or goes back to, so a site can keep its URL in sync. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3B1XCZZ7QGvZLUGgSG5H4 --- lib/codelessly_sdk.dart | 1 + lib/src/functions/functions_repository.dart | 23 ++ lib/src/transformers/utils/scoped_values.dart | 4 +- lib/src/ui/canvas_location.dart | 200 ++++++++++++ lib/src/ui/codelessly_widget.dart | 25 +- lib/src/ui/codelessly_widget_controller.dart | 20 ++ test/canvas_location_test.dart | 295 ++++++++++++++++++ 7 files changed, 565 insertions(+), 3 deletions(-) create mode 100644 lib/src/ui/canvas_location.dart create mode 100644 test/canvas_location_test.dart diff --git a/lib/codelessly_sdk.dart b/lib/codelessly_sdk.dart index ffaef123..0b3a19bf 100644 --- a/lib/codelessly_sdk.dart +++ b/lib/codelessly_sdk.dart @@ -35,6 +35,7 @@ export 'src/transformers/transformers.dart'; export 'src/transformers/utils/blend_mask.dart'; export 'src/transformers/utils/cloud_list_items.dart'; export 'src/transformers/utils/query_status.dart'; +export 'src/ui/canvas_location.dart'; export 'src/ui/codelessly_context.dart'; export 'src/ui/codelessly_route.dart'; export 'src/ui/codelessly_widget.dart'; diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index 635a0cc4..97141206 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -598,6 +598,28 @@ class FunctionsRepository { codelessly: effectiveCodelessly, ); + // Tell the host the location of the screen opened, and of this screen + // when it is gone back to. + final CodelesslyLocationCallback? onLocationChanged = + parentController.onLocationChanged; + final SDKPublishModel? model = + effectiveCodelessly.dataManager.publishModel; + String? locationOf(String? id, Map params) => + onLocationChanged == null || model == null || id == null + ? null + : CanvasLocation.locationOf(model, id, params); + final String? location = locationOf(action.destinationId, parsedParams); + final String? myLocation = locationOf( + myCanvasId ?? myLayoutId, + effectiveScopedValues.routeParams, + ); + if (location != null) { + onLocationChanged!( + location, + replace: action.navigationType == NavigationType.replace, + ); + } + if (action.navigationType == NavigationType.push) { // A chain triggered on the page can finish on this layout after // the page pops. @@ -623,6 +645,7 @@ class FunctionsRepository { layoutId: myLayoutId, canvasId: myCanvasId, ); + if (myLocation != null) onLocationChanged!(myLocation, replace: true); } } else if (action.navigationType == NavigationType.replace) { await Navigator.pushReplacement( diff --git a/lib/src/transformers/utils/scoped_values.dart b/lib/src/transformers/utils/scoped_values.dart index 146ebbac..2a46f48c 100644 --- a/lib/src/transformers/utils/scoped_values.dart +++ b/lib/src/transformers/utils/scoped_values.dart @@ -114,8 +114,10 @@ class ScopedValues { indexedItem = IndexedItemProvider.maybeOf(context); screenSize = MediaQuery.maybeSizeOf(context); nodeState = NodeStateProvider.maybeOf(context)?.state; + final ModalRoute? route = ModalRoute.of(context); routeParams = - ModalRoute.of(context)?.settings.arguments as Map? ?? + CodelesslyRouteParams.maybeOf(context, route) ?? + route?.settings.arguments as Map? ?? {}; final instance = context.read(); _appUser = null; diff --git a/lib/src/ui/canvas_location.dart b/lib/src/ui/canvas_location.dart new file mode 100644 index 00000000..f6d207f9 --- /dev/null +++ b/lib/src/ui/canvas_location.dart @@ -0,0 +1,200 @@ +import 'dart:convert'; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import '../model/sdk_publish_model.dart'; + +/// Called when navigation in a Codelessly app moves to a screen with a URL +/// path. [location] is that screen's path with its params filled in, relative +/// to the app's root and without a leading slash, such as +/// `guide/bat-star?from=tides`. [replace] is true when the screen replaced the +/// previous one or was gone back to, and false when it was pushed on top. +typedef CodelesslyLocationCallback = void Function( + String location, { + required bool replace, +}); + +/// The canvas a location opens and the route params it passes to it. +class CanvasLocationMatch { + /// The layout that holds [canvasId]. + final String layoutId; + + /// The canvas whose path matched. + final String canvasId; + + /// The path params, such as `id` for `guide/:id`, over the query params. + final Map params; + + /// Creates a [CanvasLocationMatch]. + const CanvasLocationMatch({ + required this.layoutId, + required this.canvasId, + required this.params, + }); +} + +/// Maps locations (URL paths relative to a published app's root) to canvases +/// by their [CanvasProperties.path], and back. +abstract final class CanvasLocation { + /// The segments of a path or path pattern, without empty ones. + static List segmentsOf(String path) => path + .split('/') + .map((segment) => segment.trim()) + .where((segment) => segment.isNotEmpty) + .toList(); + + /// The canvas node of [canvasId] in [layout], if loaded. + static CanvasNode? canvasOf(SDKPublishLayout layout, String canvasId) { + final BaseNode? node = layout.canvases[canvasId]?[canvasId]; + return node is CanvasNode ? node : null; + } + + /// The params [pattern] takes from [segments], or null when it does not + /// match them. + static Map? matchPattern( + List pattern, + List segments, + ) { + if (pattern.length != segments.length) return null; + final Map params = {}; + for (int i = 0; i < pattern.length; i++) { + if (pattern[i].startsWith(':') && pattern[i].length > 1) { + params[pattern[i].substring(1)] = segments[i]; + } else if (pattern[i] != segments[i]) { + return null; + } + } + return params; + } + + /// The canvas of [model] whose path matches [location], such as + /// `guide/bat-star?x=1` for a canvas at `guide/:id`. Its params are the path + /// params (`id: bat-star`) over the query params (`x: 1`). When several + /// canvases match, the one with the most literal segments wins, so + /// `guide/new` beats `guide/:id`. Null when no canvas matches. + static CanvasLocationMatch? match(SDKPublishModel model, String location) { + final Uri uri = Uri.parse(location.trim()); + final List segments = uri.pathSegments + .where((segment) => segment.isNotEmpty) + .toList(); + if (segments.isEmpty) return null; + + CanvasLocationMatch? best; + int bestLiterals = -1; + for (final SDKPublishLayout layout in model.layouts.values) { + if (model.disabledLayouts.contains(layout.id)) continue; + for (final String canvasId in layout.canvasIds) { + final CanvasNode? canvas = canvasOf(layout, canvasId); + if (canvas == null || !canvas.properties.hasPath) continue; + final List pattern = segmentsOf(canvas.properties.path!); + final Map? params = matchPattern(pattern, segments); + if (params == null) continue; + final int literals = pattern.length - params.length; + if (literals <= bestLiterals) continue; + bestLiterals = literals; + best = CanvasLocationMatch( + layoutId: layout.id, + canvasId: canvasId, + params: {...uri.queryParameters, ...params}, + ); + } + } + return best; + } + + /// The location of the screen [destinationId] (a canvas or layout id) opened + /// with [params]: its path with `:name` segments filled from [params], and + /// the other params as the query. The entry screen without a path is at the + /// root (an empty path). Null when the screen has no path or [params] lack + /// one of its path params. + static String? locationOf( + SDKPublishModel model, + String destinationId, + Map params, + ) { + final SDKPublishLayout? layout = + model.layouts[destinationId] ?? + model.layouts.values + .where((layout) => layout.canvasIds.contains(destinationId)) + .firstOrNull; + if (layout == null) return null; + + // The destination canvas's path, else that of a canvas for another + // breakpoint of its layout. + String? path = canvasOf(layout, destinationId)?.properties.path; + if (path == null || path.trim().isEmpty) { + path = layout.canvasIds + .map((canvasId) => canvasOf(layout, canvasId)?.properties.path) + .firstWhere( + (path) => path != null && path.trim().isNotEmpty, + orElse: () => null, + ); + } + if (path == null && model.entryLayoutId != layout.id) return null; + + final Map query = {...params}; + final List segments = []; + for (final String segment in segmentsOf(path ?? '')) { + if (segment.startsWith(':') && segment.length > 1) { + final String value = _stringOf(query.remove(segment.substring(1))); + if (value.isEmpty) return null; + segments.add(value); + } else { + segments.add(segment); + } + } + query.removeWhere((key, value) => value == null); + + return Uri( + pathSegments: segments, + queryParameters: query.isEmpty + ? null + : query.map((key, value) => MapEntry(key, _stringOf(value))), + ).toString(); + } + + static String _stringOf(Object? value) => switch (value) { + null => '', + String() => value, + Map() || List() => jsonEncode(value), + _ => '$value', + }; +} + +/// Gives the screen below it the route params of the location it was opened +/// at, for a screen whose route was not pushed with them: the host's own +/// route of an app opened at a location. +class CodelesslyRouteParams extends InheritedWidget { + /// The route these params belong to. A route nested below, such as one of + /// a navigator inside the screen, keeps its own params. + final ModalRoute? route; + + /// The route params. + final Map params; + + /// Creates a [CodelesslyRouteParams]. + const CodelesslyRouteParams({ + super.key, + required this.route, + required this.params, + required super.child, + }); + + /// The params for [route] that a [CodelesslyRouteParams] above [context] + /// gives, if any. + static Map? maybeOf( + BuildContext context, + ModalRoute? route, + ) { + final CodelesslyRouteParams? scope = context + .dependOnInheritedWidgetOfExactType(); + if (scope == null || scope.route != route) return null; + return scope.params; + } + + @override + bool updateShouldNotify(CodelesslyRouteParams oldWidget) => + route != oldWidget.route || !mapEquals(params, oldWidget.params); +} diff --git a/lib/src/ui/codelessly_widget.dart b/lib/src/ui/codelessly_widget.dart index 41c09985..8b8df620 100644 --- a/lib/src/ui/codelessly_widget.dart +++ b/lib/src/ui/codelessly_widget.dart @@ -296,6 +296,10 @@ class _CodelesslyWidgetState extends State { /// The layout being shown. String? effectiveLayoutID; + /// The canvas the controller's [CodelesslyWidgetController.initialLocation] + /// opened, if any. + CanvasLocationMatch? locationMatch; + /// Tracks whether this widget went through a full layout load and was made /// visible to the user successfully at least once. bool didView = false; @@ -508,8 +512,15 @@ class _CodelesslyWidgetState extends State { } final SDKPublishModel model = snapshot.data!; + final String? initialLocation = _effectiveController.initialLocation; + locationMatch = + _effectiveController.layoutID == null && initialLocation != null + ? CanvasLocation.match(model, initialLocation) + : null; effectiveLayoutID = - _effectiveController.layoutID ?? model.entryLayoutId; + _effectiveController.layoutID ?? + locationMatch?.layoutId ?? + model.entryLayoutId; if (effectiveLayoutID == null) { final errorLog = ErrorLog( @@ -539,6 +550,16 @@ class _CodelesslyWidgetState extends State { StatTracker.instance.track(StatType.view); } + // A screen opened at a location gets its params as route params, + // which its host's route was not pushed with. + Widget withRouteParams(Widget child) => locationMatch == null + ? child + : CodelesslyRouteParams( + route: ModalRoute.of(context), + params: locationMatch!.params, + child: child, + ); + Widget buildLayout(BuildContext context) => Material( clipBehavior: Clip.none, type: MaterialType.transparency, @@ -582,7 +603,7 @@ class _CodelesslyWidgetState extends State { key: ValueKey(effectiveLayoutID), layoutId: effectiveLayoutID, canvasId: canvasID, - builder: buildLayout, + builder: (context) => withRouteParams(buildLayout(context)), ), ), ); diff --git a/lib/src/ui/codelessly_widget_controller.dart b/lib/src/ui/codelessly_widget_controller.dart index 72a8bb70..9179c8a1 100644 --- a/lib/src/ui/codelessly_widget_controller.dart +++ b/lib/src/ui/codelessly_widget_controller.dart @@ -123,6 +123,18 @@ class CodelesslyWidgetController extends ChangeNotifier { /// node. final LayoutRetrieverBuilder? layoutRetrievalBuilder; + /// The location to open the app at when no [layoutID] is given, relative to + /// the app's root, such as `guide/bat-star?x=1`. The canvas whose + /// [CanvasProperties.path] matches it opens instead of the entry canvas, + /// with the path and query params as its route params. When no canvas + /// matches, the entry canvas opens. + final String? initialLocation; + + /// Called when a navigation action opens, or goes back to, a screen with a + /// URL path, so the host can show its location, e.g. in the browser's URL + /// bar. Screens without a path report nothing. + final CodelesslyLocationCallback? onLocationChanged; + /// Creates a [CodelesslyWidgetController]. /// /// If the [layoutID] is not provided, then it is assumed that the published @@ -146,6 +158,10 @@ class CodelesslyWidgetController extends ChangeNotifier { this.layoutBuilder = _defaultLayoutBuilder, this.layoutRetrievalBuilder, + // Locations. + this.initialLocation, + this.onLocationChanged, + // Optional managers. These are only used when using the global codelessly // instance. this.authManager, @@ -195,6 +211,8 @@ class CodelesslyWidgetController extends ChangeNotifier { CodelesslyWidgetLayoutBuilder? layoutBuilder, LayoutRetrieverBuilder? layoutRetrievalBuilder, FirebaseApp? firebaseApp, + String? initialLocation, + CodelesslyLocationCallback? onLocationChanged, }) { return CodelesslyWidgetController( layoutID: layoutID ?? this.layoutID, @@ -211,6 +229,8 @@ class CodelesslyWidgetController extends ChangeNotifier { layoutRetrievalBuilder ?? this.layoutRetrievalBuilder, loadingBuilder: loadingBuilder ?? this.loadingBuilder, errorBuilder: errorBuilder ?? this.errorBuilder, + initialLocation: initialLocation ?? this.initialLocation, + onLocationChanged: onLocationChanged ?? this.onLocationChanged, ); } diff --git a/test/canvas_location_test.dart b/test/canvas_location_test.dart new file mode 100644 index 00000000..21d1e656 --- /dev/null +++ b/test/canvas_location_test.dart @@ -0,0 +1,295 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _FakeCache extends Fake implements CacheManager {} + +class _FakeAuthManager extends Fake implements AuthManager { + @override + AuthData? get authData => AuthData( + authToken: 'token', + projectId: 'project', + timestamp: DateTime(2026), + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + ); +} + +class _FakeLocalRepository extends Fake implements LocalDataRepository {} + +class _FakeNetworkRepository extends Fake implements NetworkDataRepository {} + +class _FakeFirestore extends Fake implements FirebaseFirestore {} + +class _FakeLocalDatabase extends Fake implements LocalDatabase {} + +class _TestDataManager extends DataManager { + _TestDataManager(SDKPublishModel model) + : super( + 'test', + config: CodelesslyConfig(authToken: 'token'), + cacheManager: _FakeCache(), + authManager: _FakeAuthManager(), + networkDataRepository: _FakeNetworkRepository(), + localDataRepository: _FakeLocalRepository(), + firebaseFirestore: _FakeFirestore(), + errorLogger: ErrorLogger.instance, + publishModel: model, + ); + + @override + LocalDatabase get localDatabase => _FakeLocalDatabase(); +} + +TextNode _text(String id, String parent, String characters) => TextNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, 300, 24), + characters: characters, + textMixedProps: [StartEndProp(start: 0, end: characters.length)], +)..parentID = parent; + +ButtonNode _button(String id, String parent, ActionModel action) => ButtonNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, 160, 44), + properties: ButtonProperties(label: id), + reactions: [ + Reaction( + action: action, + trigger: TriggerModel(type: TriggerType.click), + ), + ], +)..parentID = parent; + +/// A layout of one canvas [id] at [path] that shows [children]. +SDKPublishLayout _screen( + String id, { + String? path, + List children = const [], +}) { + final String body = '${id}Body'; + final List kids = [for (final child in children) child(body)]; + final List nodes = [ + CanvasNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, 390, 844), + children: [body], + properties: CanvasProperties( + bodyId: body, + safeArea: const SafeAreaModel.all(false), + path: path, + ), + )..parentID = kRootNode, + AutoPlaceholderNode( + id: body, + name: body, + basicBoxLocal: NodeBox(0, 0, 390, 844), + children: [for (final kid in kids) kid.id], + mainAxisAlignment: MainAxisAlignmentC.start, + horizontalFit: SizeFit.expanded, + verticalFit: SizeFit.expanded, + )..parentID = id, + ...kids, + ]; + return SDKPublishLayout( + id: id, + pageId: 'page', + projectId: 'project', + canvases: { + id: {for (final node in nodes) node.id: node}, + }, + lastUpdated: DateTime(2026), + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, + ); +} + +/// The Tidepool screens: Tides (the entry, no path), the guide, a creature +/// and the journal. +SDKPublishModel _model({ActionModel? tidesAction}) { + final model = SDKPublishModel( + projectId: 'project', + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, + entryLayoutId: 'tides', + ); + for (final layout in [ + _screen( + 'tides', + children: [ + (body) => _text('tidesTitle', body, 'Tides'), + if (tidesAction != null) (body) => _button('Go', body, tidesAction), + ], + ), + _screen( + 'guide', + path: 'guide', + children: [(body) => _text('guideTitle', body, 'Guide')], + ), + _screen( + 'creature', + path: '/guide/:id/', + children: [ + (body) => _text('creatureId', body, r'id=${route.id}'), + (body) => _text('creatureFrom', body, r'from=${route.from}'), + ], + ), + _screen('newCreature', path: 'guide/new'), + _screen('journal', path: 'journal'), + _screen('settings'), + ]) { + model.layouts[layout.id] = layout; + } + return model; +} + +Future _pumpApp( + WidgetTester tester, + SDKPublishModel model, { + String? initialLocation, + CodelesslyLocationCallback? onLocationChanged, +}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(390, 844); + addTearDown(tester.view.reset); + + final codelessly = Codelessly( + config: CodelesslyConfig(authToken: 'token'), + publishDataManager: _TestDataManager(model)..status = CStatus.loaded(), + )..status = CStatus.loaded(); + await tester.pumpWidget( + MaterialApp( + home: CodelesslyWidget( + controller: CodelesslyWidgetController( + codelessly: codelessly, + initialLocation: initialLocation, + onLocationChanged: onLocationChanged, + ), + ), + ), + ); + await tester.pump(); +} + +void main() { + group('CanvasLocation', () { + final SDKPublishModel model = _model(); + + test('matches a canvas by its path', () { + final CanvasLocationMatch? match = CanvasLocation.match( + model, + '/guide/bat-star?from=tides&id=ignored', + ); + expect(match?.layoutId, 'creature'); + expect(match?.params, {'id': 'bat-star', 'from': 'tides'}); + expect(CanvasLocation.match(model, 'journal/')?.canvasId, 'journal'); + expect(CanvasLocation.match(model, 'guide')?.canvasId, 'guide'); + }); + + test('prefers literal segments over params', () { + expect(CanvasLocation.match(model, 'guide/new')?.canvasId, 'newCreature'); + }); + + test('matches nothing for the root or an unknown path', () { + expect(CanvasLocation.match(model, ''), isNull); + expect(CanvasLocation.match(model, '/'), isNull); + expect(CanvasLocation.match(model, 'guide/bat-star/more'), isNull); + expect(CanvasLocation.match(model, 'kraken'), isNull); + }); + + test('fills a path with params', () { + expect( + CanvasLocation.locationOf(model, 'creature', { + 'id': 'sea star', + 'from': 'tides', + 'none': null, + }), + 'guide/sea%20star?from=tides', + ); + expect(CanvasLocation.locationOf(model, 'journal', {}), 'journal'); + // The entry screen is at the root. + expect(CanvasLocation.locationOf(model, 'tides', {}), ''); + expect(CanvasLocation.locationOf(model, 'tides', {'a': 1}), '?a=1'); + // No path, or a path param missing. + expect(CanvasLocation.locationOf(model, 'settings', {}), isNull); + expect(CanvasLocation.locationOf(model, 'creature', {}), isNull); + }); + }); + + group('initialLocation', () { + testWidgets('opens the canvas at that path with its params', ( + tester, + ) async { + await _pumpApp( + tester, + _model(), + initialLocation: 'guide/bat-star?from=link', + ); + expect(find.text('id=bat-star'), findsOneWidget); + expect(find.text('from=link'), findsOneWidget); + expect(find.text('Tides'), findsNothing); + }); + + testWidgets('opens the entry canvas at an unknown path', (tester) async { + await _pumpApp(tester, _model(), initialLocation: 'nowhere'); + expect(find.text('Tides'), findsOneWidget); + }); + }); + + group('onLocationChanged', () { + testWidgets('reports a push and going back', (tester) async { + final List<(String, bool)> locations = []; + await _pumpApp( + tester, + _model( + tidesAction: NavigationAction( + navigationType: NavigationType.push, + destinationId: 'creature', + params: {'id': 'kraken', 'from': 'tides'}, + ), + ), + onLocationChanged: (location, {required replace}) => + locations.add((location, replace)), + ); + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(find.text('id=kraken'), findsOneWidget); + expect(locations, [('guide/kraken?from=tides', false)]); + + tester.state(find.byType(Navigator)).pop(); + await tester.pumpAndSettle(); + expect(find.text('Tides'), findsOneWidget); + expect(locations.last, ('', true)); + }); + + testWidgets('reports nothing for a screen without a path', (tester) async { + final List locations = []; + await _pumpApp( + tester, + _model( + tidesAction: NavigationAction( + navigationType: NavigationType.replace, + destinationId: 'settings', + ), + ), + onLocationChanged: (location, {required replace}) => + locations.add(location), + ); + await tester.tap(find.text('Go')); + await tester.pumpAndSettle(); + expect(locations, isEmpty); + }); + }); +} From f36f20290fb9f088d75a588304bd747c0f37c4bc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 03:23:08 +0000 Subject: [PATCH 13/27] Test that a 7-column grid's pinned cells fill their columns Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3B1XCZZ7QGvZLUGgSG5H4 --- test/fixtures/day_strip_grid.json | 1 + test/grid_cell_stretch_test.dart | 60 +++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 test/fixtures/day_strip_grid.json create mode 100644 test/grid_cell_stretch_test.dart diff --git a/test/fixtures/day_strip_grid.json b/test/fixtures/day_strip_grid.json new file mode 100644 index 00000000..2a8bd657 --- /dev/null +++ b/test/fixtures/day_strip_grid.json @@ -0,0 +1 @@ +{"0SfOJNhvYxUvAF6ovb6w": {"variables": {"data": "${= map(range(0, 6), i => formatDate(addDays(formatDate(now(sunDays[0].offset), 'yyyy-MM-dd'), i), 'yyyy-MM-dd'))}"}, "id": "0SfOJNhvYxUvAF6ovb6w", "name": "Day strip", "horizontalFit": "expanded", "verticalFit": "shrinkWrap", "basicBoxLocal": [40, 138, 1104, 87], "margin": [0, 20, 0, 0], "children": ["0SfOJNhv3Sjvz06eUrBb"], "allowedTypes": [], "deniedTypes": ["gridView"], "isScrollable": false, "useFlutterListView": false, "type": "gridView", "properties": {"gridDelegate": {"crossAxisSpacing": 7, "mainAxisExtent": 87, "crossAxisCount": 7, "type": "fixedCrossAxisCount"}}}, "0SfOJNhv3Sjvz06eUrBb": {"id": "0SfOJNhv3Sjvz06eUrBb", "name": "Day cell holder", "horizontalFit": "expanded", "verticalFit": "expanded", "reactions": [{"name": "Tap", "action": {"type": "setVariable", "variable": {"id": "0SfNPIrIeU9ATTBR1dsp", "name": "selectedDay"}, "newValue": "${item}"}, "trigger": {"type": "click"}, "id": "0SfOJNiHYwCnjABRyvma"}], "basicBoxLocal": [0, 0, 151.714, 87], "children": ["0SfOJNhuBhTxoaJNoRE9", "0SfOJNhvCIoovS8unN6r"], "type": "frame"}, "0SfOJNhuBhTxoaJNoRE9": {"variables": {"visible": "${= selectedDay == item}"}, "id": "0SfOJNhuBhTxoaJNoRE9", "name": "Rectangle", "edgePins": {"left": 3, "top": 3, "right": 0, "bottom": 0}, "positioningMode": "pin", "basicBoxLocal": [3, 3, 148.714, 84], "fills": [{"id": "p288", "type": "solid", "color": "#D9573B"}], "cornerRadius": [4, true, "circular"], "type": "rectangle"}, "0SfOJNhvCIoovS8unN6r": {"variables": {"fill-p286": "${= selectedDay == item ? '#1F2B2E' : '#FBF7EE'}"}, "id": "0SfOJNhvCIoovS8unN6r", "name": "Day cell", "edgePins": {"left": 0, "top": 0, "right": 3, "bottom": 3}, "positioningMode": "pin", "basicBoxLocal": [0, 0, 148.714, 84], "padding": [0, 10], "children": ["0SfOJNhulkVxCHU9Y2eE", "0SfOJNhuD2OGWfkLrY_o", "0SfOJNhv6HHFBqroJZz3", "0SfOJNhvYnpLzJFoVBdp"], "fills": [{"id": "p286", "type": "solid", "color": "#FBF7EE"}], "strokes": [{"id": "p287", "type": "solid", "color": "#1F2B2E"}], "strokeWeight": 1.5, "cornerRadius": [4, true, "circular"], "rowColumnType": "column", "mainAxisAlignment": "start", "type": "rowColumn"}, "0SfOJNhulkVxCHU9Y2eE": {"variables": {"textColor": "${= selectedDay == item ? '#9CC5B5' : '#4A5558'}"}, "id": "0SfOJNhulkVxCHU9Y2eE", "name": "${= item == formatDate(now(sunDays[0].of\u2026", "horizontalFit": "shrinkWrap", "verticalFit": "shrinkWrap", "characters": "${= item == formatDate(now(sunDays[0].offset), 'yyyy-MM-dd') ? 'TODAY' : upper(formatDate(item, 'EEE'))}", "textMixedProps": [{"fills": [{"id": "p281", "type": "solid", "color": "#1F2B2E"}], "fontSize": 10, "letterSpacing": {"value": 1.2}, "fontName": {"family": "IBM Plex Mono", "style": "Medium", "weight": "w500"}, "lineHeight": {"value": 14}, "start": 0, "end": 104}], "basicBoxLocal": [56.357, 10, 36, 14], "type": "text"}, "0SfOJNhuD2OGWfkLrY_o": {"variables": {"textColor": "${= selectedDay == item ? '#F3ECDD' : '#1F2B2E'}"}, "id": "0SfOJNhuD2OGWfkLrY_o", "name": "${= day(item)}", "horizontalFit": "shrinkWrap", "verticalFit": "shrinkWrap", "characters": "${= day(item)}", "textMixedProps": [{"fills": [{"id": "p282", "type": "solid", "color": "#1F2B2E"}], "fontSize": 22, "letterSpacing": {"value": -0.2}, "fontName": {"family": "Fraunces", "style": "SemiBold", "weight": "w600"}, "lineHeight": {"value": 28}, "start": 0, "end": 14}], "basicBoxLocal": [61.544, 26, 25.626, 28], "margin": [0, 2, 0, 0], "type": "text"}, "0SfOJNhv6HHFBqroJZz3": {"variables": {"visible": "${= find(days, d => d.date == item).lowest != null}"}, "id": "0SfOJNhv6HHFBqroJZz3", "name": "Row Column", "horizontalFit": "shrinkWrap", "basicBoxLocal": [64.457, 56, 19.8, 16], "margin": [0, 2, 0, 0], "children": ["0SfOJNhviuRCJI6AxerP", "0SfOJNhvse2Ai7eqH4Q0"], "rowColumnType": "row", "type": "rowColumn"}, "0SfOJNhviuRCJI6AxerP": {"variables": {"visible": "${= length(find(days, d => d.date == item).windows) > 0}"}, "id": "0SfOJNhviuRCJI6AxerP", "name": "Row Column", "basicBoxLocal": [-5.5, 4.5, 7, 7], "margin": [0, 0, 4, 0], "fills": [{"id": "p283", "type": "solid", "color": "#D9573B"}], "cornerRadius": [4, true, "circular"], "rowColumnType": "column", "mainAxisAlignment": "start", "crossAxisAlignment": "start", "type": "rowColumn"}, "0SfOJNhvse2Ai7eqH4Q0": {"variables": {"textColor": "${= selectedDay == item ? '#F3ECDD' : '#4A5558'}"}, "id": "0SfOJNhvse2Ai7eqH4Q0", "name": "${= (storage.units == 'm' ? replace(fixe\u2026", "horizontalFit": "shrinkWrap", "verticalFit": "shrinkWrap", "characters": "${= (storage.units == 'm' ? replace(fixed((find(days, d => d.date == item).lowest.v) * 0.3048, 2), '-0.00', '0.00') : replace(fixed(find(days, d => d.date == item).lowest.v, 1), '-0.0', '0.0'))}", "textMixedProps": [{"fills": [{"id": "p284", "type": "solid", "color": "#1F2B2E"}], "fontSize": 11, "fontName": {"family": "IBM Plex Mono", "style": "Medium", "weight": "w500"}, "lineHeight": {"value": 18}, "start": 0, "end": 194}], "basicBoxLocal": [0, -1, 19.8, 18], "type": "text"}, "0SfOJNhvYnpLzJFoVBdp": {"variables": {"visible": "${= find(days, d => d.date == item).lowest == null}"}, "id": "0SfOJNhvYnpLzJFoVBdp", "name": "Rectangle", "basicBoxLocal": [59.357, 60, 30, 8], "margin": [0, 6, 0, 2], "fills": [{"id": "p285", "type": "solid", "color": "#E9DFCB"}], "cornerRadius": [2, true, "circular"], "type": "rectangle"}} \ No newline at end of file diff --git a/test/grid_cell_stretch_test.dart b/test/grid_cell_stretch_test.dart new file mode 100644 index 00000000..b2048886 --- /dev/null +++ b/test/grid_cell_stretch_test.dart @@ -0,0 +1,60 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'responsive_variance_test.dart' show pumpPage; + +void main() { + testWidgets('day strip grid cells fill their columns', (tester) async { + final Map raw = jsonDecode( + File('test/fixtures/day_strip_grid.json').readAsStringSync(), + ); + final nodes = { + for (final e in raw.entries) + e.key as String: NodeJsonConverter().fromJson(e.value as Map)!, + }; + for (final node in nodes.values) { + for (final child in node.childrenOrEmpty) { + nodes[child]!.parentID = node.id; + } + } + const gridId = '0SfOJNhvYxUvAF6ovb6w'; + nodes['canvas'] = CanvasNode( + id: 'canvas', + name: 'canvas', + basicBoxLocal: NodeBox(0, 0, 1104, 400), + children: ['body'], + properties: CanvasProperties( + bodyId: 'body', + safeArea: const SafeAreaModel.all(false), + ), + )..parentID = kRootNode; + nodes['body'] = AutoPlaceholderNode( + id: 'body', + name: 'body', + basicBoxLocal: NodeBox(0, 0, 1104, 400), + children: [gridId], + )..parentID = 'canvas'; + nodes[gridId]! + ..parentID = 'body' + ..variables.clear(); + // Seven items without a data source. + final grid = nodes[gridId]! as GridViewNode; + grid.properties.itemCount = 7; + nodes['0SfOJNhuBhTxoaJNoRE9']!.variables.clear(); + await pumpPage(tester, nodes, screen: const Size(1104, 400)); + expect(tester.takeException(), isNull); + // The coral block behind each cell is pinned 3 px in on the top left. + final block = nodes['0SfOJNhuBhTxoaJNoRE9']!; + final blocks = find.byKey( + ValueKey('Rectangle Transformer of ${block.debugLabel}'), + ); + expect(blocks, findsWidgets); + // (1104 - 6 * 7) / 7 = 151.7 per column, less the 3 px offset. + expect(tester.getSize(blocks.first).width, closeTo(148.7, 1)); + }); +} From e820425f024a9654a44cf867e30553d8d7d696f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 03:37:48 +0000 Subject: [PATCH 14/27] Let dialogs follow the screen and add loose flexible sizing - A dialog whose responsive canvas holds a responsive layout (a variance node with breakpoints) fills the screen, so its variant follows the screen width: a full-screen sheet on mobile, a centered card on desktop. Plain fixed-size dialog canvases keep their design size. - In a dialog, taps where nothing paints or reacts (a canvas without a background, an empty frame) reach the barrier, so a dismissible full-screen dialog closes on them. - A squeezable shrink-wrapped node in a Row/Column is wrapped in a loose Flexible: it hugs its content and shrinks to the room left. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3B1XCZZ7QGvZLUGgSG5H4 --- CHANGELOG.md | 11 ++ .../passive_canvas_transformer.dart | 65 ++++++++- .../passive_rectangle_transformer.dart | 25 +++- .../passive_row_column_transformer.dart | 6 + lib/src/ui/codelessly_dialog_widget.dart | 6 +- test/flex_layout_test.dart | 113 ++++++++++++++++ test/responsive_variance_test.dart | 125 ++++++++++++++++++ 7 files changed, 342 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47f58ac1..20c76d69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ `LineChartGeometry` holds the pure parts (dates, domain, interpolation, Catmull-Rom control points, readout text and placement). - `FormulaEvaluator.formatDate` exposes the `formatDate` formula function. +- A dialog whose canvas is responsive and holds a responsive layout (a + `VarianceNode` with breakpoints) fills the screen instead of keeping its + design size, so its variant follows the screen width and each variant + places itself (a full-screen sheet on mobile, a centered card on desktop). + Dialogs with plain fixed-size canvases are unchanged. +- In a dialog (`TapThroughScope`), taps on parts of the layout where nothing + paints or reacts (a canvas without a background, an empty frame) reach the + barrier, so a dismissible full-screen dialog closes on them. +- A `squeezable` node that wraps its content on a Row/Column's main axis is + laid out as `Flexible(fit: FlexFit.loose)`: it hugs its content and + shrinks to the room left when the Row/Column runs out of space. ## 8.2.0 diff --git a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart index 0525d1cb..3f33144e 100644 --- a/lib/src/transformers/node_transformers/passive_canvas_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_canvas_transformer.dart @@ -195,8 +195,15 @@ class PassiveCanvasTransformer extends NodeWidgetTransformer { body: body, ); } else { + final Color? background = retrieveBackgroundColor(context, node); + // Under a TapThroughScope, a canvas without a background takes no taps + // on its empty parts, so they reach its dialog's barrier. + final bool transparent = + (background == null || background.a == 0) && + TapThroughScope.of(context); scaffold = Material( - color: retrieveBackgroundColor(context, node), + type: transparent ? MaterialType.transparency : MaterialType.canvas, + color: transparent ? null : background, child: body, ); } @@ -377,6 +384,9 @@ class PassiveCanvasTransformer extends NodeWidgetTransformer { settings: settings, builder: (context) => CanvasViewport( node: node, + followsScreen: + node.scaleMode == ScaleMode.responsive && + hasResponsiveLayout(node, getNode), child: switch (node.scaleMode) { ScaleMode.responsive => _wrapInScaffoldForResponsive( context: context, @@ -393,6 +403,20 @@ class PassiveCanvasTransformer extends NodeWidgetTransformer { ); } + /// Whether [node] or a node under it is a responsive layout (a + /// [VarianceNode] whose variants map to breakpoints), in any variant. + static bool hasResponsiveLayout(BaseNode node, GetNode getNode) { + if (node is VarianceNode) { + if (node.isResponsive) return true; + return node.allVariantsChildren.any( + (id) => hasResponsiveLayout(getNode(id), getNode), + ); + } + return node.childrenOrEmpty.any( + (id) => hasResponsiveLayout(getNode(id), getNode), + ); + } + static Color? retrieveBackgroundColor(BuildContext context, CanvasNode node) { final fills = [ for (final fill in node.fills) @@ -439,6 +463,13 @@ enum CanvasSizing { /// The canvas keeps the size it was designed at, within the space it is /// given. Used where the host only centers the layout and lets it choose /// its size, such as a dialog. + /// + /// A responsive canvas with a responsive layout in it (see + /// [PassiveCanvasTransformer.hasResponsiveLayout]) fills the space + /// instead, as with [fill]: its variant follows the screen, and each + /// variant places itself in it (a full-screen sheet on a phone, a + /// centered card on a desktop). Give such a canvas no background so the + /// barrier shows around what its layout draws. design, } @@ -462,6 +493,22 @@ class CanvasSizingScope extends InheritedWidget { sizing != oldWidget.sizing; } +/// Makes the empty parts of the layout below let taps through to what is +/// behind it: the parts where neither a node's fills, strokes or content +/// paint nor a reactive node is (a transparent canvas, an empty frame). Set +/// by `CodelesslyDialogWidget`, so a tap beside a full-screen dialog's +/// layout reaches the barrier and closes a dismissible dialog. +class TapThroughScope extends InheritedWidget { + const TapThroughScope({super.key, required super.child}); + + /// Whether a [TapThroughScope] is above [context]. + static bool of(BuildContext context) => + context.getInheritedWidgetOfExactType() != null; + + @override + bool updateShouldNotify(TapThroughScope oldWidget) => false; +} + /// The width the canvas above is laid out at, which picks the variant a /// responsive [VarianceNode] shows (see [VarianceNode.variantForWidth]), the /// same width the editor's design canvas has. @@ -495,9 +542,19 @@ class CanvasWidthScope extends InheritedWidget { /// on an unbounded axis it gets its design size (see [CanvasSizing.fill]). class CanvasViewport extends StatelessWidget { final CanvasNode node; + + /// Whether the canvas fills the space under [CanvasSizing.design] too: a + /// responsive canvas that holds a responsive layout. + final bool followsScreen; + final Widget child; - const CanvasViewport({super.key, required this.node, required this.child}); + const CanvasViewport({ + super.key, + required this.node, + this.followsScreen = false, + required this.child, + }); /// The canvas box for [constraints] under [sizing], per axis. A null /// height leaves the height to the canvas content (see @@ -540,7 +597,9 @@ class CanvasViewport extends StatelessWidget { @override Widget build(BuildContext context) { - final CanvasSizing sizing = CanvasSizingScope.of(context); + final CanvasSizing sizing = followsScreen + ? CanvasSizing.fill + : CanvasSizingScope.of(context); return LayoutBuilder( builder: (context, constraints) { final box = resolve(node, constraints, sizing: sizing); diff --git a/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart b/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart index 9af3c5bd..73488895 100644 --- a/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_rectangle_transformer.dart @@ -158,16 +158,33 @@ class PassiveRectangleWidget extends StatelessWidget { size: node.basicBoxLocal.size, child: Container( clipBehavior: getClipBehavior(node), - decoration: BoxDecoration( - boxShadow: retrieveBoxShadow(node, scopedValues), - borderRadius: getBorderRadius(node), - ), + // This box only clips and casts shadows; the node's fills, strokes + // and children take taps where they paint. Under a TapThroughScope, + // its empty parts let taps through. + decoration: TapThroughScope.of(context) + ? _TapThroughDecoration( + boxShadow: retrieveBoxShadow(node, scopedValues), + borderRadius: getBorderRadius(node), + ) + : BoxDecoration( + boxShadow: retrieveBoxShadow(node, scopedValues), + borderRadius: getBorderRadius(node), + ), child: child, ), ); } } +/// A [BoxDecoration] that takes no taps itself. +class _TapThroughDecoration extends BoxDecoration { + const _TapThroughDecoration({super.boxShadow, super.borderRadius}); + + @override + bool hitTest(Size size, Offset position, {TextDirection? textDirection}) => + false; +} + List wrapWithInkWell( BuildContext context, BaseNode node, diff --git a/lib/src/transformers/node_transformers/passive_row_column_transformer.dart b/lib/src/transformers/node_transformers/passive_row_column_transformer.dart index 56f8f2ba..49d7208d 100644 --- a/lib/src/transformers/node_transformers/passive_row_column_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_row_column_transformer.dart @@ -315,6 +315,12 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { } } + // A squeezable node that wraps its content hugs it while it fits and + // shrinks to the room the Row/Column has left when it doesn't. + if (mainAxisFit == SizeFit.shrinkWrap && node.squeezable) { + childWidget = Flexible(flex: flex, child: childWidget); + } + return childWidget; } diff --git a/lib/src/ui/codelessly_dialog_widget.dart b/lib/src/ui/codelessly_dialog_widget.dart index 86a735bc..3d96e358 100644 --- a/lib/src/ui/codelessly_dialog_widget.dart +++ b/lib/src/ui/codelessly_dialog_widget.dart @@ -16,10 +16,12 @@ class CodelesslyDialogWidget extends StatelessWidget { @override Widget build(BuildContext context) { // A dialog is centered and sized by its layout, so its canvas keeps its - // design size instead of filling the screen. + // design size instead of filling the screen, unless it holds a + // responsive layout (see CanvasSizing.design). Taps on its empty parts + // reach the barrier. return CanvasSizingScope( sizing: CanvasSizing.design, - child: Builder(builder: _buildDialog), + child: TapThroughScope(child: Builder(builder: _buildDialog)), ); } diff --git a/test/flex_layout_test.dart b/test/flex_layout_test.dart index 0b0eeef3..476a3fa4 100644 --- a/test/flex_layout_test.dart +++ b/test/flex_layout_test.dart @@ -640,4 +640,117 @@ void main() { expect(sizes['c']!.width, 100); }); }); + + group('a squeezable shrinkWrap child (loose Flexible)', () { + TextNode label(String id, String characters, {bool squeezable = false}) => + TextNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, 120, 10), + horizontalFit: SizeFit.shrinkWrap, + verticalFit: SizeFit.shrinkWrap, + characters: characters, + maxLines: 1, + overflow: TextOverflowC.ellipsis, + textMixedProps: [ + StartEndProp(start: 0, end: characters.length, fontSize: 10), + ], + )..squeezable = squeezable; + + RenderParagraph paragraphOf(WidgetTester tester, String characters) => + tester.renderObject( + find.byWidgetPredicate( + (widget) => + widget is RichText && widget.text.toPlainText() == characters, + ), + ); + + // A name and its count in a 200 wide row; the test font is 10 px a + // character. + Future pumpRow(WidgetTester tester, String characters, Size screen) { + final name = label('name', characters, squeezable: true); + final count = label('count', 'x3'); + final row = flex( + 'row', + kRootNode, + [name, count], + type: RowColumnType.row, + width: 200, + height: 10, + ); + return pump(tester, row, [name, count], screen: screen, host: Host.loose); + } + + for (final screen in screens) { + testWidgets('hugs a short name and ellipsizes a long one at ' + '${screen.width.toInt()}', (tester) async { + await pumpRow(tester, 'Ann', screen); + final RenderParagraph short = paragraphOf(tester, 'Ann'); + expect(short.size.width, 30); + expect(short.didExceedMaxLines, isFalse); + expect( + tester.getTopLeft(find.text('x3')).dx, + tester.getTopRight(find.text('Ann')).dx, + ); + + const String long = 'Bartholomew Montgomeryfitzgerald'; + await pumpRow(tester, long, screen); + final RenderParagraph name = paragraphOf(tester, long); + // The name gives up what the count needs, and ellipsizes. + expect(name.size.width, 180); + expect(name.didExceedMaxLines, isTrue); + expect(paragraphOf(tester, 'x3').size.width, 20); + expect( + tester.getTopRight(find.text('x3')).dx, + tester.getTopRight(rectOf('row')).dx, + ); + }); + } + + // A dialog body: a header and a card that is as tall as its content, up + // to the height the column has left. + testWidgets('a card hugs its content up to the room left', (tester) async { + for (final int items in [1, 6]) { + final header = rect('header', '', width: 300, height: 50); + final children = [ + for (int i = 0; i < items; i++) + rect('item$i', '', width: 300, height: 100), + ]; + final card = flex( + 'card', + '', + children, + type: RowColumnType.column, + width: 300, + height: 100, + verticalFit: SizeFit.shrinkWrap, + isScrollable: true, + )..squeezable = true; + final column = flex( + 'column', + kRootNode, + [header, card], + type: RowColumnType.column, + width: 300, + height: 400, + ); + for (final screen in screens) { + final sizes = await pump( + tester, + column, + [header, card, ...children], + screen: screen, + host: Host.loose, + ); + expect(sizes['column'], const Size(300, 400)); + expect( + sizes['card'], + Size(300, items == 1 ? 100 : 350), + reason: '$items items at $screen', + ); + expect(sizes['item0'], const Size(300, 100)); + } + } + }); + }); } diff --git a/test/responsive_variance_test.dart b/test/responsive_variance_test.dart index a002803b..a82a2ce2 100644 --- a/test/responsive_variance_test.dart +++ b/test/responsive_variance_test.dart @@ -1,5 +1,6 @@ import 'package:codelessly_api/codelessly_api.dart'; import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:codelessly_sdk/src/ui/codelessly_dialog_widget.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; @@ -139,6 +140,79 @@ Future pumpPage( await tester.pump(); } +/// Shows [nodes]' canvas the way the SDK's show-dialog action does: a +/// [CodelesslyDialogWidget] in a dismissible dialog, on a screen of [screen]. +Future showPageInDialog( + WidgetTester tester, + Map nodes, { + required Size screen, +}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = screen; + addTearDown(tester.view.reset); + final registry = NodeRegistry()..setNodes(nodes); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + // A fresh app, without a dialog left open by an earlier call. + await tester.pumpWidget(const SizedBox()); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => showDialog( + context: context, + useRootNavigator: false, + builder: (context) => CodelesslyDialogWidget( + showCloseButton: false, + builder: (context) => manager.buildWidgetFromNode( + nodes['canvas']!, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + child: const Text('open'), + ), + ), + ), + ), + ), + ); + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); +} + +/// [responsivePage] as a sheet: full screen on mobile, a centered 560 wide +/// card on tablet and desktop. +Map responsiveSheet() { + final nodes = responsivePage(); + // No background: the barrier shows around the card. + (nodes['canvas']! as CanvasNode).fills = []; + for (final breakpoint in [ + ScreenBreakpoint.tablet, + ScreenBreakpoint.desktop, + ]) { + final root = nodes['${breakpoint.name}-root']! as RowColumnNode; + nodes[root.id] = RowColumnNode( + id: root.id, + name: root.name, + basicBoxLocal: NodeBox(0, 0, 560, 400), + rowColumnType: RowColumnType.column, + mainAxisAlignment: MainAxisAlignmentC.start, + crossAxisAlignment: CrossAxisAlignmentC.stretch, + alignment: AlignmentModel.center, + fills: [PaintModel.whitePaint], + children: root.children, + )..parentID = root.parentID; + } + return nodes; +} + /// A variant's root and its rectangles. Set idsOf(ScreenBreakpoint breakpoint, int count) => { '${breakpoint.name}-root', @@ -286,4 +360,55 @@ void main() { expect(tester.takeException(), isNull); expect(builtRectangles(nodes), idsOf(ScreenBreakpoint.tablet, 3)); }); + + testWidgets('a dialog with a responsive layout follows the screen', ( + tester, + ) async { + for (final screen in screens) { + final nodes = responsiveSheet(); + await showPageInDialog(tester, nodes, screen: screen); + + expect(tester.takeException(), isNull); + final ScreenBreakpoint breakpoint = ScreenBreakpoint.forWidth( + screen.width, + ); + final int count = switch (breakpoint) { + ScreenBreakpoint.mobile => 2, + ScreenBreakpoint.tablet => 3, + ScreenBreakpoint.desktop => 4, + }; + expect(builtRectangles(nodes), idsOf(breakpoint, count)); + // The canvas fills the screen; each variant places itself in it: the + // mobile sheet fills it, the card is centered. + expect(tester.getSize(rectOf(nodes['canvas']!)), screen); + final Rect root = tester.getRect( + rectOf(nodes['${breakpoint.name}-root']!), + ); + if (breakpoint == ScreenBreakpoint.mobile) { + expect(root, Offset.zero & screen); + } else { + expect(root.size, const Size(560, 400)); + expect(root.center, screen.center(Offset.zero)); + } + } + }); + + testWidgets('a dialog with a responsive layout closes on a barrier tap', ( + tester, + ) async { + final nodes = responsiveSheet(); + await showPageInDialog(tester, nodes, screen: const Size(1440, 900)); + expect(builtRectangles(nodes), idsOf(ScreenBreakpoint.desktop, 4)); + + // Inside the card, below its rectangles, the dialog stays open. + await tester.tapAt(const Offset(720, 600)); + await tester.pumpAndSettle(); + expect(builtRectangles(nodes), idsOf(ScreenBreakpoint.desktop, 4)); + + // Outside of it, the empty canvas lets the tap through to the barrier. + await tester.tapAt(const Offset(100, 100)); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + expect(builtRectangles(nodes), isEmpty); + }); } From 5f031916e96db45fb3af08c0f69a7d366715d812 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 03:41:57 +0000 Subject: [PATCH 15/27] Style toasts, confirm dialogs and dropdown menus - Toasts take the action's button color, an optional close (x) button, corner radius, border, shadows (e.g. a hard stamped shadow), padding, max width, message and button fonts, icon size and a bottom offset per screen size. - The built-in confirm dialog takes a border and shadows in place of its elevation, title/message/button fonts, and outlined buttons with their own radius, colors and shadows (a filled cancel button included). - FeedbackTheme gains border, shadows, buttonBorder and buttonShadows; fromNodes reads the outline and shadow most tappable shapes share. - A dropdown with a menuStyle opens a card anchored under the field: color, border, radius, shadows, offset, row height and padding, a highlighted, bolder selected row with a check mark. Rows (and the field) can show a per-item image from an itemImage template like ${item.image}. - A data-source dropdown finds its selected item by content. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3B1XCZZ7QGvZLUGgSG5H4 --- CHANGELOG.md | 12 + lib/src/functions/app_confirm_dialog.dart | 281 ++++++++---- lib/src/functions/app_toast.dart | 118 +++-- .../passive_dropdown_transformer.dart | 407 +++++++++++++++--- lib/src/ui/feedback_theme.dart | 176 +++++++- test/confirm_dialog_test.dart | 145 +++++++ test/dropdown_menu_test.dart | 222 ++++++++++ test/feedback_actions_test.dart | 51 +++ test/toast_test.dart | 159 +++++++ 9 files changed, 1397 insertions(+), 174 deletions(-) create mode 100644 test/dropdown_menu_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 20c76d69..4a439eda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ ## Unreleased +- Toasts, the built-in confirm dialog and dropdown menus take the new + `ShowToastAction`, `ConfirmAction` and `DropdownProperties.menuStyle` + styling: toast action color, close button, radius, border, hard shadows, + fonts, icon size and per-screen-size bottom offsets; confirm dialog border, + shadows, fonts and outlined, shadowed buttons; and a dropdown menu drawn as + a card anchored under the field with a border, shadows, a highlighted + selected row with a check mark and per-item images (`itemImage`). +- `FeedbackTheme` has `border`, `shadows`, `buttonBorder` and + `buttonShadows`; `FeedbackTheme.fromNodes` reads the outline and shadow + most of a layout's tappable shapes share for the confirm dialog's buttons. +- A data-source dropdown finds its selected item by content, so a map value + selects the equal item. - Render `LineChartNode` with `PassiveLineChartTransformer` and `PassiveLineChartWidget`: bands, grid, gradient area, area bands, a polyline or Catmull-Rom curve, reference lines, x axis, markers, vertical diff --git a/lib/src/functions/app_confirm_dialog.dart b/lib/src/functions/app_confirm_dialog.dart index 76f330fe..8a4c6e5f 100644 --- a/lib/src/functions/app_confirm_dialog.dart +++ b/lib/src/functions/app_confirm_dialog.dart @@ -12,8 +12,9 @@ typedef ConfirmOutcome = ({bool confirmed, Map? values}); /// /// Without a [ConfirmAction.dialogId] the dialog is built here and takes the /// look of the layout that shows it ([FeedbackTheme.of]): its font, surface, -/// text and primary colors and its buttons' corner radius. The action's own -/// colors and radius override that look. +/// text and primary colors and its buttons' corner radius, outline and +/// shadows. The action's own colors, radii, borders, shadows and text styles +/// override that look. /// /// With a [ConfirmAction.dialogId] the dialog is that canvas. A button in it /// answers with a "Navigate back" action whose params hold @@ -95,6 +96,21 @@ class AppConfirmDialog { textColor: action.textColor?.toFlutterColor(), confirmColor: action.confirmColor?.toFlutterColor(), cornerRadius: action.cornerRadius, + border: action.border == null ? null : borderSideOf(action.border!), + shadows: action.shadows == null ? null : boxShadowsOf(action.shadows), + titleStyle: action.titleStyle, + messageStyle: action.messageStyle, + buttonStyle: action.buttonStyle, + buttonRadius: action.buttonRadius, + buttonBorder: action.buttonBorder == null + ? null + : borderSideOf(action.buttonBorder!), + buttonShadows: action.buttonShadows == null + ? null + : boxShadowsOf(action.buttonShadows), + confirmTextColor: action.confirmTextColor?.toFlutterColor(), + cancelColor: action.cancelColor?.toFlutterColor(), + cancelTextColor: action.cancelTextColor?.toFlutterColor(), ), ); return (confirmed: confirmed ?? false, values: null); @@ -117,6 +133,17 @@ class ThemedConfirmDialog extends StatelessWidget { this.textColor, this.confirmColor, this.cornerRadius, + this.border, + this.shadows, + this.titleStyle, + this.messageStyle, + this.buttonStyle, + this.buttonRadius, + this.buttonBorder, + this.buttonShadows, + this.confirmTextColor, + this.cancelColor, + this.cancelTextColor, }); /// The app's look. @@ -149,6 +176,40 @@ class ThemedConfirmDialog extends StatelessWidget { /// Overrides the dialog's corner radius. final double? cornerRadius; + /// Overrides the theme's dialog outline. + final BorderSide? border; + + /// Overrides the theme's dialog shadows; set, they replace the elevation. + final List? shadows; + + /// Changes to the title's font, size and color. + final TextStyleModel? titleStyle; + + /// Changes to the message's font, size and color. + final TextStyleModel? messageStyle; + + /// Changes to the buttons' font, size and label color. + final TextStyleModel? buttonStyle; + + /// Overrides the theme's button corner radius. + final double? buttonRadius; + + /// Overrides the theme's button outline. + final BorderSide? buttonBorder; + + /// Overrides the theme's button shadows. The cancel button takes them only + /// when it has a [cancelColor]. + final List? buttonShadows; + + /// Label color of the confirm button. + final Color? confirmTextColor; + + /// Fill of the cancel button; null leaves it a text button. + final Color? cancelColor; + + /// Label color of the cancel button. + final Color? cancelTextColor; + @override Widget build(BuildContext context) { final Color surface = backgroundColor ?? theme.surface; @@ -159,105 +220,157 @@ class ThemedConfirmDialog extends StatelessWidget { : ResolvedFeedbackTheme.onColor(surface)); final Color confirmFill = confirmColor ?? (destructive ? theme.error : theme.primary); - final Color onConfirm = ResolvedFeedbackTheme.onColor(confirmFill); final double radius = cornerRadius ?? theme.radius; + final double buttonCorner = buttonRadius ?? theme.buttonRadius; + final BorderSide buttonSide = + buttonBorder ?? theme.buttonBorder ?? BorderSide.none; + final List buttonShadowList = + buttonShadows ?? theme.buttonShadows ?? const []; final OutlinedBorder buttonShape = RoundedRectangleBorder( - borderRadius: BorderRadius.circular(theme.buttonRadius), + borderRadius: BorderRadius.circular(buttonCorner), ); - final TextStyle buttonText = theme.buttonStyle.copyWith( - fontSize: 15, - height: 1.2, + final TextStyle buttonText = buttonStyle.applyTo( + theme.buttonStyle.copyWith(fontSize: 15, height: 1.2), ); + final Color onConfirm = + buttonStyle?.color?.toFlutterColor() ?? + confirmTextColor ?? + ResolvedFeedbackTheme.onColor(confirmFill); + final Color onCancel = + buttonStyle?.color?.toFlutterColor() ?? cancelTextColor ?? onSurface; const Size buttonSize = Size(64, 44); const EdgeInsets buttonPadding = EdgeInsets.symmetric(horizontal: 18); + final BorderSide? dialogSide = border ?? theme.border; + final List? dialogShadows = shadows ?? theme.shadows; - return Theme( - data: theme.apply(Theme.of(context)), - child: Dialog( - backgroundColor: surface, - surfaceTintColor: Colors.transparent, - elevation: 8, - insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(radius), - ), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 400), - child: Semantics( - scopesRoute: true, - namesRoute: true, - explicitChildNodes: true, - label: title.isEmpty ? null : title, - child: Padding( - padding: const EdgeInsets.fromLTRB(24, 24, 24, 16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (title.isNotEmpty) - Text( - title, - style: theme.titleStyle.copyWith( - fontSize: 18, - height: 1.3, - color: onSurface, - ), + Widget withShadow(Widget button) => buttonShadowList.isEmpty + ? button + : DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(buttonCorner), + boxShadow: buttonShadowList, + ), + child: button, + ); + + final Widget cancel = TextButton( + key: const ValueKey('codelessly-confirm-cancel'), + onPressed: () => Navigator.of(context).pop(false), + style: TextButton.styleFrom( + foregroundColor: onCancel, + backgroundColor: cancelColor, + textStyle: buttonText, + shape: buttonShape, + side: buttonSide, + minimumSize: buttonSize, + padding: buttonPadding, + ), + child: Text(cancelLabel), + ); + final Widget confirm = FilledButton( + key: const ValueKey('codelessly-confirm-ok'), + onPressed: () => Navigator.of(context).pop(true), + style: FilledButton.styleFrom( + backgroundColor: confirmFill, + foregroundColor: onConfirm, + textStyle: buttonText, + shape: buttonShape, + side: buttonSide, + minimumSize: buttonSize, + padding: buttonPadding, + ), + child: Text(confirmLabel), + ); + + Widget content = ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: Semantics( + scopesRoute: true, + namesRoute: true, + explicitChildNodes: true, + label: title.isEmpty ? null : title, + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title.isNotEmpty) + Text( + title, + style: titleStyle.applyTo( + theme.titleStyle.copyWith( + fontSize: 18, + height: 1.3, + color: onSurface, ), - if (title.isNotEmpty && message.isNotEmpty) - const SizedBox(height: 8), - if (message.isNotEmpty) - Flexible( - child: SingleChildScrollView( - child: Text( - message, - style: theme.bodyStyle.copyWith( - fontSize: 15, - height: 1.45, - color: onSurface.withValues(alpha: 0.74), - ), + ), + ), + if (title.isNotEmpty && message.isNotEmpty) + const SizedBox(height: 8), + if (message.isNotEmpty) + Flexible( + child: SingleChildScrollView( + child: Text( + message, + style: messageStyle.applyTo( + theme.bodyStyle.copyWith( + fontSize: 15, + height: 1.45, + color: onSurface.withValues(alpha: 0.74), ), ), ), - const SizedBox(height: 20), - OverflowBar( - alignment: MainAxisAlignment.end, - overflowAlignment: OverflowBarAlignment.end, - spacing: 8, - overflowSpacing: 8, - children: [ - TextButton( - key: const ValueKey('codelessly-confirm-cancel'), - onPressed: () => Navigator.of(context).pop(false), - style: TextButton.styleFrom( - foregroundColor: onSurface, - textStyle: buttonText, - shape: buttonShape, - minimumSize: buttonSize, - padding: buttonPadding, - ), - child: Text(cancelLabel), - ), - FilledButton( - key: const ValueKey('codelessly-confirm-ok'), - onPressed: () => Navigator.of(context).pop(true), - style: FilledButton.styleFrom( - backgroundColor: confirmFill, - foregroundColor: onConfirm, - textStyle: buttonText, - shape: buttonShape, - minimumSize: buttonSize, - padding: buttonPadding, - ), - child: Text(confirmLabel), - ), - ], ), + ), + const SizedBox(height: 20), + OverflowBar( + alignment: MainAxisAlignment.end, + overflowAlignment: OverflowBarAlignment.end, + // Outlined or shadowed buttons need room between them. + spacing: buttonShadowList.isEmpty ? 8 : 12, + overflowSpacing: 8, + children: [ + cancelColor != null ? withShadow(cancel) : cancel, + withShadow(confirm), ], ), - ), + ], ), ), ), ); + // Shadows of its own replace the dialog's elevation: they are drawn + // around a box that carries its fill and outline. + if (dialogShadows != null) { + content = DecoratedBox( + key: const ValueKey('codelessly-confirm-surface'), + decoration: BoxDecoration( + color: surface, + borderRadius: BorderRadius.circular(radius), + border: dialogSide == null ? null : Border.fromBorderSide(dialogSide), + boxShadow: dialogShadows, + ), + child: content, + ); + } + + return Theme( + data: theme.apply(Theme.of(context)), + child: Dialog( + backgroundColor: dialogShadows != null ? Colors.transparent : surface, + surfaceTintColor: Colors.transparent, + shadowColor: dialogShadows != null ? Colors.transparent : null, + elevation: dialogShadows != null ? 0 : 8, + insetPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radius), + side: dialogShadows == null + ? dialogSide ?? BorderSide.none + : BorderSide.none, + ), + child: content, + ), + ); } } diff --git a/lib/src/functions/app_toast.dart b/lib/src/functions/app_toast.dart index 51faed5e..58b04cfd 100644 --- a/lib/src/functions/app_toast.dart +++ b/lib/src/functions/app_toast.dart @@ -11,8 +11,9 @@ import 'action_chain.dart'; /// new toast replaces the one on screen. /// /// The toast takes the app's look ([FeedbackTheme.of]): its font, and the -/// color of its [ShowToastAction.style]. It can carry a button, e.g. Undo, -/// that runs [ShowToastAction.actions]. +/// color of its [ShowToastAction.style]. The action's own colors, fonts, +/// radius, border and shadows override that look. It can carry a button, +/// e.g. Undo, that runs [ShowToastAction.actions], and a close (×) button. class AppToast { AppToast._(); @@ -175,34 +176,63 @@ class _ToastViewState extends State<_ToastView> { ? null : IconTheme.merge( data: IconThemeData(color: textColor), - child: retrieveIconWidget(action.icon!, 18) ?? const SizedBox(), + child: + retrieveIconWidget(action.icon!, action.iconSize) ?? + const SizedBox(), ); final bool hasButton = widget.onAction != null; + final bool hasClose = action.showClose; final bool top = action.position == ToastPosition.top; - final EdgeInsets safe = MediaQuery.paddingOf(context); - final TextStyle messageStyle = theme.bodyStyle.copyWith( - color: textColor, - fontSize: 14, - height: 1.35, - fontWeight: FontWeight.w600, - decoration: TextDecoration.none, + final MediaQueryData media = MediaQuery.of(context); + final TextStyle messageStyle = action.messageStyle.applyTo( + theme.bodyStyle.copyWith( + color: textColor, + fontSize: 14, + height: 1.35, + fontWeight: FontWeight.w600, + decoration: TextDecoration.none, + ), + ); + final Color actionColor = + action.actionColor?.toFlutterColor() ?? messageStyle.color!; + final TextStyle actionStyle = action.actionStyle.applyTo( + theme.buttonStyle.copyWith( + fontSize: 14, + fontWeight: FontWeight.w700, + color: actionColor, + decoration: TextDecoration.none, + ), ); + final Color closeColor = + action.closeColor?.toFlutterColor() ?? messageStyle.color!; + final double radius = + action.cornerRadius ?? + (hasButton ? theme.buttonRadius.clamp(8, 999).toDouble() : 999); + final double buttonRadius = action.cornerRadius != null + ? (action.cornerRadius! / 2).clamp(0, theme.buttonRadius).toDouble() + : theme.buttonRadius; Widget toast = Container( - constraints: const BoxConstraints(minHeight: 44, maxWidth: 560), - padding: EdgeInsets.fromLTRB(18, 10, hasButton ? 8 : 18, 10), + key: const ValueKey('codelessly-toast'), + constraints: BoxConstraints(minHeight: 44, maxWidth: action.maxWidth), + padding: + action.padding?.flutterEdgeInsets ?? + EdgeInsets.fromLTRB(18, 10, hasButton || hasClose ? 8 : 18, 10), decoration: BoxDecoration( color: background, - borderRadius: BorderRadius.circular( - hasButton ? theme.buttonRadius.clamp(8, 999) : 999, - ), - boxShadow: const [ - BoxShadow( - color: Color(0x33000000), - blurRadius: 18, - offset: Offset(0, 6), - ), - ], + borderRadius: BorderRadius.circular(radius), + border: action.border == null + ? null + : Border.fromBorderSide(borderSideOf(action.border!)), + boxShadow: action.shadows != null + ? boxShadowsOf(action.shadows) + : const [ + BoxShadow( + color: Color(0x33000000), + blurRadius: 18, + offset: Offset(0, 6), + ), + ], ), child: Row( mainAxisSize: MainAxisSize.min, @@ -215,20 +245,43 @@ class _ToastViewState extends State<_ToastView> { key: const ValueKey('codelessly-toast-action'), onPressed: _onActionPressed, style: TextButton.styleFrom( - foregroundColor: textColor, + foregroundColor: actionColor, minimumSize: const Size(48, 36), padding: const EdgeInsets.symmetric(horizontal: 12), - textStyle: theme.buttonStyle.copyWith( - fontSize: 14, - fontWeight: FontWeight.w700, - ), + textStyle: actionStyle, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(theme.buttonRadius), + borderRadius: BorderRadius.circular(buttonRadius), ), ), child: Text(widget.actionLabel), ), ], + if (hasClose) ...[ + SizedBox(width: hasButton ? 0 : 8), + Semantics( + button: true, + label: 'Dismiss', + excludeSemantics: true, + child: IconButton( + key: const ValueKey('codelessly-toast-close'), + onPressed: _close, + tooltip: null, + iconSize: 18, + color: closeColor, + constraints: const BoxConstraints.tightFor( + width: 36, + height: 36, + ), + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(buttonRadius), + ), + ), + icon: const Icon(Icons.close), + ), + ), + ], ], ), ); @@ -253,10 +306,13 @@ class _ToastViewState extends State<_ToastView> { return Positioned( left: 16, right: 16, - top: top ? safe.top + 16 : null, - bottom: top ? null : action.bottomOffset, + top: top ? media.padding.top + 16 : null, + bottom: top ? null : action.bottomOffsetFor(media.size.width), // Only a toast with a button takes taps; the rest passes them through. - child: IgnorePointer(ignoring: !hasButton || !_visible, child: toast), + child: IgnorePointer( + ignoring: !(hasButton || hasClose) || !_visible, + child: toast, + ), ); } } diff --git a/lib/src/transformers/node_transformers/passive_dropdown_transformer.dart b/lib/src/transformers/node_transformers/passive_dropdown_transformer.dart index 8d6ffccd..120be106 100644 --- a/lib/src/transformers/node_transformers/passive_dropdown_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_dropdown_transformer.dart @@ -1,4 +1,5 @@ import 'package:codelessly_api/codelessly_api.dart'; +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -90,6 +91,75 @@ class PassiveDropdownWidget extends StatelessWidget { required this.settings, }); + /// The label of the item provided at [context]. + String labelOf(BuildContext context, Object? value) { + if (!node.properties.useDataSource) return '$value'; + final ScopedValues scopedValues = ScopedValues.of(context); + final String labelText = + PropertyValueDelegate.getPropertyValue( + node, + 'itemLabel', + scopedValues: scopedValues, + ) ?? + node.properties.itemLabel; + return PropertyValueDelegate.substituteVariables( + labelText, + nullSubstitutionMode: settings.nullSubstitutionMode, + scopedValues: scopedValues, + ); + } + + /// The image before the label of the item provided at [context], if the + /// dropdown has one. + Widget? imageOf(BuildContext context) { + final DropdownProperties props = node.properties; + if (props.itemImage.trim().isEmpty) return null; + final String url = PropertyValueDelegate.substituteVariables( + props.itemImage, + nullSubstitutionMode: NullSubstitutionMode.emptyString, + scopedValues: ScopedValues.of(context), + ).trim(); + final double size = props.itemImageSize; + return Padding( + padding: const EdgeInsetsDirectional.only(end: 10), + child: SizedBox.square( + dimension: size, + child: url.isEmpty + ? null + : ClipRRect( + borderRadius: BorderRadius.circular(props.itemImageRadius), + child: Image.network( + url, + key: const ValueKey('codelessly-dropdown-item-image'), + width: size, + height: size, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => + SizedBox.square(dimension: size), + ), + ), + ), + ); + } + + /// An item's row: its image, if any, and its label. + Widget itemContent(BuildContext context, Object? value, TextStyle style) { + final Widget? image = imageOf(context); + final Widget label = Text( + labelOf(context, value), + style: style, + overflow: TextOverflow.ellipsis, + ); + if (image == null) return label; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + image, + Flexible(child: label), + ], + ); + } + List> buildItems(BuildContext context, List items) { return [ for (final (index, value) in items.indexed) @@ -103,30 +173,13 @@ class PassiveDropdownWidget extends StatelessWidget { child: IndexedItemProvider( item: IndexedItem(index, value), child: Builder( - builder: (context) { - String label = '$value'; - if (node.properties.useDataSource) { - String? labelText = - PropertyValueDelegate.getPropertyValue( - node, - 'itemLabel', - scopedValues: ScopedValues.of(context), - ); - labelText ??= node.properties.itemLabel; - label = PropertyValueDelegate.substituteVariables( - labelText, - nullSubstitutionMode: settings.nullSubstitutionMode, - scopedValues: ScopedValues.of(context), - ); - } - return Text( - label, - style: TextUtils.retrieveTextStyleFromProp( - node.properties.itemTextStyle, - ), - overflow: TextOverflow.ellipsis, - ); - }, + builder: (context) => itemContent( + context, + value, + TextUtils.retrieveTextStyleFromProp( + node.properties.itemTextStyle, + ), + ), ), ), ), @@ -145,36 +198,18 @@ class PassiveDropdownWidget extends StatelessWidget { child: IndexedItemProvider( item: IndexedItem(index, value), child: Builder( - builder: (context) { - String label = '$value'; - if (node.properties.useDataSource) { - String? labelText = - PropertyValueDelegate.getPropertyValue( - node, - 'itemLabel', - scopedValues: ScopedValues.of(context), - ); - labelText ??= node.properties.itemLabel; - label = PropertyValueDelegate.substituteVariables( - labelText, - nullSubstitutionMode: settings.nullSubstitutionMode, - scopedValues: ScopedValues.of(context), - ); - } - return Container( - constraints: BoxConstraints( - maxWidth: - node.basicBoxLocal.width - node.properties.iconSize, - ), - child: Text( - label, - style: TextUtils.retrieveTextStyleFromProp( - node.properties.selectedItemTextStyle, - ), - overflow: TextOverflow.ellipsis, + builder: (context) => Container( + constraints: BoxConstraints( + maxWidth: node.basicBoxLocal.width - node.properties.iconSize, + ), + child: itemContent( + context, + value, + TextUtils.retrieveTextStyleFromProp( + node.properties.selectedItemTextStyle, ), - ); - }, + ), + ), ), ), ), @@ -200,6 +235,14 @@ class PassiveDropdownWidget extends StatelessWidget { scopedValues: scopedValues, ) ?? node.value; + // Data source items are maps and lists read anew on each build: the + // selected one is the item equal to the value by content. + const DeepCollectionEquality equality = DeepCollectionEquality(); + final List matches = [ + for (final Object? item in items) + if (equality.equals(item, value)) item, + ]; + value = matches.length == 1 ? matches.single : null; return AdaptiveNodeBox( node: node, @@ -211,7 +254,7 @@ class PassiveDropdownWidget extends StatelessWidget { child: Builder( builder: (context) { final child = DropdownButton( - value: items.isEmpty || items.count(value) != 1 ? null : value, + value: value, isDense: node.properties.dense, isExpanded: node.properties.expanded, autofocus: node.properties.autoFocus, @@ -260,13 +303,265 @@ class PassiveDropdownWidget extends StatelessWidget { selectedItemBuilder: (context) => selectedItemBuilder(context, items), ); + Widget field = child; + final DropdownMenuStyleModel? menuStyle = node.properties.menuStyle; + if (menuStyle != null) { + // The field stays the dropdown button; the menu it opens is + // a card anchored under it. + field = _AnchoredDropdownMenu( + widget: this, + style: menuStyle, + items: items, + value: value, + enabled: node.properties.enabled, + child: child, + ); + } if (node.horizontalFit.isWrap) { - return IntrinsicWidth(child: child); + return IntrinsicWidth(child: field); } - return child; + return field; }, ), ), ); } } + +/// Opens a [DropdownMenuStyleModel]-styled menu anchored under [child], the +/// dropdown's field, in place of the platform's dropdown menu. +class _AnchoredDropdownMenu extends StatefulWidget { + const _AnchoredDropdownMenu({ + required this.widget, + required this.style, + required this.items, + required this.value, + required this.enabled, + required this.child, + }); + + final PassiveDropdownWidget widget; + final DropdownMenuStyleModel style; + final List items; + final Object? value; + final bool enabled; + final Widget child; + + @override + State<_AnchoredDropdownMenu> createState() => _AnchoredDropdownMenuState(); +} + +class _AnchoredDropdownMenuState extends State<_AnchoredDropdownMenu> { + final OverlayPortalController _portal = OverlayPortalController(); + final LayerLink _link = LayerLink(); + Size _fieldSize = Size.zero; + bool _below = true; + + void _open() { + if (!widget.enabled || _portal.isShowing) return; + final RenderBox? box = context.findRenderObject() as RenderBox?; + if (box == null || !box.hasSize) return; + final double screenHeight = MediaQuery.sizeOf(context).height; + final double bottom = box.localToGlobal(Offset.zero).dy + box.size.height; + setState(() { + _fieldSize = box.size; + _below = + bottom + widget.style.offset + widget.style.maxHeight <= + screenHeight || + bottom < screenHeight / 2; + }); + _portal.show(); + widget.widget.onTap?.call(); + } + + void _close() { + if (_portal.isShowing) setState(_portal.hide); + } + + void _pick(int index, Object value) { + _close(); + widget.widget.onChanged?.call(index, value); + } + + @override + Widget build(BuildContext context) { + return OverlayPortal( + controller: _portal, + overlayChildBuilder: _buildMenu, + child: CompositedTransformTarget( + link: _link, + child: FocusableActionDetector( + enabled: widget.enabled, + mouseCursor: widget.enabled + ? SystemMouseCursors.click + : MouseCursor.defer, + actions: { + ActivateIntent: CallbackAction( + onInvoke: (_) => _open(), + ), + }, + child: Semantics( + button: true, + enabled: widget.enabled, + expanded: _portal.isShowing, + onTap: widget.enabled ? _open : null, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _open, + // The button only draws the field; the menu is this one. + child: IgnorePointer(child: ExcludeFocus(child: widget.child)), + ), + ), + ), + ), + ); + } + + Widget _buildMenu(BuildContext context) { + final DropdownMenuStyleModel style = widget.style; + final DropdownProperties props = widget.widget.node.properties; + final double radius = + style.radius ?? props.borderRadius.borderRadius.topLeft.x; + final BorderSide side = style.border == null + ? BorderSide.none + : borderSideOf(style.border!); + final List shadows = style.shadows != null + ? boxShadowsOf(style.shadows) + : kElevationToShadow[props.elevation.clamp(0, 24)] ?? + kElevationToShadow[8]!; + final TextStyle itemStyle = TextUtils.retrieveTextStyleFromProp( + props.itemTextStyle, + ); + final TextStyle selectedStyle = style.selectedTextStyle.applyTo(itemStyle); + final Color? hover = + style.hoverItemColor?.toFlutterColor() ?? + props.hoverColor?.toFlutterColor(); + final EdgeInsets itemPadding = + style.itemPadding?.flutterEdgeInsets ?? + const EdgeInsets.symmetric(horizontal: 14); + + final Widget menu = Container( + key: const ValueKey('codelessly-dropdown-menu'), + width: _fieldSize.width < style.minWidth + ? style.minWidth + : _fieldSize.width, + constraints: BoxConstraints(maxHeight: style.maxHeight), + decoration: BoxDecoration( + color: + style.color?.toFlutterColor() ?? + props.dropdownColor.toFlutterColor(), + borderRadius: BorderRadius.circular(radius), + border: side == BorderSide.none ? null : Border.fromBorderSide(side), + boxShadow: shadows, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular( + (radius - side.width).clamp(0, double.infinity), + ), + child: Material( + type: MaterialType.transparency, + child: SingleChildScrollView( + padding: + style.padding?.flutterEdgeInsets ?? + const EdgeInsets.symmetric(vertical: 6), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (final (index, item) in widget.items.indexed) + IndexedItemProvider( + item: IndexedItem(index, item), + child: Builder( + builder: (context) { + final bool selected = item == widget.value; + final TextStyle textStyle = selected + ? selectedStyle + : itemStyle; + return Semantics( + selected: selected, + button: true, + child: Material( + color: selected + ? style.selectedItemColor?.toFlutterColor() ?? + Colors.transparent + : Colors.transparent, + child: InkWell( + key: ValueKey('codelessly-dropdown-item-$index'), + hoverColor: hover, + splashColor: props.splashColor?.toFlutterColor(), + onTap: () => _pick(index, item), + child: Container( + height: style.itemHeight, + padding: itemPadding, + alignment: + props + .itemAlignment + .flutterAlignmentGeometry ?? + AlignmentDirectional.centerStart, + child: Row( + children: [ + ?widget.widget.imageOf(context), + Expanded( + child: Text( + widget.widget.labelOf(context, item), + style: textStyle, + overflow: TextOverflow.ellipsis, + ), + ), + if (selected && style.showCheck) + Padding( + padding: + const EdgeInsetsDirectional.only( + start: 8, + ), + child: Icon( + Icons.check, + key: const ValueKey( + 'codelessly-dropdown-check', + ), + size: style.checkSize, + color: + style.checkColor + ?.toFlutterColor() ?? + textStyle.color, + ), + ), + ], + ), + ), + ), + ), + ); + }, + ), + ), + ], + ), + ), + ), + ), + ); + + return Stack( + children: [ + Positioned.fill( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: _close, + ), + ), + CompositedTransformFollower( + link: _link, + showWhenUnlinked: false, + targetAnchor: _below ? Alignment.bottomLeft : Alignment.topLeft, + followerAnchor: _below ? Alignment.topLeft : Alignment.bottomLeft, + offset: Offset(0, _below ? style.offset : -style.offset), + child: Align( + alignment: _below ? Alignment.topLeft : Alignment.bottomLeft, + child: menu, + ), + ), + ], + ); + } +} diff --git a/lib/src/ui/feedback_theme.dart b/lib/src/ui/feedback_theme.dart index 2e424273..a37f2486 100644 --- a/lib/src/ui/feedback_theme.dart +++ b/lib/src/ui/feedback_theme.dart @@ -1,5 +1,6 @@ import 'package:codelessly_api/codelessly_api.dart'; import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart' show listEquals; import 'package:flutter/material.dart'; import '../../codelessly_sdk.dart'; @@ -28,6 +29,10 @@ class FeedbackTheme extends ThemeExtension { this.warning, this.radius, this.buttonRadius, + this.border, + this.shadows, + this.buttonBorder, + this.buttonShadows, }); /// Font of messages, without size or color. @@ -66,6 +71,18 @@ class FeedbackTheme extends ThemeExtension { /// Corner radius of buttons. final double? buttonRadius; + /// Outline of dialogs. + final BorderSide? border; + + /// Shadows of dialogs, in place of their elevation. + final List? shadows; + + /// Outline of buttons. + final BorderSide? buttonBorder; + + /// Shadows of filled buttons. + final List? buttonShadows; + /// Fills this theme's missing values from [other]. FeedbackTheme merge(FeedbackTheme? other) { if (other == null) return this; @@ -82,6 +99,10 @@ class FeedbackTheme extends ThemeExtension { warning: warning ?? other.warning, radius: radius ?? other.radius, buttonRadius: buttonRadius ?? other.buttonRadius, + border: border ?? other.border, + shadows: shadows ?? other.shadows, + buttonBorder: buttonBorder ?? other.buttonBorder, + buttonShadows: buttonShadows ?? other.buttonShadows, ); } @@ -132,6 +153,10 @@ class FeedbackTheme extends ThemeExtension { final Map textColors = {}; final Map primaryVotes = {}; final Map radiusVotes = {}; + // Outlines and shadows of tappable shapes, keyed by their look. + final Map buttonBorderVotes = {}; + final Map, int)> buttonShadowVotes = {}; + int tappableShapes = 0; TextProp? buttonLabel; Color? surface; @@ -179,8 +204,34 @@ class FeedbackTheme extends ThemeExtension { (Reaction r) => r.trigger.type == TriggerType.click, ): // A tappable shape with a solid fill is a button too. - final Color? fill = _solidFill((scene as GeometryMixin).fills); - if (fill != null) vote(primaryVotes, fill.toARGB32(), 1); + final GeometryMixin shape = scene as GeometryMixin; + final Color? fill = _solidFill(shape.fills); + if (fill != null) { + vote(primaryVotes, fill.toARGB32(), 1); + tappableShapes++; + final Color? stroke = shape.strokeWeight > 0 + ? _solidFill(shape.strokes) + : null; + if (stroke != null) { + final BorderSide side = BorderSide( + color: stroke, + width: shape.strokeWeight, + ); + buttonBorderVotes[side] = (buttonBorderVotes[side] ?? 0) + 1; + } + if (scene is BlendMixin) { + final List shadows = boxShadowsOf( + (scene as BlendMixin).effects, + ); + if (shadows.isNotEmpty) { + final String key = shadows.join(); + buttonShadowVotes[key] = ( + shadows, + (buttonShadowVotes[key]?.$2 ?? 0) + 1, + ); + } + } + } if (scene is CornerMixin) { vote( radiusVotes, @@ -235,6 +286,17 @@ class FeedbackTheme extends ThemeExtension { (MapEntry e) => e.value, )?.key; + // Outlined, stamped buttons: when most tappable shapes share an outline + // or a shadow, so do the dialog's buttons. + final MapEntry? buttonBorder = maxBy( + buttonBorderVotes.entries, + (MapEntry e) => e.value, + ); + final (List, int)? buttonShadows = maxBy( + buttonShadowVotes.values, + ((List, int) e) => e.$2, + ); + return FeedbackTheme( bodyStyle: bodyStyle, titleStyle: titleStyle, @@ -245,6 +307,14 @@ class FeedbackTheme extends ThemeExtension { surface: surface, onSurface: onSurface == null ? null : Color(onSurface), buttonRadius: buttonRadius?.toDouble(), + buttonBorder: + buttonBorder != null && buttonBorder.value * 2 > tappableShapes + ? buttonBorder.key + : null, + buttonShadows: + buttonShadows != null && buttonShadows.$2 * 2 > tappableShapes + ? buttonShadows.$1 + : null, ); } @@ -284,6 +354,10 @@ class FeedbackTheme extends ThemeExtension { Color? warning, double? radius, double? buttonRadius, + BorderSide? border, + List? shadows, + BorderSide? buttonBorder, + List? buttonShadows, }) => FeedbackTheme( bodyStyle: bodyStyle ?? this.bodyStyle, titleStyle: titleStyle ?? this.titleStyle, @@ -297,6 +371,10 @@ class FeedbackTheme extends ThemeExtension { warning: warning ?? this.warning, radius: radius ?? this.radius, buttonRadius: buttonRadius ?? this.buttonRadius, + border: border ?? this.border, + shadows: shadows ?? this.shadows, + buttonBorder: buttonBorder ?? this.buttonBorder, + buttonShadows: buttonShadows ?? this.buttonShadows, ); @override @@ -315,6 +393,10 @@ class FeedbackTheme extends ThemeExtension { warning: Color.lerp(warning, other.warning, t), radius: t < 0.5 ? radius : other.radius, buttonRadius: t < 0.5 ? buttonRadius : other.buttonRadius, + border: t < 0.5 ? border : other.border, + shadows: BoxShadow.lerpList(shadows, other.shadows, t), + buttonBorder: t < 0.5 ? buttonBorder : other.buttonBorder, + buttonShadows: BoxShadow.lerpList(buttonShadows, other.buttonShadows, t), ); } @@ -332,7 +414,11 @@ class FeedbackTheme extends ThemeExtension { other.success == success && other.warning == warning && other.radius == radius && - other.buttonRadius == buttonRadius; + other.buttonRadius == buttonRadius && + other.border == border && + listEquals(other.shadows, shadows) && + other.buttonBorder == buttonBorder && + listEquals(other.buttonShadows, buttonShadows); @override int get hashCode => Object.hash( @@ -348,6 +434,10 @@ class FeedbackTheme extends ThemeExtension { warning, radius, buttonRadius, + border, + shadows == null ? null : Object.hashAll(shadows!), + buttonBorder, + buttonShadows == null ? null : Object.hashAll(buttonShadows!), ); } @@ -368,6 +458,10 @@ class ResolvedFeedbackTheme { required this.warning, required this.radius, required this.buttonRadius, + required this.border, + required this.shadows, + required this.buttonBorder, + required this.buttonShadows, }); factory ResolvedFeedbackTheme._resolve(FeedbackTheme t, ThemeData theme) { @@ -402,6 +496,10 @@ class ResolvedFeedbackTheme { warning: t.warning ?? const Color(0xFFF2A600), radius: t.radius ?? (buttonRadius + 8).clamp(12, 28).toDouble(), buttonRadius: buttonRadius, + border: t.border, + shadows: t.shadows, + buttonBorder: t.buttonBorder, + buttonShadows: t.buttonShadows, ); } @@ -444,6 +542,18 @@ class ResolvedFeedbackTheme { /// Corner radius of buttons. final double buttonRadius; + /// Outline of dialogs, if any. + final BorderSide? border; + + /// Shadows of dialogs in place of their elevation, if set. + final List? shadows; + + /// Outline of buttons, if any. + final BorderSide? buttonBorder; + + /// Shadows of filled buttons, if any. + final List? buttonShadows; + /// Black or white, whichever reads on [background]. static Color onColor(Color background) => ThemeData.estimateBrightnessForColor(background) == Brightness.dark @@ -496,3 +606,63 @@ class FeedbackThemeScope extends InheritedWidget { bool updateShouldNotify(FeedbackThemeScope oldWidget) => theme != oldWidget.theme; } + +/// The visible drop shadows of [effects] as [BoxShadow]s. +List boxShadowsOf(Iterable? effects) => [ + for (final Effect effect in effects ?? const []) + if (effect.type == EffectType.dropShadow && effect.visible) + BoxShadow( + color: effect.color?.toFlutterColor() ?? const Color(0x40000000), + offset: Offset( + effect.offset?.x.toDouble() ?? 0, + effect.offset?.y.toDouble() ?? 0, + ), + blurRadius: effect.radius, + spreadRadius: effect.spread ?? 0, + ), +]; + +/// [model] as a Flutter [BorderSide]; [BorderSide.none] for a border of +/// style none. +BorderSide borderSideOf(BorderSideModel model) => + model.style == BorderStyleEnum.none || model.width <= 0 + ? BorderSide.none + : BorderSide(color: model.color.toFlutterColor(), width: model.width); + +/// Applies a [TextStyleModel] to a Flutter [TextStyle]. +extension TextStyleModelApply on TextStyleModel? { + /// [base] with this style's values in place of its own. A font family + /// loads like a text node's, Google Fonts included. + TextStyle applyTo(TextStyle base) { + final TextStyleModel? model = this; + if (model == null) return base; + TextStyle style = base; + final FontName? font = model.fontName; + if (font != null) { + final TextStyle loaded = TextUtils.retrieveTextStyle(fontName: font); + style = style.copyWith( + fontFamily: loaded.fontFamily, + fontFamilyFallback: loaded.fontFamilyFallback, + fontWeight: loaded.fontWeight, + fontStyle: loaded.fontStyle, + ); + } else { + if (model.fontWeight != null) { + style = style.copyWith( + fontWeight: FontWeight.values[model.fontWeight!.index], + ); + } + if (model.italic != null) { + style = style.copyWith( + fontStyle: model.italic! ? FontStyle.italic : FontStyle.normal, + ); + } + } + return style.copyWith( + fontSize: model.fontSize, + height: model.lineHeight, + letterSpacing: model.letterSpacing, + color: model.color?.toFlutterColor(), + ); + } +} diff --git a/test/confirm_dialog_test.dart b/test/confirm_dialog_test.dart index c30a88ed..fcf70403 100644 --- a/test/confirm_dialog_test.dart +++ b/test/confirm_dialog_test.dart @@ -59,4 +59,149 @@ void main() { expect(find.text('Leave album?'), findsOneWidget); expect(find.text('You can join again later.'), findsOneWidget); }); + + testWidgets('a styled dialog takes its border, stamped shadows, fonts and ' + 'outlined buttons', (tester) async { + const Color ink = Color(0xFF1F2B2E); + const Color paper = Color(0xFFF3ECDD); + const Color card = Color(0xFFFBF7EE); + const Color inkSoft = Color(0xFF4A5558); + const Color danger = Color(0xFFB3392B); + Map stamp(double d) => { + 'type': 'dropShadow', + 'radius': 0, + 'spread': 0, + 'color': '#1F2B2E', + 'offset': {'x': d, 'y': d}, + }; + final ConfirmAction styled = ActionModel.fromJson({ + 'type': 'confirm', + 'title': 'Delete this sighting?', + 'message': 'It leaves your log for good.', + 'confirmLabel': 'Delete', + 'destructive': true, + 'backgroundColor': '#F3ECDD', + 'textColor': '#1F2B2E', + 'cornerRadius': 6, + 'border': {'color': '#1F2B2E', 'width': 1.5}, + 'shadows': [stamp(6)], + 'titleStyle': { + 'fontFamily': 'Fraunces', + 'fontWeight': 'w600', + 'fontSize': 19, + }, + 'messageStyle': {'fontFamily': 'Work Sans', 'color': '#4A5558'}, + 'buttonStyle': {'fontFamily': 'Work Sans', 'fontWeight': 'w600'}, + 'buttonRadius': 4, + 'buttonBorder': {'color': '#1F2B2E', 'width': 1.5}, + 'buttonShadows': [stamp(3)], + 'confirmColor': '#B3392B', + 'confirmTextColor': '#F3ECDD', + 'cancelColor': '#FBF7EE', + 'cancelTextColor': '#1F2B2E', + }) as ConfirmAction; + + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => TextButton( + onPressed: () => AppConfirmDialog.show(context, styled), + child: const Text('Open'), + ), + ), + ), + ); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + // A paper card with an ink outline and a hard shadow, no elevation. + final Dialog dialog = tester.widget(find.byType(Dialog)); + expect(dialog.elevation, 0); + final BoxDecoration surface = + tester + .widget( + find.byKey(const ValueKey('codelessly-confirm-surface')), + ) + .decoration + as BoxDecoration; + expect(surface.color, paper); + expect(surface.borderRadius, BorderRadius.circular(6)); + expect( + surface.border, + const Border.fromBorderSide(BorderSide(color: ink, width: 1.5)), + ); + expect(surface.boxShadow, const [ + BoxShadow(color: ink, offset: Offset(6, 6)), + ]); + + TextStyle styleOf(String text) => tester + .widget( + find.descendant(of: find.text(text), matching: find.byType(RichText)), + ) + .text + .style!; + String? family(String name, FontWeightNumeric weight) => + TextUtils.retrieveTextStyle( + fontName: FontName(family: name, weight: weight), + ).fontFamily; + final TextStyle title = styleOf('Delete this sighting?'); + expect(title.fontFamily, family('Fraunces', FontWeightNumeric.w600)); + expect(title.fontSize, 19); + expect(title.color, ink); + final TextStyle message = styleOf('It leaves your log for good.'); + expect(message.fontFamily, family('Work Sans', FontWeightNumeric.w400)); + expect(message.color, inkSoft); + expect( + styleOf('Delete').fontFamily, + family('Work Sans', FontWeightNumeric.w600), + ); + + // Both buttons are outlined with a 4 radius; the confirm is danger red + // and the cancel a card, each with a stamped shadow. + final ButtonStyle ok = tester + .widget( + find.byKey(const ValueKey('codelessly-confirm-ok')), + ) + .style!; + final ButtonStyle cancel = tester + .widget( + find.byKey(const ValueKey('codelessly-confirm-cancel')), + ) + .style!; + expect(ok.backgroundColor!.resolve({}), danger); + expect(ok.foregroundColor!.resolve({}), paper); + expect(cancel.backgroundColor!.resolve({}), card); + expect(cancel.foregroundColor!.resolve({}), ink); + for (final ButtonStyle style in [ok, cancel]) { + expect(style.side!.resolve({}), const BorderSide(color: ink, width: 1.5)); + expect( + (style.shape!.resolve({})! as RoundedRectangleBorder).borderRadius, + BorderRadius.circular(4), + ); + } + for (final String key in [ + 'codelessly-confirm-ok', + 'codelessly-confirm-cancel', + ]) { + final BoxDecoration shadow = + tester + .widget( + find + .ancestor( + of: find.byKey(ValueKey(key)), + matching: find.byType(DecoratedBox), + ) + .first, + ) + .decoration + as BoxDecoration; + expect(shadow.boxShadow, const [ + BoxShadow(color: ink, offset: Offset(3, 3)), + ]); + } + + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + expect(find.byType(Dialog), findsNothing); + }); } diff --git a/test/dropdown_menu_test.dart b/test/dropdown_menu_test.dart new file mode 100644 index 00000000..f462ff57 --- /dev/null +++ b/test/dropdown_menu_test.dart @@ -0,0 +1,222 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +const Color ink = Color(0xFF1F2B2E); +const Color card = Color(0xFFFBF7EE); +const Color foam = Color(0xFFD4E7DF); +const Color tide = Color(0xFF1F6F78); + +const List> species = [ + {'name': 'Ochre sea star', 'image': 'https://plates.test/ochre.png'}, + {'name': 'Giant green anemone', 'image': 'https://plates.test/anemone.png'}, + {'name': 'Purple urchin', 'image': 'https://plates.test/urchin.png'}, +]; + +/// The Tidepool species menu: a card under the field with an ink outline, +/// a stamped shadow and the selected row on foam with a check mark. +final Map menuStyle = { + 'color': '#FBF7EE', + 'border': {'color': '#1F2B2E', 'width': 1.5}, + 'radius': 4, + 'shadows': [ + { + 'type': 'dropShadow', + 'radius': 0, + 'spread': 0, + 'color': '#1F2B2E', + 'offset': {'x': 4, 'y': 4}, + }, + ], + 'itemHeight': 40, + 'selectedItemColor': '#D4E7DF', + 'selectedTextStyle': {'fontWeight': 'w600'}, + 'showCheck': true, + 'checkColor': '#1F6F78', + 'minWidth': 240, +}; + +DropdownNode dropdown({Map? style, Object? value}) => + DropdownNode( + id: 'species', + name: 'Species', + basicBoxLocal: NodeBox(0, 0, 200, 44), + value: value, + properties: DropdownProperties.fromJson({ + 'items': [], + 'useDataSource': true, + 'itemLabel': r'${item.name}', + 'itemImage': r'${item.image}', + 'itemImageSize': 28, + 'menuStyle': ?style, + }), + )..variables['data'] = 'species'; + +Future> pumpDropdown( + WidgetTester tester, + DropdownNode node, +) async { + final List<(int, Object)> changes = []; + final CodelesslyContext ctx = CodelesslyContext.empty() + ..variables['species'] = Observable( + VariableData( + id: 'species', + name: 'species', + value: species, + type: VariableType.list, + ), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: ctx, + child: MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: Padding( + padding: const EdgeInsets.all(20), + child: SizedBox( + width: 200, + height: 44, + child: PassiveDropdownWidget( + node: node, + settings: const WidgetBuildSettings(debugLabel: 'test'), + onChanged: (index, value) => changes.add((index, value)), + ), + ), + ), + ), + ), + ), + ), + ); + return changes; +} + +void main() { + testWidgets('a styled dropdown opens an anchored, stamped menu card', ( + tester, + ) async { + final List<(int, Object)> changes = await pumpDropdown( + tester, + dropdown(style: menuStyle, value: species[1]), + ); + // The field shows the selected item with its plate. + expect(find.text('Giant green anemone'), findsOneWidget); + + await tester.tap(find.byType(PassiveDropdownWidget)); + await tester.pumpAndSettle(); + final Finder menu = find.byKey(const ValueKey('codelessly-dropdown-menu')); + expect(menu, findsOneWidget); + + // Under the field, at least 240 wide. + final Rect field = tester.getRect(find.byType(PassiveDropdownWidget)); + final Rect box = tester.getRect(menu); + expect(box.top, field.bottom + 6); + expect(box.left, field.left); + expect(box.width, 240); + + final BoxDecoration decoration = + tester.widget(menu).decoration! as BoxDecoration; + expect(decoration.color, card); + expect(decoration.borderRadius, BorderRadius.circular(4)); + expect( + decoration.border, + const Border.fromBorderSide(BorderSide(color: ink, width: 1.5)), + ); + expect(decoration.boxShadow, const [ + BoxShadow(color: ink, offset: Offset(4, 4)), + ]); + + // Rows: each with its plate, the selected one on foam, bold, checked. + Finder inMenu(Finder finder) => find.descendant(of: menu, matching: finder); + expect(inMenu(find.text('Ochre sea star')), findsOneWidget); + final List urls = tester + .widgetList(inMenu(find.byType(Image))) + .map((image) => (image.image as NetworkImage).url) + .toList(); + expect(urls, [for (final s in species) s['image']]); + expect( + tester + .getSize( + inMenu(find.byKey(const ValueKey('codelessly-dropdown-item-0'))), + ) + .height, + 40, + ); + + final Material selectedRow = tester.widget( + find + .ancestor( + of: find.byKey(const ValueKey('codelessly-dropdown-item-1')), + matching: find.byType(Material), + ) + .first, + ); + expect(selectedRow.color, foam); + final Material otherRow = tester.widget( + find + .ancestor( + of: find.byKey(const ValueKey('codelessly-dropdown-item-0')), + matching: find.byType(Material), + ) + .first, + ); + expect(otherRow.color, Colors.transparent); + final RichText selectedLabel = tester.widget( + find.descendant( + of: inMenu(find.text('Giant green anemone')), + matching: find.byType(RichText), + ), + ); + expect(selectedLabel.text.style!.fontWeight, FontWeight.w600); + final Icon check = tester.widget( + find.byKey(const ValueKey('codelessly-dropdown-check')), + ); + expect(check.icon, Icons.check); + expect(check.color, tide); + expect( + find.descendant( + of: find.byKey(const ValueKey('codelessly-dropdown-item-1')), + matching: find.byKey(const ValueKey('codelessly-dropdown-check')), + ), + findsOneWidget, + ); + + // Picking a row closes the menu and reports it. + await tester.tap(inMenu(find.text('Purple urchin'))); + await tester.pumpAndSettle(); + expect(menu, findsNothing); + expect(changes.single.$1, 2); + expect(changes.single.$2, species[2]); + + // Tapping outside closes it without a change. + await tester.tap(find.byType(PassiveDropdownWidget)); + await tester.pumpAndSettle(); + expect(menu, findsOneWidget); + await tester.tapAt(const Offset(700, 500)); + await tester.pumpAndSettle(); + expect(menu, findsNothing); + expect(changes, hasLength(1)); + }); + + testWidgets('a dropdown without a menu style keeps the platform menu', ( + tester, + ) async { + await pumpDropdown(tester, dropdown(value: species[0])); + await tester.tap(find.byType(PassiveDropdownWidget)); + await tester.pumpAndSettle(); + expect( + find.byKey(const ValueKey('codelessly-dropdown-menu')), + findsNothing, + ); + // The platform menu lists the items, with their plates. + expect(find.text('Purple urchin'), findsWidgets); + expect( + find.byKey(const ValueKey('codelessly-dropdown-item-image')), + findsWidgets, + ); + }); +} diff --git a/test/feedback_actions_test.dart b/test/feedback_actions_test.dart index 2ed0c177..d39f3b9d 100644 --- a/test/feedback_actions_test.dart +++ b/test/feedback_actions_test.dart @@ -290,6 +290,57 @@ void main() { expect(theme.buttonRadius, 6); }); + test('reads the outline and hard shadow most tappable shapes share', () { + RectangleNode stamped(String id, {bool outlined = true}) => RectangleNode( + id: id, + name: id, + basicBoxLocal: NodeBox(0, 0, 160, 44), + fills: [paint(brandPrimary)], + strokes: [if (outlined) paint(brandText)], + strokeWeight: outlined ? 1.5 : 0, + effects: [ + Effect.dropShadow( + id: 'stamp', + radius: 0, + spread: 0, + color: brandText, + offset: const Vec(3, 3), + ), + ], + reactions: [ + Reaction( + action: ShowToastAction(message: 'x'), + trigger: TriggerModel(type: TriggerType.click), + ), + ], + )..parentID = 'mainBody'; + + final FeedbackTheme theme = FeedbackTheme.fromNodes([ + ...mainScreen(ShowToastAction(message: 'x')), + stamped('a'), + stamped('b'), + stamped('c', outlined: false), + ]); + expect( + theme.buttonBorder, + BorderSide(color: brandText.toFlutterColor(), width: 1.5), + ); + expect(theme.buttonShadows, [ + BoxShadow( + color: brandText.toFlutterColor(), + offset: const Offset(3, 3), + ), + ]); + expect(theme.border, isNull); + + // A layout whose buttons have none keeps plain buttons. + final FeedbackTheme plain = FeedbackTheme.fromNodes( + mainScreen(ShowToastAction(message: 'x')), + ); + expect(plain.buttonBorder, isNull); + expect(plain.buttonShadows, isNull); + }); + test('the app\'s extension wins over the layout', () { final FeedbackTheme layout = FeedbackTheme.fromNodes( mainScreen(ShowToastAction(message: 'x')), diff --git a/test/toast_test.dart b/test/toast_test.dart index 940850e8..005c1447 100644 --- a/test/toast_test.dart +++ b/test/toast_test.dart @@ -65,4 +65,163 @@ void main() { await tester.pump(const Duration(milliseconds: 300)); expect(find.text('Second'), findsNothing); }); + + group('a styled toast', () { + const Color ink = Color(0xFF1F2B2E); + const Color paper = Color(0xFFF3ECDD); + const Color seaGlass = Color(0xFF9CC5B5); + const Color coral = Color(0xFFD9573B); + + ShowToastAction styled() => ActionModel.fromJson({ + 'type': 'showToast', + 'message': 'Ochre sea star added', + 'actionLabel': 'View', + 'duration': 4000, + 'backgroundColor': '#1F2B2E', + 'textColor': '#F3ECDD', + 'actionColor': '#9CC5B5', + 'showClose': true, + 'cornerRadius': 4, + 'border': {'color': '#1F2B2E', 'width': 1.5}, + 'shadows': [ + { + 'type': 'dropShadow', + 'radius': 0, + 'spread': 0, + 'color': '#1F2B2E', + 'offset': {'x': 4, 'y': 4}, + }, + ], + 'maxWidth': 480, + 'messageStyle': { + 'fontFamily': 'Work Sans', + 'fontWeight': 'w600', + 'fontSize': 13, + }, + 'actionStyle': {'fontFamily': 'Work Sans', 'fontWeight': 'w600'}, + 'icon': { + 'type': 'icon', + 'show': true, + 'color': '#D9573B', + 'icon': { + 'type': 'CUPERTINO_ICON', + 'name': 'star_fill', + 'codepoint': 0xf4b8, + 'fontFamily': 'CupertinoIcons', + }, + }, + 'iconSize': 34, + 'bottomOffset': 24, + 'bottomOffsets': {'mobile': 84}, + }) as ShowToastAction; + + Future pumpHost(WidgetTester tester, Size size) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.reset); + late BuildContext context; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Builder( + builder: (c) { + context = c; + return const SizedBox(); + }, + ), + ), + ), + ); + return context; + } + + TextStyle styleOf(WidgetTester tester, String text) => tester + .widget( + find.descendant(of: find.text(text), matching: find.byType(RichText)), + ) + .text + .style!; + + testWidgets('takes its colors, fonts, radius, border, shadow and icon', ( + tester, + ) async { + final BuildContext context = await pumpHost(tester, const Size(390, 844)); + AppToast.show(context, styled()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(const Duration(milliseconds: 300)); + + final Container box = tester.widget( + find.byKey(const ValueKey('codelessly-toast')), + ); + final BoxDecoration decoration = box.decoration! as BoxDecoration; + expect(decoration.color, ink); + expect(decoration.borderRadius, BorderRadius.circular(4)); + expect( + decoration.border, + const Border.fromBorderSide(BorderSide(color: ink, width: 1.5)), + ); + expect(decoration.boxShadow, const [ + BoxShadow(color: ink, offset: Offset(4, 4)), + ]); + expect(box.constraints!.maxWidth, 480); + + final String? workSans = TextUtils.retrieveTextStyle( + fontName: const FontName( + family: 'Work Sans', + weight: FontWeightNumeric.w600, + ), + ).fontFamily; + final TextStyle message = styleOf(tester, 'Ochre sea star added'); + expect(message.color, paper); + expect(message.fontSize, 13); + expect(message.fontFamily, workSans); + // The button has its own color, not the message's. + final TextStyle action = styleOf(tester, 'View'); + expect(action.color, seaGlass); + expect(action.fontFamily, workSans); + + // The icon takes its own color and the toast's icon size. + final SvgIcon icon = tester.widget( + find.descendant( + of: find.byKey(const ValueKey('codelessly-toast')), + matching: find.byType(SvgIcon), + ), + ); + expect(icon.color, coral); + expect(icon.size, 34); + + // Above a mobile tab bar. + expect( + tester.getRect(find.byKey(const ValueKey('codelessly-toast'))).bottom, + 844 - 84, + ); + + // The close button dismisses it. + final Finder close = find.byKey(const ValueKey('codelessly-toast-close')); + expect(tester.widget(close).color, paper); + await tester.tap(close); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Ochre sea star added'), findsNothing); + }); + + testWidgets('sits at the offset of the screen size', (tester) async { + final BuildContext context = await pumpHost( + tester, + const Size(1440, 900), + ); + AppToast.show(context, styled()); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(const Duration(milliseconds: 300)); + expect( + tester.getRect(find.byKey(const ValueKey('codelessly-toast'))).bottom, + 900 - 24, + ); + AppToast.hide(); + await tester.pumpAndSettle(); + }); + }); } From 15f2f9121404cb768be0dba5111879d34f2dd643 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 03:47:27 +0000 Subject: [PATCH 16/27] Let hosts pin the clock formulas read FormulaClock.pinned sets the instant now() and today() (and relative times) take, so a host can show an app at a given moment: previews of a given day and reproducible screenshots of date-dependent screens. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3B1XCZZ7QGvZLUGgSG5H4 --- .../utils/property_value_delegate.dart | 2 +- lib/src/utils/formula.dart | 17 +++++++++++-- test/formula_clock_test.dart | 25 +++++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 test/formula_clock_test.dart diff --git a/lib/src/transformers/utils/property_value_delegate.dart b/lib/src/transformers/utils/property_value_delegate.dart index 3f4dfd8a..9f7cbd3b 100644 --- a/lib/src/transformers/utils/property_value_delegate.dart +++ b/lib/src/transformers/utils/property_value_delegate.dart @@ -1082,7 +1082,7 @@ String formatDate(DateTime date) { /// Formats [date] relative to now: `Just now`, `5 min ago`, `2 hours ago`, /// `3 days ago`, then [formatDate] after a week. String formatTimeAgo(DateTime date, {DateTime? now}) { - final Duration diff = (now ?? DateTime.now()).difference(date); + final Duration diff = (now ?? FormulaClock.now()).difference(date); if (diff.inMinutes < 1) return 'Just now'; if (diff.inHours < 1) return '${diff.inMinutes} min ago'; if (diff.inDays < 1) { diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart index 04ae8102..b91ac771 100644 --- a/lib/src/utils/formula.dart +++ b/lib/src/utils/formula.dart @@ -996,12 +996,25 @@ Duration? _offset(Object? value) { return Duration(minutes: m[1] == '-' ? -minutes : minutes); } +/// The instant formulas take as now (`now()`, `today()`). +/// +/// A host can pin it to show an app at a given moment, for previews and +/// reproducible screenshots of date-dependent screens (the Codelessly site +/// pins it from a `?now=` URL parameter). +abstract final class FormulaClock { + /// The pinned instant, or null for the device clock. + static DateTime? pinned; + + /// Now: [pinned], or the device clock. + static DateTime now() => pinned ?? DateTime.now(); +} + /// The wall-clock time now at the UTC [offset] (see [_offset]), or local /// time without one. Returned without a zone, like other formula dates. DateTime _nowAt(Object? offset) { final Duration? delta = _offset(offset); - if (delta == null) return DateTime.now(); - final DateTime utc = DateTime.now().toUtc().add(delta); + if (delta == null) return FormulaClock.now().toLocal(); + final DateTime utc = FormulaClock.now().toUtc().add(delta); return DateTime( utc.year, utc.month, diff --git a/test/formula_clock_test.dart b/test/formula_clock_test.dart new file mode 100644 index 00000000..3e70f08a --- /dev/null +++ b/test/formula_clock_test.dart @@ -0,0 +1,25 @@ +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + tearDown(() => FormulaClock.pinned = null); + + String eval(String formula) => FormulaEvaluator.stringify( + FormulaEvaluator.evaluate(formula, (path) => null), + ); + + test('a pinned clock is what now() and today() read', () { + FormulaClock.pinned = DateTime.parse('2026-10-22T13:20:00-07:00'); + expect( + eval("formatDate(now('-07:00'), 'EEE, MMM d · h:mm a')"), + 'Thu, Oct 22 · 1:20 PM', + ); + expect(eval("formatDate(today('-07:00'), 'yyyy-MM-dd')"), '2026-10-22'); + expect(eval("formatDate(now('Z'), 'HH:mm')"), '20:20'); + }); + + test('without a pin, now() is the device clock', () { + final String year = '${DateTime.now().year}'; + expect(eval("formatDate(now(), 'yyyy')"), year); + }); +} From ae5df44818b2127c5f971f1d462bce3fb26f9601 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 03:50:03 +0000 Subject: [PATCH 17/27] Let a line chart's axis title and readout units be bound yAxisTitle and the cursor readout's scale, decimals and suffix read their bindings, so a chart can follow a units setting (feet or metres). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3B1XCZZ7QGvZLUGgSG5H4 --- .../passive_line_chart_transformer.dart | 10 +++++---- test/line_chart_test.dart | 21 +++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/lib/src/transformers/node_transformers/passive_line_chart_transformer.dart b/lib/src/transformers/node_transformers/passive_line_chart_transformer.dart index 8ddd356e..13ebd6f4 100644 --- a/lib/src/transformers/node_transformers/passive_line_chart_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_line_chart_transformer.dart @@ -260,7 +260,7 @@ class LineChartSpec { color: LineChartGeometry.parseColor(t['color']), ), ], - yAxisTitle: props.yAxisTitle, + yAxisTitle: text(bound('yAxisTitle')) ?? props.yAxisTitle, areaBands: [ for (final Map b in maps('areaBands')) if ((x(b['from']), x(b['to']), n(b['below']), c(b)) case ( @@ -341,9 +341,11 @@ class LineChartSpec { props.tooltipValueStyle, ), tooltipXFormat: props.tooltipXFormat, - tooltipYScale: props.tooltipYScale, - tooltipYDecimals: props.tooltipYDecimals, - tooltipYSuffix: props.tooltipYSuffix, + // The readout's units can follow a setting (feet or metres). + tooltipYScale: n(bound('tooltipYScale')) ?? props.tooltipYScale, + tooltipYDecimals: + n(bound('tooltipYDecimals'))?.round() ?? props.tooltipYDecimals, + tooltipYSuffix: text(bound('tooltipYSuffix')) ?? props.tooltipYSuffix, tooltipTrend: props.tooltipTrend, ); } diff --git a/test/line_chart_test.dart b/test/line_chart_test.dart index 1053419a..dbe6d744 100644 --- a/test/line_chart_test.dart +++ b/test/line_chart_test.dart @@ -373,4 +373,25 @@ void main() { expect(find.byType(PassiveLineChartWidget), findsOneWidget); }); }); + + testWidgets('the axis title and readout units can be bound', ( + tester, + ) async { + await pumpChart( + tester, + chart( + variables: { + 'yAxisTitle': r"${= 1 > 0 ? 'M' : 'FT'}", + 'tooltipYScale': r'${= 0.3048}', + 'tooltipYDecimals': r'${= 2}', + 'tooltipYSuffix': r"${= ' m'}", + }, + ), + ); + final spec = painterOf(tester).spec; + expect(spec.yAxisTitle, 'M'); + expect(spec.tooltipYScale, 0.3048); + expect(spec.tooltipYDecimals, 2); + expect(spec.tooltipYSuffix, ' m'); + }); } From 768fc2bcdeef9f31feee1717a75208870d89b795 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 16:54:50 +0000 Subject: [PATCH 18/27] Fold canvas URL paths into CodelesslyRoute Master added Codelessly.route and pendingRoute (links by screen id); the canvas URL paths added on this branch duplicated that with a controller API. The controller API is gone: a link's path is now matched against the screens' URL paths (guide/bat-star opens the screen at guide/:id with id=bat-star) before it's read as a screen id, and Codelessly.pathOf gives the address for a route, the screen's URL path when it has one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3B1XCZZ7QGvZLUGgSG5H4 --- lib/src/codelessly.dart | 36 ++++++++-- lib/src/functions/functions_repository.dart | 23 ------ lib/src/transformers/utils/scoped_values.dart | 4 +- lib/src/ui/canvas_location.dart | 48 ------------- lib/src/ui/codelessly_route.dart | 25 +++++-- lib/src/ui/codelessly_widget.dart | 25 +------ lib/src/ui/codelessly_widget_controller.dart | 20 ------ test/api_call_result_test.dart | 1 - test/canvas_location_test.dart | 71 +++++++------------ test/grid_cell_stretch_test.dart | 1 - 10 files changed, 76 insertions(+), 178 deletions(-) diff --git a/lib/src/codelessly.dart b/lib/src/codelessly.dart index 1416b91c..c88e014a 100644 --- a/lib/src/codelessly.dart +++ b/lib/src/codelessly.dart @@ -254,8 +254,7 @@ class Codelessly { final Map _navigationListeners = {}; /// The screen shown and its parameters, for the browser's address: a host - /// listens and writes [CodelesslyRoute.toPath] to the URL. Dialogs don't - /// change it. + /// listens and writes [pathOf] it to the URL. Dialogs don't change it. final ValueNotifier route = ValueNotifier(null); /// A screen to open once the app's first screen shows, e.g. the one a @@ -902,19 +901,44 @@ class Codelessly { } } + /// The link for [route]: the screen's URL path with its parameters filled + /// in when it has one (`guide/bat-star` for a screen at `guide/:id`; the + /// app's first screen without a path is the empty path), else + /// [CodelesslyRoute.toPath]. + String pathOf(CodelesslyRoute route) { + final SDKPublishModel? model = dataManager.publishModel; + return (model == null + ? null + : CanvasLocation.locationOf(model, route.canvasId, route.params)) ?? + route.toPath(); + } + /// Opens [pendingRoute] over the first screen shown, unless it is that - /// screen. + /// screen. A link's path is matched against the screens' URL paths first, + /// then read as a screen id. void _openPendingRoute(BuildContext context, String canvasId) { final CodelesslyRoute? pending = pendingRoute; if (pending == null) return; pendingRoute = null; - if (pending.canvasId == canvasId) return; + final SDKPublishModel? model = dataManager.publishModel; + final String? path = pending.path; + final CanvasLocationMatch? match = model == null || path == null + ? null + : CanvasLocation.match( + model, + Uri( + path: path, + queryParameters: pending.params.isEmpty ? null : pending.params, + ).toString(), + ); + final String destination = match?.canvasId ?? pending.canvasId; + if (destination == canvasId) return; unawaited( FunctionsRepository.navigate( context, NavigationAction( - destinationId: pending.canvasId, - params: pending.params, + destinationId: destination, + params: match?.params ?? pending.params, ), codelessly: this, ), diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index 97141206..635a0cc4 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -598,28 +598,6 @@ class FunctionsRepository { codelessly: effectiveCodelessly, ); - // Tell the host the location of the screen opened, and of this screen - // when it is gone back to. - final CodelesslyLocationCallback? onLocationChanged = - parentController.onLocationChanged; - final SDKPublishModel? model = - effectiveCodelessly.dataManager.publishModel; - String? locationOf(String? id, Map params) => - onLocationChanged == null || model == null || id == null - ? null - : CanvasLocation.locationOf(model, id, params); - final String? location = locationOf(action.destinationId, parsedParams); - final String? myLocation = locationOf( - myCanvasId ?? myLayoutId, - effectiveScopedValues.routeParams, - ); - if (location != null) { - onLocationChanged!( - location, - replace: action.navigationType == NavigationType.replace, - ); - } - if (action.navigationType == NavigationType.push) { // A chain triggered on the page can finish on this layout after // the page pops. @@ -645,7 +623,6 @@ class FunctionsRepository { layoutId: myLayoutId, canvasId: myCanvasId, ); - if (myLocation != null) onLocationChanged!(myLocation, replace: true); } } else if (action.navigationType == NavigationType.replace) { await Navigator.pushReplacement( diff --git a/lib/src/transformers/utils/scoped_values.dart b/lib/src/transformers/utils/scoped_values.dart index 2a46f48c..146ebbac 100644 --- a/lib/src/transformers/utils/scoped_values.dart +++ b/lib/src/transformers/utils/scoped_values.dart @@ -114,10 +114,8 @@ class ScopedValues { indexedItem = IndexedItemProvider.maybeOf(context); screenSize = MediaQuery.maybeSizeOf(context); nodeState = NodeStateProvider.maybeOf(context)?.state; - final ModalRoute? route = ModalRoute.of(context); routeParams = - CodelesslyRouteParams.maybeOf(context, route) ?? - route?.settings.arguments as Map? ?? + ModalRoute.of(context)?.settings.arguments as Map? ?? {}; final instance = context.read(); _appUser = null; diff --git a/lib/src/ui/canvas_location.dart b/lib/src/ui/canvas_location.dart index f6d207f9..dec387b8 100644 --- a/lib/src/ui/canvas_location.dart +++ b/lib/src/ui/canvas_location.dart @@ -1,21 +1,9 @@ import 'dart:convert'; import 'package:codelessly_api/codelessly_api.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; import '../model/sdk_publish_model.dart'; -/// Called when navigation in a Codelessly app moves to a screen with a URL -/// path. [location] is that screen's path with its params filled in, relative -/// to the app's root and without a leading slash, such as -/// `guide/bat-star?from=tides`. [replace] is true when the screen replaced the -/// previous one or was gone back to, and false when it was pushed on top. -typedef CodelesslyLocationCallback = void Function( - String location, { - required bool replace, -}); - /// The canvas a location opens and the route params it passes to it. class CanvasLocationMatch { /// The layout that holds [canvasId]. @@ -162,39 +150,3 @@ abstract final class CanvasLocation { _ => '$value', }; } - -/// Gives the screen below it the route params of the location it was opened -/// at, for a screen whose route was not pushed with them: the host's own -/// route of an app opened at a location. -class CodelesslyRouteParams extends InheritedWidget { - /// The route these params belong to. A route nested below, such as one of - /// a navigator inside the screen, keeps its own params. - final ModalRoute? route; - - /// The route params. - final Map params; - - /// Creates a [CodelesslyRouteParams]. - const CodelesslyRouteParams({ - super.key, - required this.route, - required this.params, - required super.child, - }); - - /// The params for [route] that a [CodelesslyRouteParams] above [context] - /// gives, if any. - static Map? maybeOf( - BuildContext context, - ModalRoute? route, - ) { - final CodelesslyRouteParams? scope = context - .dependOnInheritedWidgetOfExactType(); - if (scope == null || scope.route != route) return null; - return scope.params; - } - - @override - bool updateShouldNotify(CodelesslyRouteParams oldWidget) => - route != oldWidget.route || !mapEquals(params, oldWidget.params); -} diff --git a/lib/src/ui/codelessly_route.dart b/lib/src/ui/codelessly_route.dart index 28e2fc74..64e670b3 100644 --- a/lib/src/ui/codelessly_route.dart +++ b/lib/src/ui/codelessly_route.dart @@ -1,16 +1,23 @@ /// A screen of a Codelessly app and the parameters it was opened with, as a -/// link: `{canvasId}?key=value`. Hosts put it in the browser's address -/// (see [Codelessly.route]) and hand it back on a reload (see -/// [Codelessly.pendingRoute]), so a reload opens the same screen with the -/// same parameters. +/// link: `{canvasId}?key=value`, or the screen's own URL path when it has one +/// (`guide/bat-star` for a screen at `guide/:id`, see +/// [Codelessly.pathOf]). Hosts put it in the browser's address (see +/// [Codelessly.route]) and hand it back on a reload or a shared link (see +/// [Codelessly.pendingRoute]), so it opens the same screen with the same +/// parameters. class CodelesslyRoute { - /// The screen (canvas) id. + /// The screen (canvas) id. For a link read from a URL path, its last + /// segment, which [path] may resolve to another screen. final String canvasId; /// The parameters the screen was opened with (`${route.key}`). final Map params; - const CodelesslyRoute(this.canvasId, [this.params = const {}]); + /// The whole path of a link read by [tryParse], such as `guide/bat-star`, + /// matched against the screens' URL paths when it opens. + final String? path; + + const CodelesslyRoute(this.canvasId, [this.params = const {}, this.path]); /// The route of a screen opened with [arguments], the navigation action's /// parameters. Values that aren't text, numbers or yes/no are left out: @@ -33,7 +40,11 @@ class CodelesslyRoute { if (segment.isNotEmpty) segment, ]; if (segments.isEmpty) return null; - return CodelesslyRoute(segments.last, uri.queryParameters); + return CodelesslyRoute( + segments.last, + uri.queryParameters, + segments.join('/'), + ); } /// The link: `{canvasId}` and its parameters as a query. diff --git a/lib/src/ui/codelessly_widget.dart b/lib/src/ui/codelessly_widget.dart index 8b8df620..41c09985 100644 --- a/lib/src/ui/codelessly_widget.dart +++ b/lib/src/ui/codelessly_widget.dart @@ -296,10 +296,6 @@ class _CodelesslyWidgetState extends State { /// The layout being shown. String? effectiveLayoutID; - /// The canvas the controller's [CodelesslyWidgetController.initialLocation] - /// opened, if any. - CanvasLocationMatch? locationMatch; - /// Tracks whether this widget went through a full layout load and was made /// visible to the user successfully at least once. bool didView = false; @@ -512,15 +508,8 @@ class _CodelesslyWidgetState extends State { } final SDKPublishModel model = snapshot.data!; - final String? initialLocation = _effectiveController.initialLocation; - locationMatch = - _effectiveController.layoutID == null && initialLocation != null - ? CanvasLocation.match(model, initialLocation) - : null; effectiveLayoutID = - _effectiveController.layoutID ?? - locationMatch?.layoutId ?? - model.entryLayoutId; + _effectiveController.layoutID ?? model.entryLayoutId; if (effectiveLayoutID == null) { final errorLog = ErrorLog( @@ -550,16 +539,6 @@ class _CodelesslyWidgetState extends State { StatTracker.instance.track(StatType.view); } - // A screen opened at a location gets its params as route params, - // which its host's route was not pushed with. - Widget withRouteParams(Widget child) => locationMatch == null - ? child - : CodelesslyRouteParams( - route: ModalRoute.of(context), - params: locationMatch!.params, - child: child, - ); - Widget buildLayout(BuildContext context) => Material( clipBehavior: Clip.none, type: MaterialType.transparency, @@ -603,7 +582,7 @@ class _CodelesslyWidgetState extends State { key: ValueKey(effectiveLayoutID), layoutId: effectiveLayoutID, canvasId: canvasID, - builder: (context) => withRouteParams(buildLayout(context)), + builder: buildLayout, ), ), ); diff --git a/lib/src/ui/codelessly_widget_controller.dart b/lib/src/ui/codelessly_widget_controller.dart index 9179c8a1..72a8bb70 100644 --- a/lib/src/ui/codelessly_widget_controller.dart +++ b/lib/src/ui/codelessly_widget_controller.dart @@ -123,18 +123,6 @@ class CodelesslyWidgetController extends ChangeNotifier { /// node. final LayoutRetrieverBuilder? layoutRetrievalBuilder; - /// The location to open the app at when no [layoutID] is given, relative to - /// the app's root, such as `guide/bat-star?x=1`. The canvas whose - /// [CanvasProperties.path] matches it opens instead of the entry canvas, - /// with the path and query params as its route params. When no canvas - /// matches, the entry canvas opens. - final String? initialLocation; - - /// Called when a navigation action opens, or goes back to, a screen with a - /// URL path, so the host can show its location, e.g. in the browser's URL - /// bar. Screens without a path report nothing. - final CodelesslyLocationCallback? onLocationChanged; - /// Creates a [CodelesslyWidgetController]. /// /// If the [layoutID] is not provided, then it is assumed that the published @@ -158,10 +146,6 @@ class CodelesslyWidgetController extends ChangeNotifier { this.layoutBuilder = _defaultLayoutBuilder, this.layoutRetrievalBuilder, - // Locations. - this.initialLocation, - this.onLocationChanged, - // Optional managers. These are only used when using the global codelessly // instance. this.authManager, @@ -211,8 +195,6 @@ class CodelesslyWidgetController extends ChangeNotifier { CodelesslyWidgetLayoutBuilder? layoutBuilder, LayoutRetrieverBuilder? layoutRetrievalBuilder, FirebaseApp? firebaseApp, - String? initialLocation, - CodelesslyLocationCallback? onLocationChanged, }) { return CodelesslyWidgetController( layoutID: layoutID ?? this.layoutID, @@ -229,8 +211,6 @@ class CodelesslyWidgetController extends ChangeNotifier { layoutRetrievalBuilder ?? this.layoutRetrievalBuilder, loadingBuilder: loadingBuilder ?? this.loadingBuilder, errorBuilder: errorBuilder ?? this.errorBuilder, - initialLocation: initialLocation ?? this.initialLocation, - onLocationChanged: onLocationChanged ?? this.onLocationChanged, ); } diff --git a/test/api_call_result_test.dart b/test/api_call_result_test.dart index 8e3419ae..7da49344 100644 --- a/test/api_call_result_test.dart +++ b/test/api_call_result_test.dart @@ -1,4 +1,3 @@ -import 'package:codelessly_api/codelessly_api.dart'; import 'package:codelessly_sdk/codelessly_sdk.dart'; import 'package:codelessly_sdk/src/functions/action_chain.dart'; import 'package:flutter/material.dart'; diff --git a/test/canvas_location_test.dart b/test/canvas_location_test.dart index 21d1e656..d20b33b6 100644 --- a/test/canvas_location_test.dart +++ b/test/canvas_location_test.dart @@ -155,11 +155,11 @@ SDKPublishModel _model({ActionModel? tidesAction}) { return model; } -Future _pumpApp( +/// Opens [model]'s app with [pendingRoute], as a site does for a link. +Future _pumpApp( WidgetTester tester, SDKPublishModel model, { - String? initialLocation, - CodelesslyLocationCallback? onLocationChanged, + CodelesslyRoute? pendingRoute, }) async { tester.view.devicePixelRatio = 1; tester.view.physicalSize = const Size(390, 844); @@ -169,18 +169,16 @@ Future _pumpApp( config: CodelesslyConfig(authToken: 'token'), publishDataManager: _TestDataManager(model)..status = CStatus.loaded(), )..status = CStatus.loaded(); + codelessly.pendingRoute = pendingRoute; await tester.pumpWidget( MaterialApp( home: CodelesslyWidget( - controller: CodelesslyWidgetController( - codelessly: codelessly, - initialLocation: initialLocation, - onLocationChanged: onLocationChanged, - ), + controller: CodelesslyWidgetController(codelessly: codelessly), ), ), ); - await tester.pump(); + await tester.pumpAndSettle(); + return codelessly; } void main() { @@ -228,30 +226,30 @@ void main() { }); }); - group('initialLocation', () { - testWidgets('opens the canvas at that path with its params', ( + group('links', () { + testWidgets('a link to a URL path opens that screen with its params', ( tester, ) async { await _pumpApp( tester, _model(), - initialLocation: 'guide/bat-star?from=link', + pendingRoute: CodelesslyRoute.tryParse('guide/bat-star?from=link'), ); expect(find.text('id=bat-star'), findsOneWidget); expect(find.text('from=link'), findsOneWidget); - expect(find.text('Tides'), findsNothing); }); - testWidgets('opens the entry canvas at an unknown path', (tester) async { - await _pumpApp(tester, _model(), initialLocation: 'nowhere'); - expect(find.text('Tides'), findsOneWidget); + testWidgets('a link to a screen id still opens it', (tester) async { + await _pumpApp( + tester, + _model(), + pendingRoute: CodelesslyRoute.tryParse('creature?id=kraken'), + ); + expect(find.text('id=kraken'), findsOneWidget); }); - }); - group('onLocationChanged', () { - testWidgets('reports a push and going back', (tester) async { - final List<(String, bool)> locations = []; - await _pumpApp( + testWidgets("the address shows the screen's URL path", (tester) async { + final Codelessly codelessly = await _pumpApp( tester, _model( tidesAction: NavigationAction( @@ -260,36 +258,17 @@ void main() { params: {'id': 'kraken', 'from': 'tides'}, ), ), - onLocationChanged: (location, {required replace}) => - locations.add((location, replace)), ); + expect(codelessly.pathOf(codelessly.route.value!), ''); await tester.tap(find.text('Go')); await tester.pumpAndSettle(); expect(find.text('id=kraken'), findsOneWidget); - expect(locations, [('guide/kraken?from=tides', false)]); - - tester.state(find.byType(Navigator)).pop(); - await tester.pumpAndSettle(); - expect(find.text('Tides'), findsOneWidget); - expect(locations.last, ('', true)); - }); - - testWidgets('reports nothing for a screen without a path', (tester) async { - final List locations = []; - await _pumpApp( - tester, - _model( - tidesAction: NavigationAction( - navigationType: NavigationType.replace, - destinationId: 'settings', - ), - ), - onLocationChanged: (location, {required replace}) => - locations.add(location), + expect( + codelessly.pathOf(codelessly.route.value!), + 'guide/kraken?from=tides', ); - await tester.tap(find.text('Go')); - await tester.pumpAndSettle(); - expect(locations, isEmpty); + // A screen without a URL path falls back to its id. + expect(codelessly.pathOf(const CodelesslyRoute('settings')), 'settings'); }); }); } diff --git a/test/grid_cell_stretch_test.dart b/test/grid_cell_stretch_test.dart index b2048886..9b5766e4 100644 --- a/test/grid_cell_stretch_test.dart +++ b/test/grid_cell_stretch_test.dart @@ -2,7 +2,6 @@ import 'dart:convert'; import 'dart:io'; import 'package:codelessly_api/codelessly_api.dart'; -import 'package:codelessly_sdk/codelessly_sdk.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; From d8740a4c9d2ae11afc6c16884a11ff6c8cb014ee Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 17:18:24 +0000 Subject: [PATCH 19/27] A labelled button reads only its label and keeps its tap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A semanticLabel on a tappable node was merged with the children's text ("Settings ft · ≤1.0"), so the button couldn't be named as designed. The label now replaces the children, and the node's semantics carry its tap and long press (the gesture handler below is excluded with the children). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3B1XCZZ7QGvZLUGgSG5H4 --- .../widget_node_transformer_manager.dart | 66 +++++++++++++------ test/semantics_test.dart | 65 +++++++++++++++++- 2 files changed, 109 insertions(+), 22 deletions(-) diff --git a/lib/src/transformers/widget_node_transformer_manager.dart b/lib/src/transformers/widget_node_transformer_manager.dart index 88e943e8..b15102ae 100644 --- a/lib/src/transformers/widget_node_transformer_manager.dart +++ b/lib/src/transformers/widget_node_transformer_manager.dart @@ -214,26 +214,28 @@ abstract class WidgetNodeTransformerManager } /// Semantics a node's `semanticLabel` variable gives it (a text with paths - /// or formulas): one node with that label. It replaces what the children - /// read, or, on a button (a node that reacts to taps), comes first. A `semanticSelected` - /// formula marks it selected, e.g. the chosen tab or chip. + /// or formulas): one node with that label, replacing what the children + /// read. A button (a node that reacts to taps) keeps its tap and long press + /// on that node; a widget (a field, a dropdown) keeps its own semantics, + /// read after the label. A `semanticSelected` formula marks it selected, + /// e.g. the chosen tab or chip. Widget _wrapWithCustomSemantics( BaseNode node, Widget widget, { String? label, String? selected, }) { - final bool tappable = - node is ReactionMixin && - (node as ReactionMixin).reactions.any( - (reaction) => - reaction.trigger.type == TriggerType.click && - reaction.action.enabled, - ); - final bool interactive = - tappable || - (node is CustomPropertiesMixin && - node.handlesDefaultReactionsInternally); + List reactionsOf(TriggerType type) => [ + if (node is ReactionMixin) + for (final Reaction reaction in (node as ReactionMixin).reactions) + if (reaction.trigger.type == type && reaction.action.enabled) + reaction, + ]; + final List taps = reactionsOf(TriggerType.click); + final List longPresses = reactionsOf(TriggerType.longPress); + final bool tappable = taps.isNotEmpty; + final bool widgetHandlesInput = + node is CustomPropertiesMixin && node.handlesDefaultReactionsInternally; return Builder( builder: (context) { final ScopedValues scopedValues = ScopedValues.of(context); @@ -250,18 +252,42 @@ abstract class WidgetNodeTransformerManager selected, scopedValues: scopedValues, ); - final Widget child = Semantics( + if (widgetHandlesInput) { + // A field or dropdown's state and actions live on its own + // semantics, which merge in after the label. + return MergeSemantics( + child: Semantics( + container: true, + selected: isSelected, + label: text, + child: widget, + ), + ); + } + return Semantics( container: true, button: tappable ? true : null, selected: isSelected, label: text, - // A label replaces the children's text, except on a tappable node - // or a widget (a field, a dropdown): its actions and state live on - // a child, so the children merge in after the label. - excludeSemantics: text != null && !interactive, + // The children's text and the tap handler below are left out, so + // the node's own tap and long press are given here. + excludeSemantics: text != null, + onTap: text != null && tappable + ? () => FunctionsRepository.triggerAction( + context, + TriggerType.click, + reactions: taps, + ) + : null, + onLongPress: text != null && longPresses.isNotEmpty + ? () => FunctionsRepository.triggerAction( + context, + TriggerType.longPress, + reactions: longPresses, + ) + : null, child: widget, ); - return interactive ? MergeSemantics(child: child) : child; }, ); } diff --git a/test/semantics_test.dart b/test/semantics_test.dart index ffe9b8e6..99c2e9f3 100644 --- a/test/semantics_test.dart +++ b/test/semantics_test.dart @@ -1,6 +1,7 @@ import 'package:codelessly_api/codelessly_api.dart'; import 'package:codelessly_sdk/codelessly_sdk.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; @@ -63,7 +64,8 @@ void main() { isSelected: true, ), ); - // On a button with text, the label comes first, then the text. + // On a button with text, the label replaces the text (as the reference + // app's buttons read) and the button keeps its tap. final withText = TextNode.fromJson({ 'id': 'label', 'name': 'Label', @@ -113,7 +115,11 @@ void main() { ), ), ); - expect(find.bySemanticsLabel(RegExp(r'^Saturday\n24$')), findsOneWidget); + expect( + tester.getSemantics(find.bySemanticsLabel('Saturday')), + matchesSemantics(label: 'Saturday', isButton: true, hasTapAction: true), + ); + expect(find.bySemanticsLabel(RegExp('24')), findsNothing); handle.dispose(); }); @@ -185,4 +191,59 @@ void main() { expect(data.flagsCollection.isTextField, isTrue); handle.dispose(); }); + + testWidgets('a labelled button runs its reactions from its semantics tap', ( + tester, + ) async { + final SemanticsHandle handle = tester.ensureSemantics(); + final List calls = []; + final node = + RectangleNode( + id: 'settings', + name: 'Settings', + basicBoxLocal: NodeBox(0, 0, 40, 40), + reactions: [ + Reaction( + action: CallFunctionAction(name: 'record'), + trigger: TriggerModel(type: TriggerType.click), + ), + ], + ) + ..parentID = kRootNode + ..variables['semanticLabel'] = 'Settings'; + final registry = NodeRegistry()..setNodes({node.id: node}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty() + ..functions['record'] = (context, reference, params) => + calls.add('tap'), + child: MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + node, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ); + final SemanticsNode semantics = tester.getSemantics( + find.bySemanticsLabel('Settings'), + ); + tester.binding.pipelineOwner.semanticsOwner!.performAction( + semantics.id, + SemanticsAction.tap, + ); + await tester.pump(); + expect(calls, ['tap']); + handle.dispose(); + }); } From 819278d3ae61daf35699ae1c7d717e51e0a1ee12 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 17:18:51 +0000 Subject: [PATCH 20/27] Use the semantics controller to tap in the semantics test Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3B1XCZZ7QGvZLUGgSG5H4 --- test/semantics_test.dart | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/test/semantics_test.dart b/test/semantics_test.dart index 99c2e9f3..74ed5c7c 100644 --- a/test/semantics_test.dart +++ b/test/semantics_test.dart @@ -1,7 +1,6 @@ import 'package:codelessly_api/codelessly_api.dart'; import 'package:codelessly_sdk/codelessly_sdk.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; @@ -235,13 +234,7 @@ void main() { ), ), ); - final SemanticsNode semantics = tester.getSemantics( - find.bySemanticsLabel('Settings'), - ); - tester.binding.pipelineOwner.semanticsOwner!.performAction( - semantics.id, - SemanticsAction.tap, - ); + tester.semantics.tap(find.semantics.byLabel('Settings')); await tester.pump(); expect(calls, ['tap']); handle.dispose(); From 051968847c2df16a8532d0f715e1ca399782610e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 17:31:14 +0000 Subject: [PATCH 21/27] Don't cap an expanded dropdown's value at its design width The selected value was capped at the node's stored design width minus the icon, so a dropdown laid out wider than its design box (a station picker in a wider header) cut its value short. An expanded dropdown already gives the value the field's width. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3B1XCZZ7QGvZLUGgSG5H4 --- .../passive_dropdown_transformer.dart | 11 ++-- test/dropdown_value_width_test.dart | 52 +++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 test/dropdown_value_width_test.dart diff --git a/lib/src/transformers/node_transformers/passive_dropdown_transformer.dart b/lib/src/transformers/node_transformers/passive_dropdown_transformer.dart index 120be106..45f55b0a 100644 --- a/lib/src/transformers/node_transformers/passive_dropdown_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_dropdown_transformer.dart @@ -199,9 +199,14 @@ class PassiveDropdownWidget extends StatelessWidget { item: IndexedItem(index, value), child: Builder( builder: (context) => Container( - constraints: BoxConstraints( - maxWidth: node.basicBoxLocal.width - node.properties.iconSize, - ), + // An expanded dropdown gives the value the field's width; the + // design width only caps one that sizes to its value. + constraints: node.properties.expanded + ? null + : BoxConstraints( + maxWidth: + node.basicBoxLocal.width - node.properties.iconSize, + ), child: itemContent( context, value, diff --git a/test/dropdown_value_width_test.dart b/test/dropdown_value_width_test.dart new file mode 100644 index 00000000..f9e87868 --- /dev/null +++ b/test/dropdown_value_width_test.dart @@ -0,0 +1,52 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +void main() { + testWidgets('an expanded dropdown shows its value across the field, ' + 'whatever its design width', (tester) async { + const String spot = 'Fitzgerald'; + // Laid out at 300 wide, stored with a narrower design box. + final node = DropdownNode( + id: 'spot', + name: 'Spot', + basicBoxLocal: NodeBox(0, 0, 90, 44), + value: spot, + properties: DropdownProperties( + items: const [spot, 'Point Pinos'], + expanded: true, + ), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 300, + height: 44, + child: PassiveDropdownWidget( + node: node, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ), + ); + final Size text = tester.getSize(find.text(spot).first); + final TextPainter full = TextPainter( + text: TextSpan( + text: spot, + style: tester.widget(find.text(spot).first).style, + ), + textDirection: TextDirection.ltr, + )..layout(); + expect(text.width, greaterThanOrEqualTo(full.width - 0.5)); + }); +} From af07334af4161da6510abfd241230e64a9439ecb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 17:59:01 +0000 Subject: [PATCH 22/27] Keep a toast's semantics stable so its button can be tapped on the web The toast left the accessibility tree while transparent and ignored taps until it had faded in, so its semantics node changed after it was added. The web engine stacks a node only when its siblings change, so the changed node lost its stacking and sat under the screen: a click on Undo landed on the list behind it. The toast now keeps its semantics and takes taps from its first frame, and ignores them only while it fades out. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3B1XCZZ7QGvZLUGgSG5H4 --- lib/src/functions/app_toast.dart | 13 ++++++-- test/toast_test.dart | 51 ++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/lib/src/functions/app_toast.dart b/lib/src/functions/app_toast.dart index 58b04cfd..22fa0b40 100644 --- a/lib/src/functions/app_toast.dart +++ b/lib/src/functions/app_toast.dart @@ -128,6 +128,11 @@ class _ToastViewState extends State<_ToastView> { static const Duration _fade = Duration(milliseconds: 220); bool _visible = false; + + /// Fading out: taps pass through. Fading in, the toast already takes them: + /// its semantics stay as they are from the first frame, since the web + /// engine keeps a node's stacking only while its siblings don't change. + bool _closing = false; Timer? _timer; @override @@ -142,7 +147,10 @@ class _ToastViewState extends State<_ToastView> { void _close() { if (!mounted) return; _timer?.cancel(); - setState(() => _visible = false); + setState(() { + _visible = false; + _closing = true; + }); _timer = Timer(_fade, widget.onDone); } @@ -291,6 +299,7 @@ class _ToastViewState extends State<_ToastView> { label: widget.message, child: AnimatedOpacity( opacity: _visible ? 1 : 0, + alwaysIncludeSemantics: true, duration: _fade, child: AnimatedSlide( offset: _visible ? Offset.zero : Offset(0, top ? -0.25 : 0.25), @@ -310,7 +319,7 @@ class _ToastViewState extends State<_ToastView> { bottom: top ? null : action.bottomOffsetFor(media.size.width), // Only a toast with a button takes taps; the rest passes them through. child: IgnorePointer( - ignoring: !(hasButton || hasClose) || !_visible, + ignoring: !(hasButton || hasClose) || _closing, child: toast, ), ); diff --git a/test/toast_test.dart b/test/toast_test.dart index 005c1447..0cbba611 100644 --- a/test/toast_test.dart +++ b/test/toast_test.dart @@ -224,4 +224,55 @@ void main() { await tester.pumpAndSettle(); }); }); + + testWidgets('a toast button can be tapped from the first frame', ( + tester, + ) async { + final SemanticsHandle handle = tester.ensureSemantics(); + late BuildContext context; + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Builder( + builder: (c) { + context = c; + return const SizedBox(); + }, + ), + ), + ), + ); + FunctionsRepository.triggerAction( + context, + TriggerType.click, + reactions: [ + Reaction( + action: ShowToastAction( + message: 'Sighting deleted', + actionLabel: 'Undo', + actions: [ShowToastAction(message: 'Restored')], + ), + trigger: TriggerModel(type: TriggerType.click), + ), + ], + ); + // Its semantics don't change once it shows (the web engine keeps a new + // node's stacking only while its siblings stay the same). + await tester.pump(); + expect( + tester.getSemantics(find.bySemanticsLabel('Undo')), + matchesSemantics( + label: 'Undo', + isButton: true, + hasTapAction: true, + hasEnabledState: true, + isEnabled: true, + isFocusable: true, + hasFocusAction: true, + ), + ); + await tester.pumpAndSettle(const Duration(seconds: 6)); + handle.dispose(); + }); } From 7bb277a39881c94badc2600ab903fa8d8e7dd384 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 18:12:52 +0000 Subject: [PATCH 23/27] Finish an API call's handlers after the widget that made it unmounts callApi returned without running onSuccess/onError when the widget that started the call had unmounted meanwhile, e.g. a settings dialog closed while a reload it started was loading: the rest of the reload was lost and the screen stayed loading. The handlers now run on a mounted context of the chain (its opener), as the chain's other actions do. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3B1XCZZ7QGvZLUGgSG5H4 --- lib/src/functions/functions_repository.dart | 9 +- test/api_call_after_unmount_test.dart | 146 ++++++++++++++++++++ 2 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 test/api_call_after_unmount_test.dart diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index 635a0cc4..7ced9460 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -327,9 +327,14 @@ class FunctionsRepository { failure = error; failureStack = stackTrace; } - if (!context.mounted) return response; + // The widget that made the call can be gone by now (a dialog closed + // while it loaded); its handlers then run on the chain's opener. + // ignore: use_build_context_synchronously + final BuildContext? outcomeContext = ActionChain.contextFor(context); + if (outcomeContext == null) return response; await handleApiOutcome( - context, + // ignore: use_build_context_synchronously + outcomeContext, action, response: response, failure: failure, diff --git a/test/api_call_after_unmount_test.dart b/test/api_call_after_unmount_test.dart new file mode 100644 index 00000000..cc16c2df --- /dev/null +++ b/test/api_call_after_unmount_test.dart @@ -0,0 +1,146 @@ +import 'dart:async'; + +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:codelessly_sdk/src/functions/action_chain.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:provider/provider.dart'; + +class _FakeCache extends Fake implements CacheManager {} + +class _FakeAuthManager extends Fake implements AuthManager {} + +class _FakeLocalRepository extends Fake implements LocalDataRepository {} + +class _FakeNetworkRepository extends Fake implements NetworkDataRepository {} + +class _FakeFirestore extends Fake implements FirebaseFirestore {} + +class _TestDataManager extends DataManager { + _TestDataManager(SDKPublishModel model, CodelesslyConfig config) + : super( + 'test', + config: config, + cacheManager: _FakeCache(), + authManager: _FakeAuthManager(), + networkDataRepository: _FakeNetworkRepository(), + localDataRepository: _FakeLocalRepository(), + firebaseFirestore: _FakeFirestore(), + errorLogger: ErrorLogger.instance, + publishModel: model, + ); +} + +void main() { + testWidgets('an API call finishes its chain after the dialog that started ' + 'it closes', (tester) async { + final Completer answer = Completer(); + final List calls = []; + final model = SDKPublishModel( + projectId: 'project', + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, + ); + model.apis['sun'] = HttpApiData( + id: 'sun', + name: 'Sun times', + directory: '', + url: 'https://example.com/sun', + owner: 'owner', + teams: const {}, + users: const {}, + roles: const {}, + public: false, + ); + final config = CodelesslyConfig(authToken: 'token'); + final codelessly = Codelessly( + config: config, + client: MockClient((request) async { + await answer.future; + return http.Response('{"sunrise": "7:25"}', 200, request: request); + }), + publishDataManager: _TestDataManager(model, config), + ); + final codelesslyContext = CodelesslyContext.empty() + ..functions = { + 'close': (context, reference, params) => Navigator.of(context).pop(), + 'record': (context, reference, params) => + calls.add('${params['what']}'), + }; + late BuildContext pageContext; + await tester.pumpWidget( + Provider.value( + value: codelessly, + child: ChangeNotifierProvider.value( + value: codelesslyContext, + child: MaterialApp( + home: ActionChainHost( + child: Builder( + builder: (context) { + pageContext = context; + return const Scaffold(); + }, + ), + ), + ), + ), + ), + ); + // A settings dialog: picking a setting reloads data, and the dialog is + // closed before the data arrives. + final openerHosts = ActionChainOpener.hostsFor(pageContext); + unawaited( + showDialog( + context: pageContext, + builder: (context) => ActionChainOpener( + openerHosts: openerHosts, + child: ActionChainHost( + child: Builder( + builder: (context) => TextButton( + onPressed: () => FunctionsRepository.triggerAction( + context, + TriggerType.click, + reactions: [ + Reaction( + action: ApiCallAction( + apiId: 'sun', + onSuccess: [ + CallFunctionAction( + name: 'record', + params: {'what': 'loaded'}, + ), + ], + ), + trigger: TriggerModel(type: TriggerType.click), + ), + ], + ), + child: const Text('Reload'), + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Reload')); + await tester.pump(); + Navigator.of(pageContext).pop(); + await tester.pumpAndSettle(); + expect(find.text('Reload'), findsNothing); + + await tester.runAsync(() async { + answer.complete(); + await Future.delayed(const Duration(milliseconds: 50)); + }); + await tester.pump(); + expect(calls, ['loaded']); + }); +} From 2b1317f25661321c6c97af2d0521547ea7f4170f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 23:28:31 +0000 Subject: [PATCH 24/27] Changelog for the branch's SDK changes; format Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3B1XCZZ7QGvZLUGgSG5H4 --- CHANGELOG.md | 25 +++++++++++++++++++++++++ lib/src/ui/layout_builder.dart | 1 - test/line_chart_test.dart | 9 ++------- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a439eda..93ede8ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ ## Unreleased +- A text's color can be bound with a `textColor` variable or formula. +- Formulas: `range`, `interpolate`, `runs`, `minBy`, `maxBy`, `obj`, + `slice`, `substring`, `replace`, `split`, `wallTime`, and `now(offset)` / + `today(offset)` at a UTC offset. `FormulaClock.pinned` lets a host pin the + clock they read (previews, reproducible screenshots). +- API calls: the response (or the error) is the chain's `${result}`; query + parameters are sent; `onSuccess`/`onError` actions run, and with + `onError` a failure no longer stops the chain; the handlers still run when + the widget that made the call has unmounted (a dialog closed mid-load). + `Codelessly(client:)` takes an HTTP client. +- Load-time API calls run in order with the canvas's other load actions. +- A row aligned to text baselines gets a text baseline (it asserted). +- Any node can bind `semanticLabel` and `semanticSelected` for screen + readers. A labelled button reads only its label and keeps its tap; a + labelled field or dropdown keeps its own semantics. +- A pop with a destination goes there when there is nothing to pop. +- Image fills honour `PaintModel.saturation` (greyscale at 0). +- Links: a `CodelesslyRoute` path is matched against the screens' URL paths + (`CanvasProperties.path`, e.g. `guide/:id`) before it is read as a screen + id, and `Codelessly.pathOf` gives the address for a route + (`CanvasLocation` does the matching). +- A line chart's axis title and readout units can be bound. +- An expanded dropdown no longer cuts its value at its design width. +- A toast's semantics are stable from its first frame, so its button can be + tapped on the web. - Toasts, the built-in confirm dialog and dropdown menus take the new `ShowToastAction`, `ConfirmAction` and `DropdownProperties.menuStyle` styling: toast action color, close button, radius, border, hard shadows, diff --git a/lib/src/ui/layout_builder.dart b/lib/src/ui/layout_builder.dart index 8eb60daf..a5f836e9 100644 --- a/lib/src/ui/layout_builder.dart +++ b/lib/src/ui/layout_builder.dart @@ -1,4 +1,3 @@ - import 'package:codelessly_api/codelessly_api.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; diff --git a/test/line_chart_test.dart b/test/line_chart_test.dart index dbe6d744..ba923f87 100644 --- a/test/line_chart_test.dart +++ b/test/line_chart_test.dart @@ -325,10 +325,7 @@ void main() { expect(painterOf(tester).cursorX, ms('2026-10-22 12:00')); expect( tester.getSemantics(find.bySemanticsLabel('Tide chart')), - isSemantics( - label: 'Tide chart', - value: 'Cursor 12:00 PM, 6.0 ft', - ), + isSemantics(label: 'Tide chart', value: 'Cursor 12:00 PM, 6.0 ft'), ); // Tap to focus, then step 30 minutes with the arrow keys. @@ -374,9 +371,7 @@ void main() { }); }); - testWidgets('the axis title and readout units can be bound', ( - tester, - ) async { + testWidgets('the axis title and readout units can be bound', (tester) async { await pumpChart( tester, chart( From 9b7700ccc633a53a7c2c2c06af0ffe83bfb37eed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 23:30:00 +0000 Subject: [PATCH 25/27] codelessly_sdk 8.3.0 Depends on codelessly_api 6.2.0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3B1XCZZ7QGvZLUGgSG5H4 --- CHANGELOG.md | 2 +- pubspec.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93ede8ba..37176d58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## Unreleased +## 8.3.0 - A text's color can be bound with a `textColor` variable or formula. - Formulas: `range`, `interpolate`, `runs`, `minBy`, `maxBy`, `obj`, diff --git a/pubspec.yaml b/pubspec.yaml index ea7839c4..fb46ccb2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: codelessly_sdk description: Codelessly CloudUI™ - Server-driven UI for Flutter. Supercharge your Flutter apps with dynamic UI and real-time updates. Build and publish UI without code! -version: 8.2.0 +version: 8.3.0 homepage: https://codelessly.com/ repository: https://github.com/Codelessly/CodelesslySDK issue_tracker: https://github.com/Codelessly/CodelesslySDK/issues @@ -28,7 +28,7 @@ dependencies: flutter: sdk: flutter # Codelessly - codelessly_api: ^6.1.0 + codelessly_api: ^6.2.0 json_annotation: ^4.12.0 # Firebase From 68f723ba3163ea0b8311565e6f25e6315925ef22 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 23:59:20 +0000 Subject: [PATCH 26/27] Lay out a wrapping Row/Column as a Wrap A RowColumnNode with wrap builds a Wrap in its direction, aligned by its main and cross axis alignments and runAlignment, runSpacing apart. Items can't share free space in a run: an expanded child takes its design size on the main axis and a flex spacer keeps its design size. Margins still apply. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3B1XCZZ7QGvZLUGgSG5H4 --- CHANGELOG.md | 6 + .../passive_row_column_transformer.dart | 70 ++++++-- .../passive_spacer_transformer.dart | 3 +- lib/src/utils/extensions.dart | 18 +++ test/flex_layout_test.dart | 151 ++++++++++++++++++ 5 files changed, 231 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37176d58..1030a91b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,12 @@ - A `squeezable` node that wraps its content on a Row/Column's main axis is laid out as `Flexible(fit: FlexFit.loose)`: it hugs its content and shrinks to the room left when the Row/Column runs out of space. +- A Row/Column with `wrap` is laid out as a `Wrap`: its children flow onto + a new run when they don't fit, `runSpacing` apart, aligned by its + `mainAxisAlignment`, `crossAxisAlignment` (stretch and baseline align to + the start) and `runAlignment`. A child that is expanded on the main axis + takes its design size there, a flexible one at most that, and a flex + spacer keeps its design size; margins still space the items. ## 8.2.0 diff --git a/lib/src/transformers/node_transformers/passive_row_column_transformer.dart b/lib/src/transformers/node_transformers/passive_row_column_transformer.dart index 49d7208d..2da0da9b 100644 --- a/lib/src/transformers/node_transformers/passive_row_column_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_row_column_transformer.dart @@ -19,6 +19,7 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { final bool isRow = (rowColumnNode as RowColumnMixin).rowColumnType == RowColumnType.row; + final bool wraps = isWrap(rowColumnNode); // If the node is a canvas node's body, it is a placeholder that expands to // fill the canvas, while the canvas is already meant to do that as a @@ -77,7 +78,7 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { rowColumnNode.crossAxisAlignment == CrossAxisAlignmentC.stretch; bool measureCross = false; bool stretchFallsBack = false; - if (crossMin == null && crossWraps) { + if (!wraps && crossMin == null && crossWraps) { final bool anyChildNeedsAlignment = childrenNodes.any( (child) => child.alignment != AlignmentModel.none, ); @@ -93,20 +94,34 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { final CrossAxisAlignment crossAxisAlignment = stretchFallsBack ? CrossAxisAlignment.start : rowColumnNode.crossAxisAlignment.flutterAxis; - Widget res = CodelesslyFlex( - direction: isRow ? Axis.horizontal : Axis.vertical, - mainAxisAlignment: rowColumnNode.mainAxisAlignment.flutterAxis, - crossAxisAlignment: crossAxisAlignment, - // Flex asserts a baseline to align to; texts align on the alphabetic one. - textBaseline: crossAxisAlignment == CrossAxisAlignment.baseline - ? TextBaseline.alphabetic - : null, - mainAxisSize: mainFit == SizeFit.shrinkWrap - ? MainAxisSize.min - : MainAxisSize.max, - key: ValueKey(rowColumnNode.id), - children: childrenWidgets, - ); + Widget res = wraps + ? Wrap( + direction: isRow ? Axis.horizontal : Axis.vertical, + alignment: rowColumnNode.mainAxisAlignment.flutterWrapAlignment, + crossAxisAlignment: + rowColumnNode.crossAxisAlignment.flutterWrapAlignment, + runAlignment: (rowColumnNode as RowColumnNode) + .runAlignment + .flutterWrapAlignment, + runSpacing: rowColumnNode.runSpacing, + key: ValueKey(rowColumnNode.id), + children: childrenWidgets, + ) + : CodelesslyFlex( + direction: isRow ? Axis.horizontal : Axis.vertical, + mainAxisAlignment: rowColumnNode.mainAxisAlignment.flutterAxis, + crossAxisAlignment: crossAxisAlignment, + // Flex asserts a baseline to align to; texts align on the + // alphabetic one. + textBaseline: crossAxisAlignment == CrossAxisAlignment.baseline + ? TextBaseline.alphabetic + : null, + mainAxisSize: mainFit == SizeFit.shrinkWrap + ? MainAxisSize.min + : MainAxisSize.max, + key: ValueKey(rowColumnNode.id), + children: childrenWidgets, + ); if (measureCross) { res = isRow ? IntrinsicHeight(child: res) : IntrinsicWidth(child: res); @@ -120,7 +135,8 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { node: (rowColumnNode as ScrollableMixin), fillViewport: rowColumnNode.mainAxisAlignment != MainAxisAlignmentC.start, - flexContent: hasMainAxisFlexChild(rowColumnNode, childrenNodes), + flexContent: + !wraps && hasMainAxisFlexChild(rowColumnNode, childrenNodes), child: res, ); } @@ -174,6 +190,10 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { static bool _fillsAxis(SizeFit fit, {required double? max}) => fit == SizeFit.expanded && max == null; + /// Whether [node] is a Row/Column that wraps its children onto new runs + /// ([RowColumnNode.wrap]) instead of laying them out in a [Flex]. + static bool isWrap(BaseNode node) => node is RowColumnNode && node.wrap; + /// Whether any of [children] flexes along [rowColumnNode]'s main axis /// (expanded, flexible, or a flex spacer). static bool hasMainAxisFlexChild( @@ -201,6 +221,24 @@ class PassiveRowColumnTransformer extends NodeWidgetTransformer { return childWidget; } + // A run has no free space to share: a child that flexes on the main axis + // takes its design size there (at most the run's length), and every + // child hugs or keeps its size across. Children align in their run with + // the Wrap's cross axis alignment rather than their own. + if (isWrap(parentRowColumn)) { + final bool isRow = + (parentRowColumn as RowColumnMixin).rowColumnType == + RowColumnType.row; + if ((isRow ? node.horizontalFit : node.verticalFit) == SizeFit.expanded) { + childWidget = SizedBox( + width: isRow ? node.outerBoxLocal.width : null, + height: isRow ? null : node.outerBoxLocal.height, + child: childWidget, + ); + } + return flexibleSpaceBackground?.call(childWidget) ?? childWidget; + } + final AxisC mainAxis = ((parentRowColumn as RowColumnMixin).rowColumnType == RowColumnType.row ? AxisC.horizontal diff --git a/lib/src/transformers/node_transformers/passive_spacer_transformer.dart b/lib/src/transformers/node_transformers/passive_spacer_transformer.dart index 2aa3138d..68b2885f 100644 --- a/lib/src/transformers/node_transformers/passive_spacer_transformer.dart +++ b/lib/src/transformers/node_transformers/passive_spacer_transformer.dart @@ -21,7 +21,8 @@ class PassiveSpacerTransformer extends NodeWidgetTransformer { : verticalFit); final Widget spacerWidget; - if (mainFit.isFlex) { + // A wrap has no free space to fill: a flex spacer keeps its design size. + if (mainFit.isFlex && !PassiveRowColumnTransformer.isWrap(parentNode)) { // Fills its share of the free space. Where the main axis is unbounded // there is none, and it keeps its design size instead (see // CodelesslyFlex and FitBox). diff --git a/lib/src/utils/extensions.dart b/lib/src/utils/extensions.dart index 8cc1c817..aa1b1012 100644 --- a/lib/src/utils/extensions.dart +++ b/lib/src/utils/extensions.dart @@ -491,6 +491,15 @@ extension CMainAxisAlignmentHelper on MainAxisAlignmentC { return flutter.MainAxisAlignment.spaceEvenly; } } + + flutter.WrapAlignment get flutterWrapAlignment => switch (this) { + MainAxisAlignmentC.start => flutter.WrapAlignment.start, + MainAxisAlignmentC.end => flutter.WrapAlignment.end, + MainAxisAlignmentC.center => flutter.WrapAlignment.center, + MainAxisAlignmentC.spaceBetween => flutter.WrapAlignment.spaceBetween, + MainAxisAlignmentC.spaceAround => flutter.WrapAlignment.spaceAround, + MainAxisAlignmentC.spaceEvenly => flutter.WrapAlignment.spaceEvenly, + }; } extension CCrossAxisAlignmentHelper on CrossAxisAlignmentC { @@ -508,6 +517,15 @@ extension CCrossAxisAlignmentHelper on CrossAxisAlignmentC { return flutter.CrossAxisAlignment.baseline; } } + + /// A [flutter.Wrap] has no stretch or baseline: those align to the start. + flutter.WrapCrossAlignment get flutterWrapAlignment => switch (this) { + CrossAxisAlignmentC.end => flutter.WrapCrossAlignment.end, + CrossAxisAlignmentC.center => flutter.WrapCrossAlignment.center, + CrossAxisAlignmentC.start || + CrossAxisAlignmentC.stretch || + CrossAxisAlignmentC.baseline => flutter.WrapCrossAlignment.start, + }; } extension AxisHelperExt on AxisC { diff --git a/test/flex_layout_test.dart b/test/flex_layout_test.dart index 476a3fa4..b9b169ac 100644 --- a/test/flex_layout_test.dart +++ b/test/flex_layout_test.dart @@ -753,4 +753,155 @@ void main() { } }); }); + + group('a wrapping row (Wrap)', () { + // Chips 80 wide with a 10 margin on their right, so 90 apart. + List chips({ + List heights = const [20, 20, 20, 20], + }) => [ + for (int i = 0; i < heights.length; i++) + RectangleNode( + id: 'chip$i', + name: 'chip$i', + basicBoxLocal: NodeBox(0, 0, 80, heights[i]), + margin: const EdgeInsetsModel.only(right: 10), + ), + ]; + + RowColumnNode wrapRow( + List children, { + required double width, + SizeFit horizontalFit = SizeFit.fixed, + double runSpacing = 0, + MainAxisAlignmentC mainAxisAlignment = MainAxisAlignmentC.start, + CrossAxisAlignmentC crossAxisAlignment = CrossAxisAlignmentC.start, + }) => + flex( + 'row', + kRootNode, + children, + type: RowColumnType.row, + width: width, + height: 20, + horizontalFit: horizontalFit, + verticalFit: SizeFit.shrinkWrap, + mainAxisAlignment: mainAxisAlignment, + crossAxisAlignment: crossAxisAlignment, + ) + ..wrap = true + ..runSpacing = runSpacing; + + Offset offsetOf(WidgetTester tester, String id) => + tester.getTopLeft(rectOf(id)) - tester.getTopLeft(rectOf('row')); + + testWidgets('flows onto a second run when the row is too narrow', ( + tester, + ) async { + final children = chips(); + final row = wrapRow(children, width: 200, runSpacing: 8); + final sizes = await pump( + tester, + row, + children, + screen: screens.first, + host: Host.loose, + ); + expect(offsetOf(tester, 'chip0'), Offset.zero); + expect(offsetOf(tester, 'chip1'), const Offset(90, 0)); + expect(offsetOf(tester, 'chip2'), const Offset(0, 28)); + expect(offsetOf(tester, 'chip3'), const Offset(90, 28)); + expect(sizes['row'], const Size(200, 48)); + expect(sizes['chip3'], const Size(80, 20)); + }); + + testWidgets('a row that fills the screen wraps only on a narrow one', ( + tester, + ) async { + // Six chips are 540 long: four fit on a phone, all of them on a tablet + // or a desktop. + final children = chips(heights: List.filled(6, 20)); + final row = wrapRow( + children, + width: 390, + horizontalFit: SizeFit.expanded, + runSpacing: 8, + ); + for (final (screen, host) in [ + for (final screen in screens) + for (final host in [Host.loose, Host.unbounded]) (screen, host), + ]) { + final sizes = await pump( + tester, + row, + children, + screen: screen, + host: host, + ); + final bool narrow = screen.width < 540; + for (int i = 0; i < 6; i++) { + expect( + offsetOf(tester, 'chip$i'), + narrow && i >= 4 ? Offset(90.0 * (i - 4), 28) : Offset(90.0 * i, 0), + reason: 'chip$i at $screen in $host', + ); + } + expect( + sizes['row'], + Size(screen.width, narrow ? 48 : 20), + reason: '$screen in $host', + ); + } + }); + + testWidgets('aligns runs on the main axis and items across a run', ( + tester, + ) async { + final children = chips(heights: [20, 10, 20, 10]); + final row = wrapRow( + children, + width: 200, + mainAxisAlignment: MainAxisAlignmentC.end, + crossAxisAlignment: CrossAxisAlignmentC.center, + ); + await pump( + tester, + row, + children, + screen: screens.first, + host: Host.loose, + ); + // Each run is 180 long, placed at the end of the 200 wide row; the + // shorter chip sits in the middle of its 20 tall run. + expect(offsetOf(tester, 'chip0'), const Offset(20, 0)); + expect(offsetOf(tester, 'chip1'), const Offset(110, 5)); + expect(offsetOf(tester, 'chip2'), const Offset(20, 20)); + expect(offsetOf(tester, 'chip3'), const Offset(110, 25)); + }); + + testWidgets('an expanded child and a flex spacer keep their design size', ( + tester, + ) async { + final a = rect('a', '', width: 80, height: 20); + final s = spacer('s', width: 30, height: 20); + final b = rect( + 'b', + '', + width: 60, + height: 20, + horizontalFit: SizeFit.expanded, + ); + final row = wrapRow([a, s, b], width: 300); + for (final host in Host.values) { + final sizes = await pump( + tester, + row, + [a, s, b], + screen: screens.first, + host: host, + ); + expect(sizes['b'], const Size(60, 20), reason: '$host'); + expect(offsetOf(tester, 'b'), const Offset(110, 0), reason: '$host'); + } + }); + }); } From 60af37d7f9df5b29612c029010604c87b3d77217 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 01:04:30 +0000 Subject: [PATCH 27/27] Leave the version to the next release; changes stay under Unreleased Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01F3B1XCZZ7QGvZLUGgSG5H4 --- CHANGELOG.md | 2 +- pubspec.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1030a91b..ddc45e1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## 8.3.0 +## Unreleased - A text's color can be bound with a `textColor` variable or formula. - Formulas: `range`, `interpolate`, `runs`, `minBy`, `maxBy`, `obj`, diff --git a/pubspec.yaml b/pubspec.yaml index fb46ccb2..ea7839c4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: codelessly_sdk description: Codelessly CloudUI™ - Server-driven UI for Flutter. Supercharge your Flutter apps with dynamic UI and real-time updates. Build and publish UI without code! -version: 8.3.0 +version: 8.2.0 homepage: https://codelessly.com/ repository: https://github.com/Codelessly/CodelesslySDK issue_tracker: https://github.com/Codelessly/CodelesslySDK/issues @@ -28,7 +28,7 @@ dependencies: flutter: sdk: flutter # Codelessly - codelessly_api: ^6.2.0 + codelessly_api: ^6.1.0 json_annotation: ^4.12.0 # Firebase