Flutter 前端实现 AI 生产力应用完整版
教程最终章演示用 Flutter 构建 UI 并整合前面的 AI 逻辑。这是常规的跨端 UI 开发教程,缺少创新内容。
教程最终章演示用 Flutter 构建 UI 并整合前面的 AI 逻辑。这是常规的跨端 UI 开发教程,缺少创新内容。
欢迎阅读第 3 部分!在第 1 部分中,我们实现了与 Pieces OS 的实时同步。在第 2 部分中,我们加入了 Gemini AI 来提取洞察。现在,是时候使用 Flutter UI 让它变得更美观了!🎨
一个简洁的 Flutter 应用,展示:
💼 带有状态指示器的项目
👥 与你协作过的人员(还有头像!)
📝 笔记和学习成果
🔄 “加载更多”分页
我们已经有两个运行良好的服务:
PiecesOSService - 实时数据同步
DailyRecapService - AI 驱动的洞察
在应用启动时初始化这两个服务
加载最近几天的回顾
使用精美的卡片展示它们
处理加载状态
支持分页(“加载更多”)
首先,将头像包添加到 pubspec.yaml:
flutter_initicon: ^2.0.0 # 用于根据姓名生成头像
这个包可以根据姓名首字母创建漂亮的头像——非常适合用来展示协作者!
集成服务
首先,删除 main.dart 文件中原有的所有内容,并替换为:
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:timeago/timeago.dart' as timeago;
import 'package:flutter_initicon/flutter_initicon.dart';
import 'services/pieces_os_service.dart';
import 'services/daily_recap_service.dart';
import 'models/daily_recap_models.dart';
enum LoadingState {
loading,
healthy,
piecesOsNotRunning,
geminiApiKeyMissing,
somethingWentWrong,
}
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Daily Recap Dashboard',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MyHomePage(title: 'Daily Recap Dashboard'),
);
}
}
这段代码设置了一个 Flutter 应用,使用 Material Design 主题展示每日回顾仪表盘。接下来,我们将在 main.dart 中再添加两个类:
// Place MyHomePage class after MyApp class
class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});
final String title;
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final PiecesOSService _piecesService = PiecesOSService();
DailyRecapService? _recapService;
List<DateTime> _loadedDays = [];
final Map<DateTime, DailyRecapData> _recapsCache = {};
LoadingState _loadingState = LoadingState.loading;
bool _isLoadingMore = false;
int _visibleCards = 3; // Show 3 cards initially
final Set<DateTime> _regenerating = {};
final Map<DateTime, String> _errorCache = {};
@override
void initState() {
super.initState();
_initializeServices();
}
}
我们使用 LoadingState 枚举来跟踪不同的状态——加载中、正常(成功)或各种错误情况。这样可以让我们更好地控制 UI。
初始化流程
所有内容都将在这里整合起来:
// Add this method after initState() inside _MyHomePageState class
Future<void> _initializeServices() async {
setState(() => _loadingState = LoadingState.loading);
try {
try {
// Step 1: Initialize Pieces OS
await _piecesService.initialize();
} catch (e) {
setState(() {
_loadingState = LoadingState.piecesOsNotRunning;
});
return;
}
// Step 2: Wait for summaries to load (WebSocket takes time!)
await _piecesService.waitForInitialSync();
// Step 3: Check for Gemini API key
const apiKey = String.fromEnvironment('GEMINI_API_KEY');
if (apiKey == '') {
setState(() {
_loadingState = LoadingState.geminiApiKeyMissing;
});
return;
}
// Step 4: Initialize Gemini service
if (apiKey.isNotEmpty) {
_recapService = DailyRecapService(apiKey: apiKey);
}
// Step 5: Get days with data
_loadedDays = _piecesService.getDaysWithSummaries();
// Step 6: Load recaps for first 3 days
await _loadRecapsForVisibleDays();
setState(() => _loadingState = LoadingState.healthy);
} catch (e) {
print('Error initializing: $e');
setState(() => _loadingState = LoadingState.somethingWentWrong);
}
}
这段代码会初始化 Pieces OS 服务,等待摘要完成同步,检查 Gemini API 密钥,创建回顾服务并加载前 3 天的回顾,同时相应地更新加载状态。
对于每个可见日期,我们会:
获取摘要及其内容(来自注解)
将摘要发送给 Gemini 进行分析
将摘要存储在内存中(仅限本次会话)
我们已经连接到 Pieces OS,等待摘要同步完成,检查了 Gemini API 密钥,并收集了所有包含摘要的日期。现在我们知道哪些日期有数据,但还没有生成任何回顾。
// Add this method after _initializeServices() inside _MyHomePageState class
// (the one you just made)
Future<void> _loadRecapsForVisibleDays() async {
if (_recapService == null) return;
final daysToLoad = _loadedDays.take(_visibleCards).toList();
for (final day in daysToLoad) {
if (!_recapsCache.containsKey(day)) {
try {
// Fetch summaries with content
final summariesWithContent = await getSummariesWithContentForDay(
_piecesService, day);
if (summariesWithContent.isNotEmpty) {
// Generate recap with Gemini
final recap = await _recapService!.generateDailyRecap(
date: day,
summaries: summariesWithContent,
);
setState(() {
_recapsCache[day] = recap;
});
}
} catch (e) {
print('Error loading recap for $day: $e');
}
}
}
}
注意,我们将回顾存储在内存中(_recapsCache),因此在本次会话期间,每天只会调用一次 Gemini。重启应用后,它会重新生成所有内容。(我们将在第 4 部分通过持久化缓存解决这个问题!)
Wrap 布局——响应式卡片
UI 的亮点就在这里!卡片会先横向排列,当空间不足时自动换到下一行:
// Add this method after _loadRecapsForVisibleDays() inside _MyHomePageState
class
Widget _buildRecapsList() {
final visibleDays = _loadedDays.take(_visibleCards).toList();
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// Cards flow horizontally, wrap to next row when overflow
Wrap(
spacing: 16, // Horizontal spacing between cards
runSpacing: 16, // Vertical spacing between rows
children: visibleDays.map((day) {
final recap = _recapsCache[day];
return SizedBox(
width: 400, // Fixed width for each card
child: recap != null
? DailyRecapCard(recap: recap)
: _buildLoadingCard(day),
);
}).toList(),
),
// Load More button centered
if (visibleDays.length < _loadedDays.length)
Center(
child: ElevatedButton.icon(
onPressed: _loadMore,
icon: const Icon(Icons.expand_more),
label: const Text('Load More Days'),
),
),
],
),
);
}
Wrap widget 非常适合这种场景!它会:
首先从左到右横向排列卡片
当卡片放不下时,自动换到下一行
自动适配不同的屏幕尺寸
在常见的桌面设备上,第一行可能会显示 3 张卡片;当你加载更多数据后,下方会出现更多行。在笔记本电脑上,每行可能显示 2 张。它可以自然适配!
“加载更多”功能
让我们在 UI 中构建这个经典的“加载更多”按钮:
// Add this method after _buildRecapsList() inside _MyHomePageState class
Future<void> _loadMore() async {
setState(() => _isLoadingMore = true);
// Increase visible count
setState(() => _visibleCards += 3);
// Load the new days
await _loadRecapsForVisibleDays();
setState(() => _isLoadingMore = false);
}
只有在还有更多日期可加载时才显示该按钮!点击后,它会再添加 3 张卡片,这些卡片会自然地排列到 Wrap 布局中。
构建每日回顾卡片
奇妙之处就在这里。我们从服务中获取 DailyRecapData,并将其转换为一张精美的卡片:
让我们添加一个格式化函数来格式化日期
// Place this at the top under the MyApp class
String dateFormatter(DateTime date) {
final now = DateTime.now();
final difference = now.difference(date);
if (difference.inDays == 0) {
return 'Today';
} else if (difference.inDays == 1) {
return 'Yesterday';
} else {
return timeago.format(date);
}
}
// Add this widget class after _MyHomePageState class closes
class DailyRecapCard extends StatelessWidget {
final DailyRecapData recap;
const DailyRecapCard({super.key, required this.recap});
@override Widget build(BuildContext context) { return Card( elevation: 4, child: Padding( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Date Header Text( dateFormatter(recap.date), style: Theme.of(context).textTheme.headlineSmall, ), Text( DateFormat('MMMM d, y').format(recap.date), style: Theme.of(context).textTheme.bodyMedium ?.copyWith(color: Colors.grey.shade600), ), const SizedBox(height: 16),
// Daily Summary with icon Card( color: Colors.blue.shade50, child: Padding( padding: const EdgeInsets.all(16.0), child: Row( children: [ Icon(Icons.summarize, color: Colors.blue.shade700), const SizedBox(width: 12), Expanded(child: Text(recap.summary)), ], ), ), ),
// ... rest of the sections
],
),
),
);
} }
我们刚刚添加的卡片采用响应式设计,会根据可用的数据展示不同的部分。
简洁的项目符号样式
为了提升可读性,我保持了简单的设计——不使用嵌套卡片,只使用简洁的项目符号:
```text
\\ class DailyRecapCard extends StatelessWidget {
\\ final DailyRecapData recap;
\\ const DailyRecapCard({super.key, required this.recap});
// Add this helper method inside DailyRecapCard class
List<Widget> _buildProjectWidgets(List<ProjectData> projects) {
return projects.map((project) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Icon(statusIcon, color: statusColor, size: 20),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(project.name,
style: TextStyle(fontWeight: FontWeight.bold)),
Spacer(),
// Status badge
Container(...status badge...),
],
),
Text(project.description,
style: TextStyle(color: Colors.grey)),
],
),
),
],
),
);
}).toList();
}
@override
Widget build(BuildContext context) {
// ... existing build method ...
}
}
接下来,让我们添加提醒和笔记组件!
// ... rest of the sections
// Reminders section
if (recap.reminders.isNotEmpty) ...[
const SizedBox(height: 16),
Text("Reminders", style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
...recap.reminders.map((reminder) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
color: Colors.amber.shade50,
child: ListTile(
leading: Icon(Icons.alarm, color: Colors.amber.shade700),
title: Text(reminder),
),
);
}),
],
没有嵌套卡片,也没有复杂的结构。只有简洁、易读且带有图标的项目符号!
使用 Initicon 的人物头像
这是我最喜欢的细节之一——根据姓名生成头像:
// Add this helper method after _buildProjectWidgets() inside DailyRecapCard class
List<Widget> _buildPeopleWidgets(List<String> people) {
return people.map((person) {
return Tooltip(
message: person,
child: Initicon(
text: person,
size: 40,
backgroundColor: Colors.primaries[
person.hashCode % Colors.primaries.length
],
),
);
}).toList();
}
每个人都会根据其姓名的哈希值获得一种独特的颜色。效果很棒,而且完全不需要真实图片!
项目状态指示器
项目通过图标和彩色徽章展示其状态:
// Add this helper method before _buildProjectWidgets() inside DailyRecapCard class
Widget _buildProjectWidget(ProjectData project) {
Color statusColor;
String statusText;
IconData statusIcon;
switch (project.status) {
case ProjectStatus.completed:
statusColor = Colors.green;
statusText = "Completed";
statusIcon = Icons.check_circle;
break;
case ProjectStatus.inProgress:
statusColor = Colors.orange;
statusText = "In Progress";
statusIcon = Icons.sync;
break;
case ProjectStatus.notStarted:
statusColor = Colors.red;
statusText = "Not Started";
statusIcon = Icons.radio_button_unchecked;
break;
}
return Card(
child: ListTile(
leading: Icon(statusIcon, color: statusColor),
title: Text(project.name,
style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: Text(project.description),
trailing: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: statusColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: statusColor),
),
child: Text(
statusText,
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
),
),
);
}
笔记部分——新增内容
让我们添加笔记和提醒部分,用于展示当天的重要收获:
// Notes section - add after reminders section
if (recap.notes.isNotEmpty) ...[
const SizedBox(height: 16),
Text("Notes", style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
...recap.notes.map((note) {
return Card(
margin: const EdgeInsets.only(bottom: 8),
color: Colors.purple.shade50,
child: ListTile(
leading: Icon(Icons.lightbulb, color: Colors.purple.shade700),
title: Text(note),
),
);
}),
],
处理加载状态
良好的用户体验意味着要让用户知道当前正在发生什么。我们使用 LoadingState 枚举来渲染不同的界面:
// Add this build() method after _loadMore() inside _MyHomePageState class
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.title)),
body: _loadingState == LoadingState.loading
? _buildLoadingScreen()
: _loadingState == LoadingState.healthy
? _buildRecapsList()
: _buildErrorScreen(),
);
}
现在,让我们构建加载界面!
// Add this method after _buildRecapsList() inside _MyHomePageState class
Widget _buildLoadingScreen() {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 20),
Text('Connecting to Pieces OS...'),
SizedBox(height: 10),
Text('This may take a few seconds'),
],
),
);
}
不可避免地,我们会遇到一些错误。让我们为此构建一个界面!
// Add this method after _buildLoadingScreen() inside _MyHomePageState class
Widget _buildErrorScreen() {
String message;
IconData icon;
switch (_loadingState) {
case LoadingState.piecesOsNotRunning:
message = 'Pieces OS is not running. Please start Pieces OS and try again.';
icon = Icons.cloud_off;
break;
case LoadingState.geminiApiKeyMissing:
message = 'Gemini API key is missing. Please set the API key and restart the app.';
icon = Icons.vpn_key_off;
break;
case LoadingState.somethingWentWrong:
default:
message = 'Something went wrong. Please try again later.';
icon = Icons.error_outline;
break;
}
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 64, color: Colors.red),
const SizedBox(height: 20),
Text(
message,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
],
),
);
}
现在,用户将获得具体且可操作的错误消息,而不再只是笼统的失败提示!
单独加载每张卡片
接下来,让我们让每张卡片逐一加载:
// 在 _MyHomePageState 类中的 _buildRecapsList() 方法之后添加此方法
Widget _buildLoadingCard(DateTime date) {
return Card(
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
Text(DateFormat('EEEE, MMMM d').format(date)),
const SizedBox(height: 10),
const CircularProgressIndicator(),
const SizedBox(height: 8),
const Text('Generating recap with AI...'),
],
),
),
);
}
这样可以让用户了解最新进展,他们会清楚地知道正在发生什么!
macOS 应用默认是"沙箱化"的 - 可以把它想象为一道安全防护,会阻止你的应用访问互联网或计算机上的其他应用。这也包括连接到 localhost(你自己的计算机)。
当你尝试在 localhost:39300 连接到 Pieces OS 时,macOS 会阻止它,因为你的应用没有权限进行网络连接。
在项目中找到这两个文件:
macos/Runner/DebugProfile.entitlements
macos/Runner/Release.entitlements
打开每个文件,在 <dict> 部分添加以下两行:
<key>com.apple.security.network.client</key>
<true/>
就这样!现在你的应用可以连接到 Pieces OS 和其他网络服务了。
首先设置你的工作空间以适配你的设备,然后你可以运行:
flutter run -d macos --dart-define=GEMINI_API_KEY=your-key-here
Connecting to Pieces OS...
This may take a few seconds
卡片水平流动,需要时自动换到下一行
每张卡片宽 400px,响应式适配屏幕大小
每张卡片展示摘要、项目、人员、提醒、笔记
底部有"加载更多天"按钮
显示加载微调器
用 Gemini 生成他们的回顾
将它们添加到环绕布局中
如果你发现图标(如 Icons.summarize、Icons.alarm、Icons.lightbulb 等)在你的 Flutter 应用中显示不正确,通常意味着提供这些图标的字体(Material Icons)没有被正确打包或引用,或者某个依赖项缺少隐式需求。
确保在 flutter: 部分下存在并且未被注释的 uses-material-design: true 行。这一行明确告诉 Flutter 在应用包中包含 Material Icons 字体。
# 在 pubspec.yaml 中
flutter:
uses-material-design: true
确认或添加该行后,在终端中运行 flutter pub get 以获取依赖项并更新项目配置。
重启应用:如果应用已经在运行,你必须停止并重启它(不仅仅是热重载或热重启),以加载新的资源配置。
✅ 与 Pieces OS 实时同步
✅ 使用 AI 生成日常见解
✅ 显示漂亮、响应式的卡片
✅ 处理加载和错误状态
✅ 支持"加载更多"分页功能
✅ 显示项目、人员、提醒和笔记
但仍有改进的空间……
现在有一些局限性:
每次重启都会重新生成所有回顾 - 速度慢且浪费 API 调用
无法刷新单个回顾 - 必须接受现有的内容
错误处理可以更好 - 目前只有控制台日志
没有测试 - 难以自信地维护
没有 CI/CD - 每次都需要手动测试
在第 4 部分,我们将解决所有这些问题!我们将添加:
💾 使用 Hive 进行持久缓存 - 重启时即时加载
🔄 重新生成按钮 - 按需刷新任何回顾
❌ 更好的错误处理 - 带重试的漂亮错误卡片
✅ 自动化测试 - 在 bug 发布前捕获它们
🚀 CI/CD 管道 - 自动化测试和构建
在第 4 部分见!🚀
参考 GitHub 查看完整项目。