后端: - User表加Role字段 + DoctorProfile表 + 废弃旧Doctor数据 - JWT加Role claim + 注册/登录拆分(/api/auth/register + /api/auth/login) - 医生端点全量加JWT鉴权+Role校验(15个端点) - 日历端点改造(用药/运动/随访按日期+星期匹配) - 饮食记录PUT端点 + 健康记录DELETE端点 - 用户Profile端点返回Role 前端: - 登录页重做(角色选择卡片 用户紫/医生蓝 + 审核码6666 + 注册返登录) - App路由分流(userRole→患者端/医生端) - 医生端: 工作台Dashboard + 患者管理(搜索分页) + 问诊/报告/随访CRUD - 报告审核: AI预分析+指标表格+严重程度4级+模板多选+评语 - 随访编辑: 患者选择器+日期时间+打通患者端 - 健康日历: 可点击日历+当日安排列表+用药/运动/随访颜色标记 - 饮食记录: 热量趋势7/30天+日历(一周)+当日胶囊详情+侧滑编辑删除 - 健康档案: 日期选择器+手术可添加多条+白底渐变保存按钮 - 配色系统: 清理25个未使用色+新增doctorBlue/drawerGradient/pageGrey - 患者端侧边栏: 新渐变+白底按钮+紫色汉堡图标 - AI对话: 用户气泡白底黑字+药管家改蓝色 - App启动闪屏: 防登录页闪烁 文档: color_design_system.md(20页面完整配色设计+图标底色对照表)
307 lines
12 KiB
Dart
307 lines
12 KiB
Dart
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.primary),
|
||
),
|
||
)),
|
||
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(
|
||
gradient: const LinearGradient(
|
||
colors: [AppColors.blueMeasure, AppColors.primary],
|
||
begin: Alignment.topLeft,
|
||
end: Alignment.bottomRight,
|
||
),
|
||
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');
|
||
}
|
||
}
|
||
}
|