diff --git a/.gemini/config.yaml b/.gemini/config.yaml index 347128db0..add81c0c6 100644 --- a/.gemini/config.yaml +++ b/.gemini/config.yaml @@ -7,7 +7,7 @@ have_fun: false code_review: disable: false # Set to -1 for unlimited comments. - max_review_comments: 6 + max_review_comments: 20 # For now, use the default of MEDIUM for testing. Based on desired verbosity, # we can change this to LOW or HIGH in the future. comment_severity_threshold: MEDIUM diff --git a/examples/travel_app/lib/src/catalog/checkbox_filter_chips_input.dart b/examples/travel_app/lib/src/catalog/checkbox_filter_chips_input.dart index cd7974607..5eb306dcc 100644 --- a/examples/travel_app/lib/src/catalog/checkbox_filter_chips_input.dart +++ b/examples/travel_app/lib/src/catalog/checkbox_filter_chips_input.dart @@ -123,12 +123,10 @@ final checkboxFilterChipsInput = CatalogItem( ? selectedOptionsRef['path'] as String : '${context.id}.value'; - final ValueNotifier?> notifier = context.dataContext - .subscribeToObjectArray({'path': path}); - - return ValueListenableBuilder?>( - valueListenable: notifier, - builder: (buildContext, currentSelectedValues, child) { + return BoundList( + dataContext: context.dataContext, + value: {'path': path}, + builder: (buildContext, currentSelectedValues) { var effectiveSelections = currentSelectedValues; if (effectiveSelections == null) { if (selectedOptionsRef is List) { @@ -145,7 +143,10 @@ final checkboxFilterChipsInput = CatalogItem( icon: icon, selectedOptions: selectedOptionsSet, onChanged: (newSelectedOptions) { - context.dataContext.update(path, newSelectedOptions.toList()); + context.dataContext.update( + DataPath(path), + newSelectedOptions.toList(), + ); }, ); }, diff --git a/examples/travel_app/lib/src/catalog/date_input_chip.dart b/examples/travel_app/lib/src/catalog/date_input_chip.dart index a3e61a935..c106f75b5 100644 --- a/examples/travel_app/lib/src/catalog/date_input_chip.dart +++ b/examples/travel_app/lib/src/catalog/date_input_chip.dart @@ -129,19 +129,17 @@ final dateInputChip = CatalogItem( final path = value is Map && value.containsKey('path') ? value['path'] as String : '${context.id}.value'; - final ValueNotifier notifier = context.dataContext - .subscribeToString({'path': path}); - - return ValueListenableBuilder( - valueListenable: notifier, - builder: (buildContext, currentValue, child) { + return BoundString( + dataContext: context.dataContext, + value: {'path': path}, + builder: (buildContext, currentValue) { final String? effectiveValue = currentValue ?? (value is String ? value : null); return _DateInputChip( initialValue: effectiveValue, label: datePickerData.label, onChanged: (newValue) { - context.dataContext.update(path, newValue); + context.dataContext.update(DataPath(path), newValue); }, ); }, diff --git a/examples/travel_app/lib/src/catalog/information_card.dart b/examples/travel_app/lib/src/catalog/information_card.dart index e85cd0b93..1480136d3 100644 --- a/examples/travel_app/lib/src/catalog/information_card.dart +++ b/examples/travel_app/lib/src/catalog/information_card.dart @@ -77,18 +77,12 @@ final informationCard = CatalogItem( ? context.buildChild(cardData.imageChildId!) : null; - final ValueNotifier titleNotifier = context.dataContext - .subscribeToString(cardData.title); - final ValueNotifier subtitleNotifier = context.dataContext - .subscribeToString(cardData.subtitle); - final ValueNotifier bodyNotifier = context.dataContext - .subscribeToString(cardData.body); - return _InformationCard( imageChild: imageChild, - titleNotifier: titleNotifier, - subtitleNotifier: subtitleNotifier, - bodyNotifier: bodyNotifier, + title: cardData.title, + subtitle: cardData.subtitle, + body: cardData.body, + dataContext: context.dataContext, ); }, ); @@ -96,15 +90,17 @@ final informationCard = CatalogItem( class _InformationCard extends StatelessWidget { const _InformationCard({ this.imageChild, - required this.titleNotifier, - required this.subtitleNotifier, - required this.bodyNotifier, + required this.title, + required this.subtitle, + required this.body, + required this.dataContext, }); final Widget? imageChild; - final ValueNotifier titleNotifier; - final ValueNotifier subtitleNotifier; - final ValueNotifier bodyNotifier; + final Object title; + final Object? subtitle; + final Object body; + final DataContext dataContext; @override Widget build(BuildContext context) { @@ -122,27 +118,31 @@ class _InformationCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ValueListenableBuilder( - valueListenable: titleNotifier, - builder: (context, title, _) => Text( + BoundString( + dataContext: dataContext, + value: title, + builder: (context, title) => Text( title ?? '', style: Theme.of(context).textTheme.headlineSmall, ), ), - ValueListenableBuilder( - valueListenable: subtitleNotifier, - builder: (context, subtitle, _) { - if (subtitle == null) return const SizedBox.shrink(); - return Text( - subtitle, - style: Theme.of(context).textTheme.titleMedium, - ); - }, - ), + if (subtitle != null) + BoundString( + dataContext: dataContext, + value: subtitle!, + builder: (context, subtitle) { + if (subtitle == null) return const SizedBox.shrink(); + return Text( + subtitle, + style: Theme.of(context).textTheme.titleMedium, + ); + }, + ), const SizedBox(height: 8.0), - ValueListenableBuilder( - valueListenable: bodyNotifier, - builder: (context, body, _) => + BoundString( + dataContext: dataContext, + value: body, + builder: (context, body) => MarkdownWidget(text: body ?? ''), ), ], diff --git a/examples/travel_app/lib/src/catalog/input_group.dart b/examples/travel_app/lib/src/catalog/input_group.dart index a29342465..ad752d79c 100644 --- a/examples/travel_app/lib/src/catalog/input_group.dart +++ b/examples/travel_app/lib/src/catalog/input_group.dart @@ -105,9 +105,6 @@ final inputGroup = CatalogItem( itemContext.data as Map, ); - final ValueNotifier notifier = itemContext.dataContext - .subscribeToString(inputGroupData.submitLabel); - final List children = inputGroupData.children; final JsonMap actionData = inputGroupData.action; final event = actionData['event'] as JsonMap?; @@ -127,12 +124,13 @@ final inputGroup = CatalogItem( children: children.map(itemContext.buildChild).toList(), ), const SizedBox(height: 16.0), - ValueListenableBuilder( - valueListenable: notifier, - builder: (builderContext, submitLabel, child) { + BoundString( + dataContext: itemContext.dataContext, + value: inputGroupData.submitLabel, + builder: (builderContext, submitLabel) { return ElevatedButton( - onPressed: () { - final JsonMap resolvedContext = resolveContext( + onPressed: () async { + final JsonMap resolvedContext = await resolveContext( itemContext.dataContext, contextDefinition, ); diff --git a/examples/travel_app/lib/src/catalog/itinerary.dart b/examples/travel_app/lib/src/catalog/itinerary.dart index d1f87343b..32d770273 100644 --- a/examples/travel_app/lib/src/catalog/itinerary.dart +++ b/examples/travel_app/lib/src/catalog/itinerary.dart @@ -196,15 +196,11 @@ final itinerary = CatalogItem( context.data as Map, ); - final ValueNotifier titleNotifier = context.dataContext - .subscribeToString(itineraryData.title); - final ValueNotifier subheadingNotifier = context.dataContext - .subscribeToString(itineraryData.subheading); final Widget imageChild = context.buildChild(itineraryData.imageChildId); return _Itinerary( - titleNotifier: titleNotifier, - subheadingNotifier: subheadingNotifier, + title: itineraryData.title, + subheading: itineraryData.subheading, imageChild: imageChild, days: itineraryData.days, widgetId: context.id, @@ -216,8 +212,8 @@ final itinerary = CatalogItem( ); class _Itinerary extends StatelessWidget { - final ValueNotifier titleNotifier; - final ValueNotifier subheadingNotifier; + final Object title; + final Object subheading; final Widget imageChild; final List days; final String widgetId; @@ -226,8 +222,8 @@ class _Itinerary extends StatelessWidget { final DataContext dataContext; const _Itinerary({ - required this.titleNotifier, - required this.subheadingNotifier, + required this.title, + required this.subheading, required this.imageChild, required this.days, required this.widgetId, @@ -273,13 +269,36 @@ class _Itinerary extends StatelessWidget { padding: const EdgeInsets.symmetric( horizontal: 16.0, ), - child: ValueListenableBuilder( - valueListenable: titleNotifier, - builder: (context, title, _) => Text( - title ?? '', - style: Theme.of( - context, - ).textTheme.headlineMedium, + child: BoundString( + dataContext: dataContext, + value: title, + builder: (context, title) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title ?? '', + style: Theme.of( + context, + ).textTheme.headlineMedium, + ), + const SizedBox(height: 8.0), + Builder( + builder: (context) { + return ElevatedButton( + onPressed: () async { + dispatchEvent( + UserActionEvent( + name: 'viewItinerary', + sourceComponentId: widgetId, + context: {}, + ), + ); + }, + child: const Text('View Details'), + ); + }, + ), + ], ), ), ), @@ -328,16 +347,18 @@ class _Itinerary extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ValueListenableBuilder( - valueListenable: titleNotifier, - builder: (context, title, _) => Text( + BoundString( + dataContext: dataContext, + value: title, + builder: (context, title) => Text( title ?? '', style: Theme.of(context).textTheme.headlineSmall, ), ), - ValueListenableBuilder( - valueListenable: subheadingNotifier, - builder: (context, subheading, _) => Text( + BoundString( + dataContext: dataContext, + value: subheading, + builder: (context, subheading) => Text( subheading ?? '', style: Theme.of(context).textTheme.titleMedium, ), @@ -370,13 +391,6 @@ class _ItineraryDay extends StatelessWidget { @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); - final ValueNotifier titleNotifier = dataContext.subscribeToString( - data.title, - ); - final ValueNotifier subtitleNotifier = dataContext - .subscribeToString(data.subtitle); - final ValueNotifier descriptionNotifier = dataContext - .subscribeToString(data.description); final Widget imageChild = buildChild(data.imageChildId); return Padding( @@ -402,17 +416,19 @@ class _ItineraryDay extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ValueListenableBuilder( - valueListenable: titleNotifier, - builder: (context, value, _) => Text( + BoundString( + dataContext: dataContext, + value: data.title, + builder: (context, value) => Text( value ?? '', style: theme.textTheme.headlineSmall, ), ), const SizedBox(height: 4.0), - ValueListenableBuilder( - valueListenable: subtitleNotifier, - builder: (context, value, _) => Text( + BoundString( + dataContext: dataContext, + value: data.subtitle, + builder: (context, value) => Text( value ?? '', style: theme.textTheme.titleMedium, ), @@ -423,9 +439,10 @@ class _ItineraryDay extends StatelessWidget { ], ), const SizedBox(height: 8.0), - ValueListenableBuilder( - valueListenable: descriptionNotifier, - builder: (context, description, _) => + BoundString( + dataContext: dataContext, + value: data.description, + builder: (context, description) => MarkdownWidget(text: description ?? ''), ), const SizedBox(height: 8.0), @@ -471,20 +488,6 @@ class _ItineraryEntry extends StatelessWidget { @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); - final ValueNotifier titleNotifier = dataContext.subscribeToString( - data.title, - ); - final ValueNotifier subtitleNotifier = dataContext - .subscribeToString(data.subtitle); - final ValueNotifier bodyTextNotifier = dataContext - .subscribeToString(data.bodyText); - final ValueNotifier addressNotifier = dataContext - .subscribeToString(data.address); - final ValueNotifier timeNotifier = dataContext.subscribeToString( - data.time, - ); - final ValueNotifier totalCostNotifier = dataContext - .subscribeToString(data.totalCost); return Padding( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), @@ -501,9 +504,10 @@ class _ItineraryEntry extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( - child: ValueListenableBuilder( - valueListenable: titleNotifier, - builder: (context, title, _) => Text( + child: BoundString( + dataContext: dataContext, + value: data.title, + builder: (context, title) => Text( title ?? '', style: theme.textTheme.titleMedium, ), @@ -512,10 +516,11 @@ class _ItineraryEntry extends StatelessWidget { if (data.status == ItineraryEntryStatus.chosen) const Icon(Icons.check_circle, color: Colors.green) else if (data.status == ItineraryEntryStatus.choiceRequired) - ValueListenableBuilder( - valueListenable: titleNotifier, - builder: (context, title, _) => FilledButton( - onPressed: () { + BoundString( + dataContext: dataContext, + value: data.title, + builder: (context, title) => FilledButton( + onPressed: () async { final JsonMap? actionData = data.choiceRequiredAction; if (actionData == null) { @@ -528,10 +533,11 @@ class _ItineraryEntry extends StatelessWidget { final actionName = event['name'] as String; final contextDefinition = event['context'] as JsonMap?; - final JsonMap resolvedContext = resolveContext( - dataContext, - contextDefinition, - ); + final JsonMap resolvedContext = + await resolveContext( + dataContext, + contextDefinition, + ); dispatchEvent( UserActionEvent( name: actionName, @@ -546,66 +552,80 @@ class _ItineraryEntry extends StatelessWidget { ), ], ), - OptionalValueBuilder( - listenable: subtitleNotifier, - builder: (context, subtitle) { - return Padding( - padding: const EdgeInsets.only(top: 4.0), - child: Text(subtitle, style: theme.textTheme.bodySmall), - ); - }, - ), + if (data.subtitle != null) + BoundString( + dataContext: dataContext, + value: data.subtitle!, + builder: (context, subtitle) { + return Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text( + subtitle ?? '', + style: theme.textTheme.bodySmall, + ), + ); + }, + ), const SizedBox(height: 8.0), Row( children: [ const Icon(Icons.access_time, size: 16.0), const SizedBox(width: 4.0), - ValueListenableBuilder( - valueListenable: timeNotifier, - builder: (context, time, _) => + BoundString( + dataContext: dataContext, + value: data.time, + builder: (context, time) => Text(time ?? '', style: theme.textTheme.bodyMedium), ), ], ), - OptionalValueBuilder( - listenable: addressNotifier, - builder: (context, address) { - return Padding( - padding: const EdgeInsets.only(top: 4.0), - child: Row( - children: [ - const Icon(Icons.location_on, size: 16.0), - const SizedBox(width: 4.0), - Expanded( - child: Text( - address, + if (data.address != null) + BoundString( + dataContext: dataContext, + value: data.address!, + builder: (context, address) { + return Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Row( + children: [ + const Icon(Icons.location_on, size: 16.0), + const SizedBox(width: 4.0), + Expanded( + child: Text( + address ?? '', + style: theme.textTheme.bodyMedium, + ), + ), + ], + ), + ); + }, + ), + if (data.totalCost != null) + BoundString( + dataContext: dataContext, + value: data.totalCost!, + builder: (context, totalCost) { + return Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Row( + children: [ + const Icon(Icons.attach_money, size: 16.0), + const SizedBox(width: 4.0), + Text( + totalCost ?? '', style: theme.textTheme.bodyMedium, ), - ), - ], - ), - ); - }, - ), - OptionalValueBuilder( - listenable: totalCostNotifier, - builder: (context, totalCost) { - return Padding( - padding: const EdgeInsets.only(top: 4.0), - child: Row( - children: [ - const Icon(Icons.attach_money, size: 16.0), - const SizedBox(width: 4.0), - Text(totalCost, style: theme.textTheme.bodyMedium), - ], - ), - ); - }, - ), + ], + ), + ); + }, + ), const SizedBox(height: 8.0), - ValueListenableBuilder( - valueListenable: bodyTextNotifier, - builder: (context, bodyText, _) => + BoundString( + dataContext: dataContext, + value: data.bodyText, + builder: (context, bodyText) => MarkdownWidget(text: bodyText ?? ''), ), ], diff --git a/examples/travel_app/lib/src/catalog/listings_booker.dart b/examples/travel_app/lib/src/catalog/listings_booker.dart index 8af231272..f779e7b41 100644 --- a/examples/travel_app/lib/src/catalog/listings_booker.dart +++ b/examples/travel_app/lib/src/catalog/listings_booker.dart @@ -58,12 +58,10 @@ final listingsBooker = CatalogItem( context.data as Map, ); - final ValueNotifier itineraryNameNotifier = context.dataContext - .subscribeToString(listingsBookerData.itineraryName); - - return ValueListenableBuilder( - valueListenable: itineraryNameNotifier, - builder: (builderContext, itineraryName, _) { + return BoundString( + dataContext: context.dataContext, + value: listingsBookerData.itineraryName, + builder: (builderContext, itineraryName) { return _ListingsBooker( listingSelectionIds: listingsBookerData.listingSelectionIds, itineraryName: itineraryName ?? '', @@ -325,7 +323,7 @@ class _ListingsBookerState extends State<_ListingsBooker> { ), const SizedBox(width: 8), TextButton( - onPressed: () { + onPressed: () async { final JsonMap? actionData = widget.modifyAction; if (actionData == null) { return; @@ -333,10 +331,11 @@ class _ListingsBookerState extends State<_ListingsBooker> { final actionName = actionData['name'] as String; final contextDefinition = actionData['context'] as JsonMap?; - final JsonMap resolvedContext = resolveContext( - widget.dataContext, - contextDefinition, - ); + final JsonMap resolvedContext = + await resolveContext( + widget.dataContext, + contextDefinition, + ); resolvedContext['listingSelectionId'] = listing.listingSelectionId; widget.dispatchEvent( diff --git a/examples/travel_app/lib/src/catalog/options_filter_chip_input.dart b/examples/travel_app/lib/src/catalog/options_filter_chip_input.dart index 6c8fa133e..41a750a9a 100644 --- a/examples/travel_app/lib/src/catalog/options_filter_chip_input.dart +++ b/examples/travel_app/lib/src/catalog/options_filter_chip_input.dart @@ -109,12 +109,10 @@ final optionsFilterChipInput = CatalogItem( ? valueRef['path'] as String : '${context.id}.value'; // Always subscribe to the path, even if we have a literal value. - final ValueNotifier notifier = context.dataContext - .subscribeToString({'path': path}); - - return ValueListenableBuilder( - valueListenable: notifier, - builder: (builderContext, currentValue, child) { + return BoundString( + dataContext: context.dataContext, + value: {'path': path}, + builder: (builderContext, currentValue) { // If the data model is empty at the path, fall back to the literal // value provided in the component definition. final String? effectiveValue = @@ -127,7 +125,7 @@ final optionsFilterChipInput = CatalogItem( value: effectiveValue, onChanged: (newValue) { if (newValue != null) { - context.dataContext.update(path, newValue); + context.dataContext.update(DataPath(path), newValue); } }, ); diff --git a/examples/travel_app/lib/src/catalog/tabbed_sections.dart b/examples/travel_app/lib/src/catalog/tabbed_sections.dart index 87d80ee3d..db6ce19b6 100644 --- a/examples/travel_app/lib/src/catalog/tabbed_sections.dart +++ b/examples/travel_app/lib/src/catalog/tabbed_sections.dart @@ -91,30 +91,34 @@ final tabbedSections = CatalogItem( final List<_TabSectionData> sections = tabbedSectionsData.sections.map(( section, ) { - final ValueNotifier titleNotifier = context.dataContext - .subscribeToString(section.title); - return _TabSectionData( - titleNotifier: titleNotifier, - childId: section.childId, - ); + return _TabSectionData(title: section.title, childId: section.childId); }).toList(); - return _TabbedSections(sections: sections, buildChild: context.buildChild); + return _TabbedSections( + sections: sections, + buildChild: context.buildChild, + dataContext: context.dataContext, + ); }, ); class _TabSectionData { - final ValueNotifier titleNotifier; + final Object title; final String childId; - _TabSectionData({required this.titleNotifier, required this.childId}); + _TabSectionData({required this.title, required this.childId}); } class _TabbedSections extends StatefulWidget { - const _TabbedSections({required this.sections, required this.buildChild}); + const _TabbedSections({ + required this.sections, + required this.buildChild, + required this.dataContext, + }); final List<_TabSectionData> sections; final Widget Function(String id) buildChild; + final DataContext dataContext; @override State<_TabbedSections> createState() => _TabbedSectionsState(); @@ -150,9 +154,10 @@ class _TabbedSectionsState extends State<_TabbedSections> controller: _tabController, tabs: widget.sections.map((section) { return Tab( - child: ValueListenableBuilder( - valueListenable: section.titleNotifier, - builder: (context, title, child) { + child: BoundString( + dataContext: widget.dataContext, + value: section.title, + builder: (context, title) { return Text(title ?? ''); }, ), diff --git a/examples/travel_app/lib/src/catalog/text_input_chip.dart b/examples/travel_app/lib/src/catalog/text_input_chip.dart index 6298fa84f..1d8268989 100644 --- a/examples/travel_app/lib/src/catalog/text_input_chip.dart +++ b/examples/travel_app/lib/src/catalog/text_input_chip.dart @@ -73,12 +73,10 @@ final textInputChip = CatalogItem( final path = valueRef is Map && valueRef.containsKey('path') ? valueRef['path'] as String : '${context.id}.value'; - final ValueNotifier notifier = context.dataContext - .subscribeToString({'path': path}); - - return ValueListenableBuilder( - valueListenable: notifier, - builder: (builderContext, currentValue, child) { + return BoundString( + dataContext: context.dataContext, + value: {'path': path}, + builder: (builderContext, currentValue) { final String? effectiveValue = currentValue ?? (valueRef is String ? valueRef : null); return _TextInputChip( @@ -86,7 +84,7 @@ final textInputChip = CatalogItem( value: effectiveValue, obscured: textInputChipData.obscured, onChanged: (newValue) { - context.dataContext.update(path, newValue); + context.dataContext.update(DataPath(path), newValue); }, ); }, diff --git a/examples/travel_app/lib/src/catalog/trailhead.dart b/examples/travel_app/lib/src/catalog/trailhead.dart index 863d9e54b..d82eb36cf 100644 --- a/examples/travel_app/lib/src/catalog/trailhead.dart +++ b/examples/travel_app/lib/src/catalog/trailhead.dart @@ -101,23 +101,20 @@ class _Trailhead extends StatelessWidget { spacing: 8.0, runSpacing: 8.0, children: topics.map((topicRef) { - final ValueNotifier notifier = dataContext.subscribeToString( - topicRef, - ); - - return ValueListenableBuilder( - valueListenable: notifier, - builder: (context, topic, child) { + return BoundString( + dataContext: dataContext, + value: topicRef, + builder: (context, topic) { if (topic == null) { return const SizedBox.shrink(); } return InputChip( label: Text(topic), - onPressed: () { + onPressed: () async { final event = action['event'] as JsonMap?; final String name = event?['name'] as String? ?? 'unknown'; final contextDefinition = event?['context'] as JsonMap?; - final JsonMap resolvedContext = resolveContext( + final JsonMap resolvedContext = await resolveContext( dataContext, contextDefinition, ); diff --git a/examples/travel_app/lib/src/catalog/travel_carousel.dart b/examples/travel_app/lib/src/catalog/travel_carousel.dart index 3fe7eb44c..e944eecb1 100644 --- a/examples/travel_app/lib/src/catalog/travel_carousel.dart +++ b/examples/travel_app/lib/src/catalog/travel_carousel.dart @@ -71,27 +71,20 @@ final travelCarousel = CatalogItem( itemContext.data as Map, ); - final ValueNotifier titleNotifier = itemContext.dataContext - .subscribeToString(carouselData.title); - - final List<_TravelCarouselItemData> items = carouselData.items.map((item) { - final ValueNotifier descriptionNotifier = itemContext.dataContext - .subscribeToString(item.description); - - return _TravelCarouselItemData( - descriptionNotifier: descriptionNotifier, - imageChild: itemContext.buildChild(item.imageChildId), - listingSelectionId: item.listingSelectionId, - action: item.action, - ); - }).toList(); - - return ValueListenableBuilder( - valueListenable: titleNotifier, - builder: (builderContext, title, _) { + return BoundString( + dataContext: itemContext.dataContext, + value: carouselData.title, + builder: (builderContext, title) { return _TravelCarousel( title: title, - items: items, + items: carouselData.items.map((item) { + return _TravelCarouselItemData( + description: item.description, + imageChild: itemContext.buildChild(item.imageChildId), + listingSelectionId: item.listingSelectionId, + action: item.action, + ); + }).toList(), widgetId: itemContext.id, dispatchEvent: itemContext.dispatchEvent, dataContext: itemContext.dataContext, @@ -201,13 +194,13 @@ class _TravelCarousel extends StatelessWidget { } class _TravelCarouselItemData { - final ValueNotifier descriptionNotifier; + final Object description; final Widget imageChild; final String? listingSelectionId; final JsonMap action; _TravelCarouselItemData({ - required this.descriptionNotifier, + required this.description, required this.imageChild, this.listingSelectionId, required this.action, @@ -231,55 +224,60 @@ class _TravelCarouselItem extends StatelessWidget { Widget build(BuildContext context) { return SizedBox( width: 190, - child: InkWell( - onTap: () { - final event = data.action['event'] as JsonMap?; - final String name = event?['name'] as String? ?? 'unknown'; - final contextDefinition = event?['context'] as JsonMap?; - final JsonMap resolvedContext = resolveContext( - dataContext, - contextDefinition, - ); - resolvedContext['description'] = data.descriptionNotifier.value; - if (data.listingSelectionId != null) { - resolvedContext['listingSelectionId'] = data.listingSelectionId; - } - dispatchEvent( - UserActionEvent( - name: name, - sourceComponentId: widgetId, - context: resolvedContext, - ), - ); - }, - borderRadius: BorderRadius.circular(10.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(10.0), - child: SizedBox(height: 150, width: 190, child: data.imageChild), - ), - Container( - height: 90, - padding: const EdgeInsets.all(8.0), - alignment: Alignment.center, - child: ValueListenableBuilder( - valueListenable: data.descriptionNotifier, - builder: (context, description, child) { - return Text( + child: BoundString( + dataContext: dataContext, + value: data.description, + builder: (context, description) { + return InkWell( + onTap: () async { + final event = data.action['event'] as JsonMap?; + final String name = event?['name'] as String? ?? 'unknown'; + final contextDefinition = event?['context'] as JsonMap?; + final JsonMap resolvedContext = await resolveContext( + dataContext, + contextDefinition, + ); + resolvedContext['description'] = description; + if (data.listingSelectionId != null) { + resolvedContext['listingSelectionId'] = data.listingSelectionId; + } + dispatchEvent( + UserActionEvent( + name: name, + sourceComponentId: widgetId, + context: resolvedContext, + ), + ); + }, + borderRadius: BorderRadius.circular(10.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10.0), + child: SizedBox( + height: 150, + width: 190, + child: data.imageChild, + ), + ), + Container( + height: 90, + padding: const EdgeInsets.all(8.0), + alignment: Alignment.center, + child: Text( description ?? '', textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleMedium, softWrap: true, maxLines: 3, overflow: TextOverflow.ellipsis, - ); - }, - ), + ), + ), + ], ), - ], - ), + ); + }, ), ); } diff --git a/examples/travel_app/test/checkbox_filter_chips_input_test.dart b/examples/travel_app/test/checkbox_filter_chips_input_test.dart index d51281476..525be356e 100644 --- a/examples/travel_app/test/checkbox_filter_chips_input_test.dart +++ b/examples/travel_app/test/checkbox_filter_chips_input_test.dart @@ -31,9 +31,13 @@ void main() { buildChild: (_, [_]) => const SizedBox(), dispatchEvent: (_) {}, buildContext: context, - dataContext: DataContext(DataModel(), '/'), + dataContext: DataContext( + InMemoryDataModel(), + DataPath.root, + ), getComponent: (String componentId) => null, surfaceId: 'surface1', + reportError: (e, s) {}, ), ), ); @@ -49,7 +53,7 @@ void main() { testWidgets( 'CheckboxFilterChipsInput updates DataContext with implicit binding', (WidgetTester tester) async { - final dataModel = DataModel(); + final dataModel = InMemoryDataModel(); await tester.pumpWidget( MaterialApp( home: Scaffold( @@ -70,9 +74,10 @@ void main() { buildChild: (_, [_]) => const SizedBox(), dispatchEvent: (_) {}, buildContext: context, - dataContext: DataContext(dataModel, '/'), + dataContext: DataContext(dataModel, DataPath.root), getComponent: (String componentId) => null, surfaceId: 'surface1', + reportError: (e, s) {}, ), ), ); diff --git a/examples/travel_app/test/date_input_chip_test.dart b/examples/travel_app/test/date_input_chip_test.dart index bf8c5b2fe..1baade275 100644 --- a/examples/travel_app/test/date_input_chip_test.dart +++ b/examples/travel_app/test/date_input_chip_test.dart @@ -12,7 +12,7 @@ void main() { testWidgets('DateInputChip catalog item builds with literal value', ( WidgetTester tester, ) async { - final dataModel = DataModel(); + final dataModel = InMemoryDataModel(); await tester.pumpWidget( MaterialApp( home: Scaffold( @@ -27,9 +27,10 @@ void main() { buildChild: (data, [_]) => const SizedBox(), dispatchEvent: (event) {}, buildContext: context, - dataContext: DataContext(dataModel, '/'), + dataContext: DataContext(dataModel, DataPath.root), getComponent: (String componentId) => null, surfaceId: 'surface1', + reportError: (e, s) {}, ), ); }, @@ -44,7 +45,7 @@ void main() { testWidgets('DateInputChip catalog item builds with data model value', ( WidgetTester tester, ) async { - final dataModel = DataModel(); + final dataModel = InMemoryDataModel(); dataModel.update(DataPath('/testDate'), '2025-09-20'); await tester.pumpWidget( @@ -64,9 +65,10 @@ void main() { buildChild: (data, [_]) => const SizedBox(), dispatchEvent: (event) {}, buildContext: context, - dataContext: DataContext(dataModel, '/'), + dataContext: DataContext(dataModel, DataPath.root), getComponent: (String componentId) => null, surfaceId: 'surface1', + reportError: (e, s) {}, ), ); }, @@ -86,7 +88,7 @@ void main() { testWidgets('DateInputChip updates data model on date selection', ( WidgetTester tester, ) async { - final dataModel = DataModel(); + final dataModel = InMemoryDataModel(); dataModel.update(DataPath('/testDate'), '2025-09-20'); await tester.pumpWidget( @@ -106,9 +108,10 @@ void main() { buildChild: (data, [_]) => const SizedBox(), dispatchEvent: (event) {}, buildContext: context, - dataContext: DataContext(dataModel, '/'), + dataContext: DataContext(dataModel, DataPath.root), getComponent: (String componentId) => null, surfaceId: 'surface1', + reportError: (e, s) {}, ), ); }, @@ -130,7 +133,7 @@ void main() { testWidgets('DateInputChip selects date when no initial value', ( WidgetTester tester, ) async { - final dataModel = DataModel(); + final dataModel = InMemoryDataModel(); await tester.pumpWidget( MaterialApp( @@ -149,9 +152,10 @@ void main() { buildChild: (data, [_]) => const SizedBox(), dispatchEvent: (event) {}, buildContext: context, - dataContext: DataContext(dataModel, '/'), + dataContext: DataContext(dataModel, DataPath.root), getComponent: (String componentId) => null, surfaceId: 'surface1', + reportError: (e, s) {}, ), ); }, @@ -188,7 +192,7 @@ void main() { 'DateInputChip updates implicit data model path on date selection when ' 'initialized with literal', (WidgetTester tester) async { - final dataModel = DataModel(); + final dataModel = InMemoryDataModel(); await tester.pumpWidget( MaterialApp( @@ -204,9 +208,10 @@ void main() { buildChild: (data, [_]) => const SizedBox(), dispatchEvent: (event) {}, buildContext: context, - dataContext: DataContext(dataModel, '/'), + dataContext: DataContext(dataModel, DataPath.root), getComponent: (String componentId) => null, surfaceId: 'surface1', + reportError: (e, s) {}, ), ); }, diff --git a/examples/travel_app/test/input_group_test.dart b/examples/travel_app/test/input_group_test.dart index 2d4a1b141..17c9aec11 100644 --- a/examples/travel_app/test/input_group_test.dart +++ b/examples/travel_app/test/input_group_test.dart @@ -39,9 +39,13 @@ void main() { dispatchedEvent = event; }, buildContext: context, - dataContext: DataContext(DataModel(), '/'), + dataContext: DataContext( + InMemoryDataModel(), + DataPath.root, + ), getComponent: (String componentId) => null, surfaceId: 'surface1', + reportError: (e, s) {}, ), ); }, @@ -90,9 +94,13 @@ void main() { buildChild: (_, [_]) => const SizedBox.shrink(), dispatchEvent: (UiEvent _) {}, buildContext: context, - dataContext: DataContext(DataModel(), '/'), + dataContext: DataContext( + InMemoryDataModel(), + DataPath.root, + ), getComponent: (String componentId) => null, surfaceId: 'surface1', + reportError: (e, s) {}, ), ); }, diff --git a/examples/travel_app/test/itinerary_test.dart b/examples/travel_app/test/itinerary_test.dart index 7f3c7d21e..de263aac2 100644 --- a/examples/travel_app/test/itinerary_test.dart +++ b/examples/travel_app/test/itinerary_test.dart @@ -59,10 +59,11 @@ void main() { buildChild: (data, [_]) => SizedBox(key: Key(data)), dispatchEvent: mockDispatchEvent, buildContext: context, - dataContext: DataContext(DataModel(), '/'), + dataContext: DataContext(InMemoryDataModel(), DataPath.root), getComponent: (String componentId) => throw UnimplementedError(), surfaceId: 'surface1', + reportError: (e, s) {}, ), ); return Scaffold(body: Center(child: itineraryWidget)); diff --git a/examples/travel_app/test/options_filter_chip_input_test.dart b/examples/travel_app/test/options_filter_chip_input_test.dart index 1dc5333a4..deb31b2ba 100644 --- a/examples/travel_app/test/options_filter_chip_input_test.dart +++ b/examples/travel_app/test/options_filter_chip_input_test.dart @@ -12,7 +12,7 @@ void main() { testWidgets('renders correctly and handles selection with an icon', ( WidgetTester tester, ) async { - final dataModel = DataModel(); + final dataModel = InMemoryDataModel(); final Map data = { 'chipLabel': 'Price', 'options': ['\$', '\$\$', '\$\$\$'], @@ -34,9 +34,10 @@ void main() { buildChild: (_, [_]) => const SizedBox.shrink(), dispatchEvent: (event) {}, buildContext: context, - dataContext: DataContext(dataModel, '/'), + dataContext: DataContext(dataModel, DataPath.root), getComponent: (String componentId) => null, surfaceId: 'surface1', + reportError: (e, s) {}, ), ); }, @@ -77,7 +78,7 @@ void main() { testWidgets('renders correctly and handles selection without an icon', ( WidgetTester tester, ) async { - final dataModel = DataModel(); + final dataModel = InMemoryDataModel(); final Map data = { 'chipLabel': 'Price', 'options': ['\$', '\$\$', '\$\$\$'], @@ -98,9 +99,10 @@ void main() { buildChild: (_, [_]) => const SizedBox.shrink(), dispatchEvent: (event) {}, buildContext: context, - dataContext: DataContext(dataModel, '/'), + dataContext: DataContext(dataModel, DataPath.root), getComponent: (String componentId) => null, surfaceId: 'surface1', + reportError: (e, s) {}, ), ); }, @@ -131,7 +133,7 @@ void main() { testWidgets('renders correctly and handles selection with literal value ' '(implicit binding)', (WidgetTester tester) async { - final dataModel = DataModel(); + final dataModel = InMemoryDataModel(); final Map data = { 'chipLabel': 'Price', 'options': ['\$', '\$\$', '\$\$\$'], @@ -152,9 +154,10 @@ void main() { buildChild: (_, [_]) => const SizedBox.shrink(), dispatchEvent: (event) {}, buildContext: context, - dataContext: DataContext(dataModel, '/'), + dataContext: DataContext(dataModel, DataPath.root), getComponent: (String componentId) => null, surfaceId: 'surface1', + reportError: (e, s) {}, ), ); }, diff --git a/examples/travel_app/test/tabbed_sections_test.dart b/examples/travel_app/test/tabbed_sections_test.dart index bf38ffbef..69538d617 100644 --- a/examples/travel_app/test/tabbed_sections_test.dart +++ b/examples/travel_app/test/tabbed_sections_test.dart @@ -49,9 +49,13 @@ void main() { buildChild: mockBuildChild, dispatchEvent: (event) {}, buildContext: context, - dataContext: DataContext(DataModel(), '/'), + dataContext: DataContext( + InMemoryDataModel(), + DataPath.root, + ), getComponent: (String componentId) => null, surfaceId: 'surface1', + reportError: (e, s) {}, ), ); }, diff --git a/examples/travel_app/test/trailhead_test.dart b/examples/travel_app/test/trailhead_test.dart index 15f367b72..28ecfabc9 100644 --- a/examples/travel_app/test/trailhead_test.dart +++ b/examples/travel_app/test/trailhead_test.dart @@ -36,9 +36,13 @@ void main() { dispatchedEvent = event; }, buildContext: context, - dataContext: DataContext(DataModel(), '/'), + dataContext: DataContext( + InMemoryDataModel(), + DataPath.root, + ), getComponent: (String componentId) => null, surfaceId: 'surface1', + reportError: (e, s) {}, ), ); }, @@ -84,9 +88,13 @@ void main() { buildChild: (_, [_]) => const SizedBox.shrink(), dispatchEvent: (event) {}, buildContext: context, - dataContext: DataContext(DataModel(), '/'), + dataContext: DataContext( + InMemoryDataModel(), + DataPath.root, + ), getComponent: (String componentId) => null, surfaceId: 'surface1', + reportError: (e, s) {}, ), ); }, diff --git a/examples/travel_app/test/travel_carousel_test.dart b/examples/travel_app/test/travel_carousel_test.dart index 68d1b1544..6a1229627 100644 --- a/examples/travel_app/test/travel_carousel_test.dart +++ b/examples/travel_app/test/travel_carousel_test.dart @@ -54,9 +54,13 @@ void main() { dispatchedEvent = event; }, buildContext: context, - dataContext: DataContext(DataModel(), '/'), + dataContext: DataContext( + InMemoryDataModel(), + DataPath.root, + ), getComponent: (String componentId) => null, surfaceId: 'surface1', + reportError: (e, s) {}, ), ); }, @@ -125,9 +129,13 @@ void main() { dispatchedEvent = event; }, buildContext: context, - dataContext: DataContext(DataModel(), '/'), + dataContext: DataContext( + InMemoryDataModel(), + DataPath.root, + ), getComponent: (String componentId) => null, surfaceId: 'surface1', + reportError: (e, s) {}, ), ); }, @@ -165,9 +173,13 @@ void main() { buildChild: (data, [_]) => Text(data), dispatchEvent: (event) {}, buildContext: context, - dataContext: DataContext(DataModel(), '/'), + dataContext: DataContext( + InMemoryDataModel(), + DataPath.root, + ), getComponent: (String componentId) => null, surfaceId: 'surface1', + reportError: (e, s) {}, ), ); }, diff --git a/packages/genui/lib/src/catalog/basic_catalog_widgets/audio_player.dart b/packages/genui/lib/src/catalog/basic_catalog_widgets/audio_player.dart index 42b209355..716c071b4 100644 --- a/packages/genui/lib/src/catalog/basic_catalog_widgets/audio_player.dart +++ b/packages/genui/lib/src/catalog/basic_catalog_widgets/audio_player.dart @@ -36,19 +36,11 @@ final audioPlayer = CatalogItem( dataSchema: _schema, widgetBuilder: (itemContext) { final Object? description = (itemContext.data as JsonMap)['description']; - final ValueNotifier descriptionNotifier = itemContext.dataContext - .subscribeToString(description); - - return ValueListenableBuilder( - valueListenable: descriptionNotifier, - builder: (context, description, child) { - return Semantics( - label: description, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 200, maxHeight: 100), - child: const Placeholder(child: Center(child: Text('AudioPlayer'))), - ), - ); + return BoundString( + dataContext: itemContext.dataContext, + value: description, + builder: (context, value) { + return Semantics(label: value, child: const Icon(Icons.audiotrack)); }, ); }, diff --git a/packages/genui/lib/src/catalog/basic_catalog_widgets/button.dart b/packages/genui/lib/src/catalog/basic_catalog_widgets/button.dart index 5d3c36966..8de4f877f 100644 --- a/packages/genui/lib/src/catalog/basic_catalog_widgets/button.dart +++ b/packages/genui/lib/src/catalog/basic_catalog_widgets/button.dart @@ -5,14 +5,13 @@ import 'package:flutter/material.dart'; import 'package:json_schema_builder/json_schema_builder.dart'; -import '../../functions/expression_parser.dart'; import '../../model/a2ui_schemas.dart'; import '../../model/catalog_item.dart'; import '../../model/ui_models.dart'; import '../../primitives/logging.dart'; import '../../primitives/simple_items.dart'; +import '../../utils/validation_helper.dart'; import '../../widgets/widget_utilities.dart'; -import 'widget_helpers.dart'; final _schema = S.object( properties: { @@ -103,12 +102,14 @@ final button = CatalogItem( }; // Validate checks to determine if button is enabled - return ValueListenableBuilder( - valueListenable: itemContext.dataContext.createComputedNotifier( - checksToExpression(buttonData.checks), + return StreamBuilder( + stream: ValidationHelper.validateStream( + buttonData.checks, + itemContext.dataContext, ), - builder: (context, isValid, _) { - final enabled = isValid != false; // Default to true if null (no checks) + builder: (context, snapshot) { + final isValid = snapshot.data == null; + final enabled = isValid; final Widget buttonWidget = borderless ? TextButton( @@ -194,14 +195,17 @@ final button = CatalogItem( ], ); -void _handlePress(CatalogItemContext itemContext, _ButtonData buttonData) { +Future _handlePress( + CatalogItemContext itemContext, + _ButtonData buttonData, +) async { final JsonMap actionData = buttonData.action; if (actionData.containsKey('event')) { final eventMap = actionData['event'] as JsonMap; final actionName = eventMap['name'] as String; final contextDefinition = eventMap['context'] as JsonMap?; - final JsonMap resolvedContext = resolveContext( + final JsonMap resolvedContext = await resolveContext( itemContext.dataContext, contextDefinition, ); @@ -217,12 +221,20 @@ void _handlePress(CatalogItemContext itemContext, _ButtonData buttonData) { final callName = funcMap['call'] as String; if (callName == 'closeModal') { - Navigator.of(itemContext.buildContext).pop(); + if (itemContext.buildContext.mounted) { + Navigator.of(itemContext.buildContext).pop(); + } return; } - final parser = ExpressionParser(itemContext.dataContext); - parser.evaluateFunctionCall(funcMap); + final Stream resultStream = itemContext.dataContext.resolve( + funcMap, + ); + try { + await resultStream.first; + } catch (e, stack) { + itemContext.reportError(e, stack); + } } else { genUiLogger.warning( 'Button action missing event or functionCall: $actionData', diff --git a/packages/genui/lib/src/catalog/basic_catalog_widgets/check_box.dart b/packages/genui/lib/src/catalog/basic_catalog_widgets/check_box.dart index 439fd9567..91d994d77 100644 --- a/packages/genui/lib/src/catalog/basic_catalog_widgets/check_box.dart +++ b/packages/genui/lib/src/catalog/basic_catalog_widgets/check_box.dart @@ -7,6 +7,7 @@ import 'package:json_schema_builder/json_schema_builder.dart'; import '../../model/a2ui_schemas.dart'; import '../../model/catalog_item.dart'; +import '../../model/data_model.dart'; import '../../primitives/simple_items.dart'; import '../../widgets/widget_utilities.dart'; import 'widget_helpers.dart'; @@ -49,41 +50,40 @@ final checkBox = CatalogItem( dataSchema: _schema, widgetBuilder: (itemContext) { final checkBoxData = _CheckBoxData.fromMap(itemContext.data as JsonMap); - final ValueNotifier labelNotifier = itemContext.dataContext - .subscribeToString(checkBoxData.label); final Object valueRef = checkBoxData.value; final path = (valueRef is Map && valueRef.containsKey('path')) ? valueRef['path'] as String : '${itemContext.id}.value'; - final ValueNotifier valueNotifier = itemContext.dataContext - .subscribeToBool({'path': path}); + return BoundString( + dataContext: itemContext.dataContext, + value: checkBoxData.label, + builder: (context, label) { + // Wrap the checkbox in validation + return StreamBuilder( + stream: itemContext.dataContext.evaluateConditionStream( + checksToExpression(checkBoxData.checks), + ), + initialData: true, + builder: (context, snapshot) { + final bool isValid = snapshot.data ?? true; + final bool isError = !isValid; - return ValueListenableBuilder( - valueListenable: labelNotifier, - builder: (context, label, child) { - return ValueListenableBuilder( - valueListenable: valueNotifier, - builder: (context, value, child) { - final bool effectiveValue = - value ?? (valueRef is bool ? valueRef : false); - - // Wrap the checkbox in validation - return ValueListenableBuilder( - valueListenable: itemContext.dataContext.createComputedNotifier( - checksToExpression(checkBoxData.checks), - ), - builder: (context, isValid, _) { - final isError = isValid == false; - - final Widget checkboxWidget = ListTileTheme.merge( - child: CheckboxListTile( + return ListTileTheme.merge( + child: BoundBool( + dataContext: itemContext.dataContext, + value: {'path': path}, + builder: (context, value) { + return CheckboxListTile( title: Text(label ?? ''), - value: effectiveValue, - onChanged: (newValue) { + value: value ?? false, + onChanged: (bool? newValue) { if (newValue != null) { - itemContext.dataContext.update(path, newValue); + itemContext.dataContext.update( + DataPath(path), + newValue, + ); } }, subtitle: isError @@ -96,11 +96,9 @@ final checkBox = CatalogItem( ) : null, isError: isError, - ), - ); - - return checkboxWidget; - }, + ); + }, + ), ); }, ); diff --git a/packages/genui/lib/src/catalog/basic_catalog_widgets/choice_picker.dart b/packages/genui/lib/src/catalog/basic_catalog_widgets/choice_picker.dart index a4351a363..c72e75b73 100644 --- a/packages/genui/lib/src/catalog/basic_catalog_widgets/choice_picker.dart +++ b/packages/genui/lib/src/catalog/basic_catalog_widgets/choice_picker.dart @@ -7,6 +7,7 @@ import 'package:json_schema_builder/json_schema_builder.dart'; import '../../model/a2ui_schemas.dart'; import '../../model/catalog_item.dart'; +import '../../model/data_model.dart'; import '../../primitives/simple_items.dart'; import '../../widgets/widget_utilities.dart'; import 'widget_helpers.dart'; @@ -79,60 +80,56 @@ final choicePicker = CatalogItem( ? valueRef['path'] as String : '${itemContext.id}.value'; - itemContext.dataContext.subscribe(path); - final isMutuallyExclusive = data.variant == 'mutuallyExclusive'; final isChips = data.displayStyle == 'chips'; - final Object? optionsRef = data.options; - final ValueNotifier?> optionsNotifier; - if (optionsRef is Map && optionsRef.containsKey('path')) { - optionsNotifier = itemContext.dataContext.subscribe>( - optionsRef['path'] as String, - ); - } else { - optionsNotifier = ValueNotifier(optionsRef as List?); - } - // Wrap the picker in validation - return ValueListenableBuilder( - valueListenable: itemContext.dataContext.createComputedNotifier( + return StreamBuilder( + stream: itemContext.dataContext.evaluateConditionStream( checksToExpression(data.checks), ), - builder: (context, isValid, _) { - final isError = isValid == false; + initialData: true, + builder: (context, snapshot) { + final bool isValid = snapshot.data ?? true; + final bool isError = !isValid; - final Widget pickerWidget = _ChoicePicker( - label: data.label, - optionsNotifier: optionsNotifier, - valueRef: valueRef, - path: path, - itemContext: itemContext, - isMutuallyExclusive: isMutuallyExclusive, - isChips: isChips, - filterable: data.filterable, - ); + return BoundList( + dataContext: itemContext.dataContext, + value: data.options, + builder: (context, options) { + final Widget pickerWidget = _ChoicePicker( + label: data.label, + options: options, + valueRef: valueRef, + path: path, + itemContext: itemContext, + isMutuallyExclusive: isMutuallyExclusive, + isChips: isChips, + filterable: data.filterable, + ); - if (!isError) { - return pickerWidget; - } + if (!isError) { + return pickerWidget; + } - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - pickerWidget, - Padding( - padding: const EdgeInsets.only(left: 16.0, top: 4.0), - child: Text( - 'Invalid selection', - style: TextStyle( - color: Theme.of(context).colorScheme.error, - fontSize: 12, + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + pickerWidget, + Padding( + padding: const EdgeInsets.only(left: 16.0, top: 4.0), + child: Text( + 'Invalid selection', + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 12, + ), + ), ), - ), - ), - ], + ], + ); + }, ); }, ); @@ -158,7 +155,7 @@ final choicePicker = CatalogItem( class _ChoicePicker extends StatefulWidget { const _ChoicePicker({ required this.label, - required this.optionsNotifier, + required this.options, required this.valueRef, required this.path, required this.itemContext, @@ -168,7 +165,7 @@ class _ChoicePicker extends StatefulWidget { }); final Object? label; - final ValueNotifier?> optionsNotifier; + final List? options; final Object valueRef; final String path; final CatalogItemContext itemContext; @@ -182,15 +179,6 @@ class _ChoicePicker extends StatefulWidget { class _ChoicePickerState extends State<_ChoicePicker> { String _filter = ''; - late final ValueNotifier _selectionsNotifier; - - @override - void initState() { - super.initState(); - _selectionsNotifier = widget.itemContext.dataContext.subscribe( - widget.path, - ); - } @override Widget build(BuildContext context) { @@ -203,11 +191,10 @@ class _ChoicePickerState extends State<_ChoicePicker> { if (widget.label != null) Padding( padding: const EdgeInsets.only(bottom: 8.0, left: 16.0), - child: ValueListenableBuilder( - valueListenable: widget.itemContext.dataContext.subscribeToString( - widget.label!, - ), - builder: (context, label, child) { + child: BoundString( + dataContext: widget.itemContext.dataContext, + value: widget.label!, + builder: (context, label) { if (label == null || label.isEmpty) { return const SizedBox.shrink(); } @@ -233,9 +220,10 @@ class _ChoicePickerState extends State<_ChoicePicker> { }, ), ), - ValueListenableBuilder( - valueListenable: _selectionsNotifier, - builder: (context, currentSelections, child) { + BoundObject( + dataContext: widget.itemContext.dataContext, + value: {'path': widget.path}, + builder: (context, currentSelections) { var effectiveSelections = currentSelections; if (effectiveSelections == null) { if (widget.valueRef is List) { @@ -252,108 +240,96 @@ class _ChoicePickerState extends State<_ChoicePicker> { .toList() ?? []; - return ValueListenableBuilder?>( - valueListenable: widget.optionsNotifier, - builder: (context, options, child) { - if (options == null) { - return const SizedBox.shrink(); - } - final List castOptions = options.cast(); - final List optionWidgets = []; + if (widget.options == null) { + return const SizedBox.shrink(); + } + final List castOptions = widget.options!.cast(); + final List optionWidgets = []; - for (final option in castOptions) { - final ValueNotifier labelNotifier = widget - .itemContext - .dataContext - .subscribeToString(option['label']); - final optionValue = option['value'] as String; + for (final option in castOptions) { + final optionValue = option['value'] as String; - optionWidgets.add( - ValueListenableBuilder( - valueListenable: labelNotifier, - builder: (context, label, child) { - if (widget.filterable && - _filter.isNotEmpty && - label != null && - !label.toLowerCase().contains( - _filter.toLowerCase(), - )) { - return const SizedBox.shrink(); - } + optionWidgets.add( + BoundString( + dataContext: widget.itemContext.dataContext, + value: option['label'], + builder: (context, label) { + if (widget.filterable && + _filter.isNotEmpty && + label != null && + !label.toLowerCase().contains(_filter.toLowerCase())) { + return const SizedBox.shrink(); + } - if (widget.isChips) { - final bool selected = currentStrings.contains( - optionValue, - ); - return Padding( - padding: const EdgeInsets.all(4.0), - child: FilterChip( - label: Text(label ?? ''), - selected: selected, - onSelected: (bool selected) { - _updateSelection( - selected, - optionValue, - currentStrings, - ); - }, - ), - ); - } + if (widget.isChips) { + final bool selected = currentStrings.contains( + optionValue, + ); + return Padding( + padding: const EdgeInsets.all(4.0), + child: FilterChip( + label: Text(label ?? ''), + selected: selected, + onSelected: (bool selected) { + _updateSelection( + selected, + optionValue, + currentStrings, + ); + }, + ), + ); + } - if (widget.isMutuallyExclusive) { - final Object? groupValue = currentStrings.isNotEmpty - ? currentStrings.first - : null; + if (widget.isMutuallyExclusive) { + final Object? groupValue = currentStrings.isNotEmpty + ? currentStrings.first + : null; - return RadioListTile( - controlAffinity: ListTileControlAffinity.leading, - dense: true, - title: Text(label ?? ''), - value: optionValue, - // ignore: deprecated_member_use - groupValue: groupValue is String - ? groupValue - : null, - // ignore: deprecated_member_use - onChanged: (newValue) { - if (newValue == null) return; - widget.itemContext.dataContext.update( - widget.path, - [newValue], - ); - }, + return RadioListTile( + controlAffinity: ListTileControlAffinity.leading, + dense: true, + title: Text(label ?? ''), + value: optionValue, + // ignore: deprecated_member_use + groupValue: groupValue is String ? groupValue : null, + // ignore: deprecated_member_use + onChanged: (newValue) { + if (newValue == null) return; + widget.itemContext.dataContext.update( + DataPath(widget.path), + [newValue], ); - } else { - return CheckboxListTile( - title: Text(label ?? ''), - dense: true, - controlAffinity: ListTileControlAffinity.leading, - value: currentStrings.contains(optionValue), - onChanged: (newValue) { - _updateSelection( - newValue == true, - optionValue, - currentStrings, - ); - }, + }, + ); + } else { + return CheckboxListTile( + title: Text(label ?? ''), + dense: true, + controlAffinity: ListTileControlAffinity.leading, + value: currentStrings.contains(optionValue), + onChanged: (newValue) { + _updateSelection( + newValue == true, + optionValue, + currentStrings, ); - } - }, - ), - ); - } + }, + ); + } + }, + ), + ); + } - if (widget.isChips) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Wrap(children: optionWidgets), - ); - } + if (widget.isChips) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Wrap(children: optionWidgets), + ); + } - return Column(children: optionWidgets); - }, - ); + return Column(children: optionWidgets); }, ), ], @@ -367,7 +343,9 @@ class _ChoicePickerState extends State<_ChoicePicker> { ) { if (widget.isMutuallyExclusive) { if (selected) { - widget.itemContext.dataContext.update(widget.path, [optionValue]); + widget.itemContext.dataContext.update(DataPath(widget.path), [ + optionValue, + ]); } } else { final newSelections = List.from(currentStrings); @@ -378,7 +356,10 @@ class _ChoicePickerState extends State<_ChoicePicker> { } else { newSelections.remove(optionValue); } - widget.itemContext.dataContext.update(widget.path, newSelections); + widget.itemContext.dataContext.update( + DataPath(widget.path), + newSelections, + ); } } } diff --git a/packages/genui/lib/src/catalog/basic_catalog_widgets/column.dart b/packages/genui/lib/src/catalog/basic_catalog_widgets/column.dart index e19de2cb8..3bcff8886 100644 --- a/packages/genui/lib/src/catalog/basic_catalog_widgets/column.dart +++ b/packages/genui/lib/src/catalog/basic_catalog_widgets/column.dart @@ -7,6 +7,7 @@ import 'package:json_schema_builder/json_schema_builder.dart'; import '../../model/a2ui_schemas.dart'; import '../../model/catalog_item.dart'; +import '../../model/data_model.dart'; import '../../model/ui_models.dart'; import '../../primitives/simple_items.dart'; import 'widget_helpers.dart'; @@ -178,7 +179,7 @@ final column = CatalogItem( buildWeightedChild( componentId: componentId, dataContext: itemContext.dataContext.nested( - '$dataBinding/${keys[i]}', + DataPath('$dataBinding/${keys[i]}'), ), buildChild: itemContext.buildChild, weight: weight, diff --git a/packages/genui/lib/src/catalog/basic_catalog_widgets/date_time_input.dart b/packages/genui/lib/src/catalog/basic_catalog_widgets/date_time_input.dart index fcd92a9a3..0b7890d87 100644 --- a/packages/genui/lib/src/catalog/basic_catalog_widgets/date_time_input.dart +++ b/packages/genui/lib/src/catalog/basic_catalog_widgets/date_time_input.dart @@ -7,7 +7,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:json_schema_builder/json_schema_builder.dart'; -import '../../functions/expression_parser.dart'; import '../../model/a2ui_schemas.dart'; import '../../model/catalog_item.dart'; import '../../model/data_model.dart'; @@ -98,7 +97,6 @@ class _DateTimeInput extends StatefulWidget { required this.onChanged, this.label, this.checks, - this.parser, }); final String id; @@ -109,7 +107,6 @@ class _DateTimeInput extends StatefulWidget { final VoidCallback onChanged; final String? label; final List? checks; - final ExpressionParser? parser; @override State<_DateTimeInput> createState() => _DateTimeInputState(); @@ -130,7 +127,7 @@ class _DateTimeInputState extends State<_DateTimeInput> { super.didUpdateWidget(oldWidget); if (widget.value != oldWidget.value || widget.checks != oldWidget.checks || - widget.parser != oldWidget.parser) { + widget.dataContext != oldWidget.dataContext) { _setupValidation(); } } @@ -139,9 +136,7 @@ class _DateTimeInputState extends State<_DateTimeInput> { _validationSubscription?.cancel(); _validationSubscription = null; - if (widget.checks == null || - widget.checks!.isEmpty || - widget.parser == null) { + if (widget.checks == null || widget.checks!.isEmpty) { if (_errorText != null && mounted) { setState(() => _errorText = null); } @@ -149,9 +144,10 @@ class _DateTimeInputState extends State<_DateTimeInput> { } _validationSubscription = - ValidationHelper.validateStream(widget.checks, widget.parser).listen(( - String? newError, - ) { + ValidationHelper.validateStream( + widget.checks, + widget.dataContext, + ).listen((String? newError) { if (newError != _errorText && mounted) { setState(() => _errorText = newError); } @@ -217,7 +213,7 @@ class _DateTimeInputState extends State<_DateTimeInput> { formattedValue = finalDateTime.toIso8601String(); } - widget.dataContext.update(widget.path, formattedValue); + widget.dataContext.update(DataPath(widget.path), formattedValue); widget.onChanged(); } @@ -307,16 +303,10 @@ final dateTimeInput = CatalogItem( ? valueRef['path'] as String : '${itemContext.id}.value'; - final ValueNotifier valueNotifier = itemContext.dataContext - .subscribeToString({'path': path}); - final ValueNotifier labelNotifier = itemContext.dataContext - .subscribeToString(dateTimeInputData.label); - - final parser = ExpressionParser(itemContext.dataContext); - - return ValueListenableBuilder( - valueListenable: valueNotifier, - builder: (context, value, child) { + return BoundString( + dataContext: itemContext.dataContext, + value: {'path': path}, + builder: (context, value) { var effectiveValue = value; if (effectiveValue == null) { final Object val = dateTimeInputData.value; @@ -325,9 +315,10 @@ final dateTimeInput = CatalogItem( } } - return ValueListenableBuilder( - valueListenable: labelNotifier, - builder: (context, label, child) { + return BoundString( + dataContext: itemContext.dataContext, + value: dateTimeInputData.label, + builder: (context, label) { return _DateTimeInput( id: itemContext.id, value: effectiveValue, @@ -337,7 +328,6 @@ final dateTimeInput = CatalogItem( onChanged: () {}, label: label, checks: dateTimeInputData.checks, - parser: parser, ); }, ); diff --git a/packages/genui/lib/src/catalog/basic_catalog_widgets/icon.dart b/packages/genui/lib/src/catalog/basic_catalog_widgets/icon.dart index 394eff1e6..82cf98d3c 100644 --- a/packages/genui/lib/src/catalog/basic_catalog_widgets/icon.dart +++ b/packages/genui/lib/src/catalog/basic_catalog_widgets/icon.dart @@ -7,7 +7,9 @@ import 'package:json_schema_builder/json_schema_builder.dart'; import '../../model/a2ui_schemas.dart'; import '../../model/catalog_item.dart'; + import '../../primitives/simple_items.dart'; +import '../../widgets/widget_utilities.dart'; final _schema = S.object( properties: { @@ -21,25 +23,6 @@ final _schema = S.object( required: ['component', 'name'], ); -extension type _IconData.fromMap(JsonMap _json) { - factory _IconData({required Object name}) => - _IconData.fromMap({'name': name}); - - Object? get _name => _json['name']; - - String? get literalName { - final Object? name = _name; - if (name is String) return name; - return null; - } - - String? get namePath { - final Object? name = _name; - if (name is JsonMap) return name['path'] as String?; - return null; - } -} - enum AvailableIcons { accountCircle(Icons.account_circle), add(Icons.add), @@ -116,26 +99,10 @@ final icon = CatalogItem( name: 'Icon', dataSchema: _schema, widgetBuilder: (itemContext) { - final iconData = _IconData.fromMap(itemContext.data as JsonMap); - final String? literalName = iconData.literalName; - final String? namePath = iconData.namePath; - - if (literalName != null) { - final IconData icon = - AvailableIcons.fromName(literalName)?.iconData ?? Icons.broken_image; - return Icon(icon); - } - - if (namePath == null) { - return const Icon(Icons.broken_image); - } - - final ValueNotifier notifier = itemContext.dataContext - .subscribe(namePath); - - return ValueListenableBuilder( - valueListenable: notifier, - builder: (context, currentValue, child) { + return BoundString( + dataContext: itemContext.dataContext, + value: (itemContext.data as JsonMap)['name'], + builder: (context, String? currentValue) { final String iconName = currentValue ?? ''; final IconData icon = AvailableIcons.fromName(iconName)?.iconData ?? Icons.broken_image; diff --git a/packages/genui/lib/src/catalog/basic_catalog_widgets/image.dart b/packages/genui/lib/src/catalog/basic_catalog_widgets/image.dart index 87efcd215..537cfa339 100644 --- a/packages/genui/lib/src/catalog/basic_catalog_widgets/image.dart +++ b/packages/genui/lib/src/catalog/basic_catalog_widgets/image.dart @@ -84,40 +84,24 @@ final CatalogItem image = CatalogItem( ], widgetBuilder: (itemContext) { final imageData = _ImageData.fromMap(itemContext.data as JsonMap); - final ValueNotifier notifier = itemContext.dataContext - .subscribeToString(imageData.url); - return ValueListenableBuilder( - valueListenable: notifier, - builder: (context, currentLocation, child) { - final location = currentLocation; - if (location == null || location.isEmpty) { + return BoundString( + dataContext: itemContext.dataContext, + value: imageData.url, + builder: (context, value) { + if (value == null || value.isEmpty) { genUiLogger.warning( 'Image widget created with no URL at path: ' '${itemContext.dataContext.path}', ); return const SizedBox.shrink(); } - final BoxFit? fit = imageData.fit; - final String? variant = imageData.variant; - late Widget child; - - if (location.startsWith('assets/')) { - child = Image.asset( - location, - fit: fit, - errorBuilder: (context, error, stackTrace) { - return const Icon(Icons.broken_image); - }, - ); - } else { + Widget child; + if (value.startsWith('http')) { child = Image.network( - location, - fit: fit, - errorBuilder: (context, error, stackTrace) { - return const Icon(Icons.broken_image); - }, + value, + fit: imageData.fit, frameBuilder: ( BuildContext context, @@ -153,18 +137,48 @@ final CatalogItem image = CatalogItem( ), ); }, + errorBuilder: + (BuildContext context, Object error, StackTrace? stackTrace) { + return const Icon(Icons.broken_image); + }, + ); + } else { + child = Image.asset( + value, + fit: imageData.fit, + frameBuilder: + ( + BuildContext context, + Widget child, + int? frame, + bool wasSynchronouslyLoaded, + ) { + if (wasSynchronouslyLoaded) { + return child; + } + return AnimatedOpacity( + opacity: frame == null ? 0 : 1, + duration: const Duration(seconds: 1), + curve: Curves.easeOut, + child: child, + ); + }, + errorBuilder: + (BuildContext context, Object error, StackTrace? stackTrace) { + return const Icon(Icons.broken_image); + }, ); } - if (variant == 'avatar') { + if (imageData.variant == 'avatar') { child = CircleAvatar(child: child); } - if (variant == 'header') { + if (imageData.variant == 'header') { return SizedBox(width: double.infinity, child: child); } - final double size = switch (variant) { + final double size = switch (imageData.variant) { 'icon' || 'avatar' => 32.0, 'smallFeature' => 50.0, 'mediumFeature' => 150.0, diff --git a/packages/genui/lib/src/catalog/basic_catalog_widgets/list.dart b/packages/genui/lib/src/catalog/basic_catalog_widgets/list.dart index 0604381ba..e88de6aca 100644 --- a/packages/genui/lib/src/catalog/basic_catalog_widgets/list.dart +++ b/packages/genui/lib/src/catalog/basic_catalog_widgets/list.dart @@ -115,7 +115,7 @@ final list = CatalogItem( final nestedPath = '$dataBinding/${keys[index]}'; final DataContext itemDataContext = itemContext.dataContext - .nested(nestedPath); + .nested(DataPath(nestedPath)); final Widget child = itemContext.buildChild( componentId, itemDataContext, diff --git a/packages/genui/lib/src/catalog/basic_catalog_widgets/row.dart b/packages/genui/lib/src/catalog/basic_catalog_widgets/row.dart index dfcca69d2..94a3269fd 100644 --- a/packages/genui/lib/src/catalog/basic_catalog_widgets/row.dart +++ b/packages/genui/lib/src/catalog/basic_catalog_widgets/row.dart @@ -7,6 +7,7 @@ import 'package:json_schema_builder/json_schema_builder.dart'; import '../../model/a2ui_schemas.dart'; import '../../model/catalog_item.dart'; +import '../../model/data_model.dart'; import '../../model/ui_models.dart'; import '../../primitives/simple_items.dart'; import 'widget_helpers.dart'; @@ -175,7 +176,7 @@ final row = CatalogItem( buildWeightedChild( componentId: componentId, dataContext: itemContext.dataContext.nested( - '$dataBinding/${keys[i]}', + DataPath('$dataBinding/${keys[i]}'), ), buildChild: itemContext.buildChild, weight: weight, diff --git a/packages/genui/lib/src/catalog/basic_catalog_widgets/slider.dart b/packages/genui/lib/src/catalog/basic_catalog_widgets/slider.dart index aba4a90bb..6246680cc 100644 --- a/packages/genui/lib/src/catalog/basic_catalog_widgets/slider.dart +++ b/packages/genui/lib/src/catalog/basic_catalog_widgets/slider.dart @@ -7,6 +7,7 @@ import 'package:json_schema_builder/json_schema_builder.dart'; import '../../model/a2ui_schemas.dart'; import '../../model/catalog_item.dart'; +import '../../model/data_model.dart'; import '../../primitives/simple_items.dart'; import '../../widgets/widget_utilities.dart'; import 'widget_helpers.dart'; @@ -75,16 +76,10 @@ final slider = CatalogItem( ? valueRef['path'] as String : '${itemContext.id}.value'; - final ValueNotifier valueNotifier = itemContext.dataContext - .subscribe(path); - - final ValueNotifier labelNotifier = sliderData.label != null - ? itemContext.dataContext.subscribeToString(sliderData.label!) - : ValueNotifier(null); - - return ValueListenableBuilder( - valueListenable: valueNotifier, - builder: (context, value, child) { + return BoundNumber( + dataContext: itemContext.dataContext, + value: {'path': path}, + builder: (context, value) { // If value is null (nothing in DataContext yet), fall back to // literal value if provided. var effectiveValue = value; @@ -106,7 +101,7 @@ final slider = CatalogItem( max: sliderData.max, divisions: (sliderData.max - sliderData.min).toInt(), onChanged: (newValue) { - itemContext.dataContext.update(path, newValue); + itemContext.dataContext.update(DataPath(path), newValue); }, ), ), @@ -117,23 +112,31 @@ final slider = CatalogItem( ), ); - return ValueListenableBuilder( - valueListenable: itemContext.dataContext.createComputedNotifier( - checksToExpression(sliderData.checks), - ), - builder: (context, isValid, _) { - final isError = isValid == false; + return BoundString( + dataContext: itemContext.dataContext, + value: sliderData.label, + builder: (context, label) { + return StreamBuilder( + stream: itemContext.dataContext.evaluateConditionStream( + checksToExpression(sliderData.checks), + ), + initialData: true, + builder: (context, snapshot) { + final bool isValid = snapshot.data ?? true; + final bool isError = !isValid; + + final List children = [ + if (label != null) + Padding( + padding: const EdgeInsets.only(left: 16.0, bottom: 8.0), + child: Text( + label, + style: Theme.of(context).textTheme.titleSmall, + ), + ), + sliderWidget, + ]; - return ValueListenableBuilder( - valueListenable: labelNotifier, - builder: (context, label, child) { - final List children = []; - if (label != null && label.isNotEmpty) { - children.add( - Text(label, style: Theme.of(context).textTheme.bodySmall), - ); - } - children.add(sliderWidget); if (isError) { children.add( Padding( diff --git a/packages/genui/lib/src/catalog/basic_catalog_widgets/tabs.dart b/packages/genui/lib/src/catalog/basic_catalog_widgets/tabs.dart index f3e3920ba..0f023f162 100644 --- a/packages/genui/lib/src/catalog/basic_catalog_widgets/tabs.dart +++ b/packages/genui/lib/src/catalog/basic_catalog_widgets/tabs.dart @@ -7,6 +7,7 @@ import 'package:json_schema_builder/json_schema_builder.dart'; import '../../model/a2ui_schemas.dart'; import '../../model/catalog_item.dart'; +import '../../model/data_model.dart'; import '../../primitives/simple_items.dart'; import '../../widgets/widget_utilities.dart'; @@ -49,14 +50,14 @@ class _TabsWidget extends StatefulWidget { const _TabsWidget({ required this.tabs, required this.itemContext, - required this.activeTabNotifier, + required this.activeTab, this.initialTab = 0, required this.onTabChanged, }); final List tabs; final CatalogItemContext itemContext; - final ValueNotifier activeTabNotifier; + final int? activeTab; final int initialTab; final ValueChanged onTabChanged; @@ -71,18 +72,16 @@ class _TabsWidgetState extends State<_TabsWidget> @override void initState() { super.initState(); - final int initialIndex = - (widget.activeTabNotifier.value?.toInt() ?? widget.initialTab).clamp( - 0, - widget.tabs.length - 1, - ); + final int initialIndex = (widget.activeTab ?? widget.initialTab).clamp( + 0, + widget.tabs.length - 1, + ); _tabController = TabController( length: widget.tabs.length, vsync: this, initialIndex: initialIndex, ); _tabController.addListener(_handleTabSelection); - widget.activeTabNotifier.addListener(_handleExternalChange); } @override @@ -90,17 +89,18 @@ class _TabsWidgetState extends State<_TabsWidget> super.didUpdateWidget(oldWidget); if (widget.tabs.length != oldWidget.tabs.length) { _tabController.dispose(); - final int initialIndex = - (widget.activeTabNotifier.value?.toInt() ?? widget.initialTab).clamp( - 0, - widget.tabs.length - 1, - ); + final int initialIndex = (widget.activeTab ?? widget.initialTab).clamp( + 0, + widget.tabs.length - 1, + ); _tabController = TabController( length: widget.tabs.length, vsync: this, initialIndex: initialIndex, ); _tabController.addListener(_handleTabSelection); + } else if (widget.activeTab != oldWidget.activeTab) { + _handleExternalChange(); } } @@ -111,7 +111,7 @@ class _TabsWidgetState extends State<_TabsWidget> } void _handleExternalChange() { - final int? newIndex = widget.activeTabNotifier.value?.toInt(); + final int? newIndex = widget.activeTab; if (newIndex != null && newIndex >= 0 && newIndex < widget.tabs.length && @@ -123,7 +123,6 @@ class _TabsWidgetState extends State<_TabsWidget> @override void dispose() { _tabController.dispose(); - widget.activeTabNotifier.removeListener(_handleExternalChange); super.dispose(); } @@ -136,14 +135,11 @@ class _TabsWidgetState extends State<_TabsWidget> controller: _tabController, tabs: widget.tabs.map((tabItem) { final Object? labelRef = tabItem['label'] ?? tabItem['title']; - final ValueNotifier titleNotifier = widget - .itemContext - .dataContext - .subscribeToString(labelRef); - return ValueListenableBuilder( - valueListenable: titleNotifier, - builder: (context, title, child) { - return Tab(text: title ?? ''); + return BoundString( + dataContext: widget.itemContext.dataContext, + value: labelRef, + builder: (context, label) { + return Tab(text: label ?? ''); }, ); }).toList(), @@ -191,26 +187,20 @@ final tabs = CatalogItem( ? activeTabRef['path'] as String : '${itemContext.id}.activeTab'; - final ValueNotifier activeTabNotifier = itemContext.dataContext - .subscribeToNumber({'path': path}); - - return ValueListenableBuilder( - valueListenable: activeTabNotifier, - builder: (context, currentActiveTab, child) { - var effectiveActiveTab = currentActiveTab; - if (effectiveActiveTab == null) { - if (activeTabRef is num) { - effectiveActiveTab = activeTabRef; - } - } - + return BoundNumber( + dataContext: itemContext.dataContext, + value: {'path': path}, + builder: (context, value) { + // We pass the current value to _TabsWidget, which will handle + // updating the TabController when it changes. + // We no longer pass a ValueNotifier. return _TabsWidget( tabs: tabsData.tabs, itemContext: itemContext, - activeTabNotifier: activeTabNotifier, + activeTab: value?.toInt(), initialTab: activeTabRef is num ? activeTabRef.toInt() : 0, onTabChanged: (newIndex) { - itemContext.dataContext.update(path, newIndex); + itemContext.dataContext.update(DataPath(path), newIndex); }, ); }, diff --git a/packages/genui/lib/src/catalog/basic_catalog_widgets/text.dart b/packages/genui/lib/src/catalog/basic_catalog_widgets/text.dart index d8ccb61ef..e75e0fa5d 100644 --- a/packages/genui/lib/src/catalog/basic_catalog_widgets/text.dart +++ b/packages/genui/lib/src/catalog/basic_catalog_widgets/text.dart @@ -61,12 +61,10 @@ final text = CatalogItem( widgetBuilder: (itemContext) { final textData = _TextData.fromMap(itemContext.data as JsonMap); - final ValueNotifier notifier = itemContext.dataContext - .subscribeToString(textData.text); - - return ValueListenableBuilder( - valueListenable: notifier, - builder: (context, currentValue, child) { + return BoundString( + dataContext: itemContext.dataContext, + value: textData.text, + builder: (context, value) { final TextTheme textTheme = Theme.of(context).textTheme; final String variant = textData.variant ?? 'body'; final TextStyle? baseStyle = switch (variant) { @@ -90,7 +88,7 @@ final text = CatalogItem( return Padding( padding: EdgeInsets.symmetric(vertical: verticalPadding), child: MarkdownBody( - data: currentValue ?? '', + data: value ?? '', styleSheet: MarkdownStyleSheet.fromTheme( Theme.of(context), ).copyWith(p: baseStyle), diff --git a/packages/genui/lib/src/catalog/basic_catalog_widgets/text_field.dart b/packages/genui/lib/src/catalog/basic_catalog_widgets/text_field.dart index 0b65da94c..dd2eee3bd 100644 --- a/packages/genui/lib/src/catalog/basic_catalog_widgets/text_field.dart +++ b/packages/genui/lib/src/catalog/basic_catalog_widgets/text_field.dart @@ -7,9 +7,9 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:json_schema_builder/json_schema_builder.dart'; -import '../../functions/expression_parser.dart'; import '../../model/a2ui_schemas.dart'; import '../../model/catalog_item.dart'; +import '../../model/data_model.dart'; import '../../model/ui_models.dart'; import '../../primitives/simple_items.dart'; import '../../utils/validation_helper.dart'; @@ -62,7 +62,7 @@ class _TextField extends StatefulWidget { required this.initialValue, this.label, this.checks, - this.parser, + this.context, this.textFieldType, this.validationRegexp, required this.onChanged, @@ -72,7 +72,7 @@ class _TextField extends StatefulWidget { final String initialValue; final String? label; final List? checks; - final ExpressionParser? parser; + final DataContext? context; final String? textFieldType; final String? validationRegexp; final void Function(String) onChanged; @@ -103,7 +103,7 @@ class _TextFieldState extends State<_TextField> { // related to value. } if (widget.checks != oldWidget.checks || - widget.parser != oldWidget.parser) { + widget.context != oldWidget.context) { _setupValidation(); } } @@ -114,7 +114,7 @@ class _TextFieldState extends State<_TextField> { if (widget.checks == null || widget.checks!.isEmpty || - widget.parser == null) { + widget.context == null) { if (_errorText != null && mounted) { setState(() => _errorText = null); } @@ -122,7 +122,7 @@ class _TextFieldState extends State<_TextField> { } _validationSubscription = - ValidationHelper.validateStream(widget.checks, widget.parser).listen(( + ValidationHelper.validateStream(widget.checks, widget.context).listen(( String? newError, ) { if (newError != _errorText && mounted) { @@ -158,7 +158,7 @@ class _TextFieldState extends State<_TextField> { }, onSubmitted: (val) { // Validation is handled via data model updates + stream - // But we might want to check current error state before submitting? + // But we check current error state before submitting. if (_errorText == null) { widget.onSubmitted(val); } @@ -215,19 +215,14 @@ final textField = CatalogItem( final path = (valueRef is Map && valueRef.containsKey('path')) ? valueRef['path'] as String : '${itemContext.id}.value'; - final ValueNotifier notifier = itemContext.dataContext - .subscribeToString({'path': path}); - final ValueNotifier labelNotifier = itemContext.dataContext - .subscribeToString(textFieldData.label); - - final parser = ExpressionParser(itemContext.dataContext); - - return ValueListenableBuilder( - valueListenable: notifier, - builder: (context, currentValue, child) { - return ValueListenableBuilder( - valueListenable: labelNotifier, - builder: (context, label, child) { + return BoundString( + dataContext: itemContext.dataContext, + value: {'path': path}, + builder: (context, currentValue) { + return BoundString( + dataContext: itemContext.dataContext, + value: textFieldData.label, + builder: (context, label) { final String? effectiveValue = currentValue?.toString() ?? (valueRef is String ? valueRef : null); @@ -236,20 +231,20 @@ final textField = CatalogItem( initialValue: effectiveValue ?? '', label: label, checks: textFieldData.checks, - parser: parser, + context: itemContext.dataContext, textFieldType: textFieldData.variant, validationRegexp: textFieldData.validationRegexp, onChanged: (newValue) { if (textFieldData.variant == 'number') { final num? numberValue = num.tryParse(newValue); if (numberValue != null) { - itemContext.dataContext.update(path, numberValue); + itemContext.dataContext.update(DataPath(path), numberValue); return; } } - itemContext.dataContext.update(path, newValue); + itemContext.dataContext.update(DataPath(path), newValue); }, - onSubmitted: (newValue) { + onSubmitted: (newValue) async { final JsonMap? actionData = textFieldData.onSubmittedAction; if (actionData == null) { return; @@ -259,7 +254,7 @@ final textField = CatalogItem( final eventMap = actionData['event'] as JsonMap; final actionName = eventMap['name'] as String; final contextDefinition = eventMap['context'] as JsonMap?; - final JsonMap resolvedContext = resolveContext( + final JsonMap resolvedContext = await resolveContext( itemContext.dataContext, contextDefinition, ); @@ -274,10 +269,14 @@ final textField = CatalogItem( final funcMap = actionData['functionCall'] as JsonMap; final callName = funcMap['call'] as String; if (callName == 'closeModal') { - Navigator.of(itemContext.buildContext).pop(); + if (itemContext.buildContext.mounted) { + Navigator.of(itemContext.buildContext).pop(); + } return; } - parser.evaluateFunctionCall(funcMap); + final Stream resultStream = itemContext.dataContext + .resolve(funcMap); + await resultStream.first; } }, ); diff --git a/packages/genui/lib/src/catalog/basic_catalog_widgets/widget_helpers.dart b/packages/genui/lib/src/catalog/basic_catalog_widgets/widget_helpers.dart index f322f082e..c843fae3c 100644 --- a/packages/genui/lib/src/catalog/basic_catalog_widgets/widget_helpers.dart +++ b/packages/genui/lib/src/catalog/basic_catalog_widgets/widget_helpers.dart @@ -8,6 +8,7 @@ import '../../model/catalog_item.dart'; import '../../model/data_model.dart'; import '../../primitives/logging.dart'; import '../../primitives/simple_items.dart'; +import '../../widgets/widget_utilities.dart'; /// Builder function for creating a widget from a template and a list of data. /// @@ -98,11 +99,10 @@ class ComponentChildrenBuilder extends StatelessWidget { genUiLogger.finest( 'Widget $componentId subscribing to ${dataContext.path}', ); - final ValueNotifier dataNotifier = dataContext - .subscribe(path); - return ValueListenableBuilder( - valueListenable: dataNotifier, - builder: (context, data, child) { + return BoundObject( + dataContext: dataContext, + value: {'path': path}, + builder: (context, data) { if (data != null) { return templateListWidgetBuilder( context, diff --git a/packages/genui/lib/src/catalog/basic_functions.dart b/packages/genui/lib/src/catalog/basic_functions.dart index 05b9ce1da..f4d1a8e52 100644 --- a/packages/genui/lib/src/catalog/basic_functions.dart +++ b/packages/genui/lib/src/catalog/basic_functions.dart @@ -6,6 +6,7 @@ import 'package:intl/intl.dart'; import 'package:json_schema_builder/json_schema_builder.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../functions/format_string.dart'; import '../interfaces/client_function.dart'; import '../model/data_model.dart'; import '../primitives/simple_items.dart'; @@ -254,22 +255,6 @@ class EmailFunction extends SynchronousClientFunction { } } -/// Formats a value as a string. -class FormatStringFunction extends SynchronousClientFunction { - const FormatStringFunction(); - - @override - String get name => 'formatString'; - - @override - Schema get argumentSchema => S.object(properties: {'value': S.any()}); - - @override - Object? executeSync(JsonMap args, DataContext context) { - return args['value']?.toString() ?? ''; - } -} - /// Opens a URL. class OpenUrlFunction extends SynchronousClientFunction { const OpenUrlFunction(); diff --git a/packages/genui/lib/src/engine/data_model_store.dart b/packages/genui/lib/src/engine/data_model_store.dart index 51f347dc9..5b0b9fa8e 100644 --- a/packages/genui/lib/src/engine/data_model_store.dart +++ b/packages/genui/lib/src/engine/data_model_store.dart @@ -10,7 +10,7 @@ class DataModelStore { final Set _attachedSurfaces = {}; DataModel getDataModel(String surfaceId) { - return _dataModels.putIfAbsent(surfaceId, DataModel.new); + return _dataModels.putIfAbsent(surfaceId, InMemoryDataModel.new); } void removeDataModel(String surfaceId) { @@ -27,16 +27,6 @@ class DataModelStore { _attachedSurfaces.remove(surfaceId); } - Map getClientDataSnapshot() { - final result = {}; - for (final String surfaceId in _attachedSurfaces) { - if (_dataModels.containsKey(surfaceId)) { - result[surfaceId] = _dataModels[surfaceId]!.data; - } - } - return {'version': 'v0.9', 'surfaces': result}; - } - Map get dataModels => Map.unmodifiable(_dataModels); void dispose() { diff --git a/packages/genui/lib/src/functions/expression_parser.dart b/packages/genui/lib/src/functions/format_string.dart similarity index 54% rename from packages/genui/lib/src/functions/expression_parser.dart rename to packages/genui/lib/src/functions/format_string.dart index 0b4b8846e..b22cb3948 100644 --- a/packages/genui/lib/src/functions/expression_parser.dart +++ b/packages/genui/lib/src/functions/format_string.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:json_schema_builder/json_schema_builder.dart'; +import 'package:meta/meta.dart'; import 'package:rxdart/rxdart.dart'; import '../interfaces/client_function.dart' as cf; @@ -9,6 +11,25 @@ import '../model/data_model.dart'; import '../primitives/logging.dart'; import '../primitives/simple_items.dart'; +/// Formats a value as a string. +class FormatStringFunction implements cf.ClientFunction { + const FormatStringFunction(); + + @override + String get name => 'formatString'; + + @override + Schema get argumentSchema => S.object(properties: {'value': S.any()}); + + @override + Stream execute(JsonMap args, DataContext context) { + if (!args.containsKey('value')) return Stream.value(''); + final Object? value = args['value']; + + return ExpressionParser(context).parse(value?.toString() ?? ''); + } +} + class RecursionExpectedException implements Exception { RecursionExpectedException(this.message); final String message; @@ -17,6 +38,7 @@ class RecursionExpectedException implements Exception { } /// Parses and evaluates expressions in the A2UI `${expression}` format. +@visibleForTesting class ExpressionParser { ExpressionParser(this.context); @@ -26,237 +48,28 @@ class ExpressionParser { /// Parses the input string and resolves any embedded expressions. /// - /// If the string contains a single expression that encompasses the entire - /// string (e.g. "${/foo}"), the return value may be of any type (not just - /// [String]), and may be a [Stream] if the expression involves a reactive - /// function. - /// - /// If the string contains text mixed with expressions (e.g. "Value: ${/foo}"), - /// the return value will always be a [String] (or a [Stream]). + /// The return value will always be a [Stream]. /// /// This method is the entry point for expression resolution. It handles /// escaping of the `${` sequence using a backslash (e.g. `\${`). - // parse method removed here, using the one with asStream support below - - /// Evaluates an expression and returns a [Stream] of the result. - Stream evaluateStream(Object? expression) { - // If expression is static (no interpolation/paths), return Stream.value - if (expression == null) return Stream.value(null); - if (expression is! String && expression is! Map) { - return Stream.value(expression); - } - - // Use common logic but prefer streams - final Object? result = _evaluate(expression, asStream: true); - if (result is Stream) { - return result.cast(); - } - return Stream.value(result); - } - - /// Evaluates an expression which can be a String, Map (function call/path), etc. - Object? evaluate(Object? expression) { - return _evaluate(expression, asStream: false); - } - - Object? _evaluate( - Object? expression, { - required bool asStream, - int depth = 0, - }) { - if (depth > _maxRecursionDepth) { - throw RecursionExpectedException( - 'Max recursion depth reached in _evaluate', - ); - } - - if (expression is String) { - return parse(expression, asStream: asStream, depth: depth + 1); - } - if (expression is Map) { - if (expression.containsKey('call')) { - return evaluateFunctionCall( - expression as JsonMap, - asStream: asStream, - depth: depth + 1, - ); - } - if (expression.containsKey('path')) { - return _resolvePath( - expression['path'] as String, - null, - asStream: asStream, - ); - } - } - return expression; - } - - /// Parses the input string and resolves any embedded expressions. - Object? parse(String input, {bool asStream = false, int depth = 0}) { + Stream parse(String input, {int depth = 0}) { if (depth > _maxRecursionDepth) { throw RecursionExpectedException('Max recursion depth reached in parse'); } if (!input.contains(r'${')) { - return asStream ? Stream.value(input) : input; + return Stream.value(input); } return _parseStringWithInterpolations( input, null, - asStream: asStream, depth: depth + 1, - ); - } - - /// Extracts all data paths referenced in the given input. - /// - /// This method parses the input without evaluating functions, collecting - /// all paths that would be accessed during evaluation. - Set extractDependencies(String input) { - if (!input.contains(r'${')) { - return {}; - } - final Set dependencies = {}; - _parseStringWithInterpolations(input, dependencies, depth: 0); - return dependencies; + ).map((event) => event?.toString() ?? ''); } - /// Extracts all data paths referenced in the given expression (String or - /// Map). - Set extractDependenciesFrom(Object? expression) { - final Set dependencies = {}; - _extractDependenciesFrom(expression, dependencies, depth: 0); - return dependencies; - } - - void _extractDependenciesFrom( - Object? expression, - Set dependencies, { - required int depth, - }) { - if (depth > _maxRecursionDepth) { - throw RecursionExpectedException( - 'Max recursion depth reached in dependency extraction.', - ); - } - - if (expression is String) { - if (expression.contains(r'${')) { - _parseStringWithInterpolations(expression, dependencies, depth: depth); - } - } else if (expression is Map) { - if (expression.containsKey('path')) { - dependencies.add( - context.resolvePath(DataPath(expression['path'] as String)), - ); - } else if (expression.containsKey('call')) { - evaluateFunctionCall( - expression as JsonMap, - dependencies: dependencies, - depth: depth + 1, - ); - } else { - for (final Object? value in expression.values) { - _extractDependenciesFrom(value, dependencies, depth: depth + 1); - } - } - } else if (expression is List) { - for (final Object? item in expression) { - _extractDependenciesFrom(item, dependencies, depth: depth + 1); - } - } - } - - /// Evaluates a dynamic boolean condition and returns a [Stream]. - /// - /// This is the reactive version of [evaluateConditionSync]. It should be used - /// when the condition might depend on reactive data sources or functions. - Stream evaluateConditionStream(Object? condition) { - if (condition == null) return Stream.value(false); - if (condition is bool) return Stream.value(condition); - - Object? result; - if (condition is String) { - result = parse(condition, asStream: true); - } else if (condition is Map) { - if (condition.containsKey('call')) { - result = evaluateFunctionCall(condition as JsonMap, asStream: true); - } else if (condition.containsKey('path')) { - result = _resolvePath( - condition['path'] as String, - null, - asStream: true, - ); - } else { - return Stream.value(false); - } - } else { - result = condition; - } - - if (result is Stream) { - return result.map((v) { - if (v is bool) return v; - return v != null; - }); - } - - if (result is bool) return Stream.value(result); - return Stream.value(result != null); - } - - /// Evaluates a dynamic boolean condition. - /// - /// The [condition] can be: - /// - [bool]: Returns the boolean value directly. - /// - [Map]: - /// - If it has a 'call' key, it is evaluated as a function call. - /// - If it has a 'path' key, it is evaluated as a data binding. - /// - [String]: Parsed as an expression, then checked for truthiness. - /// - /// **Note:** If the condition evaluates to a [Stream], this method currently - /// checks if the stream itself is non-null (truthy), creating a static check. - /// For reactive boolean checks, you should consume the result of [evaluate] - /// and listen to the stream. - bool evaluateConditionSync(Object? condition) { - if (condition == null) return false; - if (condition is bool) return condition; - - Object? result; - if (condition is String) { - result = parse(condition, asStream: true); - } else if (condition is Map) { - if (condition.containsKey('call')) { - result = evaluateFunctionCall(condition as JsonMap, asStream: true); - } else if (condition.containsKey('path')) { - result = _resolvePath( - condition['path'] as String, - null, - asStream: true, - ); - } else { - return false; - } - } else { - result = condition; - } - - if (result is bool) return result; - // Streams are truthy references, but that's probably not what we want - // for conditional rendering if we want *reactive* conditions. - // However, this method is synchronous. - return result != null; - } - - /// Evaluates a function call defined in [callDefinition]. - /// - /// The [callDefinition] must contain a 'call' key with the function name - /// and an optional 'args' key with a map of arguments. - Object? evaluateFunctionCall( + Stream evaluateFunctionCall( JsonMap callDefinition, { Set? dependencies, - bool asStream = false, int depth = 0, }) { if (depth > _maxRecursionDepth) { @@ -267,7 +80,7 @@ class ExpressionParser { final name = callDefinition['call'] as String?; if (name == null) { - return asStream ? Stream.value(null) : null; + return Stream.value(null); } // 1. Resolve arguments @@ -285,20 +98,14 @@ class ExpressionParser { resolvedValue = _parseStringWithInterpolations( value, dependencies, - asStream: asStream, depth: depth + 1, ); } else if (value is Map && value.containsKey('path')) { - resolvedValue = _resolvePath( - value['path'] as String, - dependencies, - asStream: asStream, - ); + resolvedValue = _resolvePath(value['path'] as String, dependencies); } else if (value is Map && value.containsKey('call')) { resolvedValue = evaluateFunctionCall( value as JsonMap, dependencies: dependencies, - asStream: asStream, depth: depth + 1, ); } else { @@ -318,23 +125,19 @@ class ExpressionParser { } if (dependencies != null) { - return null; // Dependency collection only + return Stream.value(null); // Dependency collection only } final cf.ClientFunction? func = context.getFunction(name); if (func == null) { genUiLogger.warning('Function not found: $name'); - return asStream ? Stream.value(null) : null; + return Stream.value(null); } // 2. Execute function if (!hasStreams) { // Synchronous execution (returns Stream, but args are static) - final Stream result = func.execute(args, context); - if (asStream) { - return Stream.value(result); - } - return result; + return func.execute(args, context); } // 3. Handle Stream arguments @@ -352,16 +155,13 @@ class ExpressionParser { for (var i = 0; i < keys.length; i++) { combinedArgs[keys[i]] = values[i]; } - final Stream result = func.execute(combinedArgs, context); - return result.cast(); - // return Stream.value(result); // Dead code removed too + return func.execute(combinedArgs, context); }); } - Object? _parseStringWithInterpolations( + Stream _parseStringWithInterpolations( String input, Set? dependencies, { - bool asStream = false, int depth = 0, }) { if (depth > _maxRecursionDepth) { @@ -372,7 +172,6 @@ class ExpressionParser { var i = 0; final parts = []; - var hasStreams = false; while (i < input.length) { final int startIndex = input.indexOf(r'${', i); @@ -405,26 +204,20 @@ class ExpressionParser { depth + 1, dependencies, ); - if (value is Stream) { - hasStreams = true; - } parts.add(value); i = endIndex + 1; // Skip closing '}' } - if (parts.isEmpty) return ''; + if (parts.isEmpty) return Stream.value(''); if (parts.length == 1 && parts[0] is! String) { - return parts[0]; + final Object? part = parts[0]; + return part is Stream ? part.cast() : Stream.value(part); } if (dependencies != null) { - return null; - } - - if (!hasStreams && !asStream) { - return parts.map((e) => e?.toString() ?? '').join(''); + return Stream.value(null); } // Combine streams for string interpolation @@ -624,19 +417,12 @@ class ExpressionParser { return (_resolvePath(token, dependencies), i); } - Object? _resolvePath( - String pathStr, - Set? dependencies, { - bool asStream = false, - }) { + Stream _resolvePath(String pathStr, Set? dependencies) { pathStr = pathStr.trim(); if (dependencies != null) { dependencies.add(context.resolvePath(DataPath(pathStr))); - return null; - } - if (asStream) { - return context.subscribeStream(pathStr); + return Stream.value(null); } - return context.getValue(pathStr); + return context.subscribeStream(DataPath(pathStr)); } } diff --git a/packages/genui/lib/src/interfaces/client_function.dart b/packages/genui/lib/src/interfaces/client_function.dart index c2eea5e8f..e8ed09fba 100644 --- a/packages/genui/lib/src/interfaces/client_function.dart +++ b/packages/genui/lib/src/interfaces/client_function.dart @@ -49,7 +49,11 @@ abstract class SynchronousClientFunction implements ClientFunction { @override Stream execute(JsonMap args, DataContext context) { - return Stream.value(executeSync(args, context)); + try { + return Stream.value(executeSync(args, context)); + } catch (e, stack) { + return Stream.error(e, stack); + } } /// Executes the function synchronously. diff --git a/packages/genui/lib/src/model/catalog.dart b/packages/genui/lib/src/model/catalog.dart index cfe1001d1..cab0f8e61 100644 --- a/packages/genui/lib/src/model/catalog.dart +++ b/packages/genui/lib/src/model/catalog.dart @@ -134,6 +134,7 @@ interface class Catalog { getCatalogItem: (String type) => items.firstWhereOrNull((item) => item.name == type), surfaceId: itemContext.surfaceId, + reportError: itemContext.reportError, ), ), ); diff --git a/packages/genui/lib/src/model/catalog_item.dart b/packages/genui/lib/src/model/catalog_item.dart index 00caaf412..38701405e 100644 --- a/packages/genui/lib/src/model/catalog_item.dart +++ b/packages/genui/lib/src/model/catalog_item.dart @@ -47,6 +47,7 @@ final class CatalogItemContext { required this.getComponent, required this.getCatalogItem, required this.surfaceId, + required this.reportError, }); /// The parsed data for this component from the AI-generated definition. @@ -78,6 +79,9 @@ final class CatalogItemContext { /// The ID of the surface this component belongs to. final String surfaceId; + + /// Callback to report an error that occurred within this component. + final void Function(Object error, StackTrace? stack) reportError; } /// Defines a UI layout type, its schema, and how to build its widget. diff --git a/packages/genui/lib/src/model/data_model.dart b/packages/genui/lib/src/model/data_model.dart index 3aff2b1bf..46c8e6e2b 100644 --- a/packages/genui/lib/src/model/data_model.dart +++ b/packages/genui/lib/src/model/data_model.dart @@ -7,35 +7,12 @@ import 'dart:convert'; import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; +import 'package:rxdart/rxdart.dart'; -import '../functions/expression_parser.dart'; import '../interfaces/client_function.dart' as cf; import '../primitives/logging.dart'; import '../primitives/simple_items.dart'; -extension _ValueListenableStream on ValueListenable { - Stream get asStream { - late StreamController controller; - void listener() { - if (!controller.isClosed) { - controller.add(value); - } - } - - controller = StreamController( - onListen: () { - controller.add(value); - addListener(listener); - }, - onCancel: () { - removeListener(listener); - controller.close(); - }, - ); - return controller.stream; - } -} - /// Represents a path in the data model, either absolute or relative. @immutable final class DataPath { @@ -63,11 +40,13 @@ final class DataPath { static const DataPath root = DataPath._([], true); /// The last segment of the path. - String get basename => segments.last; + String get basename => segments.isEmpty ? '' : segments.last; /// The path without the last segment. - DataPath get dirname => - DataPath._(segments.sublist(0, segments.length - 1), isAbsolute); + DataPath get dirname { + if (segments.isEmpty) return this; + return DataPath._(segments.sublist(0, segments.length - 1), isAbsolute); + } /// Joins this path with another path. DataPath join(DataPath other) { @@ -115,10 +94,9 @@ class DataContext { /// Creates a [DataContext] for the given [path]. DataContext( this._dataModel, - String path, { + this.path, { Iterable? functions, - }) : path = DataPath(path), - _functions = { + }) : _functions = { if (functions != null) for (final f in functions) f.name: f, }; @@ -138,169 +116,199 @@ class DataContext { /// Retrieves a function by name from this context. cf.ClientFunction? getFunction(String name) => _functions[name]; - /// Subscribes to a path or expression, resolving it against the current - /// context. - /// - /// If [pathOrExpression] contains `${`, it is treated as an expression. - /// Otherwise, it is treated as a path. - ValueNotifier subscribe(Object? pathOrExpression) { - if (pathOrExpression is String && pathOrExpression.contains(r'${')) { - // Expressions require reactivity based on their dependencies. - // Since `ExpressionParser` doesn't currently return dependencies, we use - // a `_ComputedValueNotifier` that attempts to extract paths from the - // expression. - return createComputedNotifier(pathOrExpression); - } else if (pathOrExpression is Map) { - // Map expressions (e.g. function calls) - return createComputedNotifier(pathOrExpression); - } else if (pathOrExpression is String) { - final DataPath absolutePath = resolvePath(DataPath(pathOrExpression)); - return _dataModel.subscribe(absolutePath); - } - return ValueNotifier(pathOrExpression as T?); + /// Subscribes to a path, resolving it against the current context. + ValueNotifier subscribe(DataPath path) { + final DataPath absolutePath = resolvePath(path); + return _dataModel.subscribe(absolutePath); } - /// Subscribes to a path or expression and returns a [Stream]. - Stream subscribeStream(Object? pathOrExpression) { - return subscribe(pathOrExpression).asStream; - } + /// Subscribes to a path and returns a [Stream]. + Stream subscribeStream(DataPath path) { + late StreamController controller; + ValueNotifier? notifier; - /// Gets a value, resolving the path/expression against the current context. - T? getValue(Object? pathOrExpression) { - if (pathOrExpression is String && pathOrExpression.contains(r'${')) { - final parser = ExpressionParser(this); - return parser.parse(pathOrExpression) as T?; - } else if (pathOrExpression is Map) { - final parser = ExpressionParser(this); - return parser.evaluate(pathOrExpression) as T?; - } else if (pathOrExpression is String) { - final DataPath absolutePath = resolvePath(DataPath(pathOrExpression)); - return _dataModel.getValue(absolutePath); + void listener() { + if (!controller.isClosed) { + controller.add(notifier!.value); + } } - return pathOrExpression as T?; + + controller = StreamController( + onListen: () { + notifier = subscribe(path); + controller.add(notifier!.value); + notifier!.addListener(listener); + }, + onCancel: () { + notifier?.removeListener(listener); + notifier?.dispose(); + notifier = null; + controller.close(); + }, + ); + return controller.stream; } + /// Gets a value, resolving the path against the current context. + T? getValue(DataPath path) => _dataModel.getValue(resolvePath(path)); + /// Updates the data model, resolving the path against the current context. - void update(String pathStr, Object? contents) { - final DataPath absolutePath = resolvePath(DataPath(pathStr)); - _dataModel.update(absolutePath, contents); - } + void update(DataPath path, Object? contents) => + _dataModel.update(resolvePath(path), contents); /// Creates a new, nested DataContext for a child widget. /// /// Used by list/template widgets to create a context for their children. - DataContext nested(String relativePath) { - final DataPath newPath = resolvePath(DataPath(relativePath)); - return DataContext._(_dataModel, newPath, _functions); - } + DataContext nested(DataPath relativePath) => + DataContext._(_dataModel, resolvePath(relativePath), _functions); /// Resolves a path against the current context's path. - DataPath resolvePath(DataPath pathToResolve) { - if (pathToResolve.isAbsolute) { - return pathToResolve; + DataPath resolvePath(DataPath pathToResolve) => + pathToResolve.isAbsolute ? pathToResolve : path.join(pathToResolve); + + /// Resolves any dynamic values (bindings or function calls) in the given + /// value. + /// + /// String values are treated as literals (no interpolation). + /// Maps with a 'path' key are resolved to the value at that path. + /// Maps with a 'call' key are executed as functions. + Stream resolve(Object? value) => _evaluateStream(value); + + Stream _evaluateStream(Object? value) { + if (value is Map) { + if (value.containsKey('path')) { + return subscribeStream(DataPath(value['path'] as String)); + } + if (value.containsKey('call')) { + return _evaluateFunctionCall(value as JsonMap); + } } - return path.join(pathToResolve); + if (value is Stream) return value.cast(); + return Stream.value(value); } - /// Resolves any expressions in the given value. - Object? resolve(Object? value) { - if (value is String) { - return ExpressionParser(this).parse(value); + Stream _evaluateFunctionCall(JsonMap callDefinition) { + final name = callDefinition['call'] as String?; + if (name == null) { + return Stream.value(null); } - if (value is Map && value.containsKey('call')) { - return ExpressionParser(this).evaluateFunctionCall(value as JsonMap); + + final cf.ClientFunction? func = getFunction(name); + if (func == null) { + genUiLogger.warning('Function not found: $name'); + return Stream.value(null); } - return value; + + // Resolve arguments + final Map args = {}; + final Object? argsJson = callDefinition['args']; + + if (argsJson is Map) { + for (final Object? key in argsJson.keys) { + final argName = key.toString(); + final Object? val = argsJson[key]; + args[argName] = _evaluateStream(val); + } + } + + final List keys = args.keys.toList(); + final List> streams = keys.map((key) { + return args[key]! as Stream; + }).toList(); + + final Stream> combinedStream = streams.isEmpty + ? Stream.value([]) + : CombineLatestStream.list(streams); + + return combinedStream.switchMap((List values) { + final Map combinedArgs = {}; + for (var i = 0; i < keys.length; i++) { + combinedArgs[keys[i]] = values[i]; + } + return func.execute(combinedArgs, this); + }); } - ValueNotifier createComputedNotifier(Object? expression) { - // Create a notifier that re-evaluates the expression when its dependencies - // change. - return _ComputedValueNotifier(this, expression); + /// Evaluates a dynamic boolean condition and returns a [Stream]. + Stream evaluateConditionStream(Object? condition) { + if (condition == null) return Stream.value(false); + if (condition is bool) return Stream.value(condition); + + final Stream resultStream = _evaluateStream(condition); + return resultStream.map((v) { + if (v is bool) return v; + return v != null; + }); } } -class _ComputedValueNotifier extends ValueNotifier { - _ComputedValueNotifier(this.context, this.expression) : super(null) { - initialEvaluation(); - } +/// Exception thrown when a value in the [DataModel] is not of the expected +/// type. +class DataModelTypeException implements Exception { + /// Creates a [DataModelTypeException]. + DataModelTypeException({ + required this.path, + required this.expectedType, + required this.actualType, + }); + + /// The path where the type mismatch occurred. + final DataPath path; - final DataContext context; - final Object? expression; - final List unsubscribers = []; - - void initialEvaluation() { - // Use ExpressionParser to robustly extract paths, including those in - // function calls and nested expressions. - final Set paths = ExpressionParser( - context, - ).extractDependenciesFrom(expression); - - for (final path in paths) { - final ValueNotifier notifier = context.subscribe( - path.toString(), - ); // Re-enter subscribe for raw paths - void listener() => evaluate(); - notifier.addListener(listener); - unsubscribers.add(() => notifier.removeListener(listener)); - } - evaluate(); - } + /// The expected type. + final Type expectedType; - StreamSubscription? _streamSubscription; - - void evaluate() { - final parser = ExpressionParser(context); - final Object? result = parser.evaluate(expression); - - _streamSubscription?.cancel(); - _streamSubscription = null; - - if (result is Stream) { - _streamSubscription = result.listen( - (data) { - value = data as T?; - }, - onError: (Object error, StackTrace stackTrace) { - genUiLogger.warning( - 'Error in computed value stream', - error, - stackTrace, - ); - value = null; - }, - ); - } else { - value = result as T?; - } - } + /// The actual type found. + final Type actualType; @override - void dispose() { - _streamSubscription?.cancel(); - for (final VoidCallback unsub in unsubscribers) { - unsub(); - } - super.dispose(); + String toString() { + return 'DataModelTypeException: Expected $expectedType at $path, ' + 'but found $actualType'; } } /// Manages the application's data model and provides a subscription-based /// mechanism for reactive UI updates. -interface class DataModel { - JsonMap _data = {}; - final Map> _subscriptions = {}; - - final List _cleanupCallbacks = []; - - /// The full contents of the data model. - JsonMap get data => _data; - +abstract interface class DataModel { /// Updates the data model at a specific absolute path and notifies all /// relevant subscribers. /// /// If [absolutePath] is null or root, the entire data model is replaced /// (if contents is a Map). + void update(DataPath? absolutePath, Object? contents); + + /// Subscribes to a specific absolute path in the data model. + ValueNotifier subscribe(DataPath absolutePath); + + /// Binds an external state [source] to a [path] in the DataModel. + /// + /// If [twoWay] is true, changes in the DataModel at [path] will also + /// update the [source] (assuming [source] is a [ValueNotifier]). + /// + /// Returns a function that disposes the binding. + void Function() bindExternalState({ + required DataPath path, + required ValueListenable source, + bool twoWay = false, + }); + + /// Disposes resources and bindings. + void dispose(); + + /// Retrieves a static, one-time value from the data model at the + /// specified absolute path without creating a subscription. + T? getValue(DataPath absolutePath); +} + +/// Standard in-memory implementation of [DataModel]. +class InMemoryDataModel implements DataModel { + JsonMap _data = {}; + final Map> _subscriptions = {}; + + final List _cleanupCallbacks = []; + + @override void update(DataPath? absolutePath, Object? contents) { genUiLogger.info( 'DataModel.update: path=$absolutePath, contents=' @@ -328,27 +336,31 @@ interface class DataModel { _notifySubscribers(absolutePath); } - /// Subscribes to a specific absolute path in the data model. + @override ValueNotifier subscribe(DataPath absolutePath) { genUiLogger.finer('DataModel.subscribe: path=$absolutePath'); - final T? initialValue = getValue(absolutePath); if (_subscriptions.containsKey(absolutePath)) { - final notifier = _subscriptions[absolutePath]! as ValueNotifier; - + final notifier = + _subscriptions[absolutePath]! as _RefCountedValueNotifier; + notifier.incrementRef(); return notifier; } - final notifier = ValueNotifier(initialValue); + + final T? initialValue = getValue(absolutePath); + final notifier = _RefCountedValueNotifier( + initialValue, + onDispose: () { + _subscriptions.remove(absolutePath); + }, + ); _subscriptions[absolutePath] = notifier; return notifier; } final List _externalSubscriptions = []; - /// Binds an external state [source] to a [path] in the DataModel. - /// - /// If [twoWay] is true, changes in the DataModel at [path] will also - /// update the [source] (assuming [source] is a [ValueNotifier]). - void bindExternalState({ + @override + void Function() bindExternalState({ required DataPath path, required ValueListenable source, bool twoWay = false, @@ -364,8 +376,10 @@ interface class DataModel { } source.addListener(onSourceChanged); - _externalSubscriptions.add(() => source.removeListener(onSourceChanged)); + void removeSourceListener() => source.removeListener(onSourceChanged); + _externalSubscriptions.add(removeSourceListener); + VoidCallback? removeModelListener; if (twoWay) { if (source is! ValueNotifier) { genUiLogger.warning( @@ -384,14 +398,28 @@ interface class DataModel { } subscription.addListener(onModelChanged); - _externalSubscriptions.add( - () => subscription.removeListener(onModelChanged), - ); + removeModelListener = () { + subscription.removeListener(onModelChanged); + // When we are done with the subscription, we should dispose it to + // decrement ref count. + subscription.dispose(); + }; + _externalSubscriptions.add(removeModelListener); } } + + return () { + removeSourceListener(); + _externalSubscriptions.remove(removeSourceListener); + + if (removeModelListener != null) { + removeModelListener(); + _externalSubscriptions.remove(removeModelListener); + } + }; } - /// Disposes resources and bindings. + @override void dispose() { for (final VoidCallback callback in _cleanupCallbacks) { callback(); @@ -403,23 +431,36 @@ interface class DataModel { } _externalSubscriptions.clear(); - for (final ValueNotifier notifier in _subscriptions.values) { + // Create a copy of values to avoid concurrent modification if dispose + // modifies the map + for (final _RefCountedValueNotifier notifier + in _subscriptions.values.toList()) { notifier.dispose(); } _subscriptions.clear(); } - /// Retrieves a static, one-time value from the data model at the - /// specified absolute path without creating a subscription. + @override T? getValue(DataPath absolutePath) { if (absolutePath == DataPath.root) { + _checkType(_data, absolutePath); return _data as T?; } - return _getValue(_data, absolutePath.segments) as T?; + final Object? value = _getValue(_data, absolutePath.segments); + _checkType(value, absolutePath); + return value as T?; + } + + void _checkType(Object? value, DataPath path) { + if (value != null && value is! T) { + throw DataModelTypeException( + path: path, + expectedType: T, + actualType: value.runtimeType, + ); + } } - /// Retrieves a static, one-time value from the data model at the - /// specified path segments without creating a subscription. Object? _getValue(Object? current, List segments) { if (segments.isEmpty) { return current; @@ -439,7 +480,6 @@ interface class DataModel { return null; } - /// Updates the given path with a new value without creating a subscription. void _updateValue(Object? current, List segments, Object? value) { if (segments.isEmpty) { return; @@ -508,6 +548,7 @@ interface class DataModel { var parent = path; while (!parent.isAbsolute || parent.segments.isNotEmpty) { if (parent == DataPath.root) break; + if (!parent.isAbsolute && parent.segments.isEmpty) break; parent = parent.dirname; if (_subscriptions.containsKey(parent)) { _subscriptions[parent]!.value = getValue(parent); @@ -523,3 +564,23 @@ interface class DataModel { } } } + +class _RefCountedValueNotifier extends ValueNotifier { + _RefCountedValueNotifier(super.value, {this.onDispose}); + + final VoidCallback? onDispose; + int _refCount = 1; + + void incrementRef() { + _refCount++; + } + + @override + void dispose() { + _refCount--; + if (_refCount <= 0) { + onDispose?.call(); + super.dispose(); + } + } +} diff --git a/packages/genui/lib/src/transport/a2ui_parser_transformer.dart b/packages/genui/lib/src/transport/a2ui_parser_transformer.dart index fb87925c5..0d6f990d6 100644 --- a/packages/genui/lib/src/transport/a2ui_parser_transformer.dart +++ b/packages/genui/lib/src/transport/a2ui_parser_transformer.dart @@ -75,12 +75,14 @@ class _A2uiParserStream { _buffer = _buffer.substring(markdownMatch.end); continue; } - } catch (_) { - // Invalid JSON in markdown block. Consumed as text implicitly if we - // advance? No, we didn't advance. We treat it as text. Fall through - // to 3? If we don't handle it here, `firstPotentialStart` might pick - // `markdownStart` again and emit it as text. So we successfully - // effectively skip "parsing as message". + } on FormatException { + // Invalid JSON in markdown block. + // Emit as text immediately so we don't get stuck in a loop + // where the fallback logic waits for more data indefinitely. + _emitBefore(markdownMatch.start); + _emitText(markdownMatch.original); + _buffer = _buffer.substring(markdownMatch.end); + continue; } } @@ -88,7 +90,7 @@ class _A2uiParserStream { final _Match? jsonMatch = _findBalancedJson(_buffer); if (jsonMatch != null) { // Prioritize markdown if it starts BEFORE the balanced JSON logic would - // pick it up? + // pick it up. if (markdownMatch != null && markdownMatch.start <= jsonMatch.start) { // We already tried markdown and failed (otherwise we continued). // Fall through. @@ -102,8 +104,13 @@ class _A2uiParserStream { _buffer = _buffer.substring(jsonMatch.end); continue; } - } catch (_) { + } on FormatException catch (_) { // Invalid JSON. + // Emit as text immediately to avoid stalling. + _emitBefore(jsonMatch.start); + _emitText(jsonMatch.original); + _buffer = _buffer.substring(jsonMatch.end); + continue; } } @@ -191,7 +198,12 @@ class _A2uiParserStream { final regex = RegExp(r'```(?:json)?\s*([\s\S]*?)\s*```'); final RegExpMatch? match = regex.firstMatch(text); if (match != null) { - return _Match(match.start, match.end, match.group(1) ?? ''); + return _Match( + match.start, + match.end, + match.group(1) ?? '', + match.group(0) ?? '', + ); } return null; } @@ -225,7 +237,8 @@ class _A2uiParserStream { } else if (char == '}') { balance--; if (balance == 0) { - return _Match(0, i + 1, input.substring(0, i + 1)); + final String text = input.substring(0, i + 1); + return _Match(0, i + 1, text, text); } } } @@ -235,8 +248,9 @@ class _A2uiParserStream { } class _Match { - _Match(this.start, this.end, this.content); + _Match(this.start, this.end, this.content, this.original); final int start; final int end; final String content; + final String original; } diff --git a/packages/genui/lib/src/utils/validation_helper.dart b/packages/genui/lib/src/utils/validation_helper.dart index 7144d9635..eaf6262b7 100644 --- a/packages/genui/lib/src/utils/validation_helper.dart +++ b/packages/genui/lib/src/utils/validation_helper.dart @@ -4,8 +4,21 @@ import 'package:rxdart/rxdart.dart'; -import '../functions/expression_parser.dart'; +import '../model/data_model.dart'; import '../primitives/simple_items.dart'; +import '../widgets/widget_utilities.dart'; + +/// A validation error with a message. +class ValidationError { + /// Creates a [ValidationError] with the given [message]. + ValidationError(this.message); + + /// The error message. + final String message; + + @override + String toString() => 'ValidationError: $message'; +} /// A helper class for handling reactive validation logic. class ValidationHelper { @@ -15,12 +28,12 @@ class ValidationHelper { /// if all checks pass. /// /// The [checks] list should contain maps with 'condition' and optional - /// 'message' keys. The [parser] is used to evaluate the conditions. + /// 'message' keys. static Stream validateStream( List? checks, - ExpressionParser? parser, + DataContext? context, ) { - if (checks == null || checks.isEmpty || parser == null) { + if (checks == null || checks.isEmpty || context == null) { return Stream.value(null); } @@ -28,7 +41,7 @@ class ValidationHelper { for (final JsonMap check in checks) { final String message = check['message'] as String? ?? 'Invalid value'; streams.add( - parser + context .evaluateConditionStream(check['condition']) .map((isValid) => (isValid, message)), ); @@ -41,4 +54,50 @@ class ValidationHelper { return null; }); } + + /// Validates a value against a schema, resolving any expressions in the + /// schema. + Future> validate( + Object? value, + JsonMap schema, + DataContext dataContext, + ) async { + final List errors = []; + + // Resolve schema constraints that might be expressions + final JsonMap resolvedSchema = await resolveContext(dataContext, schema); + + // simple validation for now, delegating to json_schema_builder would be + // ideal, but for now we just check basic constraints we support in genui + + if (resolvedSchema.containsKey('type')) { + final Object? type = resolvedSchema['type']; + if (type == 'string' && value is! String) { + errors.add( + ValidationError('Expected string, got ${value.runtimeType}'), + ); + } else if (type == 'number' && value is! num) { + errors.add( + ValidationError('Expected number, got ${value.runtimeType}'), + ); + } else if (type == 'boolean' && value is! bool) { + errors.add( + ValidationError('Expected boolean, got ${value.runtimeType}'), + ); + } + } + + if (resolvedSchema.containsKey('required') && value is Map) { + final required = resolvedSchema['required'] as List; + for (final key in required) { + if (!value.containsKey(key)) { + errors.add(ValidationError('Missing required key: $key')); + } + } + } + + // TODO: Add more validation logic as needed, potentially using a library + + return errors; + } } diff --git a/packages/genui/lib/src/widgets/surface.dart b/packages/genui/lib/src/widgets/surface.dart index 73b248fd4..c1faff9d8 100644 --- a/packages/genui/lib/src/widgets/surface.dart +++ b/packages/genui/lib/src/widgets/surface.dart @@ -86,7 +86,7 @@ class _SurfaceState extends State { rootId, DataContext( widget.surfaceContext.dataModel, - '/', + DataPath.root, functions: catalog.functions, ), ); @@ -135,6 +135,7 @@ class _SurfaceState extends State { getCatalogItem: (String type) => catalog.items.firstWhereOrNull((item) => item.name == type), surfaceId: widget.surfaceContext.surfaceId, + reportError: widget.surfaceContext.reportError, ), ); } catch (exception, stackTrace) { diff --git a/packages/genui/lib/src/widgets/widget_utilities.dart b/packages/genui/lib/src/widgets/widget_utilities.dart index 49893f946..95474758f 100644 --- a/packages/genui/lib/src/widgets/widget_utilities.dart +++ b/packages/genui/lib/src/widgets/widget_utilities.dart @@ -2,11 +2,13 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import '../functions/expression_parser.dart'; import '../model/data_model.dart'; +import '../primitives/logging.dart'; import '../primitives/simple_items.dart'; /// A builder widget that simplifies handling of nullable `ValueListenable`s. @@ -42,53 +44,294 @@ class OptionalValueBuilder extends StatelessWidget { } /// Extension methods for [DataContext] to simplify data binding. -extension DataContextExtensions on DataContext { - /// Subscribes to a string value, which can be a literal or a data-bound path. - /// - /// This method is robust against type mismatches in the data model. If the - /// underlying value is not a String, it will be converted using [toString]. - ValueNotifier subscribeToString(Object? value) { - if (value is Map && value.containsKey('path')) { - final ValueNotifier raw = subscribe( - value['path'] as String, - ); - - return _ToStringNotifier(raw); +/// A widget that binds to a value in the [DataContext] and rebuilds when it +/// changes. +/// +/// This widget handles the lifecycle of the underlying [ValueNotifier], +/// ensuring it is disposed when the widget is unmounted. +abstract class BoundValue extends StatefulWidget { + /// Creates a [BoundValue]. + const BoundValue({ + super.key, + required this.dataContext, + required this.value, + required this.builder, + }); + + /// The [DataContext] to resolve the value against. + final DataContext dataContext; + + /// The value definition (literal, path, or function call). + final Object? value; + + /// The builder function to call when the value changes. + final Widget Function(BuildContext context, T? value) builder; + + @override + State> createState(); +} + +/// State class for [BoundValue]. +abstract class BoundValueState> extends State { + ValueNotifier? _notifier; + + @override + void initState() { + super.initState(); + _initNotifier(); + } + + @override + void didUpdateWidget(W oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.value != oldWidget.value || + widget.dataContext != oldWidget.dataContext) { + _disposeNotifier(); + _initNotifier(); } - if (value is String && !value.contains(r'${')) { - return ValueNotifier(value); + } + + @override + void dispose() { + _disposeNotifier(); + super.dispose(); + } + + void _initNotifier() { + _notifier = createNotifier(); + } + + void _disposeNotifier() { + _notifier?.dispose(); + _notifier = null; + } + + /// Subclasses implement this to create the specific notifier type. + ValueNotifier createNotifier(); + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: _notifier!, + builder: (context, value, child) { + return widget.builder(context, value); + }, + ); + } +} + +/// Binds to a [String] value. +class BoundString extends BoundValue { + /// Creates a [BoundString]. + const BoundString({ + super.key, + required super.dataContext, + required super.value, + required super.builder, + }); + + @override + State createState() => _BoundStringState(); +} + +class _BoundStringState extends BoundValueState { + @override + ValueNotifier createNotifier() { + final Object? value = widget.value; + if (value is Map) { + if (value.containsKey('path')) { + final ValueNotifier raw = widget.dataContext + .subscribe(DataPath(value['path'] as String)); + return _ToStringNotifier(raw); + } + if (value.containsKey('call')) { + return _StreamToValueNotifier( + widget.dataContext.resolve(value).map((v) => v?.toString()), + ); + } } - return subscribe(value); + // Treat as literal + return ValueNotifier(value?.toString()); } +} + +/// Binds to a [bool] value. +class BoundBool extends BoundValue { + /// Creates a [BoundBool]. + const BoundBool({ + super.key, + required super.dataContext, + required super.value, + required super.builder, + }); - /// Subscribes to a boolean value, which can be a literal or a data-bound - /// path. - ValueNotifier subscribeToBool(Object? value) { - if (value is Map && value.containsKey('path')) { - final ValueNotifier raw = subscribe( - value['path'] as String, - ); - return _ToBoolNotifier(raw); + @override + State createState() => _BoundBoolState(); +} + +class _BoundBoolState extends BoundValueState { + @override + ValueNotifier createNotifier() { + final Object? value = widget.value; + if (value is Map) { + if (value.containsKey('path')) { + final ValueNotifier raw = widget.dataContext + .subscribe(DataPath(value['path'] as String)); + return _ToBoolNotifier(raw); + } + if (value.containsKey('call')) { + return _StreamToValueNotifier( + widget.dataContext.resolve(value).map((v) { + if (v is bool) return v; + return v != null; + }), + ); + } + } + if (value is bool) { + return ValueNotifier(value); + } + return ValueNotifier(null); + } +} + +/// Binds to a [num] value. +class BoundNumber extends BoundValue { + /// Creates a [BoundNumber]. + const BoundNumber({ + super.key, + required super.dataContext, + required super.value, + required super.builder, + }); + + @override + State createState() => _BoundNumberState(); +} + +class _BoundNumberState extends BoundValueState { + @override + ValueNotifier createNotifier() { + final Object? value = widget.value; + if (value is Map) { + if (value.containsKey('path')) { + final ValueNotifier raw = widget.dataContext + .subscribe(DataPath(value['path'] as String)); + return _ToNumberNotifier(raw); + } + if (value.containsKey('call')) { + return _StreamToValueNotifier( + widget.dataContext.resolve(value).map((v) { + if (v is num) return v; + if (v is String) return num.tryParse(v); + return null; + }), + ); + } } - return subscribe(value); + if (value is num) { + return ValueNotifier(value); + } + return ValueNotifier(null); } +} + +/// Binds to a [List] of objects. +class BoundList extends BoundValue> { + /// Creates a [BoundList]. + const BoundList({ + super.key, + required super.dataContext, + required super.value, + required super.builder, + }); + + @override + State createState() => _BoundListState(); +} - /// Subscribes to a list of objects, which can be a literal or a data-bound - /// path. - ValueNotifier?> subscribeToObjectArray(Object? value) { - return subscribe>(value); +class _BoundListState extends BoundValueState, BoundList> { + @override + ValueNotifier?> createNotifier() { + final Object? value = widget.value; + if (value is Map) { + if (value.containsKey('path')) { + return widget.dataContext.subscribe>( + DataPath(value['path'] as String), + ); + } + if (value.containsKey('call')) { + return _StreamToValueNotifier?>( + widget.dataContext.resolve(value).map((v) { + if (v is List) return v.cast(); + return null; + }), + ); + } + } + if (value is List) { + return ValueNotifier?>(value.cast()); + } + return ValueNotifier?>(null); } +} + +/// Binds to any [Object] value. +class BoundObject extends BoundValue { + /// Creates a [BoundObject]. + const BoundObject({ + super.key, + required super.dataContext, + required super.value, + required super.builder, + }); - /// Subscribes to a number value, which can be a literal or a data-bound - /// path. - ValueNotifier subscribeToNumber(Object? value) { - if (value is Map && value.containsKey('path')) { - final ValueNotifier raw = subscribe( - value['path'] as String, - ); - return _ToNumberNotifier(raw); + @override + State createState() => _BoundObjectState(); +} + +class _BoundObjectState extends BoundValueState { + @override + ValueNotifier createNotifier() { + final Object? value = widget.value; + if (value is Map) { + if (value.containsKey('path')) { + return widget.dataContext.subscribe( + DataPath(value['path'] as String), + ); + } + if (value.containsKey('call')) { + return _StreamToValueNotifier( + widget.dataContext.resolve(value), + ); + } } - return subscribe(value); + return ValueNotifier(value); + } +} + +class _StreamToValueNotifier extends ValueNotifier { + _StreamToValueNotifier(Stream stream, [T? initialValue]) + : super(initialValue) { + _subscription = stream.listen( + (value) => this.value = value, + onError: (Object error) { + // We log the error but don't crash. + // ValueNotifier doesn't support error state. + genUiLogger.warning( + 'Error in stream subscription for ValueNotifier', + error, + ); + }, + ); + } + + StreamSubscription? _subscription; + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); } } @@ -106,6 +349,7 @@ class _ToStringNotifier extends ValueNotifier { @override void dispose() { _source.removeListener(_update); + _source.dispose(); super.dispose(); } } @@ -134,6 +378,7 @@ class _ToBoolNotifier extends ValueNotifier { @override void dispose() { _source.removeListener(_update); + _source.dispose(); super.dispose(); } } @@ -158,28 +403,24 @@ class _ToNumberNotifier extends ValueNotifier { @override void dispose() { _source.removeListener(_update); + _source.dispose(); super.dispose(); } } /// Resolves a context map definition against a [DataContext]. /// -JsonMap resolveContext(DataContext dataContext, JsonMap? contextDefinition) { +Future resolveContext( + DataContext dataContext, + JsonMap? contextDefinition, +) async { final resolved = {}; if (contextDefinition == null) return resolved; - final parser = ExpressionParser(dataContext); - for (final MapEntry entry in contextDefinition.entries) { final String key = entry.key; final Object? value = entry.value; - if (value is String) { - resolved[key] = parser.parse(value); - } else if (value is Map && value.containsKey('path')) { - resolved[key] = dataContext.getValue(value['path'] as String); - } else { - resolved[key] = value; - } + resolved[key] = await dataContext.resolve(value).first; } return resolved; } diff --git a/packages/genui/pubspec.yaml b/packages/genui/pubspec.yaml index 18409f9c2..da87591bd 100644 --- a/packages/genui/pubspec.yaml +++ b/packages/genui/pubspec.yaml @@ -24,6 +24,7 @@ dependencies: intl: ^0.20.2 json_schema_builder: ^0.1.3 logging: ^1.3.0 + meta: ^1.17.0 rxdart: ^0.28.0 url_launcher: ^6.3.2 uuid: ^4.4.0 diff --git a/packages/genui/test/catalog/basic_functions_test.dart b/packages/genui/test/catalog/basic_functions_test.dart index 2d7b38cb2..9d2196125 100644 --- a/packages/genui/test/catalog/basic_functions_test.dart +++ b/packages/genui/test/catalog/basic_functions_test.dart @@ -15,8 +15,8 @@ void main() { late DataModel dataModel; setUp(() { - dataModel = DataModel(); - context = DataContext(dataModel, '/'); + dataModel = InMemoryDataModel(); + context = DataContext(dataModel, DataPath.root); }); Future run(ClientFunction func, Map args) async { @@ -72,12 +72,6 @@ void main() { expect(await run(func, {'value': null}), 0); }); - test('formatString', () async { - final FormatStringFunction func = BasicFunctions.formatStringFunction; - expect(await run(func, {'value': 'Hello'}), 'Hello'); - expect(await run(func, {'value': 123}), '123'); - }); - test('and', () async { final AndFunction func = BasicFunctions.andFunction; expect( diff --git a/packages/genui/test/catalog/core_widgets/audio_player_test.dart b/packages/genui/test/catalog/core_widgets/audio_player_test.dart index fd675a34f..0fa6c08e0 100644 --- a/packages/genui/test/catalog/core_widgets/audio_player_test.dart +++ b/packages/genui/test/catalog/core_widgets/audio_player_test.dart @@ -41,14 +41,14 @@ void main() { ), ); - expect(find.byType(Placeholder), findsOneWidget); + expect(find.byIcon(Icons.audiotrack), findsOneWidget); // Check for Semantics widget properties directly if find.bySemanticsLabel // fails final Semantics semantics = tester.widget( find .ancestor( - of: find.byType(Placeholder), + of: find.byIcon(Icons.audiotrack), matching: find.byType(Semantics), ) .first, diff --git a/packages/genui/test/catalog/core_widgets/button_test.dart b/packages/genui/test/catalog/core_widgets/button_test.dart index 176062a9e..011e4c75e 100644 --- a/packages/genui/test/catalog/core_widgets/button_test.dart +++ b/packages/genui/test/catalog/core_widgets/button_test.dart @@ -2,9 +2,13 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; +import 'package:genui/src/interfaces/client_function.dart'; +import 'package:json_schema_builder/json_schema_builder.dart'; void main() { testWidgets('Button widget renders and handles taps', ( @@ -64,4 +68,101 @@ void main() { await tester.tap(find.byType(ElevatedButton)); expect(message, isNotNull); }); + + testWidgets('Button widget handles stream errors gracefully', ( + WidgetTester tester, + ) async { + ChatMessage? message; + // Create a stream controller that we can use to emit errors + final streamController = StreamController.broadcast(); + + final mockFunction = MockFunction( + name: 'throwError', + onExecute: (args, context) => streamController.stream, + ); + + final manager = SurfaceController( + catalogs: [ + Catalog( + [BasicCatalogItems.button, BasicCatalogItems.text], + catalogId: 'test_catalog', + functions: [mockFunction], + ), + ], + ); + manager.onSubmit.listen((event) => message = event); + + const surfaceId = 'testSurface'; + final components = [ + const Component( + id: 'root', + type: 'Button', + properties: { + 'child': 'button_text', + 'action': {'call': 'throwError'}, + }, + ), + const Component( + id: 'button_text', + type: 'Text', + properties: {'text': 'Click Me'}, + ), + ]; + manager.handleMessage( + UpdateComponents(surfaceId: surfaceId, components: components), + ); + manager.handleMessage( + const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Surface(surfaceContext: manager.contextFor(surfaceId)), + ), + ), + ); + + await tester.pumpAndSettle(); + + // Tap the button to trigger the function call + await tester.tap(find.byType(ElevatedButton)); + + // Emit an error from the stream + streamController.addError(Exception('Stream error')); + + // Pump to process the error + await tester.pump(); + + // Wait for the message to be received, pumping the widget tree + var retries = 0; + while (message == null && retries < 50) { + await tester.pump(const Duration(milliseconds: 10)); + retries++; + } + + // Verify error was reported + expect(message, isNotNull); + + // The test passes if no unhandled exception crashes the test. + await streamController.close(); + manager.dispose(); + }); +} + +class MockFunction implements ClientFunction { + MockFunction({required this.name, required this.onExecute}); + + @override + final String name; + + final Stream Function(JsonMap args, DataContext context) onExecute; + + @override + Schema get argumentSchema => Schema.object(); + + @override + Stream execute(JsonMap args, DataContext context) { + return onExecute(args, context); + } } diff --git a/packages/genui/test/catalog/core_widgets/image_test.dart b/packages/genui/test/catalog/core_widgets/image_test.dart index 4fa8af024..c0cb0b033 100644 --- a/packages/genui/test/catalog/core_widgets/image_test.dart +++ b/packages/genui/test/catalog/core_widgets/image_test.dart @@ -10,6 +10,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:genui/genui.dart'; class _FakeHttpClient extends Fake implements HttpClient { + @override + bool autoUncompress = true; + @override Future getUrl(Uri url) async { throw const SocketException('Failed to connect'); @@ -20,88 +23,101 @@ void main() { testWidgets('Image widget handles network error gracefully', ( WidgetTester tester, ) async { - await HttpOverrides.runZoned(() async { - final manager = SurfaceController( - catalogs: [ - Catalog([BasicCatalogItems.image], catalogId: 'test_catalog'), - ], - ); - const surfaceId = 'testSurface'; - final components = [ - const Component( - id: 'root', - type: 'Image', - properties: {'url': 'https://example.com/nonexistent.png'}, - ), - ]; - manager.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), - ); - manager.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), - ); - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Surface(surfaceContext: manager.contextFor(surfaceId)), + debugNetworkImageHttpClientProvider = _FakeHttpClient.new; + try { + await HttpOverrides.runZoned(() async { + final manager = SurfaceController( + catalogs: [ + Catalog([BasicCatalogItems.image], catalogId: 'test_catalog'), + ], + ); + const surfaceId = 'testSurface'; + final components = [ + const Component( + id: 'root', + type: 'Image', + properties: {'url': 'https://example.com/nonexistent.png'}, + ), + ]; + manager.handleMessage( + UpdateComponents(surfaceId: surfaceId, components: components), + ); + manager.handleMessage( + const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Surface(surfaceContext: manager.contextFor(surfaceId)), + ), ), - ), - ); - - // Pump to allow image loading to fail - await tester.pump(); - await tester.pump(); - - // We expect the check that the image failed and is showing the broken - // image icon. - expect(find.byType(Image), findsOneWidget); - expect(find.byIcon(Icons.broken_image), findsOneWidget); - }, createHttpClient: (context) => _FakeHttpClient()); + ); + + // Pump to allow image loading to fail + await tester.pump(); + await tester.pump(); + + // We expect the check that the image failed and is showing the broken + // image icon. + expect(find.byType(Image), findsOneWidget); + expect(find.byIcon(Icons.broken_image), findsOneWidget); + }); + } finally { + debugNetworkImageHttpClientProvider = null; + } }); testWidgets('Image widget loads successfully from network', ( WidgetTester tester, ) async { - await HttpOverrides.runZoned(() async { - final manager = SurfaceController( - catalogs: [ - Catalog([BasicCatalogItems.image], catalogId: 'test_catalog'), - ], - ); - const surfaceId = 'testSurface'; - final components = [ - const Component( - id: 'root', - type: 'Image', - properties: {'url': 'https://example.com/image.png'}, - ), - ]; - manager.handleMessage( - UpdateComponents(surfaceId: surfaceId, components: components), - ); - manager.handleMessage( - const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), - ); - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Surface(surfaceContext: manager.contextFor(surfaceId)), + debugNetworkImageHttpClientProvider = _FakeSuccessHttpClient.new; + try { + await HttpOverrides.runZoned(() async { + final manager = SurfaceController( + catalogs: [ + Catalog([BasicCatalogItems.image], catalogId: 'test_catalog'), + ], + ); + const surfaceId = 'testSurface'; + final components = [ + const Component( + id: 'root', + type: 'Image', + properties: {'url': 'https://example.com/image.png'}, ), - ), - ); - - // Verify Image widget is present - expect(find.byType(Image), findsOneWidget); - // We can't easily verify the pixels without comprehensive mocking of - // HttpClientResponse but we can verify no error icon. - expect(find.byIcon(Icons.broken_image), findsNothing); - }, createHttpClient: (context) => _FakeSuccessHttpClient()); + ]; + manager.handleMessage( + UpdateComponents(surfaceId: surfaceId, components: components), + ); + manager.handleMessage( + const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), + ); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Surface(surfaceContext: manager.contextFor(surfaceId)), + ), + ), + ); + + // Verify Image widget is present + expect(find.byType(Image), findsOneWidget); + // We can't easily verify the pixels without comprehensive mocking of + // HttpClientResponse but we can verify no error icon. + expect(find.byIcon(Icons.broken_image), findsNothing); + }); + } finally { + debugNetworkImageHttpClientProvider = null; + } }); } class _FakeSuccessHttpClient extends Fake implements HttpClient { + @override + bool autoUncompress = true; + @override Future getUrl(Uri url) async { return _FakeHttpClientRequest(); diff --git a/packages/genui/test/catalog/core_widgets/row_test.dart b/packages/genui/test/catalog/core_widgets/row_test.dart index c667d49ce..3a1f17dcf 100644 --- a/packages/genui/test/catalog/core_widgets/row_test.dart +++ b/packages/genui/test/catalog/core_widgets/row_test.dart @@ -161,10 +161,6 @@ void main() { manager.handleMessage( UpdateComponents(surfaceId: surfaceId, components: components), ); - // CreateSurface must be sent to initialize the surface, can be before or after components/data updates are processed - // if buffering is working, but usually it comes first in stream. - // In this test setup, `manager.handleMessage` processes synchronously? - // If CreateSurface is last, it might trigger the build. manager.handleMessage( const CreateSurface(surfaceId: surfaceId, catalogId: 'test_catalog'), ); diff --git a/packages/genui/test/catalog/core_widgets/text_test.dart b/packages/genui/test/catalog/core_widgets/text_test.dart index bcca1e64e..29994ef75 100644 --- a/packages/genui/test/catalog/core_widgets/text_test.dart +++ b/packages/genui/test/catalog/core_widgets/text_test.dart @@ -26,10 +26,11 @@ void main() { buildChild: (_, [_]) => const SizedBox(), dispatchEvent: (UiEvent event) {}, buildContext: context, - dataContext: DataContext(DataModel(), '/'), + dataContext: DataContext(InMemoryDataModel(), DataPath.root), getComponent: (String componentId) => null, getCatalogItem: (String type) => null, surfaceId: 'surface1', + reportError: (e, s) {}, ), ), ), @@ -55,10 +56,11 @@ void main() { buildChild: (_, [_]) => const SizedBox(), dispatchEvent: (UiEvent event) {}, buildContext: context, - dataContext: DataContext(DataModel(), '/'), + dataContext: DataContext(InMemoryDataModel(), DataPath.root), getComponent: (String componentId) => null, getCatalogItem: (String type) => null, surfaceId: 'surface1', + reportError: (e, s) {}, ), ), ), @@ -108,10 +110,11 @@ void main() { buildChild: (_, [_]) => const SizedBox(), dispatchEvent: (UiEvent event) {}, buildContext: context, - dataContext: DataContext(DataModel(), '/'), + dataContext: DataContext(InMemoryDataModel(), DataPath.root), getComponent: (String componentId) => null, getCatalogItem: (String type) => null, surfaceId: 'surface1', + reportError: (e, s) {}, ), ), ), @@ -150,10 +153,11 @@ void main() { buildChild: (_, [_]) => const SizedBox(), dispatchEvent: (UiEvent event) {}, buildContext: context, - dataContext: DataContext(DataModel(), '/'), + dataContext: DataContext(InMemoryDataModel(), DataPath.root), getComponent: (String componentId) => null, getCatalogItem: (String type) => null, surfaceId: 'surface1', + reportError: (e, s) {}, ), ), ), @@ -175,4 +179,38 @@ void main() { ); expect(richText.text.style?.color, requiredColor); }); + + testWidgets( + 'Text widget does NOT evaluate expressions implicitly (renders literal)', + (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: text.widgetBuilder( + CatalogItemContext( + data: {'text': '\${foo}'}, + id: 'test_text_literal_expression', + type: 'Text', + buildChild: (_, [_]) => const SizedBox(), + dispatchEvent: (UiEvent event) {}, + buildContext: context, + dataContext: DataContext(InMemoryDataModel(), DataPath.root) + ..update(DataPath('foo'), 'bar'), + getComponent: (String componentId) => null, + getCatalogItem: (String type) => null, + surfaceId: 'surface1', + reportError: (e, s) {}, + ), + ), + ), + ), + ), + ); + + // Should render "${foo}" literally, NOT "bar". + expect(find.text('\${foo}'), findsOneWidget); + expect(find.text('bar'), findsNothing); + }, + ); } diff --git a/packages/genui/test/catalog_test.dart b/packages/genui/test/catalog_test.dart index 41f98a99b..a5219d3f6 100644 --- a/packages/genui/test/catalog_test.dart +++ b/packages/genui/test/catalog_test.dart @@ -43,10 +43,14 @@ void main() { const Text(''), // Mock child builder dispatchEvent: (UiEvent event) {}, buildContext: context, - dataContext: DataContext(DataModel(), '/'), + dataContext: DataContext( + InMemoryDataModel(), + DataPath.root, + ), getComponent: (String componentId) => null, getCatalogItem: (String type) => null, surfaceId: 'surfaceId', + reportError: (e, s) {}, ), ); }, @@ -82,10 +86,14 @@ void main() { buildChild: (_, [_]) => const SizedBox(), dispatchEvent: (UiEvent event) {}, buildContext: context, - dataContext: DataContext(DataModel(), '/'), + dataContext: DataContext( + InMemoryDataModel(), + DataPath.root, + ), getComponent: (String componentId) => null, getCatalogItem: (String type) => null, surfaceId: 'surfaceId', + reportError: (e, s) {}, ), ), throwsA(isA()), diff --git a/packages/genui/test/core/surface_widget_test.dart b/packages/genui/test/core/surface_widget_test.dart index 9eedf74f9..1cad5a8c9 100644 --- a/packages/genui/test/core/surface_widget_test.dart +++ b/packages/genui/test/core/surface_widget_test.dart @@ -54,7 +54,7 @@ void main() { late Catalog catalog; setUp(() { - dataModel = DataModel(); + dataModel = InMemoryDataModel(); catalog = Catalog([text], catalogId: 'test_catalog'); surfaceContext = FakeSurfaceContext( surfaceId: 'test_surface', @@ -160,7 +160,7 @@ void main() { tester, ) async { Object? reportedError; - dataModel = DataModel(); + dataModel = InMemoryDataModel(); surfaceContext = FakeSurfaceContext( surfaceId: 'test_surface', dataModel: dataModel, diff --git a/packages/genui/test/error_reporting_test.dart b/packages/genui/test/error_reporting_test.dart index 53660510c..1816be337 100644 --- a/packages/genui/test/error_reporting_test.dart +++ b/packages/genui/test/error_reporting_test.dart @@ -46,7 +46,7 @@ void main() { testWidgets('Surface reports error when catalog item is missing', ( tester, ) async { - final dataModel = DataModel(); + final dataModel = InMemoryDataModel(); final context = MockSurfaceContext('test_surface', dataModel); final definition = SurfaceDefinition( @@ -77,7 +77,7 @@ void main() { }); testWidgets('Surface reports error when Function throws', (tester) async { - final dataModel = DataModel(); + final dataModel = InMemoryDataModel(); // Provide a catalog with a failing function final context = MockSurfaceContext('test_surface', dataModel); @@ -100,6 +100,7 @@ void main() { await tester.pumpWidget( MaterialApp(home: Surface(surfaceContext: context)), ); + await tester.pump(); // Verify FallbackWidget and reported error expect(find.byType(FallbackWidget), findsOneWidget); @@ -172,13 +173,15 @@ final Catalog _testCatalog = Catalog( try { final Object? text = (ctx.data as Map?)?['text']; if (text is Map && text.containsKey('call')) { - final Object? result = ctx.dataContext.resolve(text); + final Object result = ctx.dataContext.resolve(text); if (result is Stream) { return StreamBuilder( stream: result, builder: (context, snapshot) { if (snapshot.hasError) { - throw Exception(snapshot.error!); + final Object error = snapshot.error!; + ctx.reportError.call(error, snapshot.stackTrace); + return FallbackWidget(error: error); } if (!snapshot.hasData) { return const SizedBox(); diff --git a/packages/genui/test/functions/expression_parser_test.dart b/packages/genui/test/functions/expression_parser_test.dart deleted file mode 100644 index e0d86df26..000000000 --- a/packages/genui/test/functions/expression_parser_test.dart +++ /dev/null @@ -1,363 +0,0 @@ -// Copyright 2025 The Flutter Authors. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'dart:async'; - -import 'package:flutter_test/flutter_test.dart'; -import 'package:genui/src/catalog/basic_catalog.dart'; -import 'package:genui/src/functions/expression_parser.dart'; -import 'package:genui/src/model/data_model.dart'; -// import 'package:genui/src/primitives/simple_items.dart'; // Unused - -void main() { - group('ExpressionParser', () { - late DataContext context; - late ExpressionParser parser; - late DataModel dataModel; - - setUp(() { - dataModel = DataModel(); - context = DataContext( - dataModel, - '/', - functions: BasicCatalogItems.asCatalog().functions, - ); - parser = ExpressionParser(context); - }); - - Future eval(Object? result) async { - if (result is Stream) { - return (await result.first) as T; - } - return result as T; - } - - group('parse', () { - test('returns input if no expressions', () { - expect(parser.parse('hello'), 'hello'); - expect(parser.parse('123'), '123'); - }); - - test('resolves simple path expression', () { - dataModel.update(DataPath('/name'), 'World'); - expect(parser.parse(r'Hello ${/name}'), 'Hello World'); - }); - - test('resolves multiple expressions', () { - dataModel.update(DataPath('/firstName'), 'John'); - dataModel.update(DataPath('/lastName'), 'Doe'); - expect(parser.parse(r'${/firstName} ${/lastName}'), 'John Doe'); - }); - - test('escapes expression', () { - expect(parser.parse(r'Value: \${/foo}'), r'Value: ${/foo}'); - }); - - test('returns non-string type for single expression', () { - dataModel.update(DataPath('/count'), 42); - expect(parser.parse(r'${/count}'), 42); - }); - - test('converts non-string values to string when mixed with text', () { - dataModel.update(DataPath('/count'), 42); - expect(parser.parse(r'Count: ${/count}'), 'Count: 42'); - }); - }); - - group('evaluateFunctionCall (Logic)', () { - test('and', () async { - expect( - await eval( - parser.evaluateFunctionCall({ - 'call': 'and', - 'args': { - 'values': [true, true], - }, - }), - ), - isTrue, - ); - expect( - await eval( - parser.evaluateFunctionCall({ - 'call': 'and', - 'args': { - 'values': [true, false], - }, - }), - ), - isFalse, - ); - }); - - test('or', () async { - expect( - await eval( - parser.evaluateFunctionCall({ - 'call': 'or', - 'args': { - 'values': [false, true], - }, - }), - ), - isTrue, - ); - expect( - await eval( - parser.evaluateFunctionCall({ - 'call': 'or', - 'args': { - 'values': [false, false], - }, - }), - ), - isFalse, - ); - }); - - test('not', () async { - expect( - await eval( - parser.evaluateFunctionCall({ - 'call': 'not', - 'args': {'value': true}, - }), - ), - isFalse, - ); - expect( - await eval( - parser.evaluateFunctionCall({ - 'call': 'not', - 'args': {'value': false}, - }), - ), - isTrue, - ); - }); - - test('standard function', () async { - // 'required' is a standard function - expect( - await eval( - parser.evaluateFunctionCall({ - 'call': 'required', - 'args': {'value': 'something'}, - }), - ), - isTrue, - ); - expect( - await eval( - parser.evaluateFunctionCall({ - 'call': 'required', - 'args': {'value': ''}, - }), - ), - isFalse, - ); - }); - - test('nested function calls via map', () async { - // not(and(true, false)) -> not(false) -> true - expect( - await eval( - parser.evaluateFunctionCall({ - 'call': 'not', - 'args': { - 'value': { - 'call': 'and', - 'args': { - 'values': [true, false], - }, - }, - }, - }), - ), - isTrue, - ); - }); - }); - - group('Function calls in expressions', () { - test('resolves simple function', () async { - expect( - await eval(parser.parse(r'${formatString(value: "Hello")}')), - 'Hello', - ); - }); - - test('resolves nested function', () async { - expect( - await eval( - parser.parse( - r'${formatString(value: ${formatString(value: "Nested")})}', - ), - ), - 'Nested', - ); - }); - - test('resolves function with path args', () async { - dataModel.update(DataPath('/val'), 'Dynamic'); - expect( - await eval(parser.parse(r'${formatString(value: ${/val})}')), - 'Dynamic', - ); - }); - - test('resolves function with quoted string containing spaces', () async { - expect( - await eval( - parser.parse(r'${formatString(value: "Hello World")}'), - ), - 'Hello World', - ); - }); - }); - - group('extractDependencies', () { - test('returns empty for no expressions', () { - expect(parser.extractDependencies('hello'), isEmpty); - }); - - test('returns single path', () { - expect(parser.extractDependencies(r'${/foo}'), {DataPath('/foo')}); - }); - - test('returns multiple paths', () { - expect(parser.extractDependencies(r'${/foo} ${/bar}'), { - DataPath('/foo'), - DataPath('/bar'), - }); - }); - - test('returns paths in function calls', () { - expect(parser.extractDependencies(r'${formatString(value: ${/foo})}'), { - DataPath('/foo'), - }); - }); - - test('returns paths in nested function calls', () { - expect( - parser.extractDependencies( - r'${upper(value: ${lower(value: ${/foo})})}', - ), - {DataPath('/foo')}, - ); - }); - - test('returns paths in nested interpolations', () { - expect(parser.extractDependencies(r'${foo(val: ${/bar})}'), { - DataPath('/bar'), - }); - }); - - test('returns paths with whitespace', () { - expect(parser.extractDependencies(r'${ /foo }'), {DataPath('/foo')}); - }); - - test('returns paths with mixed content', () { - expect(parser.extractDependencies(r'Value: ${/foo}, Count: ${/bar}'), { - DataPath('/foo'), - DataPath('/bar'), - }); - }); - }); - - group('Invalid syntax', () { - test( - 'rejects function call with raw function call as argument', - () async { - // ${foo(bar())} should NOT parse bar() as a function call. - // It should be treated as a path "bar()" which likely resolves to - // null, or fail to parse the outer function call due to syntax error - // (missing colon). - // - // "bar()" is not a valid named argument key (missing colon). - // So _parseNamedArgs will likely return empty map or partial map. - // "foo" will be called with empty/partial args. - // - // Verify that "bar" is NOT invoked. - parser = ExpressionParser(context); - - // ${not(true)} -> false. - // ${not(not(false))} -> true (if nested). - // ${not(not(false))} -> invalid syntax "not(false)" is not - // "key: value". - expect( - await eval(parser.parse(r'${not(not(false))}')), - isNot(true), - ); - }, - ); - - test( - 'rejects function call with raw function call as named argument value', - () async { - // ... existing test content ... - expect( - await eval( - parser.parse(r'${not(value: not(value: false))}'), - ), - true, - ); - }, - ); - }); - - group('Reactive Validation', () { - test('required function with DataContext updates', () { - dataModel.update(DataPath('/myDate'), null); - final Stream stream = parser.evaluateConditionStream({ - 'call': 'required', - 'args': { - 'value': {'path': '/myDate'}, - }, - }); - - expect(stream, emitsInOrder([false, true])); - - // Schedule update - Future.microtask( - () => dataModel.update(DataPath('/myDate'), '2022-01-01'), - ); - }); - }); - - group('Recursion Depth', () { - test('evaluateConditionSync throws on exceeding max depth', () { - // Create a deeply nested structure: not(not(not(...))) - // Depth 101 should trigger the limit of 100. - Map expression = {'value': true}; - for (var i = 0; i < 105; i++) { - expression = { - 'call': 'not', - 'args': {'value': expression}, - }; - } - - expect( - () => parser.evaluateConditionSync(expression), - throwsA(isA()), - ); - }); - - test('evaluateConditionStream throws on exceeding max depth', () { - Map expression = {'value': true}; - for (var i = 0; i < 105; i++) { - expression = { - 'call': 'not', - 'args': {'value': expression}, - }; - } - - expect( - () => parser.evaluateConditionStream(expression), - throwsA(isA()), - ); - }); - }); - }); -} diff --git a/packages/genui/test/functions/format_string_test.dart b/packages/genui/test/functions/format_string_test.dart new file mode 100644 index 000000000..b84200cd8 --- /dev/null +++ b/packages/genui/test/functions/format_string_test.dart @@ -0,0 +1,190 @@ +// Copyright 2025 The Flutter Authors. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:genui/src/catalog/basic_catalog.dart'; +import 'package:genui/src/functions/format_string.dart'; +import 'package:genui/src/model/data_model.dart'; +// import 'package:genui/src/primitives/simple_items.dart'; // Unused + +void main() { + group('FormatStringFunction & ExpressionParser', () { + late DataContext context; + late ExpressionParser parser; + late DataModel dataModel; + late FormatStringFunction formatStringFunction; + + setUp(() { + dataModel = InMemoryDataModel(); + context = DataContext( + dataModel, + DataPath.root, + functions: BasicCatalogItems.asCatalog().functions, + ); + parser = ExpressionParser(context); + formatStringFunction = const FormatStringFunction(); + }); + + Future eval(Object? result) async { + if (result is Stream) { + return (await result.first) as T; + } + return result as T; + } + + group('FormatStringFunction', () { + test('returns empty string if value missing', () async { + final Stream result = formatStringFunction.execute( + {}, + context, + ); + expect(await result.first, ''); + }); + + test('returns string representation of non-string values', () async { + final Stream result = formatStringFunction.execute({ + 'value': 123, + }, context); + expect(await result.first, '123'); + }); + + test('parses string with expressions', () async { + dataModel.update(DataPath('/name'), 'World'); + final Stream result = formatStringFunction.execute({ + 'value': 'Hello \${/name}', + }, context); + expect(await result.first, 'Hello World'); + }); + + test('parses string with function calls', () async { + final Stream result = formatStringFunction.execute({ + 'value': '\${required(value: "hello")}', + }, context); + expect(await result.first, 'true'); + }); + }); + + group('ExpressionParser', () { + group('parse', () { + test('returns input if no expressions', () async { + expect(await eval(parser.parse('hello')), 'hello'); + expect(await eval(parser.parse('123')), '123'); + }); + + test('resolves simple path expression', () async { + dataModel.update(DataPath('/name'), 'World'); + expect( + await eval(parser.parse(r'Hello ${/name}')), + 'Hello World', + ); + }); + + test('resolves multiple expressions', () async { + dataModel.update(DataPath('/firstName'), 'John'); + dataModel.update(DataPath('/lastName'), 'Doe'); + expect( + await eval(parser.parse(r'${/firstName} ${/lastName}')), + 'John Doe', + ); + }); + + test('escapes expression', () async { + expect( + await eval(parser.parse(r'Value: \${/foo}')), + r'Value: ${/foo}', + ); + }); + + test('returns non-string type for single expression', () async { + dataModel.update(DataPath('/count'), 42); + expect(await eval(parser.parse(r'${/count}')), '42'); + }); + + test( + 'converts non-string values to string when mixed with text', + () async { + dataModel.update(DataPath('/count'), 42); + expect( + await eval(parser.parse(r'Count: ${/count}')), + 'Count: 42', + ); + }, + ); + }); + + group('evaluateFunctionCall (Logic)', () { + test('and', () async { + expect( + await eval( + parser.evaluateFunctionCall({ + 'call': 'and', + 'args': { + 'values': [true, true], + }, + }), + ), + isTrue, + ); + expect( + await eval( + parser.evaluateFunctionCall({ + 'call': 'and', + 'args': { + 'values': [true, false], + }, + }), + ), + isFalse, + ); + }); + // ... + }); + + // ... + + group('Invalid syntax', () { + test( + 'rejects function call with raw function call as argument', + () async { + parser = ExpressionParser(context); + expect( + await eval(parser.parse(r'${not(not(false))}')), + 'false', + ); + }, + ); + + test('rejects function call with raw function call as named argument ' + 'value', () async { + expect( + await eval( + parser.parse(r'${not(value: not(value: false))}'), + ), + 'true', + ); + }); + }); + + group('Recursion Depth', () { + // Testing via evaluateFunctionCall which calls private methods + test('evaluateFunctionCall throws on exceeding max depth', () { + Map expression = {'value': true}; + for (var i = 0; i < 105; i++) { + expression = { + 'call': 'not', + 'args': {'value': expression}, + }; + } + // parse doesn't take map, evaluateFunctionCall does. + expect( + () => parser.evaluateFunctionCall(expression), + throwsA(isA()), + ); + }); + }); + }); + }); +} diff --git a/packages/genui/test/image_test.dart b/packages/genui/test/image_test.dart index fe8b41416..fb3ffba40 100644 --- a/packages/genui/test/image_test.dart +++ b/packages/genui/test/image_test.dart @@ -30,10 +30,11 @@ void main() { buildChild: (_, [_]) => const SizedBox(), dispatchEvent: (UiEvent event) {}, buildContext: context, - dataContext: DataContext(DataModel(), '/'), + dataContext: DataContext(InMemoryDataModel(), DataPath.root), getComponent: (String componentId) => null, getCatalogItem: (String type) => null, surfaceId: 'surface1', + reportError: (e, s) {}, ), ), ), @@ -70,10 +71,11 @@ void main() { buildChild: (_, [_]) => const SizedBox(), dispatchEvent: (UiEvent event) {}, buildContext: context, - dataContext: DataContext(DataModel(), '/'), + dataContext: DataContext(InMemoryDataModel(), DataPath.root), getComponent: (String componentId) => null, getCatalogItem: (String type) => null, surfaceId: 'surface1', + reportError: (e, s) {}, ), ), ), @@ -117,10 +119,11 @@ void main() { buildChild: (_, [_]) => const SizedBox(), dispatchEvent: (UiEvent event) {}, buildContext: context, - dataContext: DataContext(DataModel(), '/'), + dataContext: DataContext(InMemoryDataModel(), DataPath.root), getComponent: (String componentId) => null, getCatalogItem: (String type) => null, surfaceId: 'surface1', + reportError: (e, s) {}, ), ), ), diff --git a/packages/genui/test/model/catalog_exception_test.dart b/packages/genui/test/model/catalog_exception_test.dart index de171cf80..61a17fda1 100644 --- a/packages/genui/test/model/catalog_exception_test.dart +++ b/packages/genui/test/model/catalog_exception_test.dart @@ -25,10 +25,11 @@ void main() { buildChild: (id, [context]) => const SizedBox(), dispatchEvent: (event) {}, buildContext: context, - dataContext: DataContext(DataModel(), '/'), + dataContext: DataContext(InMemoryDataModel(), DataPath.root), getComponent: (id) => null, getCatalogItem: (type) => null, surfaceId: 'test_surface', + reportError: (e, s) {}, ); expect( @@ -65,10 +66,11 @@ void main() { buildChild: (id, [context]) => const SizedBox(), dispatchEvent: (event) {}, buildContext: context, - dataContext: DataContext(DataModel(), '/'), + dataContext: DataContext(InMemoryDataModel(), DataPath.root), getComponent: (id) => null, getCatalogItem: (type) => null, surfaceId: 'test_surface', + reportError: (e, s) {}, ); expect( diff --git a/packages/genui/test/model/data_model_edge_cases_test.dart b/packages/genui/test/model/data_model_edge_cases_test.dart index dd2649c41..8e6ca2acb 100644 --- a/packages/genui/test/model/data_model_edge_cases_test.dart +++ b/packages/genui/test/model/data_model_edge_cases_test.dart @@ -11,7 +11,7 @@ void main() { late DataModel dataModel; setUp(() { - dataModel = DataModel(); + dataModel = InMemoryDataModel(); }); test('Implicit Structure: Creates nested maps by default', () { @@ -51,14 +51,13 @@ void main() { expect((matrix?[0] as List)[0], 1); }); - test('Type Mismatch: Overwriting primitive with map fails silently ' - 'or clobbers?', () { + test('Type Mismatch: Overwriting primitive with map fails silently', () { // Setup: /a is a String dataModel.update(DataPath('/a'), 'hello'); // Attempt to write /a/b (treating /a as map) // Implementation check: _updateValue checks "if (current is Map)". - // If current is String, it does nothing? + // If current is String, it does nothing. dataModel.update(DataPath('/a/b'), 'world'); // Verify /a is still 'hello' diff --git a/packages/genui/test/model/data_model_test.dart b/packages/genui/test/model/data_model_test.dart index e66c23ef2..3156a46ee 100644 --- a/packages/genui/test/model/data_model_test.dart +++ b/packages/genui/test/model/data_model_test.dart @@ -85,8 +85,8 @@ void main() { late DataContext rootContext; setUp(() { - dataModel = DataModel(); - rootContext = DataContext(dataModel, '/'); + dataModel = InMemoryDataModel(); + rootContext = DataContext(dataModel, DataPath.root); }); test('resolves absolute paths', () { @@ -100,7 +100,7 @@ void main() { }); test('nested creates a new context', () { - final DataContext nested = rootContext.nested('a'); + final DataContext nested = rootContext.nested(DataPath('a')); expect(nested.path, DataPath('/a')); }); }); @@ -109,7 +109,7 @@ void main() { late DataModel dataModel; setUp(() { - dataModel = DataModel(); + dataModel = InMemoryDataModel(); }); test('update with null path replaces the model', () { @@ -168,6 +168,46 @@ void main() { }); }); + group('DataModel Extended', () { + late DataModel dataModel; + + setUp(() { + dataModel = InMemoryDataModel(); + }); + + test('getValue throws DataModelTypeException on type mismatch', () { + dataModel.update(DataPath.root, {'a': 'hello'}); + expect( + () => dataModel.getValue(DataPath('/a')), + throwsA(isA()), + ); + }); + + test('bindExternalState cleanup removes listeners', () { + final source = ValueNotifier(0); + + // Act + final void Function() cleanup = dataModel.bindExternalState( + path: DataPath('/a'), + source: source, + twoWay: true, + ); + + // Verify binding active + dataModel.update(DataPath('/a'), 1); + expect(source.value, 1); + + // Cleanup + cleanup(); + + // Verify listeners removed + // source has no listeners if we were the only one and we removed + // ourselves. + // ignore: invalid_use_of_protected_member + expect(source.hasListeners, isFalse); + }); + }); + group('DataModel Update Parsing', () { test('parses contents with simple string', () { dataModel.update(DataPath.root, {'a': 'hello'}); @@ -207,7 +247,7 @@ void main() { late DataModel dataModel; setUp(() { - dataModel = DataModel(); + dataModel = InMemoryDataModel(); }); test('bindExternalState initializes model from source', () { @@ -263,11 +303,6 @@ void main() { ); dataModel.dispose(); - - // Update data model shouldn't crash but won't update source if disposed? - // Update data model shouldn't crash but won't update source if disposed? - // Verify behavior: if we update source, model shouldn't update. - // But model is disposed. }); }); @@ -275,7 +310,7 @@ void main() { late DataModel dataModel; setUp(() { - dataModel = DataModel(); + dataModel = InMemoryDataModel(); }); test('Map: set and get', () { diff --git a/packages/genui/test/model/function_resolution_test.dart b/packages/genui/test/model/function_resolution_test.dart index b9e6e234f..a0069801a 100644 --- a/packages/genui/test/model/function_resolution_test.dart +++ b/packages/genui/test/model/function_resolution_test.dart @@ -12,8 +12,12 @@ void main() { late DataContext context; setUp(() { - dataModel = DataModel(); - context = DataContext(dataModel, '/', functions: BasicFunctions.all); + dataModel = InMemoryDataModel(); + context = DataContext( + dataModel, + DataPath.root, + functions: BasicFunctions.all, + ); }); test('resolves simple function call', () async { @@ -34,6 +38,23 @@ void main() { }); test('resolves nested function calls', () async { + final Map input = { + 'call': 'not', + 'args': { + 'value': { + 'call': 'and', + 'args': { + 'values': [true, false], + }, + }, + }, + }; + // and([true, false]) -> false + // not(false) -> true + expect(await eval(input, context), isTrue); + }); + + test('resolves nested async function calls with asStream', () async { final Map input = { 'call': 'required', 'args': { @@ -43,9 +64,10 @@ void main() { }, }, }; - // formatString('') -> '' - // required('') -> false - expect(await eval(input, context), isFalse); + // formatString('') -> Stream('') + // required(Stream('')) -> Stream(false) + final Stream stream = context.resolve(input); + expect(await stream.first, isFalse); }); test('resolves arguments with expressions', () async { @@ -57,17 +79,16 @@ void main() { expect(await eval(input, context), 'Hello World'); }); - test('returns original object if not a function call', () { + test('returns original object if not a function call', () async { final input = {'other': 'value'}; - // resolve should return the object itself if not a function call. - // But context.resolve is void? No, it returns Object?. - expect(context.resolve(input), input); + final Stream stream = context.resolve(input); + expect(await stream.first, input); }); }); } Future eval(Object? input, DataContext context) async { - final Object? result = context.resolve(input); + final Object result = context.resolve(input); if (result is Stream) { return (await result.first) as T; } diff --git a/packages/genui/test/transport/a2ui_parser_transformer_test.dart b/packages/genui/test/transport/a2ui_parser_transformer_test.dart index 993685f07..8f11650ba 100644 --- a/packages/genui/test/transport/a2ui_parser_transformer_test.dart +++ b/packages/genui/test/transport/a2ui_parser_transformer_test.dart @@ -139,5 +139,76 @@ void main() { await queue.cancel(); }); + + test('emits text for invalid JSON in Markdown block (no stall)', () async { + final StreamQueue queue = StreamQueue(stream); + + controller.add('Here is bad json:\n'); + controller.add('```json\n{invalid\n```\n'); + controller.add('End.'); + + expect( + (await queue.next) as TextEvent, + isA().having( + (e) => e.text, + 'text', + contains('Here is bad json:'), + ), + ); + + // Should emit the invalid markdown block as text immediately + expect( + (await queue.next) as TextEvent, + isA().having( + (e) => e.text, + 'text', + contains('```json\n{invalid\n```'), + ), + ); + + // Consume potential newline after the block + GenerationEvent nextEvent = await queue.next; + if (nextEvent is TextEvent && nextEvent.text.trim().isEmpty) { + nextEvent = await queue.next; + } + + expect( + nextEvent as TextEvent, + isA().having((e) => e.text, 'text', contains('End.')), + ); + + await queue.cancel(); + }); + + test('emits text for invalid JSON in balanced block (no stall)', () async { + final StreamQueue queue = StreamQueue(stream); + + controller.add('Start '); + controller.add('{"key": invalid} '); // Invalid JSON + controller.add('End'); + + expect( + (await queue.next) as TextEvent, + isA().having((e) => e.text, 'text', 'Start '), + ); + + // Should emit the invalid block as text immediately + expect( + (await queue.next) as TextEvent, + isA().having((e) => e.text, 'text', '{"key": invalid}'), + ); + + expect( + (await queue.next) as TextEvent, + isA().having((e) => e.text, 'text', ' '), + ); + + expect( + (await queue.next) as TextEvent, + isA().having((e) => e.text, 'text', 'End'), + ); + + await queue.cancel(); + }); }); }