diff --git a/packages/a2ui_core/lib/a2ui_core.dart b/packages/a2ui_core/lib/a2ui_core.dart new file mode 100644 index 000000000..fb02736bb --- /dev/null +++ b/packages/a2ui_core/lib/a2ui_core.dart @@ -0,0 +1,20 @@ +/// Core A2UI protocol implementation for Dart. +library a2ui_core; + +export 'src/common/errors.dart'; +export 'src/common/cancellation.dart'; +export 'src/common/reactivity.dart'; +export 'src/common/data_path.dart'; +export 'src/protocol/catalog.dart'; +export 'src/protocol/messages.dart'; +export 'src/protocol/common.dart'; +export 'src/protocol/common_schemas.dart'; +export 'src/protocol/minimal_catalog.dart'; +export 'src/state/data_model.dart'; +export 'src/state/component_model.dart'; +export 'src/state/surface_model.dart'; +export 'src/processing/processor.dart'; +export 'src/processing/expressions.dart'; +export 'src/processing/basic_functions.dart'; +export 'src/rendering/contexts.dart'; +export 'src/rendering/binder.dart'; diff --git a/packages/a2ui_core/lib/src/common/cancellation.dart b/packages/a2ui_core/lib/src/common/cancellation.dart new file mode 100644 index 000000000..64f34ff2a --- /dev/null +++ b/packages/a2ui_core/lib/src/common/cancellation.dart @@ -0,0 +1,53 @@ +/// A signal that can be used to cancel an operation. +class CancellationSignal { + bool _isCancelled = false; + + final _listeners = []; + + /// Whether the operation has been cancelled. + bool get isCancelled => _isCancelled; + + /// Cancels the operation. + void cancel() { + if (_isCancelled) return; + _isCancelled = true; + for (final void Function() listener in _listeners) { + listener(); + } + } + + /// Adds a listener to be notified when the operation is cancelled. + void addListener(void Function() listener) { + if (_isCancelled) { + listener(); + } else { + _listeners.add(listener); + } + } + + /// Removes a listener. + void removeListener(void Function() listener) { + _listeners.remove(listener); + } + + /// Throws a [CancellationException] if the operation has been cancelled. + void throwIfCancelled() { + if (_isCancelled) { + throw const CancellationException(); + } + } +} + +/// An exception thrown when an operation is cancelled. +class CancellationException implements Exception { + /// Creates a [CancellationException]. + const CancellationException([this.message]); + + /// A message describing the cancellation. + final String? message; + + @override + String toString() => message == null + ? 'CancellationException' + : 'CancellationException: $message'; +} diff --git a/packages/a2ui_core/lib/src/common/data_path.dart b/packages/a2ui_core/lib/src/common/data_path.dart new file mode 100644 index 000000000..d06496221 --- /dev/null +++ b/packages/a2ui_core/lib/src/common/data_path.dart @@ -0,0 +1,83 @@ +/// A class for handling JSON Pointer (RFC 6901) paths. +class DataPath { + final List segments; + + DataPath(this.segments); + + /// Parses a JSON Pointer string into a [DataPath]. + factory DataPath.parse(String path) { + if (path.isEmpty || path == '/') { + return DataPath([]); + } + + String normalized = path; + if (path.startsWith('/')) { + normalized = path.substring(1); + } + + if (normalized.endsWith('/')) { + normalized = normalized.substring(0, normalized.length - 1); + } + + if (normalized.isEmpty) { + return DataPath([]); + } + + final segments = normalized.split('/').map((s) { + return s.replaceAll('~1', '/').replaceAll('~0', '~'); + }).toList(); + + return DataPath(segments); + } + + /// The number of segments in the path. + int get length => segments.length; + + /// Whether the path is empty (points to the root). + bool get isEmpty => segments.isEmpty; + + /// Whether the path starts with a slash (is absolute). + bool get isAbsolute => true; // All parsed paths are treated as absolute for now in our context + + /// Joins this path with another path or segment. + DataPath append(dynamic other) { + if (other is DataPath) { + return DataPath([...segments, ...other.segments]); + } else if (other is String) { + if (other.startsWith('/')) { + return DataPath.parse(other); + } + return DataPath([...segments, ...DataPath.parse(other).segments]); + } + return DataPath([...segments, other.toString()]); + } + + /// Returns the parent path. + DataPath? get parent { + if (segments.isEmpty) return null; + return DataPath(segments.sublist(0, segments.length - 1)); + } + + @override + String toString() { + if (segments.isEmpty) return '/'; + return '/${segments.map((s) => s.replaceAll('~', '~0').replaceAll('/', '~1')).join('/')}'; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is DataPath && + segments.length == other.segments.length && + _listEquals(segments, other.segments); + + @override + int get hashCode => segments.join('/').hashCode; + + static bool _listEquals(List a, List b) { + for (int i = 0; i < a.length; i++) { + if (a[i] != b[i]) return false; + } + return true; + } +} diff --git a/packages/a2ui_core/lib/src/common/errors.dart b/packages/a2ui_core/lib/src/common/errors.dart new file mode 100644 index 000000000..e254e9433 --- /dev/null +++ b/packages/a2ui_core/lib/src/common/errors.dart @@ -0,0 +1,38 @@ +/// Base class for all A2UI specific errors. +class A2uiError implements Exception { + final String message; + final String code; + + A2uiError(this.message, [this.code = 'UNKNOWN_ERROR']); + + @override + String toString() => '$runtimeType [$code]: $message'; +} + +/// Thrown when JSON validation fails or schemas are mismatched. +class A2uiValidationError extends A2uiError { + final dynamic details; + + A2uiValidationError(String message, {this.details}) : super(message, 'VALIDATION_ERROR'); +} + +/// Thrown during DataModel mutations (invalid paths, type mismatches). +class A2uiDataError extends A2uiError { + final String? path; + + A2uiDataError(String message, {this.path}) : super(message, 'DATA_ERROR'); +} + +/// Thrown during string interpolation and function evaluation. +class A2uiExpressionError extends A2uiError { + final String? expression; + final dynamic details; + + A2uiExpressionError(String message, {this.expression, this.details}) + : super(message, 'EXPRESSION_ERROR'); +} + +/// Thrown for structural issues in the UI tree (missing surfaces, duplicate components). +class A2uiStateError extends A2uiError { + A2uiStateError(String message) : super(message, 'STATE_ERROR'); +} diff --git a/packages/a2ui_core/lib/src/common/reactivity.dart b/packages/a2ui_core/lib/src/common/reactivity.dart new file mode 100644 index 000000000..338a44809 --- /dev/null +++ b/packages/a2ui_core/lib/src/common/reactivity.dart @@ -0,0 +1,165 @@ +import 'dart:async'; + +/// An interface for objects that maintain a list of listeners. +abstract interface class Listenable { + /// Adds a listener to be notified when the object changes. + void addListener(void Function() listener); + + /// Removes a listener. + void removeListener(void Function() listener); +} + +/// An interface for objects that hold a value and notify listeners when it changes. +abstract class ValueListenable implements Listenable { + /// The current value. + T get value; +} + +bool _inBatch = false; +final _pendingNotifiers = >{}; + +/// Executes [callback] and defers notifications until it completes. +void batch(void Function() callback) { + if (_inBatch) { + callback(); + return; + } + + _inBatch = true; + try { + callback(); + } finally { + _inBatch = false; + final toNotify = _pendingNotifiers.toList(); + _pendingNotifiers.clear(); + for (final notifier in toNotify) { + notifier._notifyListeners(); + } + } +} + +/// A base class for objects that hold a value and notify listeners when it changes. +class ValueNotifier implements ValueListenable { + T _value; + final _listeners = {}; + + ValueNotifier(this._value); + + @override + T get value { + _DependencyTracker.instance?._reportRead(this); + return _value; + } + + set value(T newValue) { + if (_value == newValue) return; + _value = newValue; + notifyListeners(); + } + + @override + void addListener(void Function() listener) { + _listeners.add(listener); + } + + @override + void removeListener(void Function() listener) { + _listeners.remove(listener); + } + + /// Notifies all registered listeners. + void notifyListeners() { + if (_inBatch) { + _pendingNotifiers.add(this); + return; + } + _notifyListeners(); + } + + void _notifyListeners() { + for (final listener in List.from(_listeners)) { + if (_listeners.contains(listener)) { + listener(); + } + } + } + + /// Disposes of the notifier and its resources. + void dispose() { + _listeners.clear(); + _pendingNotifiers.remove(this); + } +} + +/// A derived notifier that automatically tracks and listens to other [ValueListenable] +/// dependencies, recalculating its value only when they change. +class ComputedNotifier extends ValueNotifier { + final T Function() _compute; + final Set> _dependencies = {}; + + ComputedNotifier(this._compute) : super(_compute()) { + _updateDependencies(); + } + + void _updateDependencies() { + final tracker = _DependencyTracker(); + final newValue = tracker.track(_compute); + + final newDeps = tracker.dependencies; + + // Unsubscribe from old dependencies no longer needed + for (final dep in _dependencies.difference(newDeps)) { + dep.removeListener(_onDependencyChanged); + } + + // Subscribe to new dependencies + for (final dep in newDeps.difference(_dependencies)) { + dep.addListener(_onDependencyChanged); + } + + _dependencies.clear(); + _dependencies.addAll(newDeps); + + super.value = newValue; + } + + void _onDependencyChanged() { + _updateDependencies(); + } + + @override + T get value { + // If we have no listeners, we might want to re-evaluate on every read + // to ensure we're fresh, but usually, Computed is used with listeners. + // For now, let's just return the cached value and rely on dependencies. + return super.value; + } + + @override + void dispose() { + for (final dep in _dependencies) { + dep.removeListener(_onDependencyChanged); + } + _dependencies.clear(); + super.dispose(); + } +} + +class _DependencyTracker { + static _DependencyTracker? instance; + final Set> dependencies = {}; + + T track(T Function() callback) { + final previous = instance; + instance = this; + try { + return callback(); + } finally { + instance = previous; + } + } + + void _reportRead(ValueListenable listenable) { + dependencies.add(listenable); + } +} diff --git a/packages/a2ui_core/lib/src/processing/basic_functions.dart b/packages/a2ui_core/lib/src/processing/basic_functions.dart new file mode 100644 index 000000000..3c2aa3264 --- /dev/null +++ b/packages/a2ui_core/lib/src/processing/basic_functions.dart @@ -0,0 +1,40 @@ +import 'package:json_schema_builder/json_schema_builder.dart'; +import '../common/reactivity.dart'; +import '../protocol/catalog.dart'; +import '../rendering/contexts.dart'; +import 'expressions.dart'; + +class FormatStringFunction extends FunctionImplementation { + @override + String get name => 'formatString'; + + @override + String get returnType => 'any'; + + @override + Schema get argumentSchema => Schema.object( + properties: { + 'value': Schema.string(description: 'The string template to interpolate.'), + }, + required: ['value'], + ); + + @override + dynamic execute(Map args, DataContext context, [dynamic cancellationSignal]) { + final template = args['value'] as String; + final parser = ExpressionParser(); + final parts = parser.parse(template); + + if (parts.isEmpty) return ''; + if (parts.length == 1 && parts[0] is String) return parts[0]; + + return ComputedNotifier(() { + final resolvedParts = parts.map((part) { + if (part is String) return part; + final listenable = context.resolveListenable(part); + return listenable.value?.toString() ?? ''; + }); + return resolvedParts.join(''); + }); + } +} diff --git a/packages/a2ui_core/lib/src/processing/expressions.dart b/packages/a2ui_core/lib/src/processing/expressions.dart new file mode 100644 index 000000000..a58a438ba --- /dev/null +++ b/packages/a2ui_core/lib/src/processing/expressions.dart @@ -0,0 +1,276 @@ +import '../common/errors.dart'; +import '../protocol/common.dart'; + +/// A parser for A2UI expressions, supporting string interpolation and function calls. +class ExpressionParser { + static const int maxDepth = 10; + + /// Parses an input string into a list of components (literals or [Map] representations of expressions). + List parse(String input, [int depth = 0]) { + if (depth > maxDepth) { + throw A2uiExpressionError('Max recursion depth reached in parse'); + } + if (!input.contains('\${')) { + return [input]; + } + + final parts = []; + final scanner = _Scanner(input); + + while (!scanner.isAtEnd) { + if (scanner.matches('\${')) { + scanner.advance(2); + final content = _extractInterpolationContent(scanner); + final parsed = parseExpression(content, depth + 1); + parts.add(parsed); + } else if (scanner.matches('\\\${')) { + scanner.advance(1); // skip \ + final start = scanner.pos; + scanner.advance(2); // skip ${ + final literal = scanner.substring(start, scanner.pos); + if (parts.isNotEmpty && parts.last is String) { + parts[parts.length - 1] = (parts.last as String) + literal; + } else { + parts.add(literal); + } + } else { + final start = scanner.pos; + while (!scanner.isAtEnd) { + if (scanner.matches('\${') || scanner.matches('\\\${')) { + break; + } + scanner.advance(); + } + final literal = scanner.substring(start, scanner.pos); + if (parts.isNotEmpty && parts.last is String) { + parts[parts.length - 1] = (parts.last as String) + literal; + } else { + parts.add(literal); + } + } + } + return parts.where((p) => p != null && p != '').toList(); + } + + String _extractInterpolationContent(_Scanner scanner) { + final start = scanner.pos; + int braceBalance = 1; + + while (!scanner.isAtEnd && braceBalance > 0) { + final char = scanner.advance(); + if (char == '{') { + braceBalance++; + } else if (char == '}') { + braceBalance--; + } else if (char == "'" || char == '"') { + final quote = char; + while (!scanner.isAtEnd) { + final c = scanner.advance(); + if (c == '\\') { + scanner.advance(); + } else if (c == quote) { + break; + } + } + } + } + + if (braceBalance > 0) { + throw A2uiExpressionError("Unclosed interpolation: missing '}'"); + } + + return scanner.input.substring(start, scanner.pos - 1); + } + + /// Parses a single expression string into its DynamicValue representation. + dynamic parseExpression(String expr, [int depth = 0]) { + final trimmed = expr.trim(); + if (trimmed.isEmpty) return ''; + + final scanner = _Scanner(trimmed); + final result = _parseExpressionInternal(scanner, depth); + scanner.skipWhitespace(); + if (!scanner.isAtEnd) { + throw A2uiExpressionError("Unexpected characters at end of expression: '${scanner.input.substring(scanner.pos)}'"); + } + return result; + } + + dynamic _parseExpressionInternal(_Scanner scanner, int depth) { + scanner.skipWhitespace(); + if (scanner.isAtEnd) return ''; + + // Nested interpolation + if (scanner.matches('\${')) { + scanner.advance(2); + final content = _extractInterpolationContent(scanner); + return parseExpression(content, depth + 1); + } + + // Literals + if (scanner.peek() == "'" || scanner.peek() == '"') { + return _parseStringLiteral(scanner); + } + if (_isDigit(scanner.peek())) { + return _parseNumberLiteral(scanner); + } + if (scanner.matchesKeyword('true')) return true; + if (scanner.matchesKeyword('false')) return false; + if (scanner.matchesKeyword('null')) return null; + + // Identifiers (Function calls or Path starts) + final token = _scanPathOrIdentifier(scanner); + scanner.skipWhitespace(); + + if (scanner.peek() == '(') { + return _parseFunctionCall(token, scanner, depth); + } else { + if (token.isEmpty) return ''; + return {'path': token}; + } + } + + String _scanPathOrIdentifier(_Scanner scanner) { + final start = scanner.pos; + while (!scanner.isAtEnd) { + final c = scanner.peek(); + if (_isAlnum(c) || c == '/' || c == '.' || c == '_' || c == '-') { + scanner.advance(); + } else { + break; + } + } + return scanner.input.substring(start, scanner.pos); + } + + dynamic _parseFunctionCall(String funcName, _Scanner scanner, int depth) { + scanner.match('('); + scanner.skipWhitespace(); + + final args = {}; + + while (!scanner.isAtEnd && scanner.peek() != ')') { + final argName = _scanIdentifier(scanner); + scanner.skipWhitespace(); + if (!scanner.match(':')) { + throw A2uiExpressionError("Expected ':' after argument name '$argName' in function '$funcName'"); + } + scanner.skipWhitespace(); + + args[argName] = _parseExpressionInternal(scanner, depth); + + scanner.skipWhitespace(); + if (scanner.peek() == ',') { + scanner.advance(); + scanner.skipWhitespace(); + } + } + + if (!scanner.match(')')) { + throw A2uiExpressionError("Expected ')' after function arguments for '$funcName'"); + } + + return { + 'call': funcName, + 'args': args, + 'returnType': 'any', + }; + } + + String _scanIdentifier(_Scanner scanner) { + final start = scanner.pos; + while (!scanner.isAtEnd && (_isAlnum(scanner.peek()) || scanner.peek() == '_')) { + scanner.advance(); + } + return scanner.input.substring(start, scanner.pos); + } + + String _parseStringLiteral(_Scanner scanner) { + final quote = scanner.advance(); + final result = StringBuffer(); + while (!scanner.isAtEnd) { + final c = scanner.advance(); + if (c == '\\') { + final next = scanner.advance(); + if (next == 'n') result.write('\n'); + else if (next == 't') result.write('\t'); + else if (next == 'r') result.write('\r'); + else result.write(next); + } else if (c == quote) { + break; + } else { + result.write(c); + } + } + return result.toString(); + } + + num _parseNumberLiteral(_Scanner scanner) { + final start = scanner.pos; + while (!scanner.isAtEnd && (_isDigit(scanner.peek()) || scanner.peek() == '.')) { + scanner.advance(); + } + return num.parse(scanner.input.substring(start, scanner.pos)); + } + + bool _isAlnum(String c) { + return RegExp(r'[a-zA-Z0-9]').hasMatch(c); + } + + bool _isDigit(String c) { + return RegExp(r'[0-9]').hasMatch(c); + } +} + +class _Scanner { + final String input; + int pos = 0; + + _Scanner(this.input); + + bool get isAtEnd => pos >= input.length; + + String peek([int offset = 0]) { + if (pos + offset >= input.length) return ''; + return input[pos + offset]; + } + + String advance([int count = 1]) { + final result = input.substring(pos, pos + count); + pos += count; + return result; + } + + bool match(String expected) { + if (peek() == expected) { + advance(); + return true; + } + return false; + } + + bool matches(String expected) { + return input.startsWith(expected, pos); + } + + bool matchesKeyword(String keyword) { + if (input.startsWith(keyword, pos)) { + final next = peek(keyword.length); + if (!RegExp(r'[a-zA-Z0-9_]').hasMatch(next)) { + advance(keyword.length); + return true; + } + } + return false; + } + + void skipWhitespace() { + while (!isAtEnd && RegExp(r'\s').hasMatch(peek())) { + advance(); + } + } + + String substring(int start, [int? end]) { + return input.substring(start, end); + } +} diff --git a/packages/a2ui_core/lib/src/processing/processor.dart b/packages/a2ui_core/lib/src/processing/processor.dart new file mode 100644 index 000000000..b247a3a10 --- /dev/null +++ b/packages/a2ui_core/lib/src/processing/processor.dart @@ -0,0 +1,223 @@ +import '../common/errors.dart'; +import '../protocol/catalog.dart'; +import '../protocol/messages.dart'; +import '../state/surface_model.dart'; +import '../state/component_model.dart'; +import 'package:json_schema_builder/json_schema_builder.dart'; + +/// The central processor for A2UI messages. +class MessageProcessor { + final SurfaceGroupModel groupModel; + final List> catalogs; + + MessageProcessor({ + required this.catalogs, + void Function(A2uiClientAction)? onAction, + }) : groupModel = SurfaceGroupModel() { + if (onAction != null) { + groupModel.onAction.addListener(() { + final action = groupModel.onAction.value; + if (action != null) { + onAction(action); + } + }); + } + } + + /// Processes a list of messages. + void processMessages(List messages) { + for (final message in messages) { + _processMessage(message); + } + } + + void _processMessage(A2uiMessage message) { + if (message is CreateSurfaceMessage) { + _processCreateSurface(message); + } else if (message is UpdateComponentsMessage) { + _processUpdateComponents(message); + } else if (message is UpdateDataModelMessage) { + _processUpdateDataModel(message); + } else if (message is DeleteSurfaceMessage) { + _processDeleteSurface(message); + } + } + + void _processCreateSurface(CreateSurfaceMessage message) { + final catalog = catalogs.firstWhere( + (c) => c.id == message.catalogId, + orElse: () => throw A2uiStateError('Catalog not found: ${message.catalogId}'), + ); + + if (groupModel.getSurface(message.surfaceId) != null) { + throw A2uiStateError('Surface ${message.surfaceId} already exists.'); + } + + final surface = SurfaceModel( + message.surfaceId, + catalog: catalog, + theme: message.theme ?? {}, + sendDataModel: message.sendDataModel, + ); + groupModel.addSurface(surface); + } + + void _processUpdateComponents(UpdateComponentsMessage message) { + final surface = groupModel.getSurface(message.surfaceId); + if (surface == null) { + throw A2uiStateError('Surface not found: ${message.surfaceId}'); + } + + for (final compJson in message.components) { + final id = compJson['id'] as String?; + final type = compJson['component'] as String?; + + if (id == null) { + throw A2uiValidationError("Component missing an 'id'."); + } + + final existing = surface.componentsModel.get(id); + final props = Map.from(compJson)..remove('id')..remove('component'); + + if (existing != null) { + if (type != null && type != existing.type) { + // Recreate if type changes + surface.componentsModel.removeComponent(id); + surface.componentsModel.addComponent(ComponentModel(id, type, props)); + } else { + existing.properties = props; + } + } else { + if (type == null) { + throw A2uiValidationError("Cannot create component $id without a 'component' type."); + } + surface.componentsModel.addComponent(ComponentModel(id, type, props)); + } + } + } + + void _processUpdateDataModel(UpdateDataModelMessage message) { + final surface = groupModel.getSurface(message.surfaceId); + if (surface == null) { + throw A2uiStateError('Surface not found: ${message.surfaceId}'); + } + + surface.dataModel.set(message.path ?? '/', message.value); + } + + void _processDeleteSurface(DeleteSurfaceMessage message) { + groupModel.deleteSurface(message.surfaceId); + } + + /// Generates client capabilities. + Map getClientCapabilities({bool includeInlineCatalogs = false}) { + final v09 = { + 'supportedCatalogIds': catalogs.map((c) => c.id).toList(), + }; + + if (includeInlineCatalogs) { + v09['inlineCatalogs'] = catalogs.map((c) => _generateInlineCatalog(c)).toList(); + } + + return { + 'v0.9': v09, + }; + } + + Map _generateInlineCatalog(Catalog catalog) { + final components = {}; + for (final entry in catalog.components.entries) { + final jsonSchema = entry.value.schema.toJsonMap(); + _processRefs(jsonSchema); + + // Wrap in A2UI envelope + components[entry.key] = { + 'allOf': [ + {'\$ref': 'common_types.json#/\$defs/ComponentCommon'}, + { + 'properties': { + 'component': {'const': entry.key}, + ...?jsonSchema['properties'], + }, + 'required': ['component', ...?(jsonSchema['required'] as List?)], + } + ] + }; + } + + final functions = catalog.functions.values.map((f) { + final jsonSchema = f.argumentSchema.toJsonMap(); + _processRefs(jsonSchema); + return { + 'name': f.name, + 'returnType': f.returnType, + 'parameters': jsonSchema, + }; + }).toList(); + + Map? theme; + if (catalog.themeSchema != null) { + theme = catalog.themeSchema!.toJsonMap(); + _processRefs(theme); + theme = theme['properties'] as Map?; + } + + return { + 'catalogId': catalog.id, + 'components': components, + if (functions.isNotEmpty) 'functions': functions, + if (theme != null) 'theme': theme, + }; + } + + void _processRefs(dynamic node) { + if (node is! Map) return; + + if (node['description'] is String && (node['description'] as String).startsWith('REF:')) { + final desc = node['description'] as String; + final parts = desc.substring(4).split('|'); + final ref = parts[0]; + final actualDesc = parts.length > 1 ? parts[1] : null; + + node.clear(); + node['\$ref'] = ref; + if (actualDesc != null) { + node['description'] = actualDesc; + } + return; + } + + node.forEach((key, value) { + if (value is Map) { + _processRefs(value); + } else if (value is List) { + for (final item in value) { + if (item is Map) { + _processRefs(item); + } + } + } + }); + } + + /// Aggregates data models for surfaces with sendDataModel enabled. + Map? getClientDataModel() { + final surfaces = {}; + for (final surface in groupModel.allSurfaces) { + if (surface.sendDataModel) { + surfaces[surface.id] = surface.dataModel.get('/'); + } + } + + if (surfaces.isEmpty) return null; + + return { + 'version': 'v0.9', + 'surfaces': surfaces, + }; + } +} + +extension SchemaExtension on Schema { + Map toJsonMap() => Map.from(value); +} diff --git a/packages/a2ui_core/lib/src/protocol/catalog.dart b/packages/a2ui_core/lib/src/protocol/catalog.dart new file mode 100644 index 000000000..dd10c4ee8 --- /dev/null +++ b/packages/a2ui_core/lib/src/protocol/catalog.dart @@ -0,0 +1,39 @@ +import 'package:json_schema_builder/json_schema_builder.dart'; +import '../common/cancellation.dart'; +import '../common/reactivity.dart'; +import '../rendering/contexts.dart'; + +/// A definition of a UI component's API. +abstract class ComponentApi { + String get name; + Schema get schema; +} + +/// A definition of a UI function's API. +abstract class FunctionApi { + String get name; + String get returnType; + Schema get argumentSchema; +} + +/// A function implementation that can be registered with a catalog. +abstract class FunctionImplementation extends FunctionApi { + /// Executes the function. Can return a static value or a [ValueListenable]. + dynamic execute(Map args, DataContext context, [CancellationSignal? cancellationSignal]); +} + +/// A collection of available components and functions. +class Catalog { + final String id; + final Map components; + final Map functions; + final Schema? themeSchema; + + Catalog({ + required this.id, + required List components, + List functions = const [], + this.themeSchema, + }) : components = {for (var c in components) c.name: c}, + functions = {for (var f in functions) f.name: f}; +} diff --git a/packages/a2ui_core/lib/src/protocol/common.dart b/packages/a2ui_core/lib/src/protocol/common.dart new file mode 100644 index 000000000..0ebcfba44 --- /dev/null +++ b/packages/a2ui_core/lib/src/protocol/common.dart @@ -0,0 +1,82 @@ +import 'package:json_schema_builder/json_schema_builder.dart'; + +/// A JSON Pointer path to a value in the data model. +class DataBinding { + final String path; + DataBinding(this.path); + + factory DataBinding.fromJson(Map json) { + return DataBinding(json['path'] as String); + } + + Map toJson() => {'path': path}; +} + +/// Invokes a named function on the client. +class FunctionCall { + final String call; + final Map args; + final String returnType; + + FunctionCall({ + required this.call, + required this.args, + this.returnType = 'boolean', + }); + + factory FunctionCall.fromJson(Map json) { + return FunctionCall( + call: json['call'] as String, + args: json['args'] as Map? ?? {}, + returnType: json['returnType'] as String? ?? 'boolean', + ); + } + + Map toJson() => { + 'call': call, + 'args': args, + 'returnType': returnType, + }; +} + +/// Triggers a server-side event or a local client-side function. +class Action { + final Map? event; + final FunctionCall? functionCall; + + Action({this.event, this.functionCall}); + + factory Action.fromJson(Map json) { + if (json.containsKey('event')) { + return Action(event: json['event'] as Map); + } else if (json.containsKey('functionCall')) { + return Action(functionCall: FunctionCall.fromJson(json['functionCall'] as Map)); + } + throw ArgumentError('Invalid action JSON: $json'); + } + + Map toJson() => { + if (event != null) 'event': event, + if (functionCall != null) 'functionCall': functionCall!.toJson(), + }; +} + +/// A template for generating a dynamic list of children. +class ChildListTemplate { + final String componentId; + final String path; + + ChildListTemplate({required this.componentId, required this.path}); + + factory ChildListTemplate.fromJson(Map json) { + return ChildListTemplate( + componentId: json['componentId'] as String, + path: json['path'] as String, + ); + } + + Map toJson() => { + 'componentId': componentId, + 'path': path, + }; +} diff --git a/packages/a2ui_core/lib/src/protocol/common_schemas.dart b/packages/a2ui_core/lib/src/protocol/common_schemas.dart new file mode 100644 index 000000000..c95fd6059 --- /dev/null +++ b/packages/a2ui_core/lib/src/protocol/common_schemas.dart @@ -0,0 +1,99 @@ +import 'package:json_schema_builder/json_schema_builder.dart'; + +class CommonSchemas { + static final dataBinding = Schema.object( + description: 'REF:common_types.json#/\$defs/DataBinding|A JSON Pointer path to a value in the data model.', + properties: { + 'path': Schema.string(description: 'A JSON Pointer path to a value in the data model.'), + }, + required: ['path'], + ); + + static final functionCall = Schema.object( + description: 'REF:common_types.json#/\$defs/FunctionCall|Invokes a named function on the client.', + properties: { + 'call': Schema.string(description: 'The name of the function to call.'), + 'args': Schema.object(description: 'Arguments passed to the function.', additionalProperties: true), + 'returnType': Schema.string( + description: 'The expected return type of the function call.', + enumValues: ['string', 'number', 'boolean', 'array', 'object', 'any', 'void'], + ), + }, + required: ['call'], + ); + + static final dynamicString = Schema.combined( + description: 'REF:common_types.json#/\$defs/DynamicString|Represents a string', + anyOf: [ + Schema.string(), + dataBinding, + functionCall, + ], + ); + + static final dynamicBoolean = Schema.combined( + description: 'REF:common_types.json#/\$defs/DynamicBoolean|A boolean value', + anyOf: [ + Schema.boolean(), + dataBinding, + functionCall, + ], + ); + + static final componentId = Schema.string( + description: 'REF:common_types.json#/\$defs/ComponentId|The unique identifier for a component.', + ); + + static final childList = Schema.combined( + description: 'REF:common_types.json#/\$defs/ChildList', + anyOf: [ + Schema.list(items: componentId), + Schema.object( + properties: { + 'componentId': componentId, + 'path': Schema.string(), + }, + required: ['componentId', 'path'], + ), + ], + ); + + static final action = Schema.combined( + description: 'REF:common_types.json#/\$defs/Action', + anyOf: [ + Schema.object( + properties: { + 'event': Schema.object( + properties: { + 'name': Schema.string(), + 'context': Schema.object(additionalProperties: true), + }, + required: ['name'], + ), + }, + required: ['event'], + ), + Schema.object( + properties: { + 'functionCall': functionCall, + }, + required: ['functionCall'], + ), + ], + ); + + static final checkable = Schema.object( + description: 'REF:common_types.json#/\$defs/Checkable', + properties: { + 'checks': Schema.list( + items: Schema.object( + properties: { + 'condition': dynamicBoolean, + 'message': Schema.string(), + }, + required: ['condition', 'message'], + ), + ), + }, + ); +} diff --git a/packages/a2ui_core/lib/src/protocol/messages.dart b/packages/a2ui_core/lib/src/protocol/messages.dart new file mode 100644 index 000000000..4effb09eb --- /dev/null +++ b/packages/a2ui_core/lib/src/protocol/messages.dart @@ -0,0 +1,146 @@ +import 'package:json_schema_builder/json_schema_builder.dart'; + +/// Base class for all A2UI messages. +abstract class A2uiMessage { + final String version; + A2uiMessage({this.version = 'v0.9'}); + + Map toJson(); +} + +/// Signals the client to create a new surface. +class CreateSurfaceMessage extends A2uiMessage { + final String surfaceId; + final String catalogId; + final Map? theme; + final bool sendDataModel; + + CreateSurfaceMessage({ + super.version, + required this.surfaceId, + required this.catalogId, + this.theme, + this.sendDataModel = false, + }); + + @override + Map toJson() => { + 'version': version, + 'createSurface': { + 'surfaceId': surfaceId, + 'catalogId': catalogId, + if (theme != null) 'theme': theme, + 'sendDataModel': sendDataModel, + }, + }; +} + +/// Updates a surface with a new set of components. +class UpdateComponentsMessage extends A2uiMessage { + final String surfaceId; + final List> components; + + UpdateComponentsMessage({ + super.version, + required this.surfaceId, + required this.components, + }); + + @override + Map toJson() => { + 'version': version, + 'updateComponents': { + 'surfaceId': surfaceId, + 'components': components, + }, + }; +} + +/// Updates the data model for an existing surface. +class UpdateDataModelMessage extends A2uiMessage { + final String surfaceId; + final String? path; + final dynamic value; + + UpdateDataModelMessage({ + super.version, + required this.surfaceId, + this.path, + this.value, + }); + + @override + Map toJson() => { + 'version': version, + 'updateDataModel': { + 'surfaceId': surfaceId, + if (path != null) 'path': path, + if (value != null) 'value': value, + }, + }; +} + +/// Signals the client to delete a surface. +class DeleteSurfaceMessage extends A2uiMessage { + final String surfaceId; + + DeleteSurfaceMessage({ + super.version, + required this.surfaceId, + }); + + @override + Map toJson() => { + 'version': version, + 'deleteSurface': { + 'surfaceId': surfaceId, + }, + }; +} + +/// Reports a user-initiated action from a component. +class A2uiClientAction { + final String name; + final String surfaceId; + final String sourceComponentId; + final DateTime timestamp; + final Map context; + + A2uiClientAction({ + required this.name, + required this.surfaceId, + required this.sourceComponentId, + required this.timestamp, + required this.context, + }); + + Map toJson() => { + 'name': name, + 'surfaceId': surfaceId, + 'sourceComponentId': sourceComponentId, + 'timestamp': timestamp.toIso8601String(), + 'context': context, + }; +} + +/// Reports a client-side error. +class A2uiClientError { + final String code; + final String surfaceId; + final String message; + final dynamic details; + + A2uiClientError({ + required this.code, + required this.surfaceId, + required this.message, + this.details, + }); + + Map toJson() => { + 'code': code, + 'surfaceId': surfaceId, + 'message': message, + if (details != null) 'details': details, + }; +} diff --git a/packages/a2ui_core/lib/src/protocol/minimal_catalog.dart b/packages/a2ui_core/lib/src/protocol/minimal_catalog.dart new file mode 100644 index 000000000..ee6519d44 --- /dev/null +++ b/packages/a2ui_core/lib/src/protocol/minimal_catalog.dart @@ -0,0 +1,135 @@ +import 'package:json_schema_builder/json_schema_builder.dart'; +import 'catalog.dart'; +import 'common_schemas.dart'; +import '../rendering/contexts.dart'; +import '../common/reactivity.dart'; + +class MinimalTextApi extends ComponentApi { + @override + String get name => 'Text'; + + @override + Schema get schema => Schema.object( + properties: { + 'text': CommonSchemas.dynamicString, + 'variant': Schema.string(enumValues: ['h1', 'h2', 'h3', 'h4', 'h5', 'caption', 'body']), + }, + required: ['text'], + ); +} + +class MinimalRowApi extends ComponentApi { + @override + String get name => 'Row'; + + @override + Schema get schema => Schema.object( + properties: { + 'children': CommonSchemas.childList, + 'justify': Schema.string(enumValues: ['center', 'end', 'spaceAround', 'spaceBetween', 'spaceEvenly', 'start', 'stretch']), + 'align': Schema.string(enumValues: ['start', 'center', 'end', 'stretch']), + }, + required: ['children'], + ); +} + +class MinimalColumnApi extends ComponentApi { + @override + String get name => 'Column'; + + @override + Schema get schema => Schema.object( + properties: { + 'children': CommonSchemas.childList, + 'justify': Schema.string(enumValues: ['start', 'center', 'end', 'spaceBetween', 'spaceAround', 'spaceEvenly', 'stretch']), + 'align': Schema.string(enumValues: ['center', 'end', 'start', 'stretch']), + }, + required: ['children'], + ); +} + +class MinimalButtonApi extends ComponentApi { + @override + String get name => 'Button'; + + @override + Schema get schema => Schema.combined( + allOf: [ + CommonSchemas.checkable, + Schema.object( + properties: { + 'child': CommonSchemas.componentId, + 'variant': Schema.string(enumValues: ['primary', 'borderless']), + 'action': CommonSchemas.action, + }, + required: ['child', 'action'], + ), + ], + ); +} + +class MinimalTextFieldApi extends ComponentApi { + @override + String get name => 'TextField'; + + @override + Schema get schema => Schema.combined( + allOf: [ + CommonSchemas.checkable, + Schema.object( + properties: { + 'label': CommonSchemas.dynamicString, + 'value': CommonSchemas.dynamicString, + 'variant': Schema.string(enumValues: ['longText', 'number', 'shortText', 'obscured']), + 'validationRegexp': Schema.string(), + }, + required: ['label'], + ), + ], + ); +} + +class CapitalizeFunction extends FunctionImplementation { + @override + String get name => 'capitalize'; + + @override + String get returnType => 'string'; + + @override + Schema get argumentSchema => Schema.object( + properties: { + 'value': CommonSchemas.dynamicString, + }, + required: ['value'], + ); + + @override + dynamic execute(Map args, DataContext context, [dynamic cancellationSignal]) { + final val = args['value']?.toString() ?? ''; + if (val.isEmpty) return ''; + return val[0].toUpperCase() + val.substring(1); + } +} + +class MinimalCatalog extends Catalog { + MinimalCatalog() : super( + id: 'https://a2ui.org/specification/v0_9/catalogs/minimal/minimal_catalog.json', + components: [ + MinimalTextApi(), + MinimalRowApi(), + MinimalColumnApi(), + MinimalButtonApi(), + MinimalTextFieldApi(), + ], + functions: [ + CapitalizeFunction(), + ], + themeSchema: Schema.object( + properties: { + 'primaryColor': Schema.string(pattern: r'^#[0-9a-fA-F]{6}$'), + }, + additionalProperties: true, + ), + ); +} diff --git a/packages/a2ui_core/lib/src/rendering/binder.dart b/packages/a2ui_core/lib/src/rendering/binder.dart new file mode 100644 index 000000000..f2a0ccfa3 --- /dev/null +++ b/packages/a2ui_core/lib/src/rendering/binder.dart @@ -0,0 +1,318 @@ +import 'dart:async'; +import 'package:json_schema_builder/json_schema_builder.dart'; +import '../common/reactivity.dart'; +import '../protocol/common.dart'; +import 'contexts.dart'; + +/// Represents the intended runtime behavior of a property parsed from its schema. +enum Behavior { + dynamic, + action, + structural, + checkable, + static, + object, + array, +} + +class BehaviorNode { + final Behavior type; + final Map? shape; + final BehaviorNode? element; + + BehaviorNode(this.type, {this.shape, this.element}); +} + +class ChildNode { + final String id; + final String basePath; + ChildNode(this.id, this.basePath); + + Map toJson() => {'id': id, 'basePath': basePath}; +} + +/// A framework-agnostic engine that transforms raw A2UI JSON payload +/// configurations into a single, cohesive reactive stream of resolved properties. +class GenericBinder { + final ComponentContext context; + final Schema schema; + late final BehaviorNode _behaviorTree; + + final _resolvedProps = ValueNotifier>({}); + final List _dataListeners = []; + bool _isConnected = false; + + ValueListenable> get resolvedProps => _resolvedProps; + + GenericBinder(this.context, this.schema) { + _behaviorTree = _scrapeSchemaBehavior(schema); + _resolveInitialProps(); + connect(); + } + + void _resolveInitialProps() { + final props = context.componentModel.properties; + _resolvedProps.value = _resolveAndBind(props, _behaviorTree, [], true); + } + + /// Connects to the component model for updates. + void connect() { + if (_isConnected) return; + _isConnected = true; + context.componentModel.onUpdated.addListener(_rebuildAllBindings); + _rebuildAllBindings(); + } + + void _rebuildAllBindings() { + for (final unsub in _dataListeners) { + unsub(); + } + _dataListeners.clear(); + + final props = context.componentModel.properties; + _resolvedProps.value = _resolveAndBind(props, _behaviorTree, [], false); + } + + dynamic _resolveAndBind(dynamic value, BehaviorNode behavior, List path, bool isSync) { + if (value == null) return null; + + switch (behavior.type) { + case Behavior.dynamic: + final listenable = context.dataContext.resolveListenable(value); + if (!isSync) { + void listener() { + _updateDeepValue(path, listenable.value); + } + listenable.addListener(listener); + _dataListeners.add(() => listenable.removeListener(listener)); + } + return listenable.value; + + case Behavior.action: + return () async { + final dynamic resolved = context.dataContext.resolveSync(value); + final Map resolvedAction; + if (resolved is Map) { + resolvedAction = Map.from(resolved); + } else { + resolvedAction = {'event': {'name': value.toString()}}; + } + await context.dispatchAction(resolvedAction); + }; + + case Behavior.structural: + if (value is Map && value.containsKey('path') && value.containsKey('componentId')) { + final tpl = ChildListTemplate.fromJson(Map.from(value)); + final listenable = context.dataContext.resolveListenable({'path': tpl.path}); + + List resolveChildren(dynamic val) { + final list = val is List ? val : []; + final nestedCtx = context.dataContext.nested(tpl.path); + return List.generate(list.length, (i) => ChildNode(tpl.componentId, nestedCtx.resolvePath(i.toString()))); + } + + if (!isSync) { + void listener() { + _updateDeepValue(path, resolveChildren(listenable.value)); + } + listenable.addListener(listener); + _dataListeners.add(() => listenable.removeListener(listener)); + } + return resolveChildren(listenable.value); + } + if (value is List) { + return value.map((id) => ChildNode(id.toString(), context.dataContext.path)).toList(); + } + return value; + + case Behavior.checkable: + final rules = value is List ? value : []; + final results = List.filled(rules.length, true); + final messages = rules.map((r) => r['message']?.toString() ?? 'Validation failed').toList(); + + void updateValidationState() { + final errors = []; + for (int i = 0; i < results.length; i++) { + if (!results[i]) errors.add(messages[i]); + } + final parentPath = path.sublist(0, path.length - 1); + _updateDeepValue([...parentPath, 'isValid'], errors.isEmpty); + _updateDeepValue([...parentPath, 'validationErrors'], errors); + } + + for (int i = 0; i < rules.length; i++) { + final condition = rules[i]['condition'] ?? rules[i]; + final listenable = context.dataContext.resolveListenable(condition); + results[i] = listenable.value == true; + + if (!isSync) { + void listener() { + results[i] = listenable.value == true; + updateValidationState(); + } + listenable.addListener(listener); + _dataListeners.add(() => listenable.removeListener(listener)); + } + } + + // Return original rules for 'checks' property + return value; + + case Behavior.object: + if (value is! Map) return value; + final result = {}; + final shape = behavior.shape ?? {}; + + for (final entry in value.entries) { + final key = entry.key; + final childBehavior = shape[key] ?? BehaviorNode(Behavior.static); + result[key] = _resolveAndBind(entry.value, childBehavior, [...path, key], isSync); + } + + // Inject validation properties if 'checks' is present in shape + if (shape.containsKey('checks') && result.containsKey('checks')) { + final rules = value['checks'] as List? ?? []; + bool isValid = true; + final errors = []; + for (final rule in rules) { + final condition = rule['condition'] ?? rule; + final val = context.dataContext.resolveSync(condition); + if (val != true) { + isValid = false; + errors.add(rule['message']?.toString() ?? 'Validation failed'); + } + } + result['isValid'] = isValid; + result['validationErrors'] = errors; + } + + // Add setters for dynamic properties + for (final entry in shape.entries) { + if (entry.value.type == Behavior.dynamic) { + final key = entry.key; + final setterName = 'set${key[0].toUpperCase()}${key.substring(1)}'; + final rawValue = value[key]; + if (rawValue is Map && rawValue.containsKey('path')) { + result[setterName] = (dynamic newValue) { + context.dataContext.set(rawValue['path'] as String, newValue); + }; + } + } + } + return result; + + case Behavior.array: + if (value is! List) return value; + final elementBehavior = behavior.element ?? BehaviorNode(Behavior.static); + return value.asMap().entries.map((e) => _resolveAndBind(e.value, elementBehavior, [...path, e.key.toString()], isSync)).toList(); + + case Behavior.static: + default: + return value; + } + } + + void _updateDeepValue(List path, dynamic newValue) { + _resolvedProps.value = _cloneAndUpdate(_resolvedProps.value, path, newValue); + } + + Map _cloneAndUpdate(Map map, List path, dynamic newValue) { + if (path.isEmpty) return newValue as Map; + + final result = Map.from(map); + dynamic current = result; + + for (int i = 0; i < path.length - 1; i++) { + final key = path[i]; + if (current is Map) { + current[key] = current[key] is Map ? Map.from(current[key]) : (current[key] is List ? List.from(current[key]) : {}); + current = current[key]; + } else if (current is List) { + final idx = int.parse(key); + current[idx] = current[idx] is Map ? Map.from(current[idx]) : (current[idx] is List ? List.from(current[idx]) : {}); + current = current[idx]; + } + } + + final lastKey = path.last; + if (current is Map) { + current[lastKey] = newValue; + } else if (current is List) { + current[int.parse(lastKey)] = newValue; + } + + return result; + } + + BehaviorNode _scrapeSchemaBehavior(Schema schema, [String? propertyName]) { + final map = schema.value; + + if (propertyName == 'checks') return BehaviorNode(Behavior.checkable); + + // Recursively collect all schemas from allOf/anyOf/oneOf + final List> schemasToInspect = []; + void collectSchemas(Map s) { + schemasToInspect.add(s); + if (s['allOf'] is List) { + for (final sub in s['allOf'] as List) { + if (sub is Map) collectSchemas(sub.cast()); + } + } + if (s['anyOf'] is List) { + for (final sub in s['anyOf'] as List) { + if (sub is Map) collectSchemas(sub.cast()); + } + } + if (s['oneOf'] is List) { + for (final sub in s['oneOf'] as List) { + if (sub is Map) collectSchemas(sub.cast()); + } + } + } + collectSchemas(map.cast()); + + bool hasEvent = schemasToInspect.any((s) => s['properties'] != null && (s['properties'] as Map)['event'] != null); + bool hasFunctionCall = schemasToInspect.any((s) => s['properties'] != null && (s['properties'] as Map)['functionCall'] != null); + if (hasEvent || hasFunctionCall) return BehaviorNode(Behavior.action); + + bool hasPath = schemasToInspect.any((s) => s['properties'] != null && (s['properties'] as Map)['path'] != null && (s['properties'] as Map)['componentId'] == null); + if (hasPath) return BehaviorNode(Behavior.dynamic); + + bool hasStructural = schemasToInspect.any((s) => s['properties'] != null && (s['properties'] as Map)['componentId'] != null && (s['properties'] as Map)['path'] != null); + if (hasStructural) return BehaviorNode(Behavior.structural); + + final type = map['type']; + final Map allProperties = {}; + for (final s in schemasToInspect) { + if (s['properties'] is Map) { + allProperties.addAll((s['properties'] as Map).cast()); + } + } + + if (type == 'object' || allProperties.isNotEmpty) { + final shape = {}; + for (final entry in allProperties.entries) { + shape[entry.key] = _scrapeSchemaBehavior(Schema.fromMap(entry.value as Map), entry.key); + } + return BehaviorNode(Behavior.object, shape: shape); + } + + if (type == 'array') { + final items = map['items']; + if (items is Map) { + return BehaviorNode(Behavior.array, element: _scrapeSchemaBehavior(Schema.fromMap(items as Map))); + } + } + + return BehaviorNode(Behavior.static); + } + + void dispose() { + for (final unsub in _dataListeners) { + unsub(); + } + _dataListeners.clear(); + context.componentModel.onUpdated.removeListener(_rebuildAllBindings); + _resolvedProps.dispose(); + } +} diff --git a/packages/a2ui_core/lib/src/rendering/contexts.dart b/packages/a2ui_core/lib/src/rendering/contexts.dart new file mode 100644 index 000000000..e9cdfe73a --- /dev/null +++ b/packages/a2ui_core/lib/src/rendering/contexts.dart @@ -0,0 +1,124 @@ +import '../common/reactivity.dart'; +import '../common/data_path.dart'; +import '../protocol/common.dart'; +import '../protocol/catalog.dart'; +import '../state/data_model.dart'; +import '../state/surface_model.dart'; +import '../state/component_model.dart'; + +/// A contextual view of the main DataModel. +class DataContext { + final SurfaceModel surface; + final String path; + + DataContext(this.surface, this.path); + + DataModel get dataModel => surface.dataModel; + + /// Resolves a path against this context. + String resolvePath(String relativePath) { + if (relativePath.startsWith('/')) return relativePath; + if (relativePath == '' || relativePath == '.') return path; + + final base = path == '/' ? '' : (path.endsWith('/') ? path.substring(0, path.length - 1) : path); + return '$base/$relativePath'; + } + + /// Synchronously evaluates a dynamic value. + dynamic resolveSync(dynamic value) { + if (value is Map && value.containsKey('path')) { + return dataModel.get(resolvePath(value['path'] as String)); + } + if (value is Map && value.containsKey('call')) { + final call = FunctionCall.fromJson(Map.from(value)); + final args = {}; + for (final entry in call.args.entries) { + args[entry.key] = resolveSync(entry.value); + } + final result = surface.catalog.invoker(call.call, args, this); + if (result is ValueListenable) { + return result.value; + } + return result; + } + if (value is Map) { + final result = {}; + for (final entry in value.entries) { + result[entry.key as String] = resolveSync(entry.value); + } + return result; + } + if (value is List) { + return value.map((item) => resolveSync(item)).toList(); + } + return value; + } + + /// Reactively evaluates a dynamic value. + ValueListenable resolveListenable(dynamic value) { + if (value is Map && value.containsKey('path')) { + return dataModel.watch(resolvePath(value['path'] as String)); + } + if (value is Map && value.containsKey('call')) { + final call = FunctionCall.fromJson(Map.from(value)); + return ComputedNotifier(() { + final args = {}; + for (final entry in call.args.entries) { + final resolved = resolveListenable(entry.value); + args[entry.key] = resolved.value; + } + final result = surface.catalog.invoker(call.call, args, this); + if (result is ValueListenable) { + return result.value; + } + return result; + }); + } + return ValueNotifier(value); + } + + /// Creates a nested data context. + DataContext nested(String relativePath) { + return DataContext(surface, resolvePath(relativePath)); + } + + /// Sets a value in the data model. + void set(String relativePath, dynamic value) { + dataModel.set(resolvePath(relativePath), value); + } +} + +/// Context provided to components during rendering. +class ComponentContext { + final SurfaceModel surface; + final ComponentModel componentModel; + final DataContext dataContext; + + ComponentContext(this.surface, this.componentModel, {String? basePath}) + : dataContext = DataContext(surface, basePath ?? '/'); + + /// Dispatches an action from the component. + Future dispatchAction(Map action) { + return surface.dispatchAction(action, componentModel.id); + } + + /// Resolves a child component's context. + ComponentContext childContext(String childId, {String? basePath}) { + final childModel = surface.componentsModel.get(childId); + if (childModel == null) { + throw ArgumentError('Child component not found: $childId'); + } + return ComponentContext(surface, childModel, basePath: basePath ?? dataContext.path); + } +} + +extension CatalogInvokerExtension on Catalog { + /// Helper to invoke functions. + dynamic invoker(String name, Map args, DataContext context) { + final fn = functions[name]; + if (fn == null) { + throw ArgumentError('Function not found: $name'); + } + return fn.execute(args, context); + } +} diff --git a/packages/a2ui_core/lib/src/state/component_model.dart b/packages/a2ui_core/lib/src/state/component_model.dart new file mode 100644 index 000000000..ada78db8b --- /dev/null +++ b/packages/a2ui_core/lib/src/state/component_model.dart @@ -0,0 +1,84 @@ +import '../common/reactivity.dart'; +import '../common/errors.dart'; + +/// Represents the state model for an individual UI component. +class ComponentModel { + final String id; + final String type; + Map _properties; + final _onUpdated = ValueNotifier(null); + + /// Fires whenever the component's properties are updated. + ValueListenable get onUpdated => _onUpdated; + + ComponentModel(this.id, this.type, Map initialProperties) + : _properties = Map.from(initialProperties); + + /// The current properties of the component. + Map get properties => _properties; + + set properties(Map newProperties) { + _properties = Map.from(newProperties); + _onUpdated.value = this; + } + + /// Disposes of the component and its resources. + void dispose() { + _onUpdated.dispose(); + } + + /// Returns a JSON representation of the component tree. + Map toJson() { + return { + 'id': id, + 'component': type, + ..._properties, + }; + } +} + +/// Manages the collection of components for a specific surface. +class SurfaceComponentsModel { + final Map _components = {}; + final _onCreated = ValueNotifier(null); + final _onDeleted = ValueNotifier(null); + + /// Fires when a new component is added to the model. + ValueListenable get onCreated => _onCreated; + /// Fires when a component is removed, providing the ID of the deleted component. + ValueListenable get onDeleted => _onDeleted; + + /// Retrieves a component by its ID. + ComponentModel? get(String id) => _components[id]; + + /// Returns an iterator over the components in the model. + Iterable get all => _components.values; + + /// Adds a component to the model. + void addComponent(ComponentModel component) { + if (_components.containsKey(component.id)) { + throw A2uiStateError("Component with id '${component.id}' already exists."); + } + _components[component.id] = component; + _onCreated.value = component; + } + + /// Removes a component from the model by its ID. + void removeComponent(String id) { + final component = _components.remove(id); + if (component != null) { + component.dispose(); + _onDeleted.value = id; + } + } + + /// Disposes of the model and all its components. + void dispose() { + for (final component in _components.values) { + component.dispose(); + } + _components.clear(); + _onCreated.dispose(); + _onDeleted.dispose(); + } +} diff --git a/packages/a2ui_core/lib/src/state/data_model.dart b/packages/a2ui_core/lib/src/state/data_model.dart new file mode 100644 index 000000000..820c02df3 --- /dev/null +++ b/packages/a2ui_core/lib/src/state/data_model.dart @@ -0,0 +1,160 @@ +import '../common/data_path.dart'; +import '../common/reactivity.dart'; +import '../common/errors.dart'; +import 'dart:collection'; +import 'package:collection/collection.dart'; + +/// A standalone, observable data store representing the client-side state. +/// It handles JSON Pointer path resolution and subscription management. +class DataModel { + dynamic _data; + final Map>> _notifiers = {}; + + DataModel([dynamic initialData]) : _data = initialData ?? {}; + + /// Synchronously gets data at a specific JSON pointer path. + dynamic get(String path) { + final dataPath = DataPath.parse(path); + if (dataPath.isEmpty) return _data; + + dynamic current = _data; + for (final segment in dataPath.segments) { + if (current == null) return null; + if (current is Map) { + current = current[segment]; + } else if (current is List) { + final index = int.tryParse(segment); + if (index == null || index < 0 || index >= current.length) return null; + current = current[index]; + } else { + return null; + } + } + return current; + } + + /// Updates data at a specific path and notifies listeners. + void set(String path, dynamic value) { + final dataPath = DataPath.parse(path); + + batch(() { + if (dataPath.isEmpty) { + _data = value; + } else { + _data ??= {}; + dynamic current = _data; + for (int i = 0; i < dataPath.segments.length - 1; i++) { + final segment = dataPath.segments[i]; + final nextSegment = dataPath.segments[i + 1]; + final isNextNumeric = int.tryParse(nextSegment) != null; + + if (current is Map) { + if (!current.containsKey(segment) || current[segment] == null) { + current[segment] = isNextNumeric ? [] : {}; + } + current = current[segment]; + } else if (current is List) { + final index = int.tryParse(segment); + if (index == null) { + throw A2uiDataError("Cannot use non-numeric segment '$segment' on a list.", path: path); + } + while (current.length <= index) { + current.add(null); + } + if (current[index] == null) { + current[index] = isNextNumeric ? [] : {}; + } + current = current[index]; + } else { + throw A2uiDataError("Cannot set path '$path': intermediate segment '$segment' is a primitive.", path: path); + } + } + + final lastSegment = dataPath.segments.last; + if (current is Map) { + if (value == null) { + current.remove(lastSegment); + } else { + current[lastSegment] = value; + } + } else if (current is List) { + final index = int.tryParse(lastSegment); + if (index == null) { + throw A2uiDataError("Cannot use non-numeric segment '$lastSegment' on a list.", path: path); + } + while (current.length <= index) { + current.add(null); + } + current[index] = value; + } + } + + _notifyPathAndRelated(dataPath); + }); + } + + /// Returns a [ValueListenable] for a specific path. + /// Internally cached using a [WeakReference] to prevent leaks. + ValueListenable watch(String path) { + String normalizedPath = DataPath.parse(path).toString(); + if (normalizedPath == '') normalizedPath = '/'; + final ref = _notifiers[normalizedPath]; + if (ref != null) { + final notifier = ref.target; + if (notifier != null) { + return notifier as ValueListenable; + } + } + + final notifier = ValueNotifier(get(normalizedPath)); + _notifiers[normalizedPath] = WeakReference(notifier); + _pruneNotifiers(); + return notifier; + } + + void _notifyPathAndRelated(DataPath dataPath) { + final normalizedPath = dataPath.toString(); + + // Notify all active notifiers that are related to this path + for (final entryPath in _notifiers.keys.toList()) { + if (entryPath == '/' || entryPath == '') { + _getAndNotify(entryPath); + continue; + } + + if (entryPath == normalizedPath) { + _getAndNotify(entryPath); + } else if (normalizedPath.startsWith('$entryPath/')) { + _getAndNotify(entryPath); + } else if (entryPath.startsWith('$normalizedPath/')) { + _getAndNotify(entryPath); + } + } + } + + void _getAndNotify(String path) { + final ref = _notifiers[path]; + if (ref == null) return; + + final notifier = ref.target; + if (notifier == null) { + _notifiers.remove(path); + return; + } + + final newValue = get(path); + notifier.value = newValue; + notifier.notifyListeners(); + } + + void _pruneNotifiers() { + _notifiers.removeWhere((key, ref) => ref.target == null); + } + + void dispose() { + for (final ref in _notifiers.values) { + ref.target?.dispose(); + } + _notifiers.clear(); + } +} diff --git a/packages/a2ui_core/lib/src/state/surface_model.dart b/packages/a2ui_core/lib/src/state/surface_model.dart new file mode 100644 index 000000000..f748ee728 --- /dev/null +++ b/packages/a2ui_core/lib/src/state/surface_model.dart @@ -0,0 +1,124 @@ +import 'dart:async'; +import '../common/reactivity.dart'; +import '../protocol/catalog.dart'; +import '../protocol/messages.dart'; +import '../protocol/common.dart'; +import '../rendering/contexts.dart'; +import 'data_model.dart'; +import 'component_model.dart'; + +/// The state model for a single UI surface. +class SurfaceModel { + final String id; + final Catalog catalog; + final Map theme; + final bool sendDataModel; + + final DataModel dataModel; + final SurfaceComponentsModel componentsModel; + + final _onAction = ValueNotifier(null); + final _onError = ValueNotifier(null); + + /// Fires whenever an action is dispatched from this surface. + ValueListenable get onAction => _onAction; + + /// Fires whenever an error occurs on this surface. + ValueListenable get onError => _onError; + + SurfaceModel( + this.id, { + required this.catalog, + this.theme = const {}, + this.sendDataModel = false, + }) : dataModel = DataModel(), + componentsModel = SurfaceComponentsModel(); + + /// Dispatches an action from this surface. + Future dispatchAction(Map payload, String sourceComponentId) async { + if (payload.containsKey('event')) { + final event = payload['event'] as Map; + final action = A2uiClientAction( + name: event['name'] ?? 'unknown', + surfaceId: id, + sourceComponentId: sourceComponentId, + timestamp: DateTime.now(), + context: Map.from(event['context'] ?? {}), + ); + _onAction.value = action; + } else if (payload.containsKey('functionCall')) { + final callJson = payload['functionCall'] as Map; + final call = FunctionCall.fromJson(callJson); + catalog.invoker(call.call, Map.from(call.args), DataContext(this, '/')); + } + } + + /// Dispatches an error from this surface. + Future dispatchError(A2uiClientError error) async { + _onError.value = error; + } + + /// Disposes of the surface and its resources. + void dispose() { + dataModel.dispose(); + componentsModel.dispose(); + _onAction.dispose(); + _onError.dispose(); + } +} + +/// The root state model for the A2UI system. +class SurfaceGroupModel { + final Map> _surfaces = {}; + + final _onSurfaceCreated = ValueNotifier?>(null); + final _onSurfaceDeleted = ValueNotifier(null); + final _onAction = ValueNotifier(null); + + /// Fires when a new surface is added. + ValueListenable?> get onSurfaceCreated => _onSurfaceCreated; + /// Fires when a surface is removed. + ValueListenable get onSurfaceDeleted => _onSurfaceDeleted; + /// Fires when an action is dispatched from ANY surface in the group. + ValueListenable get onAction => _onAction; + + /// Adds a surface to the group. + void addSurface(SurfaceModel surface) { + if (_surfaces.containsKey(surface.id)) { + return; + } + _surfaces[surface.id] = surface; + surface.onAction.addListener(() { + final action = surface.onAction.value; + if (action != null) { + _onAction.value = action; + } + }); + _onSurfaceCreated.value = surface; + } + + /// Removes a surface from the group by its ID. + void deleteSurface(String id) { + final surface = _surfaces.remove(id); + if (surface != null) { + surface.dispose(); + _onSurfaceDeleted.value = id; + } + } + + /// Retrieves a surface by its ID. + SurfaceModel? getSurface(String id) => _surfaces[id]; + + /// Returns all active surfaces. + Iterable> get allSurfaces => _surfaces.values; + + /// Disposes of the group and all its surfaces. + void dispose() { + for (final id in List.from(_surfaces.keys)) { + deleteSurface(id); + } + _onSurfaceCreated.dispose(); + _onSurfaceDeleted.dispose(); + _onAction.dispose(); + } +} diff --git a/packages/a2ui_core/pubspec.yaml b/packages/a2ui_core/pubspec.yaml new file mode 100644 index 000000000..07d21d581 --- /dev/null +++ b/packages/a2ui_core/pubspec.yaml @@ -0,0 +1,16 @@ +name: a2ui_core +description: Core A2UI protocol implementation for Dart. +version: 0.1.0 +publish_to: none + +environment: + sdk: '>=3.2.0 <4.0.0' + +dependencies: + collection: ^1.18.0 + json_schema_builder: ^0.1.3 + meta: ^1.11.0 + uuid: ^4.2.1 + +dev_dependencies: + test: ^1.24.9 diff --git a/packages/a2ui_core/test/binder_test.dart b/packages/a2ui_core/test/binder_test.dart new file mode 100644 index 000000000..d803aed5a --- /dev/null +++ b/packages/a2ui_core/test/binder_test.dart @@ -0,0 +1,100 @@ +import 'package:test/test.dart'; +import 'package:a2ui_core/src/rendering/binder.dart'; +import 'package:a2ui_core/src/rendering/contexts.dart'; +import 'package:a2ui_core/src/protocol/minimal_catalog.dart'; +import 'package:a2ui_core/src/state/surface_model.dart'; +import 'package:a2ui_core/src/state/component_model.dart'; +import 'package:a2ui_core/src/common/reactivity.dart'; + +void main() { + group('GenericBinder', () { + late MinimalCatalog catalog; + late SurfaceModel surface; + + setUp(() { + catalog = MinimalCatalog(); + surface = SurfaceModel('s1', catalog: catalog); + }); + + test('resolves dynamic properties', () { + final comp = ComponentModel('c1', 'Text', { + 'text': {'path': '/val'} + }); + surface.componentsModel.addComponent(comp); + surface.dataModel.set('/val', 'initial'); + + final context = ComponentContext(surface, comp); + final binder = GenericBinder(context, MinimalTextApi().schema); + + expect(binder.resolvedProps.value['text'], 'initial'); + + surface.dataModel.set('/val', 'updated'); + expect(binder.resolvedProps.value['text'], 'updated'); + }); + + test('resolves actions into callbacks', () async { + String? actionName; + surface.onAction.addListener(() { + actionName = surface.onAction.value?.name; + }); + + final comp = ComponentModel('c1', 'Button', { + 'child': 'c2', + 'action': { + 'event': {'name': 'test_action'} + } + }); + surface.componentsModel.addComponent(comp); + + final context = ComponentContext(surface, comp); + final binder = GenericBinder(context, MinimalButtonApi().schema); + + final action = binder.resolvedProps.value['action']; + expect(action, isA()); + await (action as Function)(); + + expect(actionName, 'test_action'); + }); + + test('resolves structural children', () { + final comp = ComponentModel('c1', 'Row', { + 'children': ['child1', 'child2'] + }); + surface.componentsModel.addComponent(comp); + + final context = ComponentContext(surface, comp); + final binder = GenericBinder(context, MinimalRowApi().schema); + + final children = binder.resolvedProps.value['children'] as List; + expect(children.length, 2); + expect(children[0].id, 'child1'); + expect(children[1].id, 'child2'); + }); + + test('resolves checkable validation', () async { + final comp = ComponentModel('c1', 'TextField', { + 'label': 'Name', + 'checks': [ + { + 'condition': {'path': '/valid'}, + 'message': 'Must be valid' + } + ] + }); + surface.componentsModel.addComponent(comp); + surface.dataModel.set('/valid', false); + + final context = ComponentContext(surface, comp); + final binder = GenericBinder(context, MinimalTextFieldApi().schema); + + // Wait for Timer.run in GenericBinder + await Future.delayed(Duration(milliseconds: 10)); + + expect(binder.resolvedProps.value['isValid'], false); + expect(binder.resolvedProps.value['validationErrors'], ['Must be valid']); + + surface.dataModel.set('/valid', true); + expect(binder.resolvedProps.value['isValid'], true); + }); + }); +} diff --git a/packages/a2ui_core/test/data_model_test.dart b/packages/a2ui_core/test/data_model_test.dart new file mode 100644 index 000000000..eb140a424 --- /dev/null +++ b/packages/a2ui_core/test/data_model_test.dart @@ -0,0 +1,81 @@ +import 'package:test/test.dart'; +import 'package:a2ui_core/src/state/data_model.dart'; +import 'package:a2ui_core/src/common/reactivity.dart'; + +void main() { + group('DataModel', () { + test('gets and sets root data', () { + final model = DataModel({'foo': 'bar'}); + expect(model.get('/'), {'foo': 'bar'}); + + model.set('/', {'baz': 'qux'}); + expect(model.get('/'), {'baz': 'qux'}); + }); + + test('gets and sets nested data', () { + final model = DataModel(); + model.set('/user/name', 'Alice'); + expect(model.get('/user/name'), 'Alice'); + expect(model.get('/user'), {'name': 'Alice'}); + }); + + test('auto-vivifies maps and lists', () { + final model = DataModel(); + model.set('/users/0/name', 'Alice'); + expect(model.get('/users'), isA()); + expect(model.get('/users/0'), isA()); + expect(model.get('/users/0/name'), 'Alice'); + }); + + test('notifies exact path changes', () { + final model = DataModel(); + final watch = model.watch('/foo'); + int count = 0; + watch.addListener(() => count++); + + model.set('/foo', 'bar'); + expect(count, 1); + expect(watch.value, 'bar'); + }); + + test('notifies ancestor changes (bubble)', () { + final model = DataModel(); + final watch = model.watch('/user'); + int count = 0; + watch.addListener(() => count++); + + model.set('/user/name', 'Alice'); + expect(count, 1); + expect(watch.value, {'name': 'Alice'}); + }); + + test('notifies descendant changes (cascade)', () { + final model = DataModel(); + model.set('/user', {'name': 'Alice'}); + + final watch = model.watch('/user/name'); + int count = 0; + watch.addListener(() => count++); + + model.set('/user', {'name': 'Bob'}); + expect(count, 1); + expect(watch.value, 'Bob'); + }); + + test('notifies root watch on any change', () { + final model = DataModel(); + final watch = model.watch('/'); + int count = 0; + watch.addListener(() => count++); + + model.set('/foo', 'bar'); + expect(count, 1); + }); + + test('removes keys when setting null', () { + final model = DataModel({'foo': 'bar'}); + model.set('/foo', null); + expect(model.get('/'), isEmpty); + }); + }); +} diff --git a/packages/a2ui_core/test/data_path_test.dart b/packages/a2ui_core/test/data_path_test.dart new file mode 100644 index 000000000..802c7e000 --- /dev/null +++ b/packages/a2ui_core/test/data_path_test.dart @@ -0,0 +1,48 @@ +import 'package:test/test.dart'; +import 'package:a2ui_core/src/common/data_path.dart'; + +void main() { + group('DataPath', () { + test('parses root path', () { + final path = DataPath.parse('/'); + expect(path.segments, isEmpty); + expect(path.toString(), '/'); + }); + + test('parses simple path', () { + final path = DataPath.parse('/foo/bar'); + expect(path.segments, ['foo', 'bar']); + expect(path.toString(), '/foo/bar'); + }); + + test('parses escaped segments', () { + final path = DataPath.parse('/foo~1bar/baz~0qux'); + expect(path.segments, ['foo/bar', 'baz~qux']); + expect(path.toString(), '/foo~1bar/baz~0qux'); + }); + + test('appends segments', () { + final path = DataPath.parse('/foo').append('bar'); + expect(path.segments, ['foo', 'bar']); + expect(path.toString(), '/foo/bar'); + }); + + test('appends numeric segments', () { + final path = DataPath.parse('/foo').append(0); + expect(path.segments, ['foo', '0']); + expect(path.toString(), '/foo/0'); + }); + + test('parent path', () { + final path = DataPath.parse('/foo/bar'); + expect(path.parent?.toString(), '/foo'); + expect(path.parent?.parent?.toString(), '/'); + expect(path.parent?.parent?.parent, isNull); + }); + + test('equality', () { + expect(DataPath.parse('/a/b'), equals(DataPath.parse('/a/b'))); + expect(DataPath.parse('/a/b'), isNot(equals(DataPath.parse('/a/c')))); + }); + }); +} diff --git a/packages/a2ui_core/test/expressions_test.dart b/packages/a2ui_core/test/expressions_test.dart new file mode 100644 index 000000000..51c194bee --- /dev/null +++ b/packages/a2ui_core/test/expressions_test.dart @@ -0,0 +1,55 @@ +import 'package:test/test.dart'; +import 'package:a2ui_core/src/processing/expressions.dart'; + +void main() { + group('ExpressionParser', () { + late ExpressionParser parser; + + setUp(() { + parser = ExpressionParser(); + }); + + test('parses literals', () { + expect(parser.parse('hello'), ['hello']); + }); + + test('parses simple interpolation', () { + expect(parser.parse('hello \${foo}'), ['hello ', {'path': 'foo'}]); + }); + + test('parses absolute paths', () { + expect(parser.parse('value is \${/user/name}'), ['value is ', {'path': '/user/name'}]); + }); + + test('parses function calls', () { + expect(parser.parse('sum is \${add(a: 10, b: 20)}'), [ + 'sum is ', + { + 'call': 'add', + 'args': {'a': 10, 'b': 20}, + 'returnType': 'any' + } + ]); + }); + + test('parses nested interpolation', () { + expect(parser.parse('\${\${\"hello\"}}'), ['hello']); + }); + + test('handles escaped interpolation', () { + expect(parser.parse('escaped \\\${foo}'), ['escaped \${foo}']); + }); + + test('parses complex paths', () { + expect(parser.parseExpression('my-path.with_underscores'), {'path': 'my-path.with_underscores'}); + }); + + test('parses string literals with spaces', () { + expect(parser.parseExpression('\"hello world\"'), 'hello world'); + }); + + test('throws on unclosed interpolation', () { + expect(() => parser.parse('hello \${world'), throwsException); + }); + }); +} diff --git a/packages/a2ui_core/test/processor_test.dart b/packages/a2ui_core/test/processor_test.dart new file mode 100644 index 000000000..cbbc1c8e8 --- /dev/null +++ b/packages/a2ui_core/test/processor_test.dart @@ -0,0 +1,97 @@ +import 'package:test/test.dart'; +import 'package:a2ui_core/src/processing/processor.dart'; +import 'package:a2ui_core/src/protocol/catalog.dart'; +import 'package:a2ui_core/src/protocol/messages.dart'; +import 'package:a2ui_core/src/protocol/minimal_catalog.dart'; +import 'package:a2ui_core/src/state/surface_model.dart'; + +void main() { + group('MessageProcessor', () { + late MinimalCatalog catalog; + late MessageProcessor processor; + + setUp(() { + catalog = MinimalCatalog(); + processor = MessageProcessor(catalogs: [catalog]); + }); + + test('creates surface', () { + processor.processMessages([ + CreateSurfaceMessage( + surfaceId: 's1', + catalogId: catalog.id, + ), + ]); + + final surface = processor.groupModel.getSurface('s1'); + expect(surface, isNotNull); + expect(surface?.id, 's1'); + expect(surface?.catalog.id, catalog.id); + }); + + test('updates components', () { + processor.processMessages([ + CreateSurfaceMessage(surfaceId: 's1', catalogId: catalog.id), + UpdateComponentsMessage( + surfaceId: 's1', + components: [ + {'id': 'root', 'component': 'Text', 'text': 'Hello'} + ], + ), + ]); + + final surface = processor.groupModel.getSurface('s1'); + final root = surface?.componentsModel.get('root'); + expect(root, isNotNull); + expect(root?.type, 'Text'); + expect(root?.properties['text'], 'Hello'); + }); + + test('updates data model', () { + processor.processMessages([ + CreateSurfaceMessage(surfaceId: 's1', catalogId: catalog.id), + UpdateDataModelMessage( + surfaceId: 's1', + path: '/user/name', + value: 'Alice', + ), + ]); + + final surface = processor.groupModel.getSurface('s1'); + expect(surface?.dataModel.get('/user/name'), 'Alice'); + }); + + test('deletes surface', () { + processor.processMessages([ + CreateSurfaceMessage(surfaceId: 's1', catalogId: catalog.id), + DeleteSurfaceMessage(surfaceId: 's1'), + ]); + + expect(processor.groupModel.getSurface('s1'), isNull); + }); + + test('generates client capabilities with inline catalogs', () { + final caps = processor.getClientCapabilities(includeInlineCatalogs: true); + expect(caps['v0.9']['supportedCatalogIds'], contains(catalog.id)); + + final inline = caps['v0.9']['inlineCatalogs'] as List; + expect(inline.first['catalogId'], catalog.id); + expect(inline.first['components'], contains('Text')); + }); + + test('aggregates client data model', () { + processor.processMessages([ + CreateSurfaceMessage(surfaceId: 's1', catalogId: catalog.id, sendDataModel: true), + UpdateDataModelMessage(surfaceId: 's1', path: '/foo', value: 'bar'), + CreateSurfaceMessage(surfaceId: 's2', catalogId: catalog.id, sendDataModel: false), + UpdateDataModelMessage(surfaceId: 's2', path: '/secret', value: 'baz'), + ]); + + final dataModel = processor.getClientDataModel(); + expect(dataModel, isNotNull); + expect(dataModel?['surfaces'], contains('s1')); + expect(dataModel?['surfaces'], isNot(contains('s2'))); + expect(dataModel?['surfaces']['s1'], {'foo': 'bar'}); + }); + }); +} diff --git a/packages/a2ui_core/test/reactivity_test.dart b/packages/a2ui_core/test/reactivity_test.dart new file mode 100644 index 000000000..8053f7bed --- /dev/null +++ b/packages/a2ui_core/test/reactivity_test.dart @@ -0,0 +1,82 @@ +import 'package:test/test.dart'; +import 'package:a2ui_core/src/common/reactivity.dart'; + +void main() { + group('Reactivity', () { + test('ValueNotifier notifies listeners', () { + final notifier = ValueNotifier(10); + int callCount = 0; + notifier.addListener(() => callCount++); + + notifier.value = 20; + expect(callCount, 1); + expect(notifier.value, 20); + + notifier.value = 20; // No change + expect(callCount, 1); + }); + + test('ComputedNotifier tracks dependencies', () { + final a = ValueNotifier(1); + final b = ValueNotifier(2); + final sum = ComputedNotifier(() => a.value + b.value); + + expect(sum.value, 3); + + int callCount = 0; + sum.addListener(() => callCount++); + + a.value = 10; + expect(sum.value, 12); + expect(callCount, 1); + + b.value = 20; + expect(sum.value, 30); + expect(callCount, 2); + }); + + test('ComputedNotifier updates dependencies dynamically', () { + final useA = ValueNotifier(true); + final a = ValueNotifier(1); + final b = ValueNotifier(2); + final result = ComputedNotifier(() => useA.value ? a.value : b.value); + + expect(result.value, 1); + + int callCount = 0; + result.addListener(() => callCount++); + + b.value = 10; // Should not notify as b is not a dependency yet + expect(callCount, 0); + + useA.value = false; + expect(result.value, 10); + expect(callCount, 1); + + a.value = 100; // Should not notify as a is no longer a dependency + expect(callCount, 1); + + b.value = 20; + expect(callCount, 2); + expect(result.value, 20); + }); + + test('batch defers notifications', () { + final a = ValueNotifier(1); + final b = ValueNotifier(2); + final sum = ComputedNotifier(() => a.value + b.value); + + int callCount = 0; + sum.addListener(() => callCount++); + + batch(() { + a.value = 10; + b.value = 20; + expect(callCount, 0); // Not yet notified + }); + + expect(callCount, 1); // Notified exactly once + expect(sum.value, 30); + }); + }); +}