Skip to content

Commit 97e8180

Browse files
Restructure itinerary and add the concept of "choosing" specific details (#274)
1 parent 020c97f commit 97e8180

19 files changed

+920
-372
lines changed

examples/travel_app/lib/main.dart

Lines changed: 62 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ class _TravelPlannerPageState extends State<TravelPlannerPage> {
212212

213213
void _handleUserMessageFromUi(UserMessage message) {
214214
setState(() {
215-
_conversation.add(UserUiInteractionMessage.text(message.toString()));
215+
_conversation.add(UserUiInteractionMessage.text(message.text));
216216
});
217217
_scrollToBottom();
218218
_triggerInference();
@@ -370,28 +370,32 @@ to the user.
370370
activities, while for longer trips this likely involves choosing which
371371
specific places to stay in and how many nights in each place.
372372
373-
At this step, you should first show an OptionsFilterChipInput which contains
374-
several options like the number of people, the destination, the length of
375-
time, the budget, preferred activity types etc.
373+
At this step, you should first show an inputGroup which contains
374+
several input chips like the number of people, the destination, the length
375+
of time, the budget, preferred activity types etc.
376376
377377
Then, when the user clicks search, you should update the surface to have
378-
a Column with the existing OptionsFilterChipInput, a
379-
ItineraryWithDetails containing the full itinerary, and a Trailhead
380-
containing some options of specific details to book e.g. "Book accommodation in Kyoto", "Train options from Tokyo to Osaka".
381-
378+
<<<<<<< HEAD
379+
a Column with the existing inputGroup, an itineraryWithDetails. When
380+
creating the itinerary, include all necessary `itineraryEntry` items for
381+
hotels and transport with generic details and a status of `choiceRequired`.
382+
382383
Note that during this step, the user may change their search parameters and
383384
resubmit, in which case you should regenerate the itinerary to match their
384385
desires, updating the existing surface.
385386
386387
4. Booking: Booking each part of the itinerary one step at a time. This
387-
involves booking every accomodation, transport and activity in the itinerary
388+
involves booking every accommodation, transport and activity in the itinerary
388389
one step at a time.
389390
390-
Here, you should just focus on one items at a time, using the
391-
OptionsFilterChipInput to ask the user for preferences, and the
392-
TravelCarousel to show the user different options. When the user chooses an
393-
option, you can confirm it has been chosen and immediately prompt the user
394-
to book the next detail, e.g. an activity, accomodation, transport etc.
391+
Here, you should just focus on one item at a time, using an `inputGroup`
392+
with chips to ask the user for preferences, and the `travelCarousel` to show
393+
the user different options. When the user chooses an option, you can confirm
394+
it has been chosen and immediately prompt the user to book the next detail,
395+
e.g. an activity, accommodation, transport etc. When a booking is confirmed,
396+
update the original `itineraryWithDetails` to reflect the booking by
397+
updating the relevant `itineraryEntry` to have the status `chosen` and
398+
including the booking details in the `bodyText`.
395399
396400
IMPORTANT: The user may start from different steps in the flow, and it is your job to
397401
understand which step of the flow the user is at, and when they are ready to
@@ -448,18 +452,23 @@ suggesting what the user might want to do next (e.g. book the next detail in the
448452
itinerary, repeat a search, research some related topic) so that they can click
449453
rather than typing.
450454
451-
- ItineraryWithDetails: When generating content to go inside ItineraryWithDetails, use
452-
ItineraryItem, but try to occasionally break it up with other widgets e.g.
453-
SectionHeader items to break up the section, or TravelCarousel with related
454-
content. E.g. after an itinerary item like a beach visit, you could include a
455-
carousel of local fish, or alternative beaches to visit.
455+
- Itinerary Structure: Itineraries have a three-level structure. The root is
456+
`itineraryWithDetails`, which provides an overview. Inside the modal view of an
457+
`itineraryWithDetails`, you should use one or more `itineraryDay` widgets to
458+
represent each day of the trip. Each `itineraryDay` should then contain a list
459+
of `itineraryEntry` widgets, which represent specific activities, bookings, or
460+
transport for that day.
456461
457462
- Inputs: When you are asking for information from the user, you should always include a
458463
submit button of some kind so that the user can indicate that they are done
459464
providing information. The `InputGroup` has a submit button, but if
460465
you are not using that, you can use an `ElevatedButton`. Only use
461466
`OptionsFilterChipInput` widgets inside of a `InputGroup`.
462467
468+
- State management: Try to maintain state by being aware of the user's
469+
selections and preferences and setting them in the initial value fields of
470+
input elements when updating surfaces or generating new ones.
471+
463472
# Images
464473
465474
If you need to use any images, find the most relevant ones from the following
@@ -528,40 +537,57 @@ contain the other widgets.
528537
"widget": {
529538
"Column": {
530539
"children": [
531-
"day1",
532-
"day2",
533-
"day3"
540+
"day1"
534541
]
535542
}
536543
}
537544
},
538545
{
539546
"id": "day1",
540547
"widget": {
541-
"ItineraryItem": {
542-
"title": "Day 1: Arrival and Exploration",
543-
"subtitle": "Arrival and Zocalo",
544-
"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."
548+
"ItineraryDay": {
549+
"title": "Day 1",
550+
"subtitle": "Arrival and Exploration",
551+
"description": "Your first day in Mexico City will be focused on settling in and exploring the historic center.",
552+
"imageChildId": "day1_image",
553+
"children": [
554+
"day1_entry1",
555+
"day1_entry2"
556+
]
557+
}
558+
}
559+
},
560+
{
561+
"id": "day1_image",
562+
"widget": {
563+
"Image": {
564+
"assetName": "assets/travel_images/mexico_city.jpg"
545565
}
546566
}
547567
},
548568
{
549-
"id": "day2",
569+
"id": "day1_entry1",
550570
"widget": {
551-
"ItineraryItem": {
552-
"title": "Day 2: Teotihuacan",
553-
"subtitle": "Ancient pyramids",
554-
"detailText": "Visit the ancient city of Teotihuacan and climb the Pyramids of the Sun and Moon."
571+
"ItineraryEntry": {
572+
"type": "transport",
573+
"title": "Arrival at MEX Airport",
574+
"time": "2:00 PM",
575+
"bodyText": "Arrive at Mexico City International Airport (MEX), clear customs, and pick up your luggage.",
576+
"status": "noBookingRequired"
555577
}
556578
}
557579
},
558580
{
559-
"id": "day3",
581+
"id": "day1_entry2",
560582
"widget": {
561-
"ItineraryItem": {
562-
"title": "Day 3: Frida Kahlo Museum",
563-
"subtitle": "Casa Azul",
564-
"detailText": "Explore the life and art of Frida Kahlo at her former home, the Casa Azul."
583+
"ItineraryEntry": {
584+
"type": "activity",
585+
"title": "Explore the Zocalo",
586+
"subtitle": "Historic Center",
587+
"time": "4:00 PM - 6:00 PM",
588+
"address": "Plaza de la Constitución S/N, Centro Histórico, Ciudad de México",
589+
"bodyText": "Head to the Zocalo, the main square of Mexico City. Visit the Metropolitan Cathedral and the National Palace.",
590+
"status": "noBookingRequired"
565591
}
566592
}
567593
}

examples/travel_app/lib/src/catalog.dart

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44

55
import 'package:flutter_genui/flutter_genui.dart';
66

7+
import 'catalog/checkbox_filter_chips_input.dart';
78
import 'catalog/information_card.dart';
89
import 'catalog/input_group.dart';
9-
import 'catalog/itinerary_item.dart';
10+
import 'catalog/itinerary_day.dart';
11+
import 'catalog/itinerary_entry.dart';
1012
import 'catalog/itinerary_with_details.dart';
1113
import 'catalog/options_filter_chip_input.dart';
1214
import 'catalog/padded_body_text.dart';
@@ -21,15 +23,17 @@ import 'catalog/travel_carousel.dart';
2123
///
2224
/// This catalog includes a mix of core widgets (like [CoreCatalogItems.column]
2325
/// and [CoreCatalogItems.text]) and custom, domain-specific widgets tailored
24-
/// for a travel planning experience, such as [travelCarousel], [itineraryItem],
25-
/// and [inputGroup]. The AI selects from these components to build a
26-
/// dynamic and interactive UI in response to user prompts.
26+
/// for a travel planning experience, such as [travelCarousel], [itineraryDay],
27+
/// and [inputGroup]. The AI selects from these components to build a dynamic
28+
/// and interactive UI in response to user prompts.
2729
final travelAppCatalog = CoreCatalogItems.asCatalog().copyWith([
2830
inputGroup,
2931
optionsFilterChipInput,
32+
checkboxFilterChipsInput,
3033
travelCarousel,
3134
itineraryWithDetails,
32-
itineraryItem,
35+
itineraryDay,
36+
itineraryEntry,
3337
tabbedSections,
3438
sectionHeader,
3539
trailhead,
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
// Copyright 2025 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
/// @docImport 'input_group.dart';
6+
library;
7+
8+
import 'package:dart_schema_builder/dart_schema_builder.dart';
9+
import 'package:flutter/material.dart';
10+
import 'package:flutter_genui/flutter_genui.dart';
11+
12+
import 'common.dart';
13+
14+
final _schema = S.object(
15+
description:
16+
'A chip used to choose from a set of options where *more than one* '
17+
'option can be chosen. This *must* be placed inside an InputGroup.',
18+
properties: {
19+
'chipLabel': S.string(
20+
description:
21+
'The title of the filter chip e.g. "amenities" or "dietary '
22+
'restrictions" etc',
23+
),
24+
'options': S.list(
25+
description: '''The list of options that the user can choose from.''',
26+
items: S.string(),
27+
),
28+
'iconName': S.string(
29+
description: 'An icon to display on the left of the chip.',
30+
enumValues: TravelIcon.values.map((e) => e.name).toList(),
31+
),
32+
'initialOptions': S.list(
33+
description:
34+
'The names of the options that should be selected '
35+
'initially. These options must exist in the "options" list.',
36+
items: S.string(description: 'An option from the "options" list.'),
37+
),
38+
},
39+
required: ['chipLabel', 'options'],
40+
);
41+
42+
extension type _CheckboxFilterChipsInputData.fromMap(
43+
Map<String, Object?> _json
44+
) {
45+
factory _CheckboxFilterChipsInputData({
46+
required String chipLabel,
47+
required List<String> options,
48+
String? iconName,
49+
List<String>? initialOptions,
50+
}) => _CheckboxFilterChipsInputData.fromMap({
51+
'chipLabel': chipLabel,
52+
'options': options,
53+
if (iconName != null) 'iconName': iconName,
54+
if (initialOptions != null) 'initialOptions': initialOptions,
55+
});
56+
57+
String get chipLabel => _json['chipLabel'] as String;
58+
List<String> get options => (_json['options'] as List).cast<String>();
59+
String? get iconName => _json['iconName'] as String?;
60+
List<String> get initialOptions =>
61+
(_json['initialOptions'] as List?)?.cast<String>() ?? [];
62+
}
63+
64+
/// An interactive chip that allows the user to select multiple options from a
65+
/// predefined list.
66+
///
67+
/// This widget is a key component for gathering user preferences. It displays a
68+
/// category (e.g., "Amenities," "Dietary Restrictions") and, when tapped,
69+
/// presents a
70+
/// modal bottom sheet containing a list of checkboxes for the available
71+
/// options.
72+
///
73+
/// It is typically used within a [inputGroup] to manage multiple facets of
74+
/// a user's query.
75+
final checkboxFilterChipsInput = CatalogItem(
76+
name: 'CheckboxFilterChipsInput',
77+
dataSchema: _schema,
78+
widgetBuilder:
79+
({
80+
required data,
81+
required id,
82+
required buildChild,
83+
required dispatchEvent,
84+
required context,
85+
required values,
86+
}) {
87+
final checkboxFilterChipsData = _CheckboxFilterChipsInputData.fromMap(
88+
data as Map<String, Object?>,
89+
);
90+
IconData? icon;
91+
if (checkboxFilterChipsData.iconName != null) {
92+
try {
93+
icon = iconFor(
94+
TravelIcon.values.byName(checkboxFilterChipsData.iconName!),
95+
);
96+
} catch (e) {
97+
// Invalid icon name, default to no icon.
98+
// Consider logging this error.
99+
icon = null;
100+
}
101+
}
102+
return _CheckboxFilterChip(
103+
initialChipLabel: checkboxFilterChipsData.chipLabel,
104+
options: checkboxFilterChipsData.options,
105+
widgetId: id,
106+
dispatchEvent: dispatchEvent,
107+
icon: icon,
108+
initialOptions: checkboxFilterChipsData.initialOptions,
109+
values: values,
110+
);
111+
},
112+
);
113+
114+
class _CheckboxFilterChip extends StatefulWidget {
115+
const _CheckboxFilterChip({
116+
required this.initialChipLabel,
117+
required this.options,
118+
required this.widgetId,
119+
required this.dispatchEvent,
120+
required this.values,
121+
this.icon,
122+
this.initialOptions,
123+
});
124+
125+
final String initialChipLabel;
126+
final List<String> options;
127+
final String widgetId;
128+
final IconData? icon;
129+
final DispatchEventCallback dispatchEvent;
130+
final List<String>? initialOptions;
131+
final Map<String, Object?> values;
132+
133+
@override
134+
State<_CheckboxFilterChip> createState() => _CheckboxFilterChipState();
135+
}
136+
137+
class _CheckboxFilterChipState extends State<_CheckboxFilterChip> {
138+
late List<String> _selectedOptions;
139+
140+
@override
141+
void initState() {
142+
super.initState();
143+
_selectedOptions = widget.initialOptions ?? [];
144+
}
145+
146+
String get _chipLabel {
147+
if (_selectedOptions.isEmpty) {
148+
return widget.initialChipLabel;
149+
}
150+
return _selectedOptions.join(', ');
151+
}
152+
153+
@override
154+
Widget build(BuildContext context) {
155+
return FilterChip(
156+
avatar: widget.icon != null ? Icon(widget.icon) : null,
157+
label: Text(_chipLabel),
158+
selected: false,
159+
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20.0)),
160+
onSelected: (bool selected) {
161+
showModalBottomSheet<void>(
162+
context: context,
163+
builder: (BuildContext context) {
164+
var tempSelectedOptions = List<String>.from(_selectedOptions);
165+
return StatefulBuilder(
166+
builder: (BuildContext context, StateSetter setModalState) {
167+
return Column(
168+
mainAxisSize: MainAxisSize.min,
169+
children: widget.options.map((option) {
170+
return CheckboxListTile(
171+
title: Text(option),
172+
value: tempSelectedOptions.contains(option),
173+
onChanged: (bool? newValue) {
174+
setModalState(() {
175+
if (newValue == true) {
176+
tempSelectedOptions.add(option);
177+
} else {
178+
tempSelectedOptions.remove(option);
179+
}
180+
});
181+
setState(() {
182+
_selectedOptions = List.from(tempSelectedOptions);
183+
});
184+
widget.values[widget.widgetId] = tempSelectedOptions;
185+
},
186+
);
187+
}).toList(),
188+
);
189+
},
190+
);
191+
},
192+
);
193+
},
194+
);
195+
}
196+
}

0 commit comments

Comments
 (0)