在Flutter中使用GenUI构建动态、AI驱动的界面
在标准的应用开发中,用户界面(UI)是静态的。你为一个按钮编写代码,编译它,它就会永远是一个按钮。GenUI彻底颠覆了这种模式。
借助Google的Generative UI SDK——GenUI,你的应用程序界面变得动态。你不再硬编码Widget树。相反,你向一个AI代理(例如Google的Gemini)提供一个被称为“目录”(Catalog)的UI组件“工具包”和一个目标。然后,AI根据用户当前的需求,实时生成UI,决定是显示一个滑块、一个文本字段还是一个复杂的卡片。
本指南将带你从零开始,构建一个功能齐全的、由AI驱动的圣诞贺卡生成器,它不仅能生成文本,还能生成实际的Flutter widget来展示它们。
你的圣诞贺卡制作器将使用Generative UI和AI来即时创建个性化的高质量圣诞贺卡。用户提供简单的输入,如收件人姓名、关系和首选颜色主题,AI就会动态生成一个节庆、精美的贺卡UI,包含衷心的文案、季节性风格和结构化布局。
通过结合Generative UI的反应式数据模型与自定义目录组件,本项目将向你展示如何引导AI生成一致、可用于生产环境的用户界面,而不是松散组装的原件。
需要注意的是,GenUI包目前处于Alpha阶段,具有高度实验性。因为它处于早期开发阶段,以下是你需要牢记的几点:
- API稳定性:本指南中描述的类、方法签名和整体架构很可能会随着Flutter团队收集社区反馈而发生变化。
- 安全性和护栏:由于UI是由LLM生成的,始终存在“幻觉”的非零概率,即AI可能会尝试使用你目录中不存在的组件或属性。
- 生产就绪度:虽然GenUI对于原型设计和内部工具来说非常令人兴奋,但它需要强大的错误处理和回退UI,以确保在AI服务不可用或返回无效结构时,用户体验依然流畅。
在你完成本指南的过程中,GenUI应该被理解为一个协作系统,而不是一个自主系统。你仍然负责定义AI可以使用的目录,审查这些组件的组装方式,并在真实场景中测试生成的界面。
本指南在一个引导式设置中演示了GenUI,其中Flutter提供结构和约束,AI在其内部运行以动态组装UI。目标不是消除开发者的判断,而是将其从手动编写Widget树转变为设计、塑造和验证生成这些树的系统。
目录
- 先决条件
- 思维模型:GenUI如何思考
- 将GenUI组件映射到圣诞贺卡应用
- 圣诞贺卡应用中的GenUiConversation
- 作为设计约束的目录
- 作为个性化核心的DataModel
- 作为AI网关的ContentGenerator
- A2uiMessage作为意图,而非UI
- 为何这种架构有效
- 项目概述:我们将构建什么
- 项目结构
- 第1步:创建一个新的Flutter项目
- 第2步:配置你的代理提供者
- 第3步:添加依赖项
- 第4步:获取Google Gemini API密钥
- 第5步:应用入口点 (main.dart)
- 根应用Widget
- 第6步:逻辑控制器(有状态的屏幕)
- 第7步:初始化GenUI和Firebase
- 第8步:向AI发送动态提示词
- 构建视图
- 文件夹:
lib/screen/data/
- 文件夹:
lib/extensions/
- 文件夹:
lib/screen/components/
- 将你自己的Widget添加到GenUI目录
- 为何添加自定义Widget?
- 第1步:添加 json_schema_builder
- 第2步:定义假日贺卡Schema
- 第3步:创建CatalogItem
- 第4步:在你的应用中注册Widget
- 第5步:教会AI使用该Widget
- 这如何适应你现有的屏幕
- 截图
- 最终想法
- 参考资料
先决条件
为了有效地遵循本指南,你需要:
- Flutter开发环境:已安装Flutter SDK(建议使用稳定版)并配置好IDE,如VS Code或Android Studio。
- 基础的Flutter知识:你应该了解Widget如何组合(Row、Column、Container)以及基础的状态管理(setState或FutureBuilder)。
- Google AI Studio API密钥:我们将使用Google的Gemini模型。你需要从Google AI Studio获取一个免费的API密钥。
思维模型:GenUI如何思考
在编写任何代码之前,理解GenUI在概念上如何看待你的应用非常重要。GenUI不是以Widget树或屏幕来思考的。它是以表面、状态和对话来思考的。
- 表面 只是一个可以显示AI生成UI的地方。
- 对话 控制这些表面如何随时间演变。
- 数据模型 持有真相。
- 消息 推动一切向前发展。
以下是完整的流程概览:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
用户操作
|
v
GenUiConversation
|
v
ContentGenerator (AI)
|
v
A2uiMessage 流
|
v
GenUiManager
|
v
DataModel + UI 表面
|
v
GenUiSurface (Flutter 重建)
|
此流程中的任何环节都不会绕过Flutter。GenUI不会在Flutter“之外”渲染UI——它只决定Flutter应该渲染什么。
将GenUI组件映射到圣诞贺卡应用
现在让我们将这个思维模型落实到我们将要构建的圣诞贺卡生成器中。这是GenUI真正发挥作用的地方。
1. 圣诞贺卡应用中的GenUiConversation
在我们将要构建的项目中,GenUiConversation代表了用户与圣诞贺卡生成器之间持续的交互。
当用户输入所爱之人的姓名、选择关系、选择颜色并点击“生成贺卡”时,你的应用会通过GenUiConversation发送该提示词。
此时,GenUiConversation已经知道对话历史。它知道这是正在生成的第一张贺卡,还是用户正在用不同的信息重新生成贺卡。正是这种上下文让AI能够为每个人创建独特的贺卡,而不是重复通用的输出。
没有GenUiConversation,每个请求都将是无状态的。有了它,应用就感觉是有意为之且个性化的。
2. 作为设计约束的目录
在圣诞贺卡应用中,目录定义了你的贺卡的视觉语言。
你可能允许AI使用文本Widget用于问候语,图像Widget用于节日背景,容器Widget用于布局,按钮用于重新生成或分享。重要的是,AI不能脱离这些约束。
这是你确保以下事项的方式:
- 贺卡看起来始终像贺卡
- AI不会发明不支持的UI
- 你的应用保持视觉一致性
从AI的角度来看,目录是它唯一被允许使用的工具箱。从你的角度来看,它是保持UI原生、可预测的安全网。
3. 作为个性化核心的DataModel
数据模型是真正实现个性化的地方。
在我们将要构建的项目中,诸如收件人姓名、问候信息、贺卡主题,甚至动画标志等值都存在于数据模型中。当用户编辑姓名或重新生成贺卡时,只有绑定到这些值的UI部分会改变。
这就是为什么GenUI感觉是动态的,但效率却不低。你不是重建整个贺卡屏幕,而只是更新依赖于已更改数据的那部分。
这也意味着AI不需要每次都重新创建整个UI。它可以简单地更新数据模型,然后让Flutter做它最擅长的事情。
4. 作为AI网关的ContentGenerator
ContentGenerator是你的应用中唯一知道如何与AI对话的部分。
在圣诞贺卡的例子中,该组件将用户的请求以及诸如“使用可用的Widget生成一个节日圣诞贺卡UI”之类的系统指令发送给模型。然后它监听AI的响应。
由于响应是以流的形式到达的,因此一旦第一条指令到达,UI就可以开始渲染。如果你以后想为贺卡添加动画或渐进式展示,这将特别有用。
从设计角度来看,这种分离至关重要。你的Flutter应用从不直接依赖AI SDK。它依赖于GenUI,而GenUI依赖于ContentGenerator。
5. A2uiMessage作为意图,而非UI
这是需要内化的最重要的概念之一:当AI决定生成一张圣诞贺卡时,它不会发送Flutter widget。相反,它发送的是A2uiMessage指令。
一条消息可能说“开始渲染一个新的表面”。另一条可能说“更新数据模型中的问候文本”。另一条可能说“替换背景图片”。
这些消息由GenUiManager处理,它将意图转化为实际的UI更改。这个额外的层正是防止GenUI变得脆弱或不可预测的关键。
为何这种架构有效
使GenUI强大的不是它使用了AI。许多工具都这样做。它的强大之处在于,AI永远不会打破Flutter的规则,因为状态是集中的,渲染是受控的,事件是明确的,并且更新是增量的。
在圣诞贺卡应用中,这意味着每张贺卡都感觉是定制的,每次交互都感觉是响应迅速的,即使AI逻辑变得更加复杂,你的应用也依然易于维护。
一旦你理解了这个流程,你就不再认为GenUI是“AI生成UI”,而是开始认为它是AI参与了你应用的状态机。
项目概述:我们将构建什么
在本教程中,我们将使用Flutter和GenUI构建一个圣诞贺卡生成器。想法简单直观:用户输入姓名,选择关系和贺卡颜色描述,AI动态生成一个代表个性化圣诞贺卡的Flutter Widget树。
该项目展示了三个核心GenUI理念协同工作:对话循环、AI驱动的UI渲染以及无需手动连接Widget的反应式状态更新。
到最后,你将不仅了解如何使用GenUI,还将了解如何围绕它构建一个真实的Flutter应用结构。
项目结构
我们将有意保持结构简单,以便后续易于理解和扩展。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
lib/
├── extensions/
│ ├── loading.dart
├── screen/
│ ├── components/
│ │ ├── color_picker_list.dart // 颜色选择Widget
│ │ ├── custom_input_section.dart // 输入表单字段
│ │ ├── error_section.dart // 错误消息显示
│ │
│ ├── data/
│ │ └── static_list_data.dart // 硬编码数据或常量
│ ├── card_generator_screen.dart // 生成贺卡的主要UI逻辑
│ └── christmas_card.dart // 特定的贺卡widget/视图
├── firebase_options.dart // Firebase配置文件
└── main.dart // 应用入口点
|
第1步:创建一个新的Flutter项目
首先创建一个新的Flutter应用。
1
2
|
flutter create genui_christmas_card
cd genui_christmas_card
|
这为我们提供了一个干净的基线,支持Material 3和正确的平台设置。
第2步:配置你的代理提供者
genui可以连接到各种代理提供者。根据你首选的提供者选择下面的部分。
配置Firebase AI Logic
要使用内置的FirebaseAiContentGenerator通过Firebase AI Logic连接到Gemini,请按照以下说明操作:
- 使用Firebase控制台创建一个新的Firebase项目。
- 为该项目启用Gemini API。
- 按照Firebase的Flutter设置中的前三个步骤将Firebase添加到你的应用中。
- 启用Gemini开发者API。
第3步:添加依赖项
GenUI是模块化的。你总是先安装核心框架,然后添加一个知道如何与你的AI提供者对话的内容生成器。
打开pubspec.yaml并更新你的依赖项:
1
2
3
4
5
6
7
8
9
10
|
dependencies:
flutter:
sdk: flutter
genui: ^0.6.0
logging: ^1.2.0
genui_firebase_ai: ^0.6.0
firebase_core: ^4.3.0
loader_overlay: ^5.0.0
flutter_spinkit: ^5.2.2
|
然后获取包:
此时,你的项目已具备动态生成UI所需的一切。
第4步:获取Google Gemini API密钥
GenUI本身不提供AI模型。你需要连接一个。为此,请转到Google AI Studio,创建一个新的API密钥,并复制它。
重要提示:对于真正的生产应用,切勿硬编码API密钥。请使用--dart-define、环境变量或后端代理。
第5步:应用入口点 (main.dart)
现在我们将开始编写实际代码。
将lib/main.dart的内容替换为以下内容:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
import 'package:flutter/material.dart';
import 'package:genui_flutter/screen/christmas_card.dart';
import 'package:logging/logging.dart';
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
void main() async{
// 启用详细日志记录,以便我们能够确切地
// 看到AI发送回GenUI的内容。
Logger.root.level = Level.ALL;
Logger.root.onRecord.listen((record) {
debugPrint(
'${record.level.name}: ${record.time}: ${record.message}',
);
});
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
runApp(const ChristmasCardApp());
}
|
这个日志设置是可选的,但强烈推荐。当出现问题时,日志通常是理解为什么AI没有生成你期望内容的最快方式。
接下来,我们为应用定义根Widget。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
import 'package:flutter/material.dart';
import 'package:loader_overlay/loader_overlay.dart';
import 'card_generator_screen.dart';
import 'package:flutter_spinkit/flutter_spinkit.dart';
class ChristmasCardApp extends StatelessWidget {
const ChristmasCardApp({super.key});
@override
Widget build(BuildContext context) {
return Directionality(
textDirection: TextDirection.ltr,
child: LoaderOverlay(
overlayWholeScreen: true,
overlayWidgetBuilder: (_) {
return const Center(
child: SpinKitWaveSpinner(color: Colors.red, size: 50.0),
);
},
child: MaterialApp(
title: 'GenUI圣诞贺卡生成器',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.red,
primary: Colors.red,
),
useMaterial3: true,
),
home: const CardGeneratorScreen(),
),
),
);
}
}
|
这是标准的Flutter——还没有任何GenUI特有的内容。真正的工作发生在CardGeneratorScreen内部。
第6步:逻辑控制器(有状态的屏幕)
这个屏幕是我们将Flutter、Firebase AI和GenUI逻辑连接在一起的地方。它处理用户输入(姓名、关系、颜色)并协调AI生成。
1
2
3
4
5
6
|
class CardGeneratorScreen extends StatefulWidget {
const CardGeneratorScreen({super.key});
@override
State<CardGeneratorScreen> createState() => _CardGeneratorScreenState();
}
|
现在是状态类,它持有所有GenUI逻辑和表单状态:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
class _CardGeneratorScreenState extends State<CardGeneratorScreen> {
// 1. 表单状态管理
final TextEditingController nameController = TextEditingController();
String selectedRelationship = 'Friend';
String selectedColorName = 'Gold';
Color selectedColorUi = Colors.amber;
// 2. GenUI核心组件
late final A2uiMessageProcessor _a2uiMessageProcessor;
late final FirebaseAiContentGenerator _contentGenerator;
late final GenUiConversation _conversation;
// 3. UI状态
String? currentSurfaceId;
String? errorMessage;
|
该应用通过一个允许动态提示词注入的表单状态来管理用户输入,而_a2uiMessageProcessor则充当解码器,将原始AI数据转换为特定的Flutter widget。
后端连接由FirebaseAiContentGenerator处理,它管理系统指令和工具目录,而_conversation对象则充当指挥,管理聊天历史并在AI和UI之间路由数据。
最后,currentSurfaceId跟踪正在显示的特定Widget树,确保GenUiSurface渲染正确的AI生成内容。
第7步:初始化GenUI和Firebase
所有的设置在initState中完成:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
|
@override
void initState() {
super.initState();
// 1. 使用允许的Widget设置处理器
_a2uiMessageProcessor = A2uiMessageProcessor(
catalogs: [CoreCatalogItems.asCatalog()],
);
// 2. 配置AI个性和规则
_contentGenerator = FirebaseAiContentGenerator(
catalog: CoreCatalogItems.asCatalog(),
systemInstruction: '''
你是一名专业的节日UI设计师和节日文案撰写人。
你的目标:使用 `surfaceUpdate` 工具生成一张高端、视觉吸引力强的圣诞贺卡,适合打印或数字分享。贺卡应感觉个性化、温暖且具有节日气氛。
设计指南:
- 布局:在带有圆角、充足内边距和边框的Container内使用垂直Column。用一种**混合了红色与 $selectedColorName** 的颜色填充Container,以创建丰富的节日主题背景。
- 排版:使用不同的字体粗细(标题用粗体,正文用普通体)。所有文本居中对齐。
- 视觉元素:包含季节性图标(🎄, ✨, ❄️)作为装饰元素。在不使布局过于拥挤的情况下,策略性地放置圣诞树表情符号。
- 个性化:以醒目的方式在贺卡中间突出显示收件人的姓名。
文案撰写指南:
- 创作一条深具个性、发自内心的节日信息(3-4句话),与关系类型相匹配(朋友间有趣,配偶间浪漫,家人间温暖)。
- 包括一个恰当的结尾/签名。
- 切勿使用占位符。始终生成**可直接显示的最终文本**。
输出说明:
- 使用 `surfaceUpdate` 工具来构建UI。
- 确保所有元素(Container、文本、表情符号)在视觉上对齐且和谐。
- 贺卡必须感觉节日、优雅且平衡。
''',
);
// 3. 开始对话并监听更新
_conversation = GenUiConversation(
contentGenerator: _contentGenerator,
a2uiMessageProcessor: _a2uiMessageProcessor,
onSurfaceAdded: _onSurfaceAdded,
onSurfaceDeleted: _onSurfaceDeleted,
);
}
void _onSurfaceAdded(SurfaceAdded update) {
setState(() {
currentSurfaceId = update.surfaceId;
});
}
|
在initState方法中,我们首先使用CoreCatalogItems配置A2uiMessageProcessor,给予AI访问标准Widget的权限。然后,我们初始化FirebaseAiContentGenerator。
注意systemInstruction:你在这里赋予了AI两个不同的角色;“UI设计师"和"文案撰写人。“你明确告诉它根据关系撰写特定内容并设计居中对齐的文本。
最后,我们在GenUiConversation中将它们链接起来,并附加一个监听器(_onSurfaceAdded)。当AI创建一个新的UI时,我们在setState内部更新currentSurfaceId,这告诉Flutter绘制新的贺卡。
第8步:向AI发送动态提示词
此方法使用用户的表单数据构建特定的提示词来启动生成过程。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
|
Future<void> generateCard() async {
if (nameController.text.trim().isEmpty) {
setState(() {
errorMessage = "请先输入一个姓名!";
});
return;
}
FocusScope.of(context).unfocus();
setState(() {
errorMessage = null;
currentSurfaceId = null;
});
try {
context.showLoader();
final prompt = '''
为我的$selectedRelationship ${nameController.text}创建一张个性化的圣诞贺卡。
主题:混合红色和$selectedColorName作为节日背景。
布局:在一个带有内边距和边框的圆角Container中使用垂直Column;将收件人姓名放在中央的突出位置。
视觉元素:在适当的地方添加圣诞树(🎄)、闪光(✨)或雪花(❄️)。
排版:粗体标题,普通正文文本,全部居中对齐。
消息:撰写一条温暖、个性化的3-4句节日问候,适合关系类型,并以恰当的签名结尾。
设计:让它看起来像一张优雅、节日的圣诞贺卡,随时可以展示或分享。
''';
await _conversation.sendRequest(UserMessage.text(prompt));
} catch (e) {
debugPrint('错误: $e');
if (mounted) {
setState(() {
errorMessage = "哎呀!创建贺卡失败。\n错误: $e";
});
}
} finally {
if (mounted) {
context.hideLoader();
}
}
}
|
generateCard方法是提示工程与代码相遇的地方。首先,它验证是否存在姓名。然后,它使用字符串插值($selectedRelationship, $selectedColorName)构建一个多行字符串。你不是发送一个通用的请求,而是发送一份详细的简报:“用金色为名叫Alice的妈妈制作一张贺卡。”
最后,_conversation.sendRequest将此提示词发送到Firebase。我们将此包装在try/catch块中,以便通过UI中显示错误消息来优雅地处理网络错误。
构建视图
现在,我们将使用我们在components/文件夹中创建的辅助组件来渲染复杂的UI。这是代码——但别担心,在这之后我们将逐一介绍每个自定义组件。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
|
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('🎄 节日贺卡制作器')...),
body: Stack(
children: [
Column(
children: [
// 1. 输入表单(重构为一个组件)
CustomInputSection(
nameController: nameController,
selectedRelationship: selectedRelationship,
selectedColorName: selectedColorName,
selectedColorUi: selectedColorUi,
onColorSelected: onColorSelected,
generateCard: generateCard,
selectRelationship: selectRelationship,
),
const Divider(height: 1),
// 2. GenUI绘图区域
Expanded(
child: Container(
color: Colors.grey[100],
child: currentSurfaceId != null
? GenUiSurface(
host: _conversation.host,
surfaceId: currentSurfaceId!,
)
: const Center(child: Text('填写详细信息...')),
),
),
],
),
if (errorMessage != null)
ErrorSection(errorMessage: errorMessage!, clearError: clearError),
],
),
);
}
}
|
在build方法中,我们使用一个Stack来允许我们在主内容顶部浮动LoadingWidget和ErrorSection。
你没有在这里编写所有输入逻辑,而是使用了CustomInputSection。这使主屏幕保持简洁,并专注于AI协调。
屏幕的下半部分包含GenUiSurface。如果currentSurfaceId存在,则使用_conversation.host渲染AI的Widget树。如果不存在,则显示占位符说明。
至此,你已经看到了渲染屏幕的完整build()方法。请注意,屏幕本身几乎不直接做视觉工作。相反,它由更小、更专注的widget和辅助文件组合成UI。这是有意的。
我们不是将表单字段、颜色选择器、错误处理和常量塞进单个屏幕文件,而是将UI清晰地划分为目的驱动的文件夹。每个文件夹代表一个UI关注点,而不是状态管理层或架构模式。
在接下来的部分中,我们将逐一浏览这些文件夹,展示每个部分如何为刚刚构建的最终屏幕做出贡献。你将看到可重用Widget的存放位置、静态UI数据的定义位置,以及主屏幕如何将所有内容连接在一起而不变得杂乱。
文件夹: lib/screen/data/
此文件夹保存用于填充下拉菜单和颜色列表的静态数据。
StaticListData: lib/screen/data/static_list_data.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
|
import 'package:flutter/material.dart';
class StaticListData {
// 用于下拉菜单的关系列表
static final List<String> relationships = [
'Husband',
'Wife',
'Son',
'Daughter',
'Grandma',
'Grandpa',
'Uncle',
'Aunt',
'Friend',
'Relative',
'Cousin',
'Grandson',
'Granddaughter',
'Mom',
'Dad',
];
// 颜色名称到实际Flutter Color对象的映射
static final Map<String, Color> colorOptions = {
'Gold': Colors.amber,
'Green': Colors.green,
'Blue': Colors.blue,
'Purple': Colors.deepPurple,
'Silver': Colors.grey,
'Yellow': Colors.yellow,
'Pink': Colors.pink,
};
}
|
该类作为常量数据的中央存储库,保存关系列表,允许轻松更新UI(例如添加"同事"或"邻居”),而无需修改核心代码;以及colorOptions映射,它将用户友好的名称如"Gold"转换为功能性的Color对象如Colors.amber,用于样式设置。
文件夹: lib/extensions/
此文件夹保存用于填充下拉菜单和颜色列表的静态数据。
LoaderOverlayExtension: lib/extensions/loading.dart
1
2
3
4
5
6
7
8
9
10
11
12
|
import 'package:flutter/material.dart';
import 'package:loader_overlay/loader_overlay.dart';
extension LoaderOverlayExtension on BuildContext {
void showLoader() {
loaderOverlay.show();
}
void hideLoader() {
loaderOverlay.hide();
}
}
|
LoaderOverlayExtension向任何BuildContext对象添加了两个方法:showLoader(),用于显示LoaderOverlay;以及hideLoader(),用于隐藏它。这允许你在Widget中的任何地方调用context.showLoader()或context.hideLoader(),而无需每次都直接引用loaderOverlay,从而提高了可读性,并在需要显示加载状态时减少了样板代码。
文件夹: lib/screen/components/
此文件夹包含可重复使用的UI组件,专门用于应用中的屏幕,特别是CardGeneratorScreen。这些是更小、模块化的Widget,它们封装了UI的一部分,使主屏幕代码更简洁、更易读和更易于维护。
ErrorSection: error_section.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
|
import 'package:flutter/material.dart';
class ErrorSection extends StatelessWidget {
final String errorMessage;
final VoidCallback clearError;
const ErrorSection({
super.key,
required this.errorMessage,
required this.clearError,
});
@override
Widget build(BuildContext context) {
return Container(
// 高不透明度背景以阻挡其后的UI
color: Colors.white.withOpacity(0.95),
child: Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, color: Colors.red, size: 60),
const SizedBox(height: 16),
// 显示从父级传递的特定错误消息
Text(
errorMessage,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 16, color: Colors.red),
),
const SizedBox(height: 20),
// 用于消除错误的按钮
ElevatedButton(
onPressed: () {
clearError();
},
child: const Text("重试"),
),
],
),
),
),
);
}
}
|
这个强大的错误处理视图使用一个大的红色图标和描述性文本来清晰表示问题,同时包含一个clearError回调,当点击"重试"按钮时触发,以重置父级状态的errorMessage变量并关闭该视图。
ColorPickerList: color_picker_list.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
|
import 'package:flutter/material.dart';
class ColorPickerList extends StatelessWidget {
const ColorPickerList({
super.key,
required String selectedColorName,
required Color selectedColorUi,
required Map<String, Color> colorOptions,
required this.onColorSelected,
}) : _selectedColorName = selectedColorName,
_colorOptions = colorOptions;
final String _selectedColorName;
final Map<String, Color> _colorOptions;
final void Function(String colorName, Color colorUi) onColorSelected;
@override
Widget build(BuildContext context) {
return SizedBox(
height: 85,
// 颜色的水平滚动列表
child: ListView(
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
children: _colorOptions.entries.map((entry) {
final isSelected = _selectedColorName == entry.key;
return GestureDetector(
onTap: () {
// 将所选颜色传回父级
onColorSelected(entry.key, entry.value);
},
child: Container(
margin: const EdgeInsets.only(right: 15),
width: 50,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// 外环动画
AnimatedContainer(
duration: const Duration(milliseconds: 250),
padding: const EdgeInsets.all(3),
decoration: BoxDecoration(
shape: BoxShape.circle,
// 仅在选择时显示边框
border: Border.all(
color: isSelected ? entry.value : Colors.transparent,
width: 2.5,
),
),
// 内部颜色圆圈
child: Container(
width: 35,
height: 35,
decoration: BoxDecoration(
color: entry.value,
shape: BoxShape.circle,
boxShadow: [
if (isSelected)
BoxShadow(
color: entry.value.withOpacity(0.3),
blurRadius: 6,
offset: const Offset(0, 3),
),
],
border: Border.all(color: Colors.white, width: 2),
),
),
),
const SizedBox(height: 6),
// 颜色名称标签
Text(
entry.key,
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 10,
color: isSelected ? entry.value : Colors.grey[600],
fontWeight:
isSelected ? FontWeight.bold : FontWeight.normal,
),
),
],
),
),
);
}).toList(),
),
);
}
}
|
这个颜色圆圈的水平列表使用带有scrollDirection: Axis.horizontal的ListView,允许用户滑动浏览各种选项,同时AnimatedContainer提供了精致的视觉反馈,当颜色被点击时,其外边框会在250毫秒内以动画形式呈现。
该Widget还包含选择逻辑,该逻辑检查isSelected状态以决定是否显示粗体文本和彩色边框,从而清楚地指示用户当前的选择。
CustomInputSection custom_input_section.dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
|
import 'package:flutter/material.dart';
import '../data/static_list_data.dart';
import 'color_picker_list.dart';
class CustomInputSection extends StatelessWidget {
final TextEditingController nameController;
final String selectedRelationship;
final String selectedColorName;
final Color selectedColorUi;
final void Function(String colorName, Color colorUi) onColorSelected;
final VoidCallback generateCard;
final Function selectRelationship;
const CustomInputSection({
super.key,
required this.nameController,
required this.selectedRelationship,
required this.selectedColorName,
required this.selectedColorUi,
required this.onColorSelected,
required this.generateCard,
required this.selectRelationship,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
child: LayoutBuilder(
builder: (context, constraints) {
bool isSmallScreen = constraints.maxWidth < 600;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0,vertical: 20),
child: Flex(
direction: isSmallScreen ? Axis.vertical : Axis.horizontal,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: isSmallScreen ? 0 : 3,
child: SizedBox(
width: isSmallScreen ? double.infinity : null,
child: TextField(
controller: nameController,
decoration: const InputDecoration(
labelText: "姓名 (例如,Alice)",
prefixIcon: Icon(Icons.person),
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
),
),
),
// 动态间距
isSmallScreen
? const SizedBox(height: 12)
: const SizedBox(width: 10),
Expanded(
flex: isSmallScreen ? 0 : 2,
child: SizedBox(
width: isSmallScreen ? double.infinity : null,
child: DropdownButtonFormField<String>(
initialValue: selectedRelationship,
decoration: const InputDecoration(
labelText: '关系',
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
items: StaticListData.relationships.map((String rel) {
return DropdownMenuItem(value: rel, child: Text(rel));
}).toList(),
onChanged: (val) => selectRelationship(val),
),
),
),
],
),
),
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.only(left: 18.0),
child: Text(
"选择一个主题颜色:",
style: TextStyle(
color: Colors.grey[700],
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Flex(
direction: isSmallScreen ? Axis.vertical : Axis.horizontal,
crossAxisAlignment: isSmallScreen
? CrossAxisAlignment.stretch
: CrossAxisAlignment.center,
children: [
isSmallScreen
? ColorPickerList(
selectedColorName: selectedColorName,
selectedColorUi: selectedColorUi,
colorOptions: StaticListData.colorOptions,
onColorSelected: onColorSelected,
)
: Expanded(
child: ColorPickerList(
selectedColorName: selectedColorName,
selectedColorUi: selectedColorUi,
colorOptions: StaticListData.colorOptions,
onColorSelected: onColorSelected,
),
),
if (isSmallScreen) const SizedBox(height: 16),
// 生成按钮
Padding(
padding: const EdgeInsets.all(18.0),
child: SizedBox(
width: isSmallScreen ? double.infinity : null,
child: ElevatedButton.icon(
onPressed: generateCard,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 16,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
icon: const Icon(Icons.auto_awesome),
label: const Text(
"生成贺卡",
style: TextStyle(fontWeight: FontWeight.bold),
),
),
),
),
],
),
),
],
);
},
),
);
}
}
|
作为架构中最复杂的组件,该Widget通过使用LayoutBuilder来监视父级约束,从而聚合所有输入,当maxWidth小于600时,动态地将Flex方向在平板和网页的Axis.horizontal与移动设备堆叠的Axis.vertical之间切换。
为了确保跨设备无缝布局,它在大型屏幕上使用Expanded来填充可用空间,同时在较小屏幕上使用SizedBox(width: double.infinity)强制输入占据设备的整个宽度,同时通过集成ColorPickerList和StaticListData来保持代码整洁。
到目前为止,在本项目中,我们完全依赖CoreCatalogItems提供的Widget。其中包括常见的UI构建块,如Text、Column、Container和Image,这些足以获得惊人的丰富结果。
但是,当你教会AI关于你自己的领域特定Widget时,GenUI才能真正大放异彩。
在我们的案例中,我们不仅仅是在生成任意的UI——我们是在生成高端、个性化的圣诞贺卡。这使得它成为自定义目录项的完美候选者。
与其希望AI每次都能从原始Widget组装出完美的布局,