Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 62 additions & 36 deletions examples/travel_app/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ class _TravelPlannerPageState extends State<TravelPlannerPage> {

void _handleUserMessageFromUi(UserMessage message) {
setState(() {
_conversation.add(UserUiInteractionMessage.text(message.toString()));
_conversation.add(UserUiInteractionMessage.text(message.text));
});
_scrollToBottom();
_triggerInference();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -448,18 +452,23 @@ 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
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
Expand Down Expand Up @@ -528,40 +537,57 @@ contain the other widgets.
"widget": {
"Column": {
"children": [
"day1",
"day2",
"day3"
"day1"
]
}
}
},
{
"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"
}
}
}
Expand Down
14 changes: 9 additions & 5 deletions examples/travel_app/lib/src/catalog.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down
196 changes: 196 additions & 0 deletions examples/travel_app/lib/src/catalog/checkbox_filter_chips_input.dart
Original file line number Diff line number Diff line change
@@ -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<String, Object?> _json
) {
factory _CheckboxFilterChipsInputData({
required String chipLabel,
required List<String> options,
String? iconName,
List<String>? 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<String> get options => (_json['options'] as List).cast<String>();
String? get iconName => _json['iconName'] as String?;
List<String> get initialOptions =>
(_json['initialOptions'] as List?)?.cast<String>() ?? [];
}

/// 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<String, Object?>,
);
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;
}
Comment on lines +96 to +100

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The comment on line 99 suggests logging this error, which is a great idea for debugging. It would be beneficial to actually implement this. You could use a logging package like package:logging to record the exception when an invalid icon name is provided.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't worry about this right now - I am adding a logging library in a separate PR

Comment on lines +96 to +100

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The catch block currently silences the error when an invalid icon name is provided from the AI. As the code comment suggests, it's better to log this error. Silently failing can make debugging difficult if icons don't appear as expected, especially since the iconName is generated by the AI and could be unpredictable.

          } catch (e, s) {
            // Invalid icon name, default to no icon.
            // Consider logging this error.
            debugPrint('Invalid icon name: ${checkboxFilterChipsData.iconName}\n$e\n$s');
            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<String> options;
final String widgetId;
final IconData? icon;
final DispatchEventCallback dispatchEvent;
final List<String>? initialOptions;
final Map<String, Object?> values;

@override
State<_CheckboxFilterChip> createState() => _CheckboxFilterChipState();
}

class _CheckboxFilterChipState extends State<_CheckboxFilterChip> {
late List<String> _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,

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The FilterChip's selected property is hardcoded to false. This means the chip never visually indicates that options have been chosen. To provide better visual feedback to the user, the selected state should reflect whether any options are selected.

      selected: _selectedOptions.isNotEmpty,

shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20.0)),
onSelected: (bool selected) {
showModalBottomSheet<void>(
context: context,
builder: (BuildContext context) {
var tempSelectedOptions = List<String>.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(),
);
},
);
},
);
},
);
}
}
Loading
Loading