diff --git a/pkgs/example/README.md b/pkgs/example/README.md index 90e2c6601..09dbcb31d 100644 --- a/pkgs/example/README.md +++ b/pkgs/example/README.md @@ -10,3 +10,9 @@ To regenerate diagrams: dart pub global activate layerlens layerlens ``` + +## TODO + +TODO before productizing: + +1. Make colors and sizes configurable. diff --git a/pkgs/example/lib/app/app.dart b/pkgs/example/lib/app/app.dart index 007dabe35..c017ae79d 100644 --- a/pkgs/example/lib/app/app.dart +++ b/pkgs/example/lib/app/app.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import '../sdk/agent/agent.dart'; import '../sdk/catalog/shared/genui_widget.dart'; -import '../sdk/model/agent.dart'; + import '../sdk/model/controller.dart'; import '../sdk/model/input.dart'; import '../sdk/model/simple_items.dart'; @@ -36,13 +36,22 @@ class _MyHomePage extends StatefulWidget { class _MyHomePageState extends State<_MyHomePage> { final _scrollController = ScrollController(); - late final GenUiAgent _agent = SimpleGenUiAgent( + late final GenUiAgent _agent = GenUiAgent( GenUiController( _scrollController, imageCatalog: _myImageCatalog, agentIconAsset: 'assets/agent_icon.png', ), - ); + )..run(); + + @override + void initState() { + super.initState(); + + _agent.controller.state.input.complete( + InitialInput('Show invitations to create a vacation travel itinerary.'), + ); + } @override Widget build(BuildContext context) { @@ -60,15 +69,11 @@ class _MyHomePageState extends State<_MyHomePage> { ), body: Padding( padding: const EdgeInsets.all(16.0), - child: Center( + child: Align( + alignment: Alignment.topLeft, child: SingleChildScrollView( controller: _scrollController, - child: GenUiWidget( - InitialInput( - 'Invite user to create a vacation travel itinerary.', - ), - _agent, - ), + child: GenUiWidget(_agent.controller), ), ), ), diff --git a/pkgs/example/lib/sdk/agent/agent.dart b/pkgs/example/lib/sdk/agent/agent.dart index aa3f63e64..6d2e6bb31 100644 --- a/pkgs/example/lib/sdk/agent/agent.dart +++ b/pkgs/example/lib/sdk/agent/agent.dart @@ -1,40 +1,72 @@ +import 'dart:async'; + import 'package:flutter/widgets.dart'; import '../catalog/messages/elicitation.dart'; import '../catalog/messages/invitation.dart'; -import '../model/agent.dart'; +import '../model/controller.dart'; import '../model/input.dart'; import '../model/simple_items.dart'; +import '../primitives/utils.dart'; import 'fake_output.dart'; -class SimpleGenUiAgent extends GenUiAgent { - SimpleGenUiAgent(super.controller); +class GenUiAgent { + GenUiAgent(this.controller); + + GenUiController controller; + + void run() => _startCycle(); + + Future _startCycle() async { + while (true) { + await _handleNextInput(); + } + } + + void dispose() { + // TODO: stop cycle + } - final List<({Input input, WidgetData output})> _history = []; + Future _handleNextInput() async { + final input = await controller.state.input.future; - @override - Future request(Input input) async { - // Simulate network delay + // Simulate network delay. await Future.delayed(const Duration(milliseconds: 1000)); - late final WidgetData output; - late final WidgetBuilder result; + late final WidgetData data; + late final WidgetBuilder builder; switch (input) { case InitialInput(): - output = fakeInvitationData; - result = (_) => Invitation(fakeInvitationData, this); + data = fakeInvitationData; + builder = (_) => Invitation(fakeInvitationData, controller); case ChatBoxInput(): - output = fakeElicitationData; - result = (_) => Elicitation(fakeElicitationData, this); + data = fakeElicitationData; + builder = (_) => Elicitation(fakeElicitationData, controller); default: throw UnimplementedError( 'The agent does not support input of type ${input.runtimeType}', ); } - _history.add((input: input, output: output)); + final newInput = await controller.state.input.future; + if (newInput != input) { + // If the input has changed, we throw away the results. + return; + } + + // Provide the builder for the widget that wait for it. + controller.state.builder.complete(builder); + + // Move the input and data to the history. + controller.state.history.add((input: input, data: data)); + + // Reset the input completer for the next input. + controller.state.input = Completer(); + controller.state.builder = Completer(); - return result; + // Scroll to the bottom after the widget is built + await Future.delayed(const Duration(milliseconds: 200)); + await scrollToBottom(controller.scrollController); } } diff --git a/pkgs/example/lib/sdk/agent/fake_output.dart b/pkgs/example/lib/sdk/agent/fake_output.dart index b7f845216..a0d134210 100644 --- a/pkgs/example/lib/sdk/agent/fake_output.dart +++ b/pkgs/example/lib/sdk/agent/fake_output.dart @@ -1,4 +1,7 @@ +import 'package:flutter/material.dart'; + import '../catalog/elements/carousel.dart'; +import '../catalog/elements/filter.dart'; import '../catalog/elements/text_intro.dart'; import '../catalog/messages/elicitation.dart'; import '../catalog/messages/invitation.dart'; @@ -34,4 +37,14 @@ final fakeElicitationData = ElicitationData( textIntroData: TextIntroData( intro: 'OK I can help generate itinerary as follows or tap to edit', ), + filterData: FilterData([ + FilterItemData(label: 'Zermatt', icon: Icons.location_pin), + FilterItemData(label: '3 days', icon: Icons.calendar_month), + FilterItemData( + label: '2 adults + 1 child', + icon: Icons.supervised_user_circle_outlined, + ), + FilterItemData(label: 'Low cost', icon: Icons.money), + FilterItemData(label: 'Medium activity', icon: Icons.run_circle_sharp), + ], submitLabel: 'Generate'), ); diff --git a/pkgs/example/lib/sdk/catalog/elements/chat_box.dart b/pkgs/example/lib/sdk/catalog/elements/chat_box.dart index 19d2f8b7f..f50212eb9 100644 --- a/pkgs/example/lib/sdk/catalog/elements/chat_box.dart +++ b/pkgs/example/lib/sdk/catalog/elements/chat_box.dart @@ -2,14 +2,16 @@ import 'package:flutter/material.dart'; import '../../model/input.dart'; class ChatBox extends StatefulWidget { - ChatBox(this.onInput, {super.key, this.fakeInput = ''}); + ChatBox(this.onInput, {super.key}); final UserInputCallback onInput; /// Fake input to simulate pre-filled text in the chat box. /// /// TODO(polina-c): Remove this in productized version. - final String fakeInput; + final String fakeInput = + 'I have 3 days in Zermatt with my wife and 11 year old daughter, ' + 'and I am wondering how to make the most out of our time.'; @override State createState() => _ChatBoxState(); @@ -24,7 +26,11 @@ class _ChatBoxState extends State { void initState() { super.initState(); _focusNode.addListener(() { - if (widget.fakeInput.isNotEmpty) { + // Reset the input on focus. + if (widget.fakeInput.isNotEmpty && + !_isSubmitted && + _focusNode.hasFocus && + _controller.text.isEmpty) { setState(() => _controller.text = widget.fakeInput); } }); diff --git a/pkgs/example/lib/sdk/catalog/elements/filter.dart b/pkgs/example/lib/sdk/catalog/elements/filter.dart new file mode 100644 index 000000000..be7b263be --- /dev/null +++ b/pkgs/example/lib/sdk/catalog/elements/filter.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; + +import '../../model/simple_items.dart'; + +class Filter extends StatefulWidget { + const Filter(this.data, {super.key}); + + final FilterData data; + + @override + State createState() => _FilterState(); +} + +class _FilterState extends State { + @override + Widget build(BuildContext context) { + return Card( + color: Theme.of(context).colorScheme.primaryContainer, + + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + runSpacing: 8.0, + spacing: 8.0, + children: widget.data.items.map(FilterItem.new).toList(), + ), + const SizedBox(height: 16.0), + ElevatedButton( + onPressed: () {}, + child: Text(widget.data.submitLabel), + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Colors.white, + ), + ), + ], + ), + ), + ); + } +} + +class FilterItem extends StatelessWidget { + const FilterItem(this.data, {super.key}); + + final FilterItemData data; + + @override + Widget build(BuildContext context) { + return ElevatedButton.icon( + icon: Icon(data.icon), // The icon to display + label: Text(data.label), // The text label for the button + onPressed: () {}, + ); + } +} + +class FilterData implements WidgetData { + final List items; + final String submitLabel; + + FilterData(this.items, {required this.submitLabel}); +} + +class FilterItemData implements WidgetData { + final String label; + final IconData icon; + + FilterItemData({required this.label, required this.icon}); +} diff --git a/pkgs/example/lib/sdk/catalog/messages/elicitation.dart b/pkgs/example/lib/sdk/catalog/messages/elicitation.dart index a00d56652..1c37fa42b 100644 --- a/pkgs/example/lib/sdk/catalog/messages/elicitation.dart +++ b/pkgs/example/lib/sdk/catalog/messages/elicitation.dart @@ -1,17 +1,17 @@ import 'package:flutter/material.dart'; -import '../../model/agent.dart'; +import '../../model/controller.dart'; import '../../model/input.dart'; import '../../model/simple_items.dart'; +import '../elements/filter.dart'; import '../elements/text_intro.dart'; import '../shared/genui_widget.dart'; -import '../shared/text_styles.dart'; class Elicitation extends StatefulWidget { final ElicitationData data; - final GenUiAgent agent; + final GenUiController controller; - const Elicitation(this.data, this.agent, {super.key}); + const Elicitation(this.data, this.controller, {super.key}); @override State createState() => _ElicitationState(); @@ -25,20 +25,14 @@ class _ElicitationState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - widget.agent.icon(width: 40, height: 40), + widget.controller.icon(width: 40, height: 40), const SizedBox(height: 8.0), TextIntro(widget.data.textIntroData), const SizedBox(height: 16.0), - Text('filter will be here', style: GenUiTextStyles.h2(context)), + Filter(widget.data.filterData), const SizedBox(height: 16.0), - ValueListenableBuilder( - valueListenable: _input, - builder: (context, input, child) { - if (input == null) return const SizedBox.shrink(); - return GenUiWidget(input, widget.agent); - }, - ), + GenUiWidget(widget.controller), ], ); } @@ -50,6 +44,7 @@ class _ElicitationState extends State { class ElicitationData extends WidgetData { final TextIntroData textIntroData; + final FilterData filterData; - ElicitationData({required this.textIntroData}); + ElicitationData({required this.filterData, required this.textIntroData}); } diff --git a/pkgs/example/lib/sdk/catalog/messages/invitation.dart b/pkgs/example/lib/sdk/catalog/messages/invitation.dart index 7e840c20f..4a3e2589b 100644 --- a/pkgs/example/lib/sdk/catalog/messages/invitation.dart +++ b/pkgs/example/lib/sdk/catalog/messages/invitation.dart @@ -2,20 +2,19 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import '../../model/agent.dart'; +import '../../model/controller.dart'; import '../../model/input.dart'; import '../../model/simple_items.dart'; import '../elements/carousel.dart'; -import '../elements/chat_box.dart'; import '../elements/text_intro.dart'; import '../shared/genui_widget.dart'; import '../shared/text_styles.dart'; class Invitation extends StatefulWidget { final InvitationData data; - final GenUiAgent agent; + final GenUiController genUi; - const Invitation(this.data, this.agent, {super.key}); + const Invitation(this.data, this.genUi, {super.key}); @override State createState() => _InvitationState(); @@ -29,21 +28,15 @@ class _InvitationState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - widget.agent.icon(width: 40, height: 40), + widget.genUi.icon(width: 40, height: 40), const SizedBox(height: 8.0), TextIntro(widget.data.textIntroData), const SizedBox(height: 16.0), Text(widget.data.exploreTitle, style: GenUiTextStyles.h2(context)), Carousel(CarouselData(items: widget.data.exploreItems), onInput), const SizedBox(height: 16.0), - ChatBox( - onInput, - fakeInput: - 'I have 3 days in Zermatt with my wife and 11 year old daughter, ' - 'and I am wondering how to make the most out of our time.', - ), - const SizedBox(height: 28.0), - GenUiWidget.wait(_input, widget.agent), + + GenUiWidget(widget.genUi), ], ); } diff --git a/pkgs/example/lib/sdk/catalog/shared/genui_widget.dart b/pkgs/example/lib/sdk/catalog/shared/genui_widget.dart index 92619df30..209b34380 100644 --- a/pkgs/example/lib/sdk/catalog/shared/genui_widget.dart +++ b/pkgs/example/lib/sdk/catalog/shared/genui_widget.dart @@ -2,17 +2,14 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import '../../model/agent.dart'; +import '../../model/controller.dart'; import '../../model/input.dart'; +import '../elements/chat_box.dart'; class GenUiWidget extends StatefulWidget { - factory GenUiWidget(Input input, GenUiAgent agent) => - GenUiWidget.wait(Completer()..complete(input), agent); + GenUiWidget(this.controller); - const GenUiWidget.wait(this.input, this.agent, {super.key}); - - final Completer input; - final GenUiAgent agent; + final GenUiController controller; @override State createState() => _GenUiWidgetState(); @@ -24,33 +21,41 @@ class _GenUiWidgetState extends State { @override void initState() { + print('Initializing GenUiWidget'); super.initState(); _initialize(); } Future _initialize() async { - final input = await widget.input.future; + final state = widget.controller.state; + + final input = await state.input.future; setState(() => _input = input); - final builder = await widget.agent.request(input); + final builder = await state.builder.future; setState(() => _builder = builder); - - // Scroll to the bottom after the widget is built - await Future.delayed(const Duration(milliseconds: 200)); - final scroll = widget.agent.controller.scrollController; - await scroll.animateTo( - scroll.position.maxScrollExtent, - duration: const Duration(milliseconds: 600), - curve: Curves.fastOutSlowIn, - ); } @override Widget build(BuildContext context) { - if (_input == null) return const SizedBox.shrink(); + if (_input == null) return _buildChatBox(); + final builder = _builder; + if (builder == null) { return const Center(child: CircularProgressIndicator()); } + return builder(context); } + + void _onInput(UserInput input) { + widget.controller.state.input.complete(input); + widget.controller.state.builder = Completer(); + _initialize(); + } + + Widget _buildChatBox() { + print('Building chat box'); + return ChatBox(_onInput); + } } diff --git a/pkgs/example/lib/sdk/model/agent.dart b/pkgs/example/lib/sdk/model/agent.dart deleted file mode 100644 index b614e7ed8..000000000 --- a/pkgs/example/lib/sdk/model/agent.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:flutter/widgets.dart'; - -import 'controller.dart'; -import 'input.dart'; - -abstract class GenUiAgent { - final GenUiController controller; - - GenUiAgent(this.controller); - - Future request(Input input); - - Widget icon({double? width, double? height}) { - return Image.asset(width: 40, height: 40, controller.agentIconAsset); - } - - void dispose() {} -} diff --git a/pkgs/example/lib/sdk/model/controller.dart b/pkgs/example/lib/sdk/model/controller.dart index 271f1c71e..669b82664 100644 --- a/pkgs/example/lib/sdk/model/controller.dart +++ b/pkgs/example/lib/sdk/model/controller.dart @@ -1,11 +1,22 @@ +import 'dart:async'; + import 'package:flutter/widgets.dart'; +import 'input.dart'; import 'simple_items.dart'; +typedef InputCallback = void Function(Input input); + class GenUiController { final ImageCatalog imageCatalog; final String agentIconAsset; + final ScrollController scrollController; + final GenUiState state = GenUiState(); + + Widget icon({double? width, double? height}) { + return Image.asset(width: width, height: height, agentIconAsset); + } GenUiController( this.scrollController, { @@ -13,3 +24,17 @@ class GenUiController { required this.agentIconAsset, }); } + +/// Controller for the GenUi operations. +/// +/// TODO (polina-c): protect the fields from being mutated by the user. +/// +/// TODO (polina-c): handle race conditions when the input is changed +/// while the agent is processing it. +class GenUiState { + Completer input = Completer(); + + Completer builder = Completer(); + + final List<({Input input, WidgetData data})> history = []; +} diff --git a/pkgs/example/lib/sdk/primitives/utils.dart b/pkgs/example/lib/sdk/primitives/utils.dart new file mode 100644 index 000000000..b412d5f3f --- /dev/null +++ b/pkgs/example/lib/sdk/primitives/utils.dart @@ -0,0 +1,9 @@ +import 'package:flutter/widgets.dart'; + +Future scrollToBottom(ScrollController controller) async { + await controller.animateTo( + controller.position.maxScrollExtent, + duration: const Duration(milliseconds: 600), + curve: Curves.fastOutSlowIn, + ); +}