1. 소개
이 Codelab에서는 Flutter, Firebase AI Logic, 새로운 genui 패키지를 사용하여 작업 목록 앱을 빌드합니다. 텍스트 기반 채팅 앱으로 시작하여 GenUI로 업그레이드하여 에이전트가 자체 UI를 만들 수 있도록 하고, 마지막으로 사용자와 에이전트가 직접 조작할 수 있는 맞춤형 대화형 UI 구성요소를 빌드합니다.

실습할 내용
- Flutter 및 Firebase AI Logic을 사용하여 기본 채팅 인터페이스 빌드
genui패키지를 통합하여 AI 기반 플랫폼 생성- 앱이 상담사의 응답을 기다리는 시점을 나타내는 진행률 표시줄 추가
- 명명된 서페이스를 만들고 UI의 전용 위치에 표시합니다.
- 작업이 표시되는 방식을 제어할 수 있는 맞춤 GenUI 카탈로그 구성요소 빌드
필요한 항목
- 웹브라우저(예: Chrome)
- 로컬에 설치된 Flutter SDK
- Firebase CLI가 설치되고 구성됨
이 Codelab은 중급 Flutter 개발자를 대상으로 합니다.
2. 시작하기 전에
Flutter 프로젝트 설정
터미널을 열고 flutter create를 실행하여 새 프로젝트를 만듭니다.
flutter create intro_to_genui
cd intro_to_genui
Flutter 프로젝트에 필요한 종속 항목을 추가합니다.
flutter pub add firebase_core firebase_ai genui json_schema_builder
최종 dependencies 섹션은 다음과 같이 표시됩니다 (버전 번호는 약간 다를 수 있음).
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
firebase_core: ^4.5.0
firebase_ai: ^3.9.0
genui: ^0.8.0
json_schema_builder: ^0.1.3
flutter pub get을 실행하여 모든 패키지를 다운로드합니다.
API 및 Firebase 사용 설정
firebase_ai 패키지를 사용하려면 먼저 프로젝트에서 Firebase AI Logic을 사용 설정해야 합니다.
- Firebase Console에서 Firebase AI Logic으로 이동합니다.
- 시작하기를 클릭하여 안내 워크플로를 실행합니다.
- 화면에 표시되는 메시지에 따라 프로젝트를 설정합니다.
자세한 내용은 Flutter 앱에 Firebase 추가 안내를 참고하세요.
API가 활성화되면 FlutterFire CLI를 사용하여 Flutter 앱에서 Firebase를 초기화합니다.
flutterfire configure
Firebase 프로젝트를 선택하고 메시지에 따라 타겟 플랫폼 (예: Android, iOS, 웹)에 맞게 구성합니다. 이 Codelab은 머신에 설치된 Flutter SDK와 Chrome만으로 완료할 수 있지만 앱은 다른 플랫폼에서도 작동합니다.
3. 기본 채팅 인터페이스 스캐폴드
생성형 UI를 도입하기 전에 앱에 기반이 필요합니다. Firebase AI Logic으로 구동되는 기본 텍스트 기반 채팅 애플리케이션이 필요합니다. 빠르게 시작하려면 채팅 인터페이스의 전체 설정을 복사하여 붙여넣습니다.

메시지 대화창 위젯 만들기
사용자와 상담사의 문자 메시지를 표시하려면 앱에 위젯이 필요합니다. lib/message_bubble.dart이라는 새 파일을 만들고 다음 클래스를 추가합니다.
import 'package:flutter/material.dart';
class MessageBubble extends StatelessWidget {
final String text;
final bool isUser;
const MessageBubble({super.key, required this.text, required this.isUser});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final bubbleColor = isUser
? colorScheme.primary
: colorScheme.surfaceContainerHighest;
final textColor = isUser
? colorScheme.onPrimary
: colorScheme.onSurfaceVariant;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 8.0),
child: Column(
crossAxisAlignment: isUser
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: isUser
? MainAxisAlignment.end
: MainAxisAlignment.start,
children: [
Flexible(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 12.0,
),
decoration: BoxDecoration(
color: bubbleColor,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(20),
topRight: const Radius.circular(20),
bottomLeft: Radius.circular(isUser ? 20 : 0),
bottomRight: Radius.circular(isUser ? 0 : 20),
),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(20),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
gradient: isUser
? LinearGradient(
colors: [
colorScheme.primary,
colorScheme.primary.withAlpha(200),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
)
: null,
),
child: Text(
text,
style: theme.textTheme.bodyLarge?.copyWith(
color: textColor,
height: 1.3,
),
),
),
),
],
),
const SizedBox(height: 2),
],
),
);
}
}
MessageBubble는 단일 채팅 메시지를 표시하는 StatelessWidget입니다. 이 위젯은 나중에 이 Codelab에서 사용자와 에이전트의 메시지를 모두 표시하는 데 사용되지만, 대부분은 멋진 Text 위젯입니다.
main.dart에서 Chat UI 구현
lib/main.dart의 전체 콘텐츠를 다음의 완전한 텍스트 챗봇 구현으로 바꿉니다.
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_ai/firebase_ai.dart';
import 'package:intro_to_genui/message_bubble.dart';
import 'firebase_options.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Just Today',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
),
home: const MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key});
@override
State<MyHomePage> createState() => _MyHomePageState();
}
sealed class ConversationItem {}
class TextItem extends ConversationItem {
final String text;
final bool isUser;
TextItem({required this.text, this.isUser = false});
}
class _MyHomePageState extends State<MyHomePage> {
final List<ConversationItem> _items = [];
final _textController = TextEditingController();
final _scrollController = ScrollController();
late final ChatSession _chatSession;
@override
void initState() {
super.initState();
final model = FirebaseAI.googleAI().generativeModel(
model: 'gemini-3-flash-preview',
);
_chatSession = model.startChat();
_chatSession.sendMessage(Content.text(systemInstruction));
}
void _scrollToBottom() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
@override
void dispose() {
_textController.dispose();
_scrollController.dispose();
super.dispose();
}
Future<void> _addMessage() async {
final text = _textController.text;
if (text.trim().isEmpty) {
return;
}
_textController.clear();
setState(() {
_items.add(TextItem(text: text, isUser: true));
});
_scrollToBottom();
final response = await _chatSession.sendMessage(Content.text(text));
if (response.text?.isNotEmpty ?? false) {
setState(() {
_items.add(TextItem(text: response.text!, isUser: false));
});
_scrollToBottom();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: const Text('Just Today'),
),
body: Column(
children: [
Expanded(
child: ListView(
controller: _scrollController,
padding: const EdgeInsets.all(16),
children: [
for (final item in _items)
switch (item) {
TextItem() => MessageBubble(
text: item.text,
isUser: item.isUser,
),
},
],
),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Row(
children: [
Expanded(
child: TextField(
controller: _textController,
onSubmitted: (_) => _addMessage(),
decoration: const InputDecoration(
hintText: 'Enter a message',
),
),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _addMessage,
child: const Text('Send'),
),
],
),
),
),
],
),
);
}
}
const systemInstruction = '''
## PERSONA
You are an expert task planner.
## GOAL
Work with me to produce a list of tasks that I should do today, and then track
the completion status of each one.
## RULES
Talk with me only about tasks that I should do today.
Do not engage in conversation about any other topic.
Do not offer suggestions unless I ask for them.
Do not offer encouragement unless I ask for it.
Do not offer advice unless I ask for it.
Do not offer opinions unless I ask for them.
## PROCESS
### Planning
* Ask me for information about tasks that I should do today.
* Synthesize a list of tasks from that information.
* Ask clarifying questions if you need to.
* When you have a list of tasks that you think I should do today, present it
to me for review.
* Respond to my suggestions for changes, if I have any, until I accept the
list.
### Tracking
* Once the list is accepted, ask me to let you know when individual tasks are
complete.
* If I tell you a task is complete, mark it as complete.
* Once all tasks are complete, send a message acknowledging that, and then
end the conversation.
''';
방금 복사하여 붙여넣은 main.dart 파일은 Firebase AI Logic과 systemInstruction의 프롬프트를 사용하여 기본 ChatSession를 설정합니다. TextItem 요소 목록을 유지하고 이전에 만든 MessageBubble 위젯을 사용하여 사용자 질문과 함께 표시하여 대화 턴을 관리합니다.
계속 진행하기 전에 다음 사항을 확인하세요.
initState메서드는 Firebase AI Logic에 대한 연결이 설정되는 곳입니다.- 앱은
TextField와 에이전트에게 메시지를 전송하는 버튼을 제공합니다. _addMessage메서드는 사용자의 메시지가 상담사에게 전송되는 곳입니다._items목록은 대화 기록이 저장되는 위치입니다.- 메시지는
MessageBubble위젯을 사용하여ListView에 표시됩니다.
앱 테스트
이제 앱을 실행하고 테스트할 수 있습니다.
flutter run -d chrome
오늘 완료하고 싶은 작업에 관해 에이전트와 채팅해 보세요. 순수 텍스트 기반 UI로도 작업을 완료할 수 있지만 생성형 UI를 사용하면 더 쉽고 빠르게 작업을 완료할 수 있습니다.
4. GenUI 패키지 통합
이제 일반 텍스트에서 생성형 UI로 업그레이드할 차례입니다. 기본 Firebase 메시지 루프를 GenUI Conversation, Catalog, SurfaceController 객체로 바꿉니다. 이를 통해 AI 모델은 채팅 스트림 내에서 실제 Flutter 위젯을 인스턴스화할 수 있습니다.

genui 패키지는 이 Codelab 전체에서 사용할 다섯 가지 클래스를 제공합니다.
SurfaceController는 모델에서 생성된 UI를 화면에 매핑합니다.A2uiTransportAdapter는 내부 GenUI 요청을 외부 언어 모델과 연결합니다.Conversation는 Flutter 앱을 위한 단일 통합 API로 컨트롤러와 전송 어댑터를 래핑합니다.Catalog는 언어 모델에 사용할 수 있는 위젯과 속성을 설명합니다.Surface은 모델에서 생성된 UI를 표시하는 위젯입니다.
생성된 Surface를 표시할 준비를 합니다.
기존 코드에는 대화 내의 단일 문자 메시지를 나타내는 TextItem 클래스가 포함되어 있습니다. 상담사가 만든 Surface를 나타내는 다른 클래스를 추가합니다.
class SurfaceItem extends ConversationItem {
final String surfaceId;
SurfaceItem({required this.surfaceId});
}
GenUI 빌딩 블록 초기화
lib/main.dart 상단에서 genui 라이브러리를 가져옵니다.
import 'package:genui/genui.dart' hide TextPart;
import 'package:genui/genui.dart' as genui;
genui 패키지와 firebase_ai 패키지 모두 TextPart 클래스를 포함합니다. 이 방식으로 genui를 가져오면 TextPart 버전이 genui.TextPart로 네임스페이스되어 이름 충돌이 방지됩니다.
_chatSession 뒤에 _MyHomePageState에서 핵심 기능 컨트롤러를 선언합니다.
class _MyHomePageState extends State<MyHomePage> {
// ... existing members
late final ChatSession _chatSession;
// Add GenUI controllers
late final SurfaceController _controller;
late final A2uiTransportAdapter _transport;
late final Conversation _conversation;
late final Catalog catalog;
그런 다음 GenUI 라이브러리의 컨트롤러를 준비하도록 initState를 업데이트합니다.
initState에서 이 줄을 삭제합니다.
_chatSession.sendMessage(Content.text(systemInstruction));
그런 다음 다음 코드를 추가합니다.
@override
void initState() {
// ... existing code ...
// Initialize the GenUI Catalog.
// The genui package provides a default set of primitive widgets (like text
// and basic buttons) out of the box using this class.
catalog = BasicCatalogItems.asCatalog();
// Create a SurfaceController to manage the state of generated surfaces.
_controller = SurfaceController(catalogs: [catalog]);
// Create a transport adapter that will process messages to and from the
// agent, looking for A2UI messages.
_transport = A2uiTransportAdapter(onSend: _sendAndReceive);
// Link the transport and SurfaceController together in a Conversation,
// which provides your app a unified API for interacting with the agent.
_conversation = Conversation(
controller: _controller,
transport: _transport,
);
}
이 코드는 컨트롤러와 어댑터를 관리하는 Conversation 파사드를 만듭니다. 이 대화는 앱이 에이전트가 만드는 내용을 파악하는 데 사용할 수 있는 이벤트 스트림과 에이전트에게 메시지를 보낼 수 있는 메서드를 제공합니다.
다음으로 대화 이벤트 리스너를 만듭니다. 여기에는 텍스트 메시지 및 오류와 관련된 이벤트뿐만 아니라 서피스 관련 이벤트도 포함됩니다.
@override
void initState() {
// ... existing code ...
// Listen to GenUI stream events to update the UI
_conversation.events.listen((event) {
setState(() {
switch (event) {
case ConversationSurfaceAdded added:
_items.add(SurfaceItem(surfaceId: added.surfaceId));
_scrollToBottom();
case ConversationSurfaceRemoved removed:
_items.removeWhere(
(item) =>
item is SurfaceItem && item.surfaceId == removed.surfaceId,
);
case ConversationContentReceived content:
_items.add(TextItem(text: content.text, isUser: false));
_scrollToBottom();
case ConversationError error:
debugPrint('GenUI Error: ${error.error}');
default:
}
});
});
}
마지막으로 시스템 프롬프트를 만들어 에이전트에게 전송합니다.
@override
void initState() {
// ... existing code ...
// Create the system prompt for the agent, which will include this app's
// system instruction as well as the schema for the catalog.
final promptBuilder = PromptBuilder.chat(
catalog: catalog,
systemPromptFragments: [systemInstruction],
);
// Send the prompt into the Conversation, which will subsequently route it
// to Firebase using the transport mechanism.
_conversation.sendRequest(
ChatMessage.system(promptBuilder.systemPromptJoined()),
);
}
디스플레이 표시 경로
다음으로 _items 목록에 SurfaceItem를 표시하도록 ListView의 build 메서드를 업데이트합니다.
Expanded(
child: ListView(
controller: _scrollController,
padding: const EdgeInsets.all(16),
children: [
for (final item in _items)
switch (item) {
TextItem() => MessageBubble(
text: item.text,
isUser: item.isUser,
),
// New!
SurfaceItem() => Surface(
surfaceContext: _controller.contextFor(
item.surfaceId,
),
),
},
],
),
),
Surface 위젯의 생성자는 표시해야 하는 서페이스를 알려주는 surfaceContext를 사용합니다. 앞서 만든 SurfaceController인 _controller은 각 화면의 정의와 상태를 제공하고 업데이트가 있을 때 다시 빌드되도록 합니다.
GenUI를 Firebase AI Logic에 연결
genui 패키지는 'Bring Your Own Model'(자체 모델 사용) 접근 방식을 사용하므로 사용자가 환경을 지원하는 LLM을 제어할 수 있습니다. 이 경우 Firebase AI Logic을 사용하지만 패키지는 다양한 에이전트 및 제공업체와 함께 작동하도록 빌드됩니다.
이러한 자유에는 약간의 추가 책임이 따릅니다. genui 패키지에서 생성된 메시지를 가져와 선택한 에이전트에게 전송해야 하고, 에이전트의 응답을 가져와 genui로 다시 전송해야 합니다.
이렇게 하려면 이전 단계의 코드에서 참조된 _sendAndReceive 메서드를 정의합니다. MyHomePageState에 다음 코드를 추가합니다.
Future<void> _sendAndReceive(ChatMessage msg) async {
final buffer = StringBuffer();
// Reconstruct the message part fragments
for (final part in msg.parts) {
if (part.isUiInteractionPart) {
buffer.write(part.asUiInteractionPart!.interaction);
} else if (part is genui.TextPart) {
buffer.write(part.text);
}
}
if (buffer.isEmpty) {
return;
}
final text = buffer.toString();
// Send the string to Firebase AI Logic.
final response = await _chatSession.sendMessage(Content.text(text));
if (response.text?.isNotEmpty ?? false) {
// Feed the response back into GenUI's transportation layer
_transport.addChunk(response.text!);
}
}
이 메서드는 에이전트에 메시지를 보내야 할 때마다 genui 패키지에 의해 호출됩니다. 메서드 끝에서 addChunk를 호출하면 에이전트의 응답이 genui 패키지로 다시 전달되어 응답을 처리하고 UI를 생성할 수 있습니다.
마지막으로 기존 _addMessage 메서드를 이 새 버전으로 완전히 대체하여 메시지가 Firebase로 직접 라우팅되지 않고 Conversation로 라우팅되도록 합니다.
Future<void> _addMessage() async {
final text = _textController.text;
if (text.trim().isEmpty) {
return;
}
_textController.clear();
setState(() {
_items.add(TextItem(text: text, isUser: true));
});
_scrollToBottom();
// Send the user's input through GenUI instead of directly to Firebase.
await _conversation.sendRequest(ChatMessage.user(text));
}
작업이 끝났습니다. 앱을 다시 실행해 봅니다. 텍스트 메시지 외에도 에이전트가 버튼, 텍스트 위젯 등의 UI 노출 영역을 생성하는 것을 확인할 수 있습니다.
에이전트에게 특정 방식으로 UI를 표시하도록 요청할 수도 있습니다. 예를 들어 '각 항목을 완료로 표시하는 버튼이 있는 열에 내 작업을 표시해 줘'와 같은 메시지를 사용해 보세요.
5. 대기 상태 추가
LLM 생성은 비동기식입니다. 대답을 기다리는 동안 채팅 인터페이스는 입력 버튼을 사용 중지하고 진행률 표시기를 표시하여 사용자가 GenUI가 콘텐츠를 생성하고 있음을 알 수 있도록 해야 합니다. 다행히 genui 패키지는 대화 상태를 추적하는 데 사용할 수 있는 Listenable를 제공합니다. 이 ConversationState 값에는 모델이 콘텐츠를 생성하는지 여부를 확인하는 isWaiting 속성이 포함됩니다.
입력 컨트롤을 ValueListenableBuilder로 래핑합니다.
lib/main.dart 하단에서 TextField 및 ElevatedButton이 포함된 Row을 래핑하여 _conversation.state을 수신 대기하는 ValueListenableBuilder을 만듭니다. state.isWaiting를 검사하면 모델이 콘텐츠를 생성하는 동안 입력을 사용 중지할 수 있습니다.
ValueListenableBuilder<ConversationState>(
valueListenable: _conversation.state,
builder: (context, state, child) {
return Row(
children: [
Expanded(
child: TextField(
controller: _textController,
// Also disable the Enter key submission when waiting!
onSubmitted: state.isWaiting ? null : (_) => _addMessage(),
decoration: const InputDecoration(
hintText: 'Enter a message',
),
),
),
const SizedBox(width: 8),
ElevatedButton(
// Disable the send button when the model is generating
onPressed: state.isWaiting ? null : _addMessage,
child: const Text('Send'),
),
],
);
},
),
진행률 표시줄 추가
기본 Column 위젯을 Stack 내부에 래핑하고 LinearProgressIndicator를 스택의 두 번째 하위 요소로 추가하여 하단에 고정합니다. 완료되면 Scaffold의 body이 다음과 같이 표시됩니다.
body: Stack( // New!
children: [
Column(
children: [
Expanded(
child: ListView(
controller: _scrollController,
padding: const EdgeInsets.all(16),
children: [
for (final item in _items)
switch (item) {
TextItem() => MessageBubble(
text: item.text,
isUser: item.isUser,
),
SurfaceItem() => Surface(
surfaceContext: _controller.contextFor(
item.surfaceId,
),
),
},
],
),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: ValueListenableBuilder<ConversationState>(
valueListenable: _conversation.state,
builder: (context, state, child) {
return Row(
children: [
Expanded(
child: TextField(
controller: _textController,
onSubmitted:
state.isWaiting ? null : (_) => _addMessage(),
decoration: const InputDecoration(
hintText: 'Enter a message',
),
),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: state.isWaiting ? null : _addMessage,
child: const Text('Send'),
),
],
);
},
),
),
),
],
),
// Listen to the state again, this time to render a progress indicator
ValueListenableBuilder<ConversationState>(
valueListenable: _conversation.state,
builder: (context, state, child) {
if (state.isWaiting) {
return const LinearProgressIndicator();
}
return const SizedBox.shrink();
},
),
],
),
6. GenUI Surface 유지
지금까지 할 일 목록은 스크롤되는 채팅 스트림에 렌더링되었으며, 새 메시지나 서페이스가 도착하면 목록에 추가되었습니다. 다음 단계에서는 서페이스 이름을 지정하고 UI 내 특정 위치에 표시하는 방법을 알아봅니다.
먼저 main.dart 상단에서 void main() 전에 서피스 ID로 사용할 상수를 선언합니다.
const taskDisplaySurfaceId = 'task_display';
둘째, Conversation 리스너의 switch 문을 업데이트하여 해당 ID가 있는 화면이 _items에 추가되지 않도록 합니다.
case ConversationSurfaceAdded added:
if (added.surfaceId != taskDisplaySurfaceId) {
_items.add(SurfaceItem(surfaceId: added.surfaceId));
_scrollToBottom();
}
다음으로 위젯 트리의 레이아웃 구조를 열어 채팅 로그 바로 위에 고정된 화면을 위한 공간을 만듭니다. 이 두 위젯을 기본 Column의 첫 번째 하위 요소로 추가합니다.
AnimatedSize(
duration: const Duration(milliseconds: 300),
child: Container(
padding: const EdgeInsets.all(16),
alignment: Alignment.topLeft,
child: Surface(
surfaceContext: _controller.contextFor(
taskDisplaySurfaceId,
),
),
),
),
const Divider(),
지금까지 에이전트는 적합하다고 판단되는 대로 서페이스를 자유롭게 만들고 사용할 수 있었습니다. 더 구체적인 안내를 제공하려면 시스템 프롬프트를 다시 확인해야 합니다. systemInstruction 상수에 저장된 프롬프트 끝에 다음 ## USER INTERFACE 섹션을 추가합니다.
const systemInstruction = '''
// ... existing prompt content ...
## USER INTERFACE
* To display the list of tasks create one and only one instance of the
TaskDisplay catalog item. Use "$taskDisplaySurfaceId" as its surface ID.
* Update $taskDisplaySurfaceId as necessary when the list changes.
* $taskDisplaySurfaceId must include a button for each task that I can use
to mark it complete. When I use that button to mark a task complete, it
should send you a message indicating what I've done.
* Avoid repeating the same information in a single message.
* When responding with text, rather than A2UI messages, be brief.
''';
에이전트가 UI 표시 경로를 언제 어떻게 사용해야 하는지 명확하게 안내하는 것이 중요합니다. 특정 카탈로그 항목과 노출 영역 ID를 사용하고 단일 인스턴스를 재사용하도록 에이전트에게 지시하면 원하는 인터페이스를 만들 수 있습니다.
해야 할 일이 더 있지만 앱을 다시 실행하여 에이전트가 UI 상단에 작업 표시 화면을 만드는 것을 확인할 수 있습니다.
7. 맞춤 카탈로그 위젯 빌드
이 시점에는 TaskDisplay 카탈로그 항목이 존재하지 않습니다. 다음 몇 단계에서는 데이터 스키마, 스키마를 파싱하는 클래스, 위젯, 모든 항목을 함께 배치하는 카탈로그 항목을 만들어 이 문제를 해결합니다.
먼저 task_display.dart이라는 파일을 만들고 다음 가져오기를 추가합니다.
import 'package:flutter/material.dart';
import 'package:genui/genui.dart';
import 'package:json_schema_builder/json_schema_builder.dart';
데이터 스키마 만들기
다음으로, 에이전트가 작업 디스플레이를 만들 때 제공할 데이터 스키마를 정의합니다. 이 프로세스에서는 json_schema_builder 패키지의 멋진 생성자를 사용하지만 기본적으로는 에이전트와 주고받는 메시지에 사용되는 JSON 스키마를 정의하는 것입니다.
구성요소 이름을 참조하는 기본 S.object로 시작합니다.
final taskDisplaySchema = S.object(
properties: {
'component': S.string(enumValues: ['TaskDisplay']),
},
);
다음으로 스키마 속성에 title, tasks, name, isCompleted, completeAction을 추가합니다.
final taskDisplaySchema = S.object(
properties: {
'component': S.string(enumValues: ['TaskDisplay']),
'title': S.string(description: 'The title of the task list'),
'tasks': S.list(
description: 'A list of tasks to be completed today',
items: S.object(
properties: {
'name': S.string(description: 'The name of the task to be completed'),
'isCompleted': S.boolean(
description: 'Whether the task is completed',
),
'completeAction': A2uiSchemas.action(
description:
'The action performed when the user has completed the task.',
),
},
),
),
},
);
completeAction 속성을 확인합니다. A2UI 작업을 나타내는 스키마 속성의 생성자인 A2uiSchemas.action로 생성됩니다. 스키마에 작업을 추가하면 앱은 기본적으로 에이전트에게 '작업을 제공할 때 작업이 완료되었음을 알리는 데 사용할 수 있는 작업의 이름과 메타데이터도 제공해 줘'라고 말하는 것입니다. 나중에 사용자가 체크박스를 탭하면 앱이 해당 작업을 호출합니다.
그런 다음 스키마에 required 필드를 추가합니다. 이는 에이전트가 매번 특정 속성을 채우도록 지시합니다. 이 경우 모든 속성이 필수입니다.
final taskDisplaySchema = S.object(
properties: {
'component': S.string(enumValues: ['TaskDisplay']),
'title': S.string(description: 'The title of the task list'),
'tasks': S.list(
description: 'A list of tasks to be completed today',
items: S.object(
properties: {
'name': S.string(description: 'The name of the task to be completed'),
'isCompleted': S.boolean(
description: 'Whether the task is completed',
),
'completeAction': A2uiSchemas.action(
description:
'The action performed when the user has completed the task.',
),
},
// New!
required: ['name', 'isCompleted', 'completeAction'],
),
),
},
// New!
required: ['title', 'tasks'],
);
데이터 파싱 클래스 만들기
이 구성요소의 인스턴스를 만들면 에이전트가 스키마와 일치하는 데이터를 전송합니다. 수신되는 JSON을 엄격한 유형의 Dart 객체로 파싱하는 두 클래스를 추가합니다. _TaskDisplayData는 루트 구조를 처리하고 내부 배열 파싱은 _TaskData에 위임합니다.
class _TaskData {
final String name;
final bool isCompleted;
final String actionName;
final JsonMap actionContext;
_TaskData({
required this.name,
required this.isCompleted,
required this.actionName,
required this.actionContext,
});
factory _TaskData.fromJson(Map<String, Object?> json) {
try {
final action = json['completeAction']! as JsonMap;
final event = action['event']! as JsonMap;
return _TaskData(
name: json['name'] as String,
isCompleted: json['isCompleted'] as bool,
actionName: event['name'] as String,
actionContext: event['context'] as JsonMap,
);
} catch (e) {
throw Exception('Invalid JSON for _TaskData: $e');
}
}
}
class _TaskDisplayData {
final String title;
final List<_TaskData> tasks;
_TaskDisplayData({required this.title, required this.tasks});
factory _TaskDisplayData.fromJson(Map<String, Object?> json) {
try {
return _TaskDisplayData(
title: (json['title'] as String?) ?? 'Tasks',
tasks: (json['tasks'] as List<Object?>)
.map((e) => _TaskData.fromJson(e as Map<String, Object?>))
.toList(),
);
} catch (e) {
throw Exception('Invalid JSON for _TaskDisplayData: $e');
}
}
}
이전에 Flutter로 빌드한 적이 있다면 이러한 클래스가 이전에 만든 클래스와 비슷할 것입니다. JsonMap를 허용하고 JSON에서 파싱된 데이터가 포함된 강력한 형식의 객체를 반환합니다.
_TaskData에서 actionName 및 actionContext 필드를 확인합니다. JSON의 completeAction 속성에서 추출되며 작업 이름과 데이터 컨텍스트 (GenUI의 데이터 모델에 있는 작업 위치에 대한 참조)가 포함됩니다. 이는 나중에 UserActionEvent를 만드는 데 사용됩니다.
데이터 모델은 genui 라이브러리에서 유지관리하는 모든 동적 UI 상태의 중앙 집중식 관찰 가능한 저장소입니다. 에이전트가 카탈로그에서 UI 구성요소를 만들면 구성요소의 스키마와 일치하는 데이터 객체도 만듭니다. 이 데이터 객체는 클라이언트의 데이터 모델에 저장되므로 위젯을 빌드하는 데 사용될 수 있고 나중에 상담사에게 보내는 메시지에서 참조될 수 있습니다 (예: 위젯에 연결하려는 completeAction).
위젯 추가
이제 목록을 표시하는 위젯을 만듭니다. _TaskDisplayData 클래스의 인스턴스와 작업이 완료될 때 호출할 콜백을 허용해야 합니다.
class _TaskDisplay extends StatelessWidget {
final _TaskDisplayData data;
final void Function(_TaskData) onCompleteTask;
const _TaskDisplay({required this.data, required this.onCompleteTask});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
data.title,
style: Theme.of(context).textTheme.titleLarge,
),
),
...data.tasks.map(
(task) => CheckboxListTile(
title: Text(
task.name,
style: TextStyle(
decoration: task.isCompleted
? TextDecoration.lineThrough
: TextDecoration.none,
),
),
value: task.isCompleted,
onChanged: task.isCompleted
? null
: (val) {
if (val == true) {
onCompleteTask(task);
}
},
),
),
],
);
}
}
CatalogItem 만들기
스키마, 파서, 위젯을 만들었으므로 이제 CatalogItem를 만들어 이 모든 것을 연결할 수 있습니다.
task_display.dart 하단에서 taskDisplay을 최상위 변수로 만들고 _TaskDisplayData을 사용하여 수신 JSON을 파싱하고 _TaskDisplay 위젯의 인스턴스를 빌드합니다.
final taskDisplay = CatalogItem(
name: 'TaskDisplay',
dataSchema: taskDisplaySchema,
widgetBuilder: (itemContext) {
final json = itemContext.data as Map<String, Object?>;
final data = _TaskDisplayData.fromJson(json);
return _TaskDisplay(
data: data,
onCompleteTask: (task) async {
// We will implement this next!
},
);
},
);
onCompleteTask 구현
위젯이 작동하려면 작업이 완료될 때 에이전트에게 다시 통신해야 합니다. 빈 onCompleteTask 자리표시자를 다음 코드로 바꿔 작업 데이터의 completeAction를 사용하여 이벤트를 만들고 디스패치합니다.
onCompleteTask: (task) async {
// A data context is a reference to a location in the data model. This line
// turns that reference into a concrete data object that the agent can use.
// It's kind of like taking a pointer and replacing it with the value it
// points to.
final JsonMap resolvedContext = await resolveContext(
itemContext.dataContext,
task.actionContext,
);
// Dispatch an event back to the agent, letting it know a task was completed.
// This will be sent to the agent in an A2UI message that includes the name
// of the action, the surface ID, and the resolved data context.
itemContext.dispatchEvent(
UserActionEvent(
name: task.actionName,
sourceComponentId: itemContext.id,
context: resolvedContext,
),
);
}
카탈로그 항목 등록
마지막으로 main.dart를 열고 새 파일을 가져와 다른 카탈로그 항목과 함께 등록합니다.
lib/main.dart의 맨 위에 다음 가져오기를 추가합니다.
import 'task_display.dart';
initState() 함수에서 catalog = BasicCatalogItems.asCatalog();를 다음으로 바꿉니다.
// The Catalog is immutable, so use copyWith to create a new version
// that includes our custom catalog item along with the basics.
catalog = BasicCatalogItems.asCatalog().copyWith(newItems: [taskDisplay]);
모두 마쳤습니다! 앱을 핫 리스타트하여 변경사항을 확인합니다.
8. 다양한 방식으로 에이전트와 상호작용해 보세요.

이제 카탈로그에 새 위젯을 추가하고 앱의 UI에 공간을 만들었으므로 에이전트와 함께 재미있는 작업을 할 수 있습니다. GenUI의 주요 이점 중 하나는 버튼, 체크박스와 같은 애플리케이션 UI를 통해 데이터와 상호작용하는 방법과 자연어를 이해하고 데이터에 대해 추론할 수 있는 에이전트를 통해 데이터와 상호작용하는 두 가지 방법을 제공한다는 것입니다. 두 가지를 모두 실험해 보세요.
- 텍스트 필드를 사용하여 3~4개의 할 일을 설명하면 목록에 표시됩니다.
- 체크박스를 사용하여 할 일을 완료 또는 미완료로 전환합니다.
- 5~6개의 작업 목록을 만든 다음, 운전이 필요한 작업을 삭제하라고 에이전트에게 요청합니다.
- 상담사에게 반복적인 작업 목록을 개별 항목으로 만들어 달라고 요청합니다 ('엄마, 아빠, 할머니에게 드릴 연하장을 사야 해. 이러한 항목에 대해 별도의 작업을 만드세요'라고 말합니다.
- 에이전트에게 모든 작업을 완료 또는 미완료로 표시하거나 처음 두세 개를 선택하라고 요청합니다.
9. 축하합니다
축하합니다. 생성형 UI와 Flutter를 사용하여 AI 기반 작업 추적 앱을 빌드했습니다.
학습한 내용
- Flutter Firebase SDK를 사용하여 Google의 기본 모델과 상호작용
- GenUI를 활용하여 Gemini가 생성한 대화형 영역 렌더링
- 사전 결정된 정적 렌더링 ID를 사용하여 레이아웃에서 화면 고정
- 강력한 상호작용 루프를 위한 맞춤 스키마 및 위젯 카탈로그 설계