Files
AI-Health/health_app/lib/pages/home/home_page.dart
MingNian f01fc9268d feat: UI清爽紫蓝风全面改造 + 对话卡片重构 + 配色体系升级
- 新增 AppColors 统一配色方案(清爽紫蓝,参考蚂蚁阿福风格)
- 新增 common_widgets(GradientBorderButton / CardActionButton / IconBox)
- AgentWelcomeCard 美化:渐变 header + AI标签 + 核心功能区 + 快捷操作区
- DataConfirmCard 统一:三合一(健康/药品/运动),可编辑字段列表
- 删除死代码:medicationConfirm/dietAnalysis/reportAnalysis/quickOptions 卡片
- 删除死路由:doctors/profileEdit/editProfile
- AI 对话去除 Markdown 符号(_stripMd),统一行间距 1.5
- 逐字淡入动画修复:key 改用 stableId 避免重复触发
- 健康概览时间修复:前端发送 UTC 避免时区歧义
- PG 数据目录迁入项目 backend/pgdata/,加入 .gitignore
- 新增欧姆龙血压计实施方案文档
2026-06-09 17:04:11 +08:00

312 lines
13 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_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();
@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;
final selectedAgent = ref.watch(selectedAgentProvider);
final theme = ShadTheme.of(context);
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(); }
});
return Scaffold(
drawer: const HealthDrawer(),
backgroundColor: theme.colorScheme.background,
body: SafeArea(
child: Column(children: [
_buildHeader(user, theme),
Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)),
_buildBottomBar(context, selectedAgent, theme),
]),
),
);
}
// ═══════ 顶部栏 ═══════
Widget _buildHeader(dynamic user, ShadThemeData theme) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: AppTheme.sLg, vertical: 10),
decoration: BoxDecoration(
color: theme.colorScheme.card,
border: Border(bottom: BorderSide(color: theme.colorScheme.border, width: 0.5)),
),
child: Row(children: [
Builder(builder: (ctx) => GestureDetector(
onTap: () => Scaffold.of(ctx).openDrawer(),
child: CircleAvatar(
radius: 20,
backgroundColor: AppTheme.primaryLight,
backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null,
child: user?.avatarUrl == null ? const Icon(Icons.person, size: 28, color: AppTheme.primary) : null,
),
)),
const SizedBox(width: 10),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(mainAxisSize: MainAxisSize.min, children: [
Icon(LucideIcons.bot, size: 19, color: AppTheme.primary),
const SizedBox(width: 4),
Text('AI 健康管家', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: theme.colorScheme.mutedForeground)),
]),
const SizedBox(height: 2),
Text('${_getGreeting()}${(user?.name != null && user!.name!.isNotEmpty) ? user.name : '用户'}',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: theme.colorScheme.foreground)),
])),
Icon(LucideIcons.bell, size: 25, color: theme.colorScheme.mutedForeground),
]),
);
}
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(ActiveAgent? selected, ShadThemeData theme) {
return Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: AppTheme.sMd),
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: _agentDefs.length,
separatorBuilder: (_, i) => const SizedBox(width: 6),
itemBuilder: (_, i) {
final (agent, label, icon) = _agentDefs[i];
return GestureDetector(
onTap: () {
// 用户标签 → 欢迎卡片,不发 AI 后端
ref.read(chatProvider.notifier).triggerAgent(agent, label);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: theme.colorScheme.card,
borderRadius: BorderRadius.circular(AppTheme.rPill),
border: Border.all(color: theme.colorScheme.border),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(icon, size: 17, color: theme.colorScheme.mutedForeground),
const SizedBox(width: 4),
Text(label, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: theme.colorScheme.mutedForeground)),
]),
),
);
},
),
);
}
// ═══════ 底部区域 ═══════
Widget _buildBottomBar(BuildContext context, ActiveAgent? selectedAgent, ShadThemeData theme) {
return Column(mainAxisSize: MainAxisSize.min, children: [
Container(
padding: const EdgeInsets.only(top: AppTheme.sSm, bottom: 6),
child: _buildAgentBar(selectedAgent, theme),
),
if (_pickedImagePath != null) _buildImagePreview(theme),
_buildCompactInputBar(theme),
]);
}
Widget _buildImagePreview(ShadThemeData theme) {
return Container(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
decoration: BoxDecoration(
color: theme.colorScheme.card,
border: Border(top: BorderSide(color: theme.colorScheme.border)),
),
child: Row(children: [
Stack(children: [
ClipRRect(
borderRadius: BorderRadius.circular(AppTheme.rXs),
child: Image.file(File(_pickedImagePath!), width: 60, height: 60, fit: BoxFit.cover),
),
Positioned(top: -4, right: -4, child: GestureDetector(
onTap: () => setState(() => _pickedImagePath = null),
child: Container(
width: 20, height: 20,
decoration: const BoxDecoration(color: AppTheme.text, shape: BoxShape.circle),
child: const Icon(Icons.close, size: 17, color: Colors.white),
),
)),
]),
const Spacer(),
Text('点击发送上传图片', style: TextStyle(fontSize: 15, color: theme.colorScheme.mutedForeground)),
]),
);
}
Widget _buildCompactInputBar(ShadThemeData theme) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: AppTheme.sSm, vertical: AppTheme.sSm),
decoration: BoxDecoration(
color: theme.colorScheme.card,
border: Border(top: BorderSide(color: theme.colorScheme.border)),
),
child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [
ShadButton.ghost(
size: ShadButtonSize.sm,
leading: Icon(LucideIcons.paperclip, size: 25, color: theme.colorScheme.mutedForeground),
onPressed: () => _showAttachmentPicker(context, theme),
),
Expanded(
child: TextField(
controller: _textCtrl,
focusNode: _focusNode,
style: TextStyle(fontSize: 18, color: theme.colorScheme.foreground),
maxLines: null,
textInputAction: TextInputAction.newline,
decoration: InputDecoration(
hintText: '输入你想说的...',
hintStyle: TextStyle(fontSize: 18, color: theme.colorScheme.mutedForeground),
filled: true,
fillColor: theme.colorScheme.muted,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.rXl),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.rXl),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.rXl),
borderSide: BorderSide.none,
),
),
onSubmitted: (_) => _sendMessage(),
),
),
const SizedBox(width: 4),
ShadButton(
size: ShadButtonSize.sm,
leading: Icon(LucideIcons.send, size: 21, color: theme.colorScheme.primaryForeground),
onPressed: _sendMessage,
),
]),
);
}
void _showAttachmentPicker(BuildContext context, ShadThemeData theme) {
showModalBottomSheet(
context: context,
backgroundColor: theme.colorScheme.card,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(AppTheme.rXl)),
),
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: AppTheme.sMd),
child: Wrap(children: [
ListTile(
leading: Icon(LucideIcons.camera, color: theme.colorScheme.foreground),
title: Text('拍照', style: TextStyle(color: theme.colorScheme.foreground)),
onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.camera); },
),
ListTile(
leading: Icon(LucideIcons.image, color: theme.colorScheme.foreground),
title: Text('从相册选', style: TextStyle(color: theme.colorScheme.foreground)),
onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.gallery); },
),
ListTile(
leading: Icon(LucideIcons.file, color: theme.colorScheme.foreground),
title: Text('传文件', style: TextStyle(color: theme.colorScheme.foreground)),
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 picker = ImagePicker();
final picked = await picker.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 picker = ImagePicker();
final picked = await picker.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');
}
}
}