diff --git a/examples/travel_app/lib/main.dart b/examples/travel_app/lib/main.dart index 71d4ff395..f11ab7125 100644 --- a/examples/travel_app/lib/main.dart +++ b/examples/travel_app/lib/main.dart @@ -212,7 +212,7 @@ class _TravelPlannerPageState extends State { void _handleUserMessageFromUi(UserMessage message) { setState(() { - _conversation.add(UserUiInteractionMessage.text(message.toString())); + _conversation.add(UserUiInteractionMessage.text(message.text)); }); _scrollToBottom(); _triggerInference(); @@ -370,28 +370,32 @@ to the user. activities, while for longer trips this likely involves choosing which specific places to stay in and how many nights in each place. - At this step, you should first show an OptionsFilterChipInput which contains - several options like the number of people, the destination, the length of - time, the budget, preferred activity types etc. + At this step, you should first show an inputGroup which contains + several input chips like the number of people, the destination, the length + of time, the budget, preferred activity types etc. Then, when the user clicks search, you should update the surface to have - a Column with the existing OptionsFilterChipInput, a - ItineraryWithDetails containing the full itinerary, and a Trailhead - containing some options of specific details to book e.g. "Book accommodation in Kyoto", "Train options from Tokyo to Osaka". - +<<<<<<< HEAD + a Column with the existing inputGroup, an itineraryWithDetails. When + creating the itinerary, include all necessary `itineraryEntry` items for + hotels and transport with generic details and a status of `choiceRequired`. + Note that during this step, the user may change their search parameters and resubmit, in which case you should regenerate the itinerary to match their desires, updating the existing surface. 4. Booking: Booking each part of the itinerary one step at a time. This - involves booking every accomodation, transport and activity in the itinerary + involves booking every accommodation, transport and activity in the itinerary one step at a time. - Here, you should just focus on one items at a time, using the - OptionsFilterChipInput to ask the user for preferences, and the - TravelCarousel to show the user different options. When the user chooses an - option, you can confirm it has been chosen and immediately prompt the user - to book the next detail, e.g. an activity, accomodation, transport etc. + Here, you should just focus on one item at a time, using an `inputGroup` + with chips to ask the user for preferences, and the `travelCarousel` to show + the user different options. When the user chooses an option, you can confirm + it has been chosen and immediately prompt the user to book the next detail, + e.g. an activity, accommodation, transport etc. When a booking is confirmed, + update the original `itineraryWithDetails` to reflect the booking by + updating the relevant `itineraryEntry` to have the status `chosen` and + including the booking details in the `bodyText`. IMPORTANT: The user may start from different steps in the flow, and it is your job to understand which step of the flow the user is at, and when they are ready to @@ -448,11 +452,12 @@ suggesting what the user might want to do next (e.g. book the next detail in the itinerary, repeat a search, research some related topic) so that they can click rather than typing. -- ItineraryWithDetails: When generating content to go inside ItineraryWithDetails, use -ItineraryItem, but try to occasionally break it up with other widgets e.g. -SectionHeader items to break up the section, or TravelCarousel with related -content. E.g. after an itinerary item like a beach visit, you could include a -carousel of local fish, or alternative beaches to visit. +- Itinerary Structure: Itineraries have a three-level structure. The root is +`itineraryWithDetails`, which provides an overview. Inside the modal view of an +`itineraryWithDetails`, you should use one or more `itineraryDay` widgets to +represent each day of the trip. Each `itineraryDay` should then contain a list +of `itineraryEntry` widgets, which represent specific activities, bookings, or +transport for that day. - Inputs: When you are asking for information from the user, you should always include a submit button of some kind so that the user can indicate that they are done @@ -460,6 +465,10 @@ providing information. The `InputGroup` has a submit button, but if you are not using that, you can use an `ElevatedButton`. Only use `OptionsFilterChipInput` widgets inside of a `InputGroup`. +- State management: Try to maintain state by being aware of the user's + selections and preferences and setting them in the initial value fields of + input elements when updating surfaces or generating new ones. + # Images If you need to use any images, find the most relevant ones from the following @@ -528,9 +537,7 @@ contain the other widgets. "widget": { "Column": { "children": [ - "day1", - "day2", - "day3" + "day1" ] } } @@ -538,30 +545,49 @@ contain the other widgets. { "id": "day1", "widget": { - "ItineraryItem": { - "title": "Day 1: Arrival and Exploration", - "subtitle": "Arrival and Zocalo", - "detailText": "Arrive at Mexico City International Airport (MEX) and check into your hotel. In the afternoon, explore the Zocalo, the main square of Mexico City." + "ItineraryDay": { + "title": "Day 1", + "subtitle": "Arrival and Exploration", + "description": "Your first day in Mexico City will be focused on settling in and exploring the historic center.", + "imageChildId": "day1_image", + "children": [ + "day1_entry1", + "day1_entry2" + ] + } + } + }, + { + "id": "day1_image", + "widget": { + "Image": { + "assetName": "assets/travel_images/mexico_city.jpg" } } }, { - "id": "day2", + "id": "day1_entry1", "widget": { - "ItineraryItem": { - "title": "Day 2: Teotihuacan", - "subtitle": "Ancient pyramids", - "detailText": "Visit the ancient city of Teotihuacan and climb the Pyramids of the Sun and Moon." + "ItineraryEntry": { + "type": "transport", + "title": "Arrival at MEX Airport", + "time": "2:00 PM", + "bodyText": "Arrive at Mexico City International Airport (MEX), clear customs, and pick up your luggage.", + "status": "noBookingRequired" } } }, { - "id": "day3", + "id": "day1_entry2", "widget": { - "ItineraryItem": { - "title": "Day 3: Frida Kahlo Museum", - "subtitle": "Casa Azul", - "detailText": "Explore the life and art of Frida Kahlo at her former home, the Casa Azul." + "ItineraryEntry": { + "type": "activity", + "title": "Explore the Zocalo", + "subtitle": "Historic Center", + "time": "4:00 PM - 6:00 PM", + "address": "Plaza de la Constitución S/N, Centro Histórico, Ciudad de México", + "bodyText": "Head to the Zocalo, the main square of Mexico City. Visit the Metropolitan Cathedral and the National Palace.", + "status": "noBookingRequired" } } } diff --git a/examples/travel_app/lib/src/catalog.dart b/examples/travel_app/lib/src/catalog.dart index 5f82fc632..096bbd62d 100644 --- a/examples/travel_app/lib/src/catalog.dart +++ b/examples/travel_app/lib/src/catalog.dart @@ -4,9 +4,11 @@ import 'package:flutter_genui/flutter_genui.dart'; +import 'catalog/checkbox_filter_chips_input.dart'; import 'catalog/information_card.dart'; import 'catalog/input_group.dart'; -import 'catalog/itinerary_item.dart'; +import 'catalog/itinerary_day.dart'; +import 'catalog/itinerary_entry.dart'; import 'catalog/itinerary_with_details.dart'; import 'catalog/options_filter_chip_input.dart'; import 'catalog/padded_body_text.dart'; @@ -21,15 +23,17 @@ import 'catalog/travel_carousel.dart'; /// /// This catalog includes a mix of core widgets (like [CoreCatalogItems.column] /// and [CoreCatalogItems.text]) and custom, domain-specific widgets tailored -/// for a travel planning experience, such as [travelCarousel], [itineraryItem], -/// and [inputGroup]. The AI selects from these components to build a -/// dynamic and interactive UI in response to user prompts. +/// for a travel planning experience, such as [travelCarousel], [itineraryDay], +/// and [inputGroup]. The AI selects from these components to build a dynamic +/// and interactive UI in response to user prompts. final travelAppCatalog = CoreCatalogItems.asCatalog().copyWith([ inputGroup, optionsFilterChipInput, + checkboxFilterChipsInput, travelCarousel, itineraryWithDetails, - itineraryItem, + itineraryDay, + itineraryEntry, tabbedSections, sectionHeader, trailhead, 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 new file mode 100644 index 000000000..e166782e8 --- /dev/null +++ b/examples/travel_app/lib/src/catalog/checkbox_filter_chips_input.dart @@ -0,0 +1,196 @@ +// Copyright 2025 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// @docImport 'input_group.dart'; +library; + +import 'package:dart_schema_builder/dart_schema_builder.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_genui/flutter_genui.dart'; + +import 'common.dart'; + +final _schema = S.object( + description: + 'A chip used to choose from a set of options where *more than one* ' + 'option can be chosen. This *must* be placed inside an InputGroup.', + properties: { + 'chipLabel': S.string( + description: + 'The title of the filter chip e.g. "amenities" or "dietary ' + 'restrictions" etc', + ), + 'options': S.list( + description: '''The list of options that the user can choose from.''', + items: S.string(), + ), + 'iconName': S.string( + description: 'An icon to display on the left of the chip.', + enumValues: TravelIcon.values.map((e) => e.name).toList(), + ), + 'initialOptions': S.list( + description: + 'The names of the options that should be selected ' + 'initially. These options must exist in the "options" list.', + items: S.string(description: 'An option from the "options" list.'), + ), + }, + required: ['chipLabel', 'options'], +); + +extension type _CheckboxFilterChipsInputData.fromMap( + Map _json +) { + factory _CheckboxFilterChipsInputData({ + required String chipLabel, + required List options, + String? iconName, + List? initialOptions, + }) => _CheckboxFilterChipsInputData.fromMap({ + 'chipLabel': chipLabel, + 'options': options, + if (iconName != null) 'iconName': iconName, + if (initialOptions != null) 'initialOptions': initialOptions, + }); + + String get chipLabel => _json['chipLabel'] as String; + List get options => (_json['options'] as List).cast(); + String? get iconName => _json['iconName'] as String?; + List get initialOptions => + (_json['initialOptions'] as List?)?.cast() ?? []; +} + +/// An interactive chip that allows the user to select multiple options from a +/// predefined list. +/// +/// This widget is a key component for gathering user preferences. It displays a +/// category (e.g., "Amenities," "Dietary Restrictions") and, when tapped, +/// presents a +/// modal bottom sheet containing a list of checkboxes for the available +/// options. +/// +/// It is typically used within a [inputGroup] to manage multiple facets of +/// a user's query. +final checkboxFilterChipsInput = CatalogItem( + name: 'CheckboxFilterChipsInput', + dataSchema: _schema, + widgetBuilder: + ({ + required data, + required id, + required buildChild, + required dispatchEvent, + required context, + required values, + }) { + final checkboxFilterChipsData = _CheckboxFilterChipsInputData.fromMap( + data as Map, + ); + IconData? icon; + if (checkboxFilterChipsData.iconName != null) { + try { + icon = iconFor( + TravelIcon.values.byName(checkboxFilterChipsData.iconName!), + ); + } catch (e) { + // Invalid icon name, default to no icon. + // Consider logging this error. + icon = null; + } + } + return _CheckboxFilterChip( + initialChipLabel: checkboxFilterChipsData.chipLabel, + options: checkboxFilterChipsData.options, + widgetId: id, + dispatchEvent: dispatchEvent, + icon: icon, + initialOptions: checkboxFilterChipsData.initialOptions, + values: values, + ); + }, +); + +class _CheckboxFilterChip extends StatefulWidget { + const _CheckboxFilterChip({ + required this.initialChipLabel, + required this.options, + required this.widgetId, + required this.dispatchEvent, + required this.values, + this.icon, + this.initialOptions, + }); + + final String initialChipLabel; + final List options; + final String widgetId; + final IconData? icon; + final DispatchEventCallback dispatchEvent; + final List? initialOptions; + final Map values; + + @override + State<_CheckboxFilterChip> createState() => _CheckboxFilterChipState(); +} + +class _CheckboxFilterChipState extends State<_CheckboxFilterChip> { + late List _selectedOptions; + + @override + void initState() { + super.initState(); + _selectedOptions = widget.initialOptions ?? []; + } + + String get _chipLabel { + if (_selectedOptions.isEmpty) { + return widget.initialChipLabel; + } + return _selectedOptions.join(', '); + } + + @override + Widget build(BuildContext context) { + return FilterChip( + avatar: widget.icon != null ? Icon(widget.icon) : null, + label: Text(_chipLabel), + selected: false, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20.0)), + onSelected: (bool selected) { + showModalBottomSheet( + context: context, + builder: (BuildContext context) { + var tempSelectedOptions = List.from(_selectedOptions); + return StatefulBuilder( + builder: (BuildContext context, StateSetter setModalState) { + return Column( + mainAxisSize: MainAxisSize.min, + children: widget.options.map((option) { + return CheckboxListTile( + title: Text(option), + value: tempSelectedOptions.contains(option), + onChanged: (bool? newValue) { + setModalState(() { + if (newValue == true) { + tempSelectedOptions.add(option); + } else { + tempSelectedOptions.remove(option); + } + }); + setState(() { + _selectedOptions = List.from(tempSelectedOptions); + }); + widget.values[widget.widgetId] = tempSelectedOptions; + }, + ); + }).toList(), + ); + }, + ); + }, + ); + }, + ); + } +} diff --git a/examples/travel_app/lib/src/catalog/common.dart b/examples/travel_app/lib/src/catalog/common.dart new file mode 100644 index 000000000..d3cacedc3 --- /dev/null +++ b/examples/travel_app/lib/src/catalog/common.dart @@ -0,0 +1,55 @@ +// Copyright 2025 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +enum TravelIcon { + location, + hotel, + restaurant, + airport, + train, + car, + date, + time, + calendar, + people, + person, + family, + wallet, + receipt, +} + +IconData iconFor(TravelIcon icon) { + switch (icon) { + case TravelIcon.location: + return Icons.location_on; + case TravelIcon.hotel: + return Icons.hotel; + case TravelIcon.restaurant: + return Icons.restaurant; + case TravelIcon.airport: + return Icons.airplanemode_active; + case TravelIcon.train: + return Icons.train; + case TravelIcon.car: + return Icons.directions_car; + case TravelIcon.date: + return Icons.date_range; + case TravelIcon.time: + return Icons.access_time; + case TravelIcon.calendar: + return Icons.calendar_today; + case TravelIcon.people: + return Icons.people; + case TravelIcon.person: + return Icons.person; + case TravelIcon.family: + return Icons.family_restroom; + case TravelIcon.wallet: + return Icons.account_balance_wallet; + case TravelIcon.receipt: + return Icons.receipt; + } +} diff --git a/examples/travel_app/lib/src/catalog/itinerary_day.dart b/examples/travel_app/lib/src/catalog/itinerary_day.dart new file mode 100644 index 000000000..cda8d6b67 --- /dev/null +++ b/examples/travel_app/lib/src/catalog/itinerary_day.dart @@ -0,0 +1,145 @@ +// Copyright 2025 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// @docImport 'itinerary_with_details.dart'; +library; + +import 'package:dart_schema_builder/dart_schema_builder.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_genui/flutter_genui.dart'; + +final _schema = S.object( + description: + 'A container for a single day in an itinerary. ' + 'It should contain a list of ItineraryEntry widgets. ' + 'This should be nested inside an ItineraryWithDetails.', + properties: { + 'title': S.string(description: 'The title for the day, e.g., "Day 1".'), + 'subtitle': S.string( + description: 'The subtitle for the day, e.g., "Arrival in Tokyo".', + ), + 'description': S.string( + description: 'A short description of the day\'s plan.', + ), + 'imageChildId': S.string( + description: + 'The ID of the Image widget to display. The Image fit should ' + 'typically be \'cover\'.', + ), + 'children': S.list( + description: + 'A list of widget IDs for the ItineraryEntry children for this day.', + items: S.string(), + ), + }, + required: ['title', 'subtitle', 'description', 'imageChildId', 'children'], +); + +extension type _ItineraryDayData.fromMap(Map _json) { + factory _ItineraryDayData({ + required String title, + required String subtitle, + required String description, + required String imageChildId, + required List children, + }) => _ItineraryDayData.fromMap({ + 'title': title, + 'subtitle': subtitle, + 'description': description, + 'imageChildId': imageChildId, + 'children': children, + }); + + String get title => _json['title'] as String; + String get subtitle => _json['subtitle'] as String; + String get description => _json['description'] as String; + String get imageChildId => _json['imageChildId'] as String; + List get children => (_json['children'] as List).cast(); +} + +final itineraryDay = CatalogItem( + name: 'ItineraryDay', + dataSchema: _schema, + widgetBuilder: + ({ + required data, + required id, + required buildChild, + required dispatchEvent, + required context, + required values, + }) { + final itineraryDayData = _ItineraryDayData.fromMap( + data as Map, + ); + return _ItineraryDay( + title: itineraryDayData.title, + subtitle: itineraryDayData.subtitle, + description: itineraryDayData.description, + imageChild: buildChild(itineraryDayData.imageChildId), + children: itineraryDayData.children.map(buildChild).toList(), + ); + }, +); + +class _ItineraryDay extends StatelessWidget { + final String title; + final String subtitle; + final String description; + final Widget imageChild; + final List children; + + const _ItineraryDay({ + required this.title, + required this.subtitle, + required this.description, + required this.imageChild, + required this.children, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), + child: Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8.0), + ), + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8.0), + child: SizedBox(height: 80, width: 80, child: imageChild), + ), + const SizedBox(width: 16.0), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.headlineSmall), + const SizedBox(height: 4.0), + Text(subtitle, style: theme.textTheme.titleMedium), + ], + ), + ), + ], + ), + const SizedBox(height: 8.0), + Text(description, style: theme.textTheme.bodyMedium), + const SizedBox(height: 8.0), + const Divider(), + ...children, + ], + ), + ), + ); + } +} diff --git a/examples/travel_app/lib/src/catalog/itinerary_entry.dart b/examples/travel_app/lib/src/catalog/itinerary_entry.dart new file mode 100644 index 000000000..47ef7b035 --- /dev/null +++ b/examples/travel_app/lib/src/catalog/itinerary_entry.dart @@ -0,0 +1,228 @@ +// Copyright 2025 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:dart_schema_builder/dart_schema_builder.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_genui/flutter_genui.dart'; + +import '../widgets/dismiss_notification.dart'; + +enum ItineraryEntryType { accommodation, transport, activity } + +enum ItineraryEntryStatus { noBookingRequired, choiceRequired, chosen } + +final _schema = S.object( + description: + 'A specific activity within a day in an itinerary. ' + 'This should be nested inside an ItineraryDay.', + properties: { + 'title': S.string(description: 'The title of the itinerary entry.'), + 'subtitle': S.string(description: 'The subtitle of the itinerary entry.'), + 'bodyText': S.string(description: 'The body text for the entry.'), + 'address': S.string(description: 'The address for the entry.'), + 'time': S.string(description: 'The time for the entry (formatted string).'), + 'totalCost': S.string(description: 'The total cost for the entry.'), + 'type': S.string( + description: 'The type of the itinerary entry.', + enumValues: ItineraryEntryType.values.map((e) => e.name).toList(), + ), + 'status': S.string( + description: + 'The booking status of the itinerary entry. ' + 'Use "noBookingRequired" for activities that do not require a ' + 'booking, like visiting a public park. ' + 'Use "choiceRequired" when the user needs to make a decision, ' + 'like selecting a specific hotel or flight. ' + 'Use "chosen" after the user has made a selection and the booking ' + 'is confirmed.', + enumValues: ItineraryEntryStatus.values.map((e) => e.name).toList(), + ), + }, + required: ['title', 'bodyText', 'time', 'type', 'status'], +); + +extension type _ItineraryEntryData.fromMap(Map _json) { + factory _ItineraryEntryData({ + required String title, + String? subtitle, + required String bodyText, + String? address, + required String time, + String? totalCost, + required String type, + required String status, + }) => _ItineraryEntryData.fromMap({ + 'title': title, + if (subtitle != null) 'subtitle': subtitle, + 'bodyText': bodyText, + if (address != null) 'address': address, + 'time': time, + if (totalCost != null) 'totalCost': totalCost, + 'type': type, + 'status': status, + }); + + String get title => _json['title'] as String; + String? get subtitle => _json['subtitle'] as String?; + String get bodyText => _json['bodyText'] as String; + String? get address => _json['address'] as String?; + String get time => _json['time'] as String; + String? get totalCost => _json['totalCost'] as String?; + ItineraryEntryType get type => + ItineraryEntryType.values.byName(_json['type'] as String); + ItineraryEntryStatus get status => + ItineraryEntryStatus.values.byName(_json['status'] as String); +} + +final itineraryEntry = CatalogItem( + name: 'ItineraryEntry', + dataSchema: _schema, + widgetBuilder: + ({ + required data, + required id, + required buildChild, + required dispatchEvent, + required context, + required values, + }) { + final itineraryEntryData = _ItineraryEntryData.fromMap( + data as Map, + ); + return _ItineraryEntry( + title: itineraryEntryData.title, + subtitle: itineraryEntryData.subtitle, + bodyText: itineraryEntryData.bodyText, + address: itineraryEntryData.address, + time: itineraryEntryData.time, + totalCost: itineraryEntryData.totalCost, + type: itineraryEntryData.type, + status: itineraryEntryData.status, + widgetId: id, + dispatchEvent: dispatchEvent, + ); + }, +); + +class _ItineraryEntry extends StatelessWidget { + final String title; + final String? subtitle; + final String bodyText; + final String? address; + final String time; + final String? totalCost; + final ItineraryEntryType type; + final ItineraryEntryStatus status; + final String widgetId; + final DispatchEventCallback dispatchEvent; + + const _ItineraryEntry({ + required this.title, + this.subtitle, + required this.bodyText, + this.address, + required this.time, + this.totalCost, + required this.type, + required this.status, + required this.widgetId, + required this.dispatchEvent, + }); + + IconData _getIconForType(ItineraryEntryType type) { + switch (type) { + case ItineraryEntryType.accommodation: + return Icons.hotel; + case ItineraryEntryType.transport: + return Icons.train; + case ItineraryEntryType.activity: + return Icons.local_activity; + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(_getIconForType(type), color: theme.primaryColor), + const SizedBox(width: 16.0), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text(title, style: theme.textTheme.titleMedium), + ), + if (status == ItineraryEntryStatus.chosen) + const Icon(Icons.check_circle, color: Colors.green) + else if (status == ItineraryEntryStatus.choiceRequired) + FilledButton( + onPressed: () { + dispatchEvent( + UiActionEvent( + widgetId: widgetId, + eventType: 'seeOptions', + value: 'Choose options in order to book $title', + ), + ); + DismissNotification().dispatch(context); + }, + child: const Text('Choose'), + ), + ], + ), + if (subtitle != null) ...[ + const SizedBox(height: 4.0), + 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), + Text(time, style: theme.textTheme.bodyMedium), + ], + ), + if (address != null) ...[ + const SizedBox(height: 4.0), + Row( + children: [ + const Icon(Icons.location_on, size: 16.0), + const SizedBox(width: 4.0), + Expanded( + child: Text( + address!, + style: theme.textTheme.bodyMedium, + ), + ), + ], + ), + ], + if (totalCost != null) ...[ + const SizedBox(height: 4.0), + 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), + Text(bodyText, style: theme.textTheme.bodyMedium), + ], + ), + ), + ], + ), + ); + } +} diff --git a/examples/travel_app/lib/src/catalog/itinerary_item.dart b/examples/travel_app/lib/src/catalog/itinerary_item.dart deleted file mode 100644 index 7a06ee97d..000000000 --- a/examples/travel_app/lib/src/catalog/itinerary_item.dart +++ /dev/null @@ -1,132 +0,0 @@ -// Copyright 2025 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -/// @docImport 'itinerary_with_details.dart'; -library; - -import 'package:dart_schema_builder/dart_schema_builder.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_genui/flutter_genui.dart'; - -final _schema = S.object( - properties: { - 'title': S.string(description: 'The title of the itinerary item.'), - 'subtitle': S.string(description: 'The subtitle of the itinerary item.'), - 'imageChildId': S.string( - description: - 'The ID of the Image widget to display. The Image fit should ' - "typically be 'cover'. Be sure to create an Image widget with a " - 'matching ID.', - ), - 'detailText': S.string(description: 'The detail text for the item.'), - }, - required: ['title', 'subtitle', 'detailText', 'imageChildId'], -); - -extension type _ItineraryItemData.fromMap(Map _json) { - factory _ItineraryItemData({ - required String title, - required String subtitle, - String? imageChildId, - required String detailText, - }) => _ItineraryItemData.fromMap({ - 'title': title, - 'subtitle': subtitle, - 'imageChildId': imageChildId, - 'detailText': detailText, - }); - - String get title => _json['title'] as String; - String get subtitle => _json['subtitle'] as String; - String? get imageChildId => _json['imageChildId'] as String?; - String get detailText => _json['detailText'] as String; -} - -/// A widget that displays a single, distinct event or activity within a larger -/// travel plan. -/// -/// It serves as a fundamental building block for creating detailed, -/// step-by-step travel itineraries. Each [itineraryItem] typically includes a -/// title, a subtitle (for time or location), an optional image, and a block of -/// text for details. -/// -/// These are most often used in a `Column` within a modal view that is launched -/// from an [itineraryWithDetails] widget, where a sequence of these items can -/// represent a full day's schedule or a list of activities. -final itineraryItem = CatalogItem( - name: 'ItineraryItem', - dataSchema: _schema, - widgetBuilder: - ({ - required data, - required id, - required buildChild, - required dispatchEvent, - required context, - required values, - }) { - final itineraryItemData = _ItineraryItemData.fromMap( - data as Map, - ); - return _ItineraryItem( - title: itineraryItemData.title, - subtitle: itineraryItemData.subtitle, - imageChild: itineraryItemData.imageChildId != null - ? buildChild(itineraryItemData.imageChildId!) - : null, - detailText: itineraryItemData.detailText, - ); - }, -); - -class _ItineraryItem extends StatelessWidget { - final String title; - final String subtitle; - final Widget? imageChild; - final String detailText; - - const _ItineraryItem({ - required this.title, - required this.subtitle, - required this.imageChild, - required this.detailText, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4.0, horizontal: 16.0), - child: Container( - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.shade300), - borderRadius: BorderRadius.circular(8.0), - ), - padding: const EdgeInsets.all(8.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8.0), - child: SizedBox(height: 80, width: 80, child: imageChild), - ), - const SizedBox(width: 16.0), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: theme.textTheme.titleMedium), - const SizedBox(height: 4.0), - Text(subtitle, style: theme.textTheme.bodySmall), - const SizedBox(height: 8.0), - Text(detailText, style: theme.textTheme.bodyMedium), - ], - ), - ), - ], - ), - ), - ); - } -} diff --git a/examples/travel_app/lib/src/catalog/itinerary_with_details.dart b/examples/travel_app/lib/src/catalog/itinerary_with_details.dart index 48b548bf2..5227be8e1 100644 --- a/examples/travel_app/lib/src/catalog/itinerary_with_details.dart +++ b/examples/travel_app/lib/src/catalog/itinerary_with_details.dart @@ -2,30 +2,32 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -/// @docImport 'itinerary_item.dart'; +/// @docImport 'itinerary_entry.dart'; library; import 'package:dart_schema_builder/dart_schema_builder.dart'; import 'package:flutter/material.dart'; import 'package:flutter_genui/flutter_genui.dart'; +import '../widgets/dismiss_notification.dart'; + final _schema = S.object( description: - 'Widget to show an itinerary or a plan for travel. Use this only for ' - 'refined plans where you have already shown the user filter options ' - 'etc.', + 'Widget to show an itinerary or a plan for travel. This should contain ' + 'a list of ItineraryDay widgets.', properties: { 'title': S.string(description: 'The title of the itinerary.'), 'subheading': S.string(description: 'The subheading of the itinerary.'), 'imageChildId': S.string( description: - 'The ID of the Image widget to display. The Image fit should ' - "typically be 'cover'. Be sure to create an Image widget with a " - 'matching ID.', + 'The ID of the Image widget to display. The Image fit ' + "should typically be 'cover'. Be sure to create an Image widget " + 'with a matching ID.', ), 'child': S.string( description: - '''The ID of a child widget to display in a modal. This should typically be a Column which contains a sequence of ItineraryItems, Text, TravelCarousel etc. Most of the content should be the trip details shown in ItineraryItems, but try to break it up with other elements showing related content. If there are multiple sections to the itinerary, you can use the TabbedSections to break them up.''', + 'The ID of a child widget to display in a modal. This should ' + 'typically be a Column which contains a sequence of ItineraryDays.', ), }, required: ['title', 'subheading', 'imageChildId', 'child'], @@ -57,8 +59,7 @@ extension type _ItineraryWithDetailsData.fromMap(Map _json) { /// prominent image to give the user a quick overview of the proposed trip. /// /// When tapped, it presents a modal bottom sheet containing the detailed -/// breakdown of the itinerary, which is typically composed of a `Column` of -/// [itineraryItem] widgets and other supplemental content. +/// breakdown of the itinerary. final itineraryWithDetails = CatalogItem( name: 'ItineraryWithDetails', dataSchema: _schema, @@ -111,51 +112,59 @@ class _ItineraryWithDetails extends StatelessWidget { ), clipBehavior: Clip.antiAlias, backgroundColor: Colors.transparent, - builder: (BuildContext context) { - return FractionallySizedBox( - heightFactor: 0.9, - child: Scaffold( - body: Stack( - children: [ - SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: double.infinity, - height: 200, // You can adjust this height as needed - child: imageChild, - ), - const SizedBox(height: 16.0), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16.0, + return NotificationListener( + onNotification: (notification) { + Navigator.of(context).pop(); + return true; + }, + child: FractionallySizedBox( + heightFactor: 0.9, + child: Scaffold( + body: Stack( + children: [ + SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: double.infinity, + height: + 200, // You can adjust this height as needed + child: imageChild, ), - child: Text( - title, - style: Theme.of(context).textTheme.headlineMedium, + const SizedBox(height: 16.0), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16.0, + ), + child: Text( + title, + style: Theme.of( + context, + ).textTheme.headlineMedium, + ), ), - ), - const SizedBox(height: 16.0), - child, - ], + const SizedBox(height: 16.0), + child, + ], + ), ), - ), - Positioned( - top: 16.0, - right: 16.0, - child: Material( - color: Colors.white.withAlpha((255 * 0.8).round()), - shape: const CircleBorder(), - clipBehavior: Clip.antiAlias, - child: IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.of(context).pop(), + Positioned( + top: 16.0, + right: 16.0, + child: Material( + color: Colors.white.withAlpha((255 * 0.8).round()), + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + child: IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.of(context).pop(), + ), ), ), - ), - ], + ], + ), ), ), ); 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 cc17ba543..508a1adaa 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 @@ -9,55 +9,7 @@ import 'package:dart_schema_builder/dart_schema_builder.dart'; import 'package:flutter/material.dart'; import 'package:flutter_genui/flutter_genui.dart'; -enum TravelIcon { - location, - hotel, - restaurant, - airport, - train, - car, - date, - time, - calendar, - people, - person, - family, - wallet, - receipt, -} - -IconData _iconFor(TravelIcon icon) { - switch (icon) { - case TravelIcon.location: - return Icons.location_on; - case TravelIcon.hotel: - return Icons.hotel; - case TravelIcon.restaurant: - return Icons.restaurant; - case TravelIcon.airport: - return Icons.airplanemode_active; - case TravelIcon.train: - return Icons.train; - case TravelIcon.car: - return Icons.directions_car; - case TravelIcon.date: - return Icons.date_range; - case TravelIcon.time: - return Icons.access_time; - case TravelIcon.calendar: - return Icons.calendar_today; - case TravelIcon.people: - return Icons.people; - case TravelIcon.person: - return Icons.person; - case TravelIcon.family: - return Icons.family_restroom; - case TravelIcon.wallet: - return Icons.account_balance_wallet; - case TravelIcon.receipt: - return Icons.receipt; - } -} +import 'common.dart'; final _schema = S.object( description: @@ -76,6 +28,12 @@ final _schema = S.object( ), 'iconName': S.string( description: 'An icon to display on the left of the chip.', + enumValues: TravelIcon.values.map((e) => e.name).toList(), + ), + 'initialValue': S.string( + description: + 'The name of the option that should be selected initially. This ' + 'option must exist in the "options" list.', ), }, required: ['chipLabel', 'options'], @@ -86,15 +44,18 @@ extension type _OptionsFilterChipInputData.fromMap(Map _json) { required String chipLabel, required List options, String? iconName, + String? initialValue, }) => _OptionsFilterChipInputData.fromMap({ 'chipLabel': chipLabel, 'options': options, if (iconName != null) 'iconName': iconName, + if (initialValue != null) 'initialValue': initialValue, }); String get chipLabel => _json['chipLabel'] as String; List get options => (_json['options'] as List).cast(); String? get iconName => _json['iconName'] as String?; + String? get initialValue => _json['initialValue'] as String?; } /// An interactive chip that allows the user to select a single option from a @@ -125,7 +86,7 @@ final optionsFilterChipInput = CatalogItem( IconData? icon; if (optionsFilterChipData.iconName != null) { try { - icon = _iconFor( + icon = iconFor( TravelIcon.values.byName(optionsFilterChipData.iconName!), ); } catch (e) { @@ -140,6 +101,7 @@ final optionsFilterChipInput = CatalogItem( widgetId: id, dispatchEvent: dispatchEvent, icon: icon, + initialValue: optionsFilterChipData.initialValue, values: values, ); }, @@ -153,6 +115,7 @@ class _OptionsFilterChip extends StatefulWidget { required this.dispatchEvent, required this.values, this.icon, + this.initialValue, }); final String initialChipLabel; @@ -160,6 +123,7 @@ class _OptionsFilterChip extends StatefulWidget { final String widgetId; final IconData? icon; final DispatchEventCallback dispatchEvent; + final String? initialValue; final Map values; @override @@ -172,7 +136,7 @@ class _OptionsFilterChipState extends State<_OptionsFilterChip> { @override void initState() { super.initState(); - _currentChipLabel = widget.initialChipLabel; + _currentChipLabel = widget.initialValue ?? widget.initialChipLabel; } @override diff --git a/examples/travel_app/lib/src/catalog/section_header.dart b/examples/travel_app/lib/src/catalog/section_header.dart index 9ddf5358a..bf20442af 100644 --- a/examples/travel_app/lib/src/catalog/section_header.dart +++ b/examples/travel_app/lib/src/catalog/section_header.dart @@ -2,7 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -/// @docImport 'itinerary_item.dart'; +/// @docImport 'itinerary_day.dart'; +/// @docImport 'itinerary_entry.dart'; library; import 'package:dart_schema_builder/dart_schema_builder.dart'; @@ -31,7 +32,7 @@ extension type _SectionHeaderData.fromMap(Map _json) { /// /// It displays a prominent title and an optional subtitle, helping to organize /// longer sequences of widgets, such as a detailed travel itinerary composed of -/// multiple [itineraryItem] widgets. Its primary role is to improve the +/// multiple [itineraryDay] widgets. Its primary role is to improve the /// structure and scannability of the UI. final sectionHeader = CatalogItem( name: 'SectionHeader', diff --git a/examples/travel_app/lib/src/widgets/dismiss_notification.dart b/examples/travel_app/lib/src/widgets/dismiss_notification.dart new file mode 100644 index 000000000..bb9d573db --- /dev/null +++ b/examples/travel_app/lib/src/widgets/dismiss_notification.dart @@ -0,0 +1,8 @@ +// Copyright 2025 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +/// A notification that indicates a dismiss action is requested. +class DismissNotification extends Notification {} diff --git a/examples/travel_app/test/checkbox_filter_chips_input_test.dart b/examples/travel_app/test/checkbox_filter_chips_input_test.dart new file mode 100644 index 000000000..40a4c41e6 --- /dev/null +++ b/examples/travel_app/test/checkbox_filter_chips_input_test.dart @@ -0,0 +1,41 @@ +// Copyright 2025 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:travel_app/src/catalog/checkbox_filter_chips_input.dart'; + +void main() { + testWidgets('CheckboxFilterChipsInput widget test', ( + WidgetTester tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + return Center( + child: checkboxFilterChipsInput.widgetBuilder( + data: { + 'chipLabel': 'Amenities', + 'options': ['Wifi', 'Pool', 'Gym'], + 'initialOptions': ['Wifi', 'Gym'], + 'iconName': 'hotel', + }, + id: 'test', + buildChild: (_) => const SizedBox(), + dispatchEvent: (_) {}, + context: context, + values: {}, + ), + ); + }, + ), + ), + ), + ); + + expect(find.text('Wifi, Gym'), findsOneWidget); + }); +} diff --git a/examples/travel_app/test/goldens/checkbox_filter_chips_input.png b/examples/travel_app/test/goldens/checkbox_filter_chips_input.png new file mode 100644 index 000000000..d067b5ebb Binary files /dev/null and b/examples/travel_app/test/goldens/checkbox_filter_chips_input.png differ diff --git a/examples/travel_app/test/goldens/itinerary_day.png b/examples/travel_app/test/goldens/itinerary_day.png new file mode 100644 index 000000000..3fd4cc0ac Binary files /dev/null and b/examples/travel_app/test/goldens/itinerary_day.png differ diff --git a/examples/travel_app/test/goldens/itinerary_entry.png b/examples/travel_app/test/goldens/itinerary_entry.png new file mode 100644 index 000000000..20f432125 Binary files /dev/null and b/examples/travel_app/test/goldens/itinerary_entry.png differ diff --git a/examples/travel_app/test/itinerary_day_test.dart b/examples/travel_app/test/itinerary_day_test.dart new file mode 100644 index 000000000..0db0cd707 --- /dev/null +++ b/examples/travel_app/test/itinerary_day_test.dart @@ -0,0 +1,42 @@ +// Copyright 2025 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:travel_app/src/catalog/itinerary_day.dart'; + +void main() { + testWidgets('ItineraryDay golden test', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + return Center( + child: itineraryDay.widgetBuilder( + data: { + 'title': 'Day 1', + 'subtitle': 'Arrival in Tokyo', + 'description': 'A day of exploring the city.', + 'imageChildId': 'tokyo_image', + 'children': [], + }, + id: 'test', + buildChild: (_) => const Placeholder(), + dispatchEvent: (_) {}, + context: context, + values: {}, + ), + ); + }, + ), + ), + ), + ); + + expect(find.text('Day 1'), findsOneWidget); + expect(find.text('Arrival in Tokyo'), findsOneWidget); + expect(find.text('A day of exploring the city.'), findsOneWidget); + }); +} diff --git a/examples/travel_app/test/itinerary_entry_test.dart b/examples/travel_app/test/itinerary_entry_test.dart new file mode 100644 index 000000000..9b2089d70 --- /dev/null +++ b/examples/travel_app/test/itinerary_entry_test.dart @@ -0,0 +1,52 @@ +// Copyright 2025 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:travel_app/src/catalog/itinerary_entry.dart'; + +void main() { + testWidgets('ItineraryEntry golden test', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + return Center( + child: itineraryEntry.widgetBuilder( + data: { + 'title': 'Arrival at HND Airport', + 'subtitle': 'Tokyo International Airport', + 'bodyText': + 'Arrive at Haneda Airport (HND), clear customs, and ' + 'pick up your luggage.', + 'time': '3:00 PM', + 'type': 'transport', + 'status': 'noBookingRequired', + }, + id: 'test', + buildChild: (_) => const SizedBox(), + dispatchEvent: (_) {}, + context: context, + values: {}, + ), + ); + }, + ), + ), + ), + ); + + expect(find.text('Arrival at HND Airport'), findsOneWidget); + expect(find.text('Tokyo International Airport'), findsOneWidget); + expect( + find.text( + 'Arrive at Haneda Airport (HND), clear customs, and pick up your ' + 'luggage.', + ), + findsOneWidget, + ); + expect(find.text('3:00 PM'), findsOneWidget); + }); +} diff --git a/examples/travel_app/test/itinerary_item_test.dart b/examples/travel_app/test/itinerary_item_test.dart deleted file mode 100644 index 4c323c99d..000000000 --- a/examples/travel_app/test/itinerary_item_test.dart +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright 2025 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:network_image_mock/network_image_mock.dart'; -import 'package:travel_app/src/catalog/itinerary_item.dart'; - -void main() { - group('ItineraryItem', () { - testWidgets('renders title, subtitle, detail text and image', ( - WidgetTester tester, - ) async { - await mockNetworkImagesFor(() async { - const testTitle = 'Test Title'; - const testSubtitle = 'Test Subtitle'; - const testDetailText = 'Test Detail Text'; - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Builder( - builder: (context) { - return itineraryItem.widgetBuilder( - data: { - 'title': testTitle, - 'subtitle': testSubtitle, - 'imageChildId': 'image_child_id', - 'detailText': testDetailText, - }, - id: 'test_id', - buildChild: (id) { - if (id == 'image_child_id') { - return Image.network( - 'https://example.com/thumbnail.jpg', - ); - } - return const SizedBox.shrink(); - }, - dispatchEvent: (event) {}, // Mock dispatchEvent - context: context, - values: {}, - ); - }, - ), - ), - ), - ); - - expect(find.text(testTitle), findsOneWidget); - expect(find.text(testSubtitle), findsOneWidget); - expect(find.text(testDetailText), findsOneWidget); - expect(find.byType(Image), findsOneWidget); - }); - }); - - testWidgets('renders without image', (WidgetTester tester) async { - await mockNetworkImagesFor(() async { - const testTitle = 'Test Title'; - const testSubtitle = 'Test Subtitle'; - const testDetailText = 'Test Detail Text'; - - await tester.pumpWidget( - MaterialApp( - home: Scaffold( - body: Builder( - builder: (context) { - return itineraryItem.widgetBuilder( - data: { - 'title': testTitle, - 'subtitle': testSubtitle, - 'detailText': testDetailText, - }, - id: 'test_id', - buildChild: (id) => - const SizedBox.shrink(), // Mock buildChild - dispatchEvent: (event) {}, // Mock dispatchEvent - context: context, - values: {}, - ); - }, - ), - ), - ), - ); - - expect(find.text(testTitle), findsOneWidget); - expect(find.text(testSubtitle), findsOneWidget); - expect(find.text(testDetailText), findsOneWidget); - expect(find.byType(Image), findsNothing); - }); - }); - }); -} diff --git a/packages/flutter_genui/lib/src/core/genui_manager.dart b/packages/flutter_genui/lib/src/core/genui_manager.dart index 08693ca90..829fa7e95 100644 --- a/packages/flutter_genui/lib/src/core/genui_manager.dart +++ b/packages/flutter_genui/lib/src/core/genui_manager.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:convert'; import 'package:flutter/foundation.dart'; @@ -116,8 +117,11 @@ class GenUiManager implements GenUiHost { @override void handleUiEvent(UiEvent event) { if (event is! UiActionEvent) throw ArgumentError('Unexpected event type'); - final value = valueStore.forSurface(event.surfaceId); - _onSubmit.add(UserMessage([TextPart(value.toString())])); + final stateValue = valueStore.forSurface(event.surfaceId); + final eventString = + 'Action: ${jsonEncode(event.value)}\n' + 'Current state: ${jsonEncode(stateValue)}'; + _onSubmit.add(UserMessage([TextPart(eventString)])); } @override