Files
AI-Health/health_app/lib/pages/home/home_page.dart
MingNian f6dd14be3b feat: 全面UI改造 — 报告/饮食/运动/用药/侧边栏/对话流/胶囊
- 报告模块:重写AI解读页,统一白色卡片+三栏布局(指标→解读→医生审核);加删除功能+后端删接口;VLM自动识别报告类型生成中文标题;去五颜六色
- 饮食分析:全面重设计,暖橙主题配色,图片自适应,餐次emoji选择器,去紫色
- 运动确认卡片:修复duration_minutes JSON类型转换,支持中文数字识别(三十分钟/半小时→30);主区域只显示运动名,字段行改为横向排列
- 流式输出:简化为最基础逐字追加,去buffer+Timer+淡入动画
- 欢迎卡片:每智能体独立渐变色(青/橙/蓝/紫/绿/粉),加卡片入场滑入+淡入动画(600ms)
- 确认卡片:头部去紫色背景改浅灰白,字段行去图标改纯信息横排,编辑框去双重边框
- 用药管理:胶囊改TabBar,添加按钮改右下FAB;打卡按钮改对号圆圈形式;药丸图标白底
- 侧边栏:加VIP服务/保险栏+图标,标题字体放大;服务包去查看更多+去VIP金色标签
- 胶囊:首页智能体胶囊白底深色字; side胶囊加阴影
- 对话流:reverse=false,今日健康出现在顶部; 对话页input bar匹配主页面样式
- 其他:个人资料去紫色+去多余入口;设置页白底图标;蓝牙页加返回按钮;报告管理改名
- 后端:加DELETE /api/reports/{id}; 修复manage_exercise数据类型转换;AI提示优化中文数字
2026-06-10 23:46:45 +08:00

303 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:file_picker/file_picker.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../core/app_colors.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
import '../../providers/chat_provider.dart';
import '../../providers/data_providers.dart';
import '../../widgets/health_drawer.dart';
import '../diet/diet_capture_page.dart';
import 'widgets/chat_messages_view.dart';
class HomePage extends ConsumerStatefulWidget {
const HomePage({super.key});
@override ConsumerState<HomePage> createState() => _HomePageState();
}
class _HomePageState extends ConsumerState<HomePage> {
final _textCtrl = TextEditingController();
final _scrollCtrl = ScrollController();
String? _pickedImagePath;
final Set<ActiveAgent> _welcomedAgents = {};
final _focusNode = FocusNode();
int _lastMsgCount = 0;
@override void initState() { super.initState(); }
@override void dispose() { _textCtrl.dispose(); _scrollCtrl.dispose(); _focusNode.dispose(); super.dispose(); }
void _sendMessage() {
final text = _textCtrl.text.trim();
final imagePath = _pickedImagePath;
if (text.isEmpty && imagePath == null) return;
_textCtrl.clear();
_focusNode.unfocus();
setState(() => _pickedImagePath = null);
if (imagePath != null) {
ref.read(chatProvider.notifier).sendImage(imagePath, text);
} else {
ref.read(chatProvider.notifier).sendMessage(text);
}
}
@override Widget build(BuildContext context) {
final chatState = ref.watch(chatProvider);
final auth = ref.watch(authProvider);
final user = auth.user;
ref.listen(cameraActionProvider, (prev, next) {
if (next == 'camera') { _pickImage(ImageSource.camera); ref.read(cameraActionProvider.notifier).clear(); }
else if (next == 'gallery') { _pickImage(ImageSource.gallery); ref.read(cameraActionProvider.notifier).clear(); }
});
ref.listen(dietActionProvider, (prev, next) {
if (next == 'pickFoodCamera') { _pickFoodImage(ImageSource.camera); ref.read(dietActionProvider.notifier).clear(); }
else if (next == 'pickFoodGallery') { _pickFoodImage(ImageSource.gallery); ref.read(dietActionProvider.notifier).clear(); }
});
final currentCount = chatState.messages.length;
if (currentCount > _lastMsgCount) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollCtrl.hasClients) {
_scrollCtrl.jumpTo(_scrollCtrl.position.maxScrollExtent);
}
});
}
_lastMsgCount = currentCount;
return Scaffold(
drawer: const HealthDrawer(),
body: Container(
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
child: SafeArea(
child: Column(children: [
_buildHeader(user),
Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)),
_buildBottomBar(context),
]),
),
),
);
}
// ═══════ 顶部栏 — 透明背景,与页面渐变无缝衔接 ═══════
Widget _buildHeader(dynamic user) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(children: [
Builder(builder: (ctx) => GestureDetector(
onTap: () => Scaffold.of(ctx).openDrawer(),
child: const Padding(
padding: EdgeInsets.all(8),
child: Icon(LucideIcons.menu, size: 22, color: AppColors.textPrimary),
),
)),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('AI 健康管家', style: TextStyle(fontSize: 13, color: AppColors.textHint)),
Text('${_getGreeting()}${(user?.name != null && user!.name!.isNotEmpty) ? user.name : '用户'}',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
])),
GestureDetector(
onTap: () => pushRoute(ref, 'notificationPrefs'),
child: const Icon(LucideIcons.bell, size: 22, color: AppColors.textSecondary),
),
]),
);
}
String _getGreeting() {
final hour = DateTime.now().hour;
if (hour < 9) return '早上好';
if (hour < 12) return '上午好';
if (hour < 18) return '下午好';
return '晚上好';
}
// ═══════ 智能体胶囊 ═══════
static final _agentDefs = [
(ActiveAgent.consultation, '问诊', LucideIcons.messageCircle),
(ActiveAgent.health, '记数据', LucideIcons.heart),
(ActiveAgent.diet, '拍饮食', LucideIcons.utensils),
(ActiveAgent.medication, '药管家', LucideIcons.pill),
(ActiveAgent.report, '报告分析', LucideIcons.fileText),
(ActiveAgent.exercise, '运动', LucideIcons.trendingUp),
];
Widget _buildAgentBar() {
return SizedBox(
height: 32,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _agentDefs.length,
separatorBuilder: (_, i) => const SizedBox(width: 8),
itemBuilder: (_, i) {
final (agent, label, icon) = _agentDefs[i];
return GestureDetector(
onTap: () => ref.read(chatProvider.notifier).triggerAgent(agent, label),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
boxShadow: AppColors.cardShadowLight,
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(icon, size: 17, color: AppColors.textPrimary),
const SizedBox(width: 5),
Text(label, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
]),
),
);
},
),
);
}
// ═══════ 底部输入区 ═══════
Widget _buildBottomBar(BuildContext context) {
return Column(mainAxisSize: MainAxisSize.min, children: [
Container(padding: const EdgeInsets.only(top: 4, bottom: 4), child: _buildAgentBar()),
if (_pickedImagePath != null) _buildImagePreview(),
_buildInputBar(),
]);
}
Widget _buildImagePreview() {
return Container(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
color: AppColors.cardInner,
child: Row(children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(File(_pickedImagePath!), width: 48, height: 48, fit: BoxFit.cover),
),
const SizedBox(width: 10),
const Text('点击发送图片', style: TextStyle(fontSize: 14, color: AppColors.textSecondary)),
const Spacer(),
GestureDetector(
onTap: () => setState(() => _pickedImagePath = null),
child: const Icon(Icons.close, size: 20, color: AppColors.textHint),
),
]),
);
}
Widget _buildInputBar() {
return Container(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 10),
child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [
GestureDetector(
onTap: () => _showAttachmentPicker(context),
child: const Padding(
padding: EdgeInsets.only(bottom: 10),
child: Icon(LucideIcons.plus, size: 24, color: AppColors.textSecondary),
),
),
const SizedBox(width: 8),
Expanded(
child: Container(
constraints: const BoxConstraints(maxHeight: 120),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
),
child: TextField(
controller: _textCtrl,
focusNode: _focusNode,
style: const TextStyle(fontSize: 15, color: AppColors.textPrimary),
maxLines: null,
textInputAction: TextInputAction.newline,
decoration: const InputDecoration(
hintText: '输入你想说的...',
hintStyle: TextStyle(fontSize: 15, color: AppColors.textHint),
filled: true, fillColor: Colors.white,
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 10),
border: OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none),
),
onSubmitted: (_) => _sendMessage(),
),
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: _sendMessage,
child: Container(
width: 40, height: 40,
margin: EdgeInsets.only(bottom: _pickedImagePath != null ? 0 : 4),
decoration: BoxDecoration(
color: AppColors.blueMeasure,
borderRadius: BorderRadius.circular(20),
),
child: const Icon(LucideIcons.send, size: 20, color: Colors.white),
),
),
]),
);
}
void _showAttachmentPicker(BuildContext context) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Wrap(children: [
ListTile(
leading: const Icon(Icons.camera_alt_outlined, color: AppColors.primary),
title: const Text('拍照'),
onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.camera); },
),
ListTile(
leading: const Icon(Icons.photo_library_outlined, color: AppColors.primary),
title: const Text('从相册选'),
onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.gallery); },
),
ListTile(
leading: const Icon(Icons.file_open_outlined, color: AppColors.primary),
title: const Text('传文件'),
onTap: () async {
Navigator.pop(ctx);
final result = await FilePicker.platform.pickFiles();
if (result != null && result.files.isNotEmpty) {
_textCtrl.text = '[文件已选择] ${result.files.first.name}';
if (mounted) setState(() {});
}
},
),
]),
),
),
);
}
Future<void> _pickImage(ImageSource source) async {
final picked = await ImagePicker().pickImage(source: source, imageQuality: 85);
if (picked != null) {
final token = await ref.read(apiClientProvider).accessToken;
if (token == null) return;
setState(() => _pickedImagePath = picked.path);
}
}
Future<void> _pickFoodImage(ImageSource source) async {
final picked = await ImagePicker().pickImage(source: source, imageQuality: 80, maxWidth: 1024, maxHeight: 1024);
if (picked != null && mounted) {
ref.read(dietProvider.notifier).reset();
ref.read(dietProvider.notifier).setImage(picked.path);
ref.read(dietProvider.notifier).analyzeImage();
pushRoute(ref, 'dietCapture');
}
}
}