fix: 图片发送/医生加载/运动超时/用药黑屏/服药打卡

- sendImage: 本地预览→上传→远程URL替换
- doctorListProvider: 8s超时+mock医生fallback
- currentExercisePlanProvider: 8s超时→显示空状态
- 用药编辑: try-catch防黑屏+刷新列表
- 服药打卡: 接入后端confirm()接口
This commit is contained in:
MingNian
2026-06-03 20:03:17 +08:00
parent 95bf5732f6
commit e3b9716f7c
11 changed files with 916 additions and 393 deletions

BIN
health_app/flutter_01.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 557 KiB

BIN
health_app/flutter_02.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

View File

@@ -1,3 +1,4 @@
import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'local_database.dart'; import 'local_database.dart';
@@ -54,6 +55,19 @@ class ApiClient {
Future<Response> delete(String path) async { Future<Response> delete(String path) async {
return _dio.delete(path); return _dio.delete(path);
} }
/// 上传文件multipart返回文件 URL
Future<String?> uploadFile(String path, File file, {String fieldName = 'file'}) async {
final formData = FormData.fromMap({
fieldName: await MultipartFile.fromFile(file.path, filename: file.path.split('/').last),
});
final res = await _dio.post(path, data: formData);
final data = res.data;
if (data is Map) {
return data['url']?.toString() ?? data['data']?['url']?.toString();
}
return null;
}
} }
/// 认证拦截器:自动注入 token + 401 刷新 /// 认证拦截器:自动注入 token + 401 刷新

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'dart:io';
import '../../core/navigation_provider.dart'; import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../providers/chat_provider.dart'; import '../../providers/chat_provider.dart';
@@ -18,6 +19,7 @@ class _HomePageState extends ConsumerState<HomePage> {
final _textCtrl = TextEditingController(); final _textCtrl = TextEditingController();
final _scrollCtrl = ScrollController(); final _scrollCtrl = ScrollController();
bool _taskCardsExpanded = true; bool _taskCardsExpanded = true;
String? _pickedImagePath;
double _lastScrollOffset = 0; double _lastScrollOffset = 0;
DateTime? _lastCollapseTime; DateTime? _lastCollapseTime;
bool _exerciseDone = false; bool _exerciseDone = false;
@@ -55,10 +57,15 @@ class _HomePageState extends ConsumerState<HomePage> {
void _sendMessage() { void _sendMessage() {
final text = _textCtrl.text.trim(); final text = _textCtrl.text.trim();
if (text.isEmpty) return; final imagePath = _pickedImagePath;
if (text.isEmpty && imagePath == null) return;
_textCtrl.clear(); _textCtrl.clear();
setState(() => _taskCardsExpanded = false); setState(() { _taskCardsExpanded = false; _pickedImagePath = null; });
ref.read(chatProvider.notifier).sendMessage(text); if (imagePath != null) {
ref.read(chatProvider.notifier).sendImage(imagePath, text);
} else {
ref.read(chatProvider.notifier).sendMessage(text);
}
} }
@override Widget build(BuildContext context) { @override Widget build(BuildContext context) {
@@ -67,6 +74,16 @@ class _HomePageState extends ConsumerState<HomePage> {
final user = auth.user; final user = auth.user;
final selectedAgent = ref.watch(selectedAgentProvider); final selectedAgent = ref.watch(selectedAgentProvider);
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();
}
});
return Scaffold( return Scaffold(
drawer: const HealthDrawer(), drawer: const HealthDrawer(),
backgroundColor: const Color(0xFFF8F7FF), backgroundColor: const Color(0xFFF8F7FF),
@@ -130,19 +147,19 @@ class _HomePageState extends ConsumerState<HomePage> {
); );
} }
// 折叠状态:只显示一行可点击的标题 // 折叠状态:与展开态容器完全相同,只保留标题
return GestureDetector( return GestureDetector(
onTap: () => setState(() => _taskCardsExpanded = true), onTap: () => setState(() => _taskCardsExpanded = true),
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
child: Center( child: Container(
child: Padding( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.all(14),
child: Row(mainAxisSize: MainAxisSize.min, children: [ decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 6, offset: const Offset(0, 1))]),
const Text('今日任务', style: TextStyle(fontSize: 13, color: Color(0xFF635BFF))), child: Row(children: [
const SizedBox(width: 4), const Text('今日任务', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
const Text('', style: TextStyle(fontSize: 12, color: Color(0xFF635BFF))), const Spacer(),
]), const Text('展开 ▾', style: TextStyle(fontSize: 12, color: Color(0xFF635BFF))),
), ]),
), ),
); );
} }
@@ -301,27 +318,11 @@ class _HomePageState extends ConsumerState<HomePage> {
dateLabel = '$diff天后'; dateLabel = '$diff天后';
} }
return Padding( return _taskRow(
padding: const EdgeInsets.only(bottom: 10), icon: Icons.event_available,
child: GestureDetector( label: '📋 $dateLabel ${followUp['hospital']} ${followUp['department']} ${followUp['type']}',
behavior: HitTestBehavior.opaque, status: 'pending',
onTap: () => pushRoute(ref, 'followups'), onTap: () => pushRoute(ref, 'followups'),
child: Row(children: [
Container(
width: 30, height: 30,
decoration: BoxDecoration(
color: const Color(0xFFF5F3FF),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.event_available, size: 15, color: Color(0xFF635BFF)),
),
const SizedBox(width: 10),
Expanded(child: Text(
'📋 $dateLabel ${followUp['hospital']} ${followUp['department']} ${followUp['type']}',
style: const TextStyle(fontSize: 13, color: Color(0xFF333333)),
)),
]),
),
); );
} }
@@ -340,7 +341,7 @@ class _HomePageState extends ConsumerState<HomePage> {
color: isOverdue ? const Color(0xFFFFEBEE) : Colors.transparent, color: isOverdue ? const Color(0xFFFFEBEE) : Colors.transparent,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Row(children: [ child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [
Container(width: 30, height: 30, decoration: BoxDecoration(color: const Color(0xFFF5F3FF), borderRadius: BorderRadius.circular(8)), child: Icon(icon, size: 15, color: const Color(0xFF635BFF))), Container(width: 30, height: 30, decoration: BoxDecoration(color: const Color(0xFFF5F3FF), borderRadius: BorderRadius.circular(8)), child: Icon(icon, size: 15, color: const Color(0xFF635BFF))),
const SizedBox(width: 10), Expanded(child: Text(label, style: const TextStyle(fontSize: 13, color: Color(0xFF333333)))), const SizedBox(width: 10), Expanded(child: Text(label, style: const TextStyle(fontSize: 13, color: Color(0xFF333333)))),
Icon(icons[effectiveStatus], size: 18, color: colors[effectiveStatus] ?? Colors.grey), Icon(icons[effectiveStatus], size: 18, color: colors[effectiveStatus] ?? Colors.grey),
@@ -381,11 +382,14 @@ class _HomePageState extends ConsumerState<HomePage> {
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
final notifier = ref.read(selectedAgentProvider.notifier); final notifier = ref.read(selectedAgentProvider.notifier);
final newAgent = isActive ? null : agent; if (isActive) {
notifier.select(newAgent); notifier.select(null);
if (newAgent != null) { } else {
ref.read(chatProvider.notifier).setAgent(newAgent); notifier.select(agent);
ref.read(chatProvider.notifier).insertAgentWelcome(newAgent); ref.read(chatProvider.notifier).setAgent(agent);
if (_welcomedAgents.add(agent)) {
ref.read(chatProvider.notifier).insertAgentWelcome(agent);
}
} }
}, },
child: Container( child: Container(
@@ -414,83 +418,60 @@ class _HomePageState extends ConsumerState<HomePage> {
// 智能体胶囊栏常驻高度36 // 智能体胶囊栏常驻高度36
_buildAgentBar(selectedAgent), _buildAgentBar(selectedAgent),
// 输入框(紧凑 // 图片预览(有选中图片时显示
if (_pickedImagePath != null) _buildImagePreview(),
// 输入框
_buildCompactInputBar(context), _buildCompactInputBar(context),
]); ]);
} }
Widget _buildCompactAgentPanel(ActiveAgent agent) { Widget _buildImagePreview() {
final titles = {ActiveAgent.consultation: 'AI 问诊', ActiveAgent.health: '记数据', ActiveAgent.diet: '拍饮食', ActiveAgent.medication: '药管家', ActiveAgent.report: '看报告', ActiveAgent.exercise: '运动计划'};
final tips = {ActiveAgent.consultation: '或直接对我说你的症状', ActiveAgent.health: '或直接对我说:"血压 135/85"', ActiveAgent.diet: '或直接对我说:"中午吃了牛肉面"', ActiveAgent.medication: '或直接对我说:"医生让我吃阿托伐他汀 20mg"', ActiveAgent.report: '或直接上传报告图片', ActiveAgent.exercise: '或直接对我说:"每周一三五散步 30 分钟"'};
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
decoration: BoxDecoration(color: Colors.white, border: Border(top: BorderSide(color: const Color(0xFFEEEEEE)))), decoration: const BoxDecoration(color: Colors.white, border: Border(top: BorderSide(color: Color(0xFFEEEEEE)))),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Row(children: [
Text(titles[agent] ?? '', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
const SizedBox(width: 6),
Expanded(child: Text(tips[agent] ?? '', style: TextStyle(fontSize: 10, color: Colors.grey[500]))),
GestureDetector(onTap: () => ref.read(selectedAgentProvider.notifier).select(null), child: Icon(Icons.close, size: 16, color: Colors.grey[400])),
]),
const SizedBox(height: 4),
SingleChildScrollView(scrollDirection: Axis.horizontal, child: Row(children: _getAgentButtons(agent))),
]));
}
Widget _buildCompactInputBar(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: Colors.white, border: Border(top: BorderSide(color: const Color(0xFFEEEEEE)))),
child: Row(children: [ child: Row(children: [
IconButton(icon: const Icon(Icons.attach_file, size: 20, color: Color(0xFF666666)), onPressed: () => _showAttachmentPicker(context), padding: const EdgeInsets.all(4)), Stack(children: [
Expanded(child: TextField(controller: _textCtrl, decoration: InputDecoration(hintText: '输入你想说的...', contentPadding: const EdgeInsets.symmetric(horizontal: 8), border: InputBorder.none, isDense: true, hintStyle: const TextStyle(fontSize: 13)), onSubmitted: (_) => _sendMessage())), ClipRRect(
IconButton(icon: const Icon(Icons.send, size: 20, color: Color(0xFF635BFF)), onPressed: _sendMessage, padding: const EdgeInsets.all(4)), borderRadius: BorderRadius.circular(8),
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: Color(0xFF333333), shape: BoxShape.circle), child: const Icon(Icons.close, size: 14, color: Colors.white)),
)),
]),
const Spacer(),
Text('点击发送上传图片', style: TextStyle(fontSize: 12, color: Colors.grey[500])),
]), ]),
); );
} }
List<Widget> _getAgentButtons(ActiveAgent agent) { Widget _buildCompactInputBar(BuildContext context) {
switch (agent) { return Container(
case ActiveAgent.health: return [_agentBtn('录入血压', Icons.favorite), _agentBtn('录入血糖', Icons.bloodtype), _agentBtn('录入心率', Icons.monitor_heart), _agentBtn('录入血氧', Icons.air), _agentBtn('录入体重', Icons.monitor_weight)]; padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
case ActiveAgent.diet: return [_agentBtn('拍照识别', Icons.camera_alt), _agentBtn('上传照片', Icons.photo_library)]; decoration: BoxDecoration(color: Colors.white, border: Border(top: BorderSide(color: const Color(0xFFEEEEEE)))),
case ActiveAgent.medication: return [_agentBtn('用药管理', Icons.medication), _agentBtn('用药提醒', Icons.alarm)]; child: Row(children: [
case ActiveAgent.consultation: return [_agentBtn('找医生', Icons.person_search)]; IconButton(icon: const Icon(Icons.attach_file, size: 24, color: Color(0xFF666666)), onPressed: () => _showAttachmentPicker(context)),
case ActiveAgent.exercise: return [_agentBtn('本周计划', Icons.calendar_view_week), _agentBtn('新建计划', Icons.add_circle_outline)]; Expanded(child: TextField(
default: return []; controller: _textCtrl,
} style: const TextStyle(fontSize: 15),
} decoration: const InputDecoration(hintText: '输入你想说的...', hintStyle: TextStyle(fontSize: 15, color: Color(0xFFBBBBBB)), contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 10), border: InputBorder.none),
onSubmitted: (_) => _sendMessage(),
Widget _agentBtn(String label, IconData icon) { )),
return Padding( IconButton(icon: const Icon(Icons.send, size: 24, color: Color(0xFF635BFF)), onPressed: _sendMessage),
padding: const EdgeInsets.only(right: 8), ]),
child: ElevatedButton.icon(
onPressed: () => _onAgentAction(label),
icon: Icon(icon, size: 14),
label: Text(label, style: const TextStyle(fontSize: 12)),
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFFF5F3FF), foregroundColor: const Color(0xFF635BFF), elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12)),
),
); );
} }
void _onAgentAction(String label) {
switch (label) {
case '拍照识别': case '上传照片': pushRoute(ref, 'dietCapture');
case '录入血压': _textCtrl.text = '血压 ';
case '录入血糖': _textCtrl.text = '血糖 ';
case '录入心率': _textCtrl.text = '心率 ';
case '录入血氧': _textCtrl.text = '血氧 ';
case '录入体重': _textCtrl.text = '体重 ';
case '用药管理': pushRoute(ref, 'medications');
case '找医生': pushRoute(ref, 'doctors');
case '本周计划': case '新建计划': pushRoute(ref, 'exercisePlan');
}
}
Future<void> _pickImage(ImageSource source) async { Future<void> _pickImage(ImageSource source) async {
final picker = ImagePicker(); final picker = ImagePicker();
final picked = await picker.pickImage(source: source, imageQuality: 85); final picked = await picker.pickImage(source: source, imageQuality: 85);
if (picked != null) { final token = await ref.read(apiClientProvider).accessToken; if (token == null) return; _textCtrl.text = '[图片已上传]'; if (mounted) setState(() {}); } if (picked != null) {
final token = await ref.read(apiClientProvider).accessToken;
if (token == null) return;
setState(() => _pickedImagePath = picked.path);
}
} }
void _showAttachmentPicker(BuildContext context) { void _showAttachmentPicker(BuildContext context) {

View File

@@ -1,7 +1,10 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/navigation_provider.dart';
import '../../../providers/chat_provider.dart'; import '../../../providers/chat_provider.dart';
import '../../../providers/data_providers.dart';
/// 对话消息列表 /// 对话消息列表
class ChatMessagesView extends ConsumerWidget { class ChatMessagesView extends ConsumerWidget {
@@ -44,14 +47,14 @@ class ChatMessagesView extends ConsumerWidget {
itemCount: messages.length, itemCount: messages.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final msg = messages[messages.length - 1 - index]; final msg = messages[messages.length - 1 - index];
return _buildMessageContent(context, msg, chatState); return _buildMessageContent(context, ref, msg, chatState);
}, },
); );
} }
// ─── 消息分发 ───────────────────────────────────────────── // ─── 消息分发 ─────────────────────────────────────────────
Widget _buildMessageContent(BuildContext context, ChatMessage msg, ChatState chatState) { Widget _buildMessageContent(BuildContext context, WidgetRef ref, ChatMessage msg, ChatState chatState) {
final isUser = msg.isUser; final isUser = msg.isUser;
if (!isUser && chatState.isStreaming && msg.content.isEmpty) { if (!isUser && chatState.isStreaming && msg.content.isEmpty) {
@@ -60,7 +63,7 @@ class ChatMessagesView extends ConsumerWidget {
switch (msg.type) { switch (msg.type) {
case MessageType.agentWelcome: case MessageType.agentWelcome:
return _buildAgentWelcomeCard(context, msg, chatState.activeAgent); return _buildAgentWelcomeCard(context, ref, msg, chatState.activeAgent);
case MessageType.dataConfirm: case MessageType.dataConfirm:
return _buildDataConfirmCard(context, msg); return _buildDataConfirmCard(context, msg);
case MessageType.medicationConfirm: case MessageType.medicationConfirm:
@@ -80,7 +83,7 @@ class ChatMessagesView extends ConsumerWidget {
// 1. AgentWelcomeCard — 智能体欢迎卡片 // 1. AgentWelcomeCard — 智能体欢迎卡片
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
Widget _buildAgentWelcomeCard(BuildContext context, ChatMessage msg, ActiveAgent agent) { Widget _buildAgentWelcomeCard(BuildContext context, WidgetRef ref, ChatMessage msg, ActiveAgent agent) {
final info = _agentInfo(agent); final info = _agentInfo(agent);
final actions = agent.actions; final actions = agent.actions;
final screenWidth = MediaQuery.of(context).size.width; final screenWidth = MediaQuery.of(context).size.width;
@@ -164,7 +167,9 @@ class ChatMessagesView extends ConsumerWidget {
child: Wrap( child: Wrap(
spacing: 10, spacing: 10,
runSpacing: 10, runSpacing: 10,
children: actions.map((a) => _agentActionBtn(a, screenWidth)).toList(), children: agent == ActiveAgent.consultation
? _buildDoctorCards(screenWidth, ref)
: actions.map((a) => _agentActionBtn(a, screenWidth, context, ref)).toList(),
), ),
), ),
@@ -189,10 +194,19 @@ class ChatMessagesView extends ConsumerWidget {
); );
} }
Widget _agentActionBtn(_AgentAction a, double screenWidth) { Widget _agentActionBtn(_AgentAction a, double screenWidth, BuildContext context, WidgetRef ref) {
return InkWell( return InkWell(
onTap: () {}, onTap: () {
borderRadius: BorderRadius.circular(14), if (a.route != null) {
if (a.route == 'camera' || a.route == 'gallery') {
ref.read(cameraActionProvider.notifier).trigger(a.route!);
} else {
pushRoute(ref, a.route!);
}
} else if (a.label == '服药打卡') {
_medicationCheckIn(ref, context);
}
}, borderRadius: BorderRadius.circular(14),
child: Container( child: Container(
width: ((screenWidth - 72) / (a.isWide ? 2 : 3)) - 10, width: ((screenWidth - 72) / (a.isWide ? 2 : 3)) - 10,
padding: const EdgeInsets.symmetric(vertical: 13, horizontal: 8), padding: const EdgeInsets.symmetric(vertical: 13, horizontal: 8),
@@ -221,6 +235,57 @@ class ChatMessagesView extends ConsumerWidget {
); );
} }
List<Widget> _buildDoctorCards(double screenWidth, WidgetRef ref) {
const doctors = [
{'name': '张医生', 'title': '主任医师', 'dept': '心内科', 'desc': '冠心病、高血压术后管理', 'id': 'doc_1'},
{'name': '李医生', 'title': '副主任医师', 'dept': '内分泌科', 'desc': '糖尿病、甲状腺疾病管理', 'id': 'doc_2'},
{'name': '王医生', 'title': '主治医师', 'dept': '营养科', 'desc': '术后营养指导、饮食方案制定', 'id': 'doc_3'},
];
return doctors.map((d) => _doctorCard(d, screenWidth, ref)).toList();
}
Widget _doctorCard(Map<String, String> doc, double screenWidth, WidgetRef ref) {
return InkWell(
onTap: () => pushRoute(ref, 'consultation', params: {'id': doc['id']!}),
borderRadius: BorderRadius.circular(14),
child: Container(
width: screenWidth * 0.38,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFEDEBFF)),
),
child: Column(children: [
CircleAvatar(
radius: 24,
backgroundColor: const Color(0xFFEDEBFF),
child: Text(doc['name']![0], style: const TextStyle(fontSize: 20, color: Color(0xFF635BFF))),
),
const SizedBox(height: 8),
Text(doc['name']!, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
Text(doc['title']!, style: const TextStyle(fontSize: 11, color: Color(0xFF999999))),
const SizedBox(height: 2),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFF5F3FF),
borderRadius: BorderRadius.circular(4),
),
child: Text(doc['dept']!, style: const TextStyle(fontSize: 10, color: Color(0xFF635BFF))),
),
const SizedBox(height: 6),
Text(
doc['desc']!,
style: const TextStyle(fontSize: 10, color: Color(0xFF888888), height: 1.3),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
]),
),
);
}
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 2. DataConfirmCard — 增强版数据确认卡片 // 2. DataConfirmCard — 增强版数据确认卡片
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
@@ -523,11 +588,6 @@ class ChatMessagesView extends ConsumerWidget {
final meta = msg.metadata; final meta = msg.metadata;
final foods = meta?['foods'] as List? ?? []; final foods = meta?['foods'] as List? ?? [];
final totalCalories = meta?['totalCalories'] as int? ?? 0; final totalCalories = meta?['totalCalories'] as int? ?? 0;
final rating = meta?['rating'] as int? ?? 0;
final warnings = meta?['warnings'] as List? ?? [];
final carbs = meta?['carbs'] as double? ?? 50.0;
final protein = meta?['protein'] as double? ?? 20.0;
final fat = meta?['fat'] as double? ?? 30.0;
final advice = meta?['advice'] as String? ?? '饮食均衡,多吃蔬菜水果,减少高油高糖食物摄入。'; final advice = meta?['advice'] as String? ?? '饮食均衡,多吃蔬菜水果,减少高油高糖食物摄入。';
return Align( return Align(
@@ -548,120 +608,71 @@ class ChatMessagesView extends ConsumerWidget {
Container( Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
decoration: const BoxDecoration( decoration: const BoxDecoration(gradient: LinearGradient(colors: [Color(0xFFFFF8E1), Color(0xFFFFF3E0)])),
gradient: LinearGradient(colors: [Color(0xFFFFF8E1), Color(0xFFFFF3E0)]), child: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [
), Text('🍽️ ', style: TextStyle(fontSize: 18)),
child: const Row( Text('饮食分析结果', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Color(0xFF1A1A2E))),
mainAxisAlignment: MainAxisAlignment.center, ]),
children: [
Text('🍽️ ', style: TextStyle(fontSize: 18)),
Text('饮食分析结果', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Color(0xFF1A1A2E))),
],
),
), ),
Padding( Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
crossAxisAlignment: CrossAxisAlignment.start, // ── 总热量(仅 >0 时显示) ──
children: [ if (totalCalories > 0) ...[
// 总热量大号数字 Center(child: Column(children: [
Center( Text('$totalCalories', style: const TextStyle(fontSize: 36, fontWeight: FontWeight.w800, color: Color(0xFFFF8F00))),
child: Column( const Text('千卡 (kcal)', style: TextStyle(fontSize: 12, color: Color(0xFFAAAAAA))),
children: [ ])),
Text('$totalCalories', style: const TextStyle(fontSize: 36, fontWeight: FontWeight.w800, color: Color(0xFFFF8F00))),
const Text('千卡 (kcal)', style: TextStyle(fontSize: 12, color: Color(0xFFAAAAAA))),
],
),
),
const SizedBox(height: 16), const SizedBox(height: 16),
],
// 三大营养素圆环指示 // ── 识别食物列表 ──
Row( if (foods.isNotEmpty) ...[
mainAxisAlignment: MainAxisAlignment.spaceEvenly, const Text('识别结果', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
children: [
_nutrientRing('碳水', carbs, const Color(0xFF42A5F5), const Color(0xFFBBDEFB)),
_nutrientRing('蛋白质', protein, const Color(0xFF66BB6A), const Color(0xFFC8E6C9)),
_nutrientRing('脂肪', fat, const Color(0xFFFFA726), const Color(0xFFFFE0B2)),
],
),
const SizedBox(height: 16),
// 食物列表
const Text('食物明细', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF333333))),
const SizedBox(height: 10), const SizedBox(height: 10),
...foods.map((food) { ...foods.map((food) {
final f = food as Map? ?? {}; final f = food is Map ? food : <String, dynamic>{};
final fCal = (f['calories'] ?? 0) as num; final name = f['name'] as String? ?? '';
final fPct = totalCalories > 0 ? (fCal / totalCalories * 100).clamp(0.0, 100.0) : 0.0; final calories = f['calories'] as num? ?? 0;
return Padding( final portion = f['portion'] as String?;
padding: const EdgeInsets.only(bottom: 10), final nutrients = f['nutrients'] as String?;
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, return Container(
children: [ margin: const EdgeInsets.only(bottom: 8),
Row( padding: const EdgeInsets.all(10),
children: [ decoration: BoxDecoration(color: const Color(0xFFFAFAFA), borderRadius: BorderRadius.circular(10), border: Border.all(color: const Color(0xFFF0F0F0))),
Text(f['name'] as String? ?? '', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Spacer(), Row(children: [
Text('${fCal.toInt()} kcal', style: const TextStyle(fontSize: 12, color: Color(0xFF888888))), Expanded(child: Text(name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Color(0xFF1A1A1A)))),
], if (calories > 0) Text('${calories is int ? calories : calories.toInt()} kcal', style: const TextStyle(fontSize: 13, color: Color(0xFF888888))),
), ]),
const SizedBox(height: 4), if (portion != null && portion.isNotEmpty) Padding(padding: const EdgeInsets.only(top: 4), child: Text(portion, style: TextStyle(fontSize: 12, color: Colors.grey[500]))),
ClipRRect( if (nutrients != null && nutrients.isNotEmpty) Padding(padding: const EdgeInsets.only(top: 2), child: Text(nutrients, style: TextStyle(fontSize: 11, color: Colors.grey[500]))),
borderRadius: BorderRadius.circular(3), ]),
child: LinearProgressIndicator(
value: fPct / 100,
minHeight: 5,
backgroundColor: const Color(0xFFF0EEFF),
valueColor: const AlwaysStoppedAnimation<Color>(Color(0xFFFFB74D)),
),
),
],
),
); );
}), }),
] else ...[
// 健康评分 Container(
const SizedBox(height: 14), padding: const EdgeInsets.all(20),
Row( decoration: BoxDecoration(color: const Color(0xFFF5F3FF), borderRadius: BorderRadius.circular(12)),
children: [ child: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [
const Text('健康评分', style: TextStyle(fontSize: 13, color: Color(0xFF666666))), Icon(Icons.hourglass_empty, size: 18, color: Color(0xFF999999)),
const SizedBox(width: 8), SizedBox(width: 8),
...List.generate(5, (i) => Padding( Text('正在分析食物中...', style: TextStyle(fontSize: 14, color: Color(0xFF999999))),
padding: const EdgeInsets.only(right: 2), ]),
child: Icon(i < rating ? Icons.star : Icons.star_border, size: 18, color: i < rating ? const Color(0xFFFFB800) : const Color(0xFFE0E0E0)),
)),
const Spacer(),
Text('$rating/5', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFFFFB800))),
],
), ),
// 警告
if (warnings.isNotEmpty) ...[
const SizedBox(height: 12),
...warnings.map((w) => Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFFFFFBF0),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFFFE082), width: 0.8),
),
child: Row(
children: [
const Text('⚠️ ', style: TextStyle(fontSize: 13)),
Expanded(child: Text(w.toString(), style: const TextStyle(fontSize: 12, color: Color(0xFFE65100)))),
],
),
)),
],
// AI 建议(可展开)
const SizedBox(height: 14),
_ExpandableAdvice(advice: advice),
const SizedBox(height: 6),
], ],
),
// ── AI 建议 ──
const SizedBox(height: 14),
const Text('AI 建议', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
const SizedBox(height: 6),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: const Color(0xFFF5F3FF), borderRadius: BorderRadius.circular(10)),
child: Text(advice, style: const TextStyle(fontSize: 13, height: 1.6, color: Color(0xFF555555))),
),
]),
), ),
], ],
), ),
@@ -669,40 +680,6 @@ class ChatMessagesView extends ConsumerWidget {
); );
} }
Widget _nutrientRing(String label, double pct, Color fgColor, Color bgColor) {
return Column(
children: [
SizedBox(
width: 56,
height: 56,
child: Stack(
alignment: Alignment.center,
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(shape: BoxShape.circle, color: bgColor),
),
SizedBox(
width: 56,
height: 56,
child: CircularProgressIndicator(
value: pct.clamp(0.0, 100.0) / 100,
strokeWidth: 5,
backgroundColor: Colors.transparent,
valueColor: AlwaysStoppedAnimation<Color>(fgColor),
),
),
Text('${pct.toInt()}%', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: fgColor)),
],
),
),
const SizedBox(height: 5),
Text(label, style: const TextStyle(fontSize: 11, color: Color(0xFF888888))),
],
);
}
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 5. ReportAnalysisCard — 增强版报告分析卡片 // 5. ReportAnalysisCard — 增强版报告分析卡片
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
@@ -972,6 +949,10 @@ class ChatMessagesView extends ConsumerWidget {
Widget _buildTextBubble(BuildContext context, ChatMessage msg) { Widget _buildTextBubble(BuildContext context, ChatMessage msg) {
final isUser = msg.isUser; final isUser = msg.isUser;
final imageUrl = msg.metadata?['imageUrl'] as String?;
final localPath = msg.metadata?['localImagePath'] as String?;
final hasImage = imageUrl != null || localPath != null;
return Align( return Align(
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container( child: Container(
@@ -992,6 +973,18 @@ class ChatMessagesView extends ConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (hasImage)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: imageUrl != null
? Image.network(imageUrl, fit: BoxFit.cover, width: double.infinity, errorBuilder: (_, __, ___) => _buildLocalFallback(localPath))
: localPath != null
? Image.file(File(localPath), fit: BoxFit.cover, width: double.infinity)
: null,
),
),
if (isUser) if (isUser)
Text(msg.content, style: const TextStyle(fontSize: 16, color: Colors.white, height: 1.4)) Text(msg.content, style: const TextStyle(fontSize: 16, color: Colors.white, height: 1.4))
else else
@@ -1022,6 +1015,18 @@ class ChatMessagesView extends ConsumerWidget {
); );
} }
Widget _buildLocalFallback(String? localPath) {
if (localPath != null) {
final file = File(localPath);
return Image.file(file, fit: BoxFit.cover, width: double.infinity);
}
return Container(
height: 100,
color: const Color(0xFFEEEEEE),
child: const Center(child: Icon(Icons.broken_image, size: 40, color: Color(0xFFBDBDBD))),
);
}
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 公共组件:通用按钮 // 公共组件:通用按钮
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
@@ -1111,6 +1116,36 @@ class ChatMessagesView extends ConsumerWidget {
} }
} }
static void _medicationCheckIn(WidgetRef ref, BuildContext context) async {
try {
final service = ref.read(medicationServiceProvider);
final reminders = await ref.read(medicationReminderProvider.future);
if (reminders.isEmpty) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('暂无待服药记录'), backgroundColor: Color(0xFFFF9800)),
);
return;
}
for (final m in reminders) {
await service.confirm(m['id']?.toString() ?? '');
}
ref.invalidate(medicationReminderProvider);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('打卡成功 已记录 ${reminders.length} 项服药'),
backgroundColor: const Color(0xFF43A047),
),
);
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('打卡失败:$e'), backgroundColor: Colors.red),
);
}
}
static (_AgentIcon, String, String) _agentInfo(ActiveAgent agent) { static (_AgentIcon, String, String) _agentInfo(ActiveAgent agent) {
return switch (agent) { return switch (agent) {
ActiveAgent.health => (Icons.favorite_border, '记数据', '录入血压、血糖、心率等日常指标'), ActiveAgent.health => (Icons.favorite_border, '记数据', '录入血压、血糖、心率等日常指标'),
@@ -1134,41 +1169,36 @@ class _AgentAction {
final String label; final String label;
final IconData icon; final IconData icon;
final bool isWide; final bool isWide;
final String? route;
const _AgentAction({required this.label, required this.icon, this.isWide = false}); const _AgentAction({required this.label, required this.icon, this.isWide = false, this.route});
} }
final _agentActions = <ActiveAgent, List<_AgentAction>>{ final _agentActions = <ActiveAgent, List<_AgentAction>>{
ActiveAgent.health: [ ActiveAgent.health: [
_AgentAction(label: '录入血压', icon: Icons.monitor_heart_outlined), _AgentAction(label: '录入血压', icon: Icons.monitor_heart_outlined, route: 'trend'),
_AgentAction(label: '录入血糖', icon: Icons.bloodtype_outlined), _AgentAction(label: '录入血糖', icon: Icons.bloodtype_outlined, route: 'trend'),
_AgentAction(label: '录入心率', icon: Icons.favorite_border), _AgentAction(label: '录入心率', icon: Icons.favorite_border, route: 'trend'),
_AgentAction(label: '录入血氧', icon: Icons.air_outlined), _AgentAction(label: '录入血氧', icon: Icons.air_outlined, route: 'trend'),
_AgentAction(label: '录入体重', icon: Icons.monitor_weight_outlined), _AgentAction(label: '录入体重', icon: Icons.monitor_weight_outlined, route: 'trend'),
], ],
ActiveAgent.diet: [ ActiveAgent.diet: [
_AgentAction(label: '拍照识别', icon: Icons.camera_alt_outlined, isWide: true), _AgentAction(label: '拍照识别', icon: Icons.camera_alt_outlined, isWide: true, route: 'camera'),
_AgentAction(label: '上传照片', icon: Icons.photo_library_outlined, isWide: true), _AgentAction(label: '上传照片', icon: Icons.photo_library_outlined, isWide: true, route: 'gallery'),
_AgentAction(label: '看舌答', icon: Icons.face_retouching_natural_outlined, isWide: true),
_AgentAction(label: '测肤质', icon: Icons.palette_outlined, isWide: true),
], ],
ActiveAgent.medication: [ ActiveAgent.medication: [
_AgentAction(label: '用药管理', icon: Icons.medication_liquid_outlined, isWide: true), _AgentAction(label: '用药管理', icon: Icons.medication_liquid_outlined, isWide: true, route: 'medications'),
_AgentAction(label: '用药提醒', icon: Icons.alarm_outlined, isWide: true), _AgentAction(label: '服药打卡', icon: Icons.check_circle_outline, isWide: true),
_AgentAction(label: '添加药品', icon: Icons.add_circle_outline, isWide: true),
],
ActiveAgent.consultation: [
_AgentAction(label: '找医生', icon: Icons.person_search_outlined, isWide: true),
_AgentAction(label: '描述症状', icon: Icons.edit_note_outlined, isWide: true),
], ],
ActiveAgent.consultation: [],
ActiveAgent.report: [ ActiveAgent.report: [
_AgentAction(label: '上传报告', icon: Icons.upload_file_outlined, isWide: true), _AgentAction(label: '上传报告', icon: Icons.upload_file_outlined, isWide: true, route: 'reports'),
_AgentAction(label: '查看历史', icon: Icons.history_outlined, isWide: true), _AgentAction(label: '查看历史', icon: Icons.history_outlined, isWide: true, route: 'reports'),
], ],
ActiveAgent.exercise: [ ActiveAgent.exercise: [
_AgentAction(label: '本周计划', icon: Icons.calendar_month_outlined, isWide: true), _AgentAction(label: '本周计划', icon: Icons.calendar_month_outlined, isWide: true, route: 'exercisePlan'),
_AgentAction(label: '新建计划', icon: Icons.add_task_outlined, isWide: true), _AgentAction(label: '新建计划', icon: Icons.add_task_outlined, isWide: true, route: 'exercisePlan'),
_AgentAction(label: '今日打卡', icon: Icons.fact_check_outlined, isWide: true), _AgentAction(label: '今日打卡', icon: Icons.fact_check_outlined, isWide: true, route: 'exercisePlan'),
], ],
}; };

View File

@@ -1,98 +1,528 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/navigation_provider.dart'; import '../../core/navigation_provider.dart';
import '../../providers/data_providers.dart';
class _MedicationItem {
String name = '';
String dosage = '';
String frequency = '每日1次';
List<TimeOfDay> times = [const TimeOfDay(hour: 8, minute: 0)];
DateTime startDate = DateTime.now();
DateTime? endDate;
int weekday = 1;
}
const _frequencies = ['每日1次', '每日2次', '每日3次', '每周1次', '按需服用'];
const _weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
class MedicationEditPage extends ConsumerStatefulWidget { class MedicationEditPage extends ConsumerStatefulWidget {
final String? medicationId; final String? medicationId;
const MedicationEditPage({super.key, this.medicationId}); const MedicationEditPage({super.key, this.medicationId});
@override ConsumerState<MedicationEditPage> createState() => _MedicationEditPageState(); @override
ConsumerState<MedicationEditPage> createState() => _MedicationEditPageState();
} }
class _MedicationEditPageState extends ConsumerState<MedicationEditPage> { class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
final _nameCtrl = TextEditingController(text: '阿司匹林肠溶片'); final _items = <_MedicationItem>[];
final _dosageCtrl = TextEditingController(text: '100mg'); final _nameCtrls = <TextEditingController>[];
String _frequency = '每日1次'; final _doseCtrls = <TextEditingController>[];
String _time = '08:00';
DateTime _startDate = DateTime.now();
String _duration = '长期服用';
@override void dispose() { _nameCtrl.dispose(); _dosageCtrl.dispose(); super.dispose(); } @override
void initState() {
super.initState();
_addItem();
}
@override Widget build(BuildContext context) { @override
void dispose() {
for (final c in _nameCtrls) {
c.dispose();
}
for (final c in _doseCtrls) {
c.dispose();
}
super.dispose();
}
void _addItem() {
setState(() {
_items.add(_MedicationItem());
_nameCtrls.add(TextEditingController());
_doseCtrls.add(TextEditingController());
});
}
void _removeItem(int index) {
setState(() {
_nameCtrls[index].dispose();
_doseCtrls[index].dispose();
_nameCtrls.removeAt(index);
_doseCtrls.removeAt(index);
_items.removeAt(index);
});
}
void _onSave() async {
for (int i = 0; i < _items.length; i++) {
_items[i].name = _nameCtrls[i].text.trim();
_items[i].dosage = _doseCtrls[i].text.trim();
}
final allValid = _items.every(
(item) => item.name.isNotEmpty && item.dosage.isNotEmpty,
);
if (!allValid) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请填写所有药品的名称和剂量')),
);
return;
}
final service = ref.read(medicationServiceProvider);
try {
for (final item in _items) {
final timesStr = item.frequency == '按需服用'
? []
: item.times.map((t) => t.format(context)).toList();
await service.create({
'name': item.name,
'dosage': item.dosage,
'frequency': item.frequency,
'times': timesStr,
'start_date': item.startDate.toIso8601String().split('T')[0],
if (item.endDate != null)
'end_date': item.endDate!.toIso8601String().split('T')[0],
if (item.frequency == '每周1次') 'weekday': item.weekday,
});
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('已添加 ${_items.length} 种药品'),
backgroundColor: const Color(0xFF635BFF),
),
);
ref.invalidate(medicationListProvider);
ref.invalidate(medicationReminderProvider);
popRoute(ref);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('保存失败:$e'),
backgroundColor: Colors.red,
),
);
// 仍然返回上一页,避免卡在黑屏
popRoute(ref);
}
}
int _timeCount(String frequency) {
switch (frequency) {
case '每日1次':
return 1;
case '每日2次':
return 2;
case '每日3次':
return 3;
case '每周1次':
return 1;
default:
return 0;
}
}
@override
Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: Colors.white, backgroundColor: const Color(0xFFF8F7FF),
appBar: AppBar( appBar: AppBar(
backgroundColor: Colors.white, backgroundColor: Colors.white,
elevation: 0, elevation: 0,
leading: IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => popRoute(ref)), leading: IconButton(
title: const Text('编辑用药', style: TextStyle(color: Color(0xFF1A1A1A), fontWeight: FontWeight.w600)), icon: const Icon(Icons.chevron_left),
onPressed: () => popRoute(ref),
),
title: const Text(
'添加用药',
style: TextStyle(
color: Color(0xFF1A1A1A),
fontWeight: FontWeight.w600,
),
),
centerTitle: true, centerTitle: true,
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: _onSave,
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存成功 ✅'), backgroundColor: Color(0xFF635BFF))); child: const Text(
Navigator.pop(context); '保存',
}, style: TextStyle(
child: const Text('保存', style: TextStyle(color: Color(0xFF635BFF), fontWeight: FontWeight.w600)), color: Color(0xFF635BFF),
fontWeight: FontWeight.w600,
),
),
), ),
], ],
), ),
body: SingleChildScrollView( body: SingleChildScrollView(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(
const Text('药品信息', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), crossAxisAlignment: CrossAxisAlignment.start,
const SizedBox(height: 12), children: [
TextField(controller: _nameCtrl, decoration: InputDecoration(hintText: '请输入药品名称', filled: true, fillColor: Colors.grey[50], border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none))), ...List.generate(_items.length, (i) => _buildCard(i)),
const SizedBox(height: 16), const SizedBox(height: 12),
TextField(controller: _dosageCtrl, decoration: InputDecoration(hintText: '100mg', filled: true, fillColor: Colors.grey[50], border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none))), _buildAddButton(),
const SizedBox(height: 24), const SizedBox(height: 40),
const Text('服用设置', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), ],
const SizedBox(height: 12), ),
GestureDetector(onTap: _pickFrequency, child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration(border: Border.all(color: const Color(0xFFE0E0E0)), borderRadius: BorderRadius.circular(12)), child: Row(children: [Text(_frequency, style: const TextStyle(fontSize: 15)), const Spacer(), const Icon(Icons.keyboard_arrow_down, size: 20, color: Color(0xFF9E9E9E))]))),
const SizedBox(height: 16),
GestureDetector(onTap: _pickTime, child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration(border: Border.all(color: const Color(0xFFE0E0E0)), borderRadius: BorderRadius.circular(12)), child: Row(children: [Text(_time, style: const TextStyle(fontSize: 15)), const Spacer(), const Icon(Icons.access_time, size: 20, color: Color(0xFF9E9E9E))]))),
const SizedBox(height: 16),
GestureDetector(onTap: _pickDate, child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration(border: Border.all(color: const Color(0xFFE0E0E0)), borderRadius: BorderRadius.circular(12)), child: Row(children: [Text('${_startDate.year}-${_startDate.month.toString().padLeft(2, '0')}-${_startDate.day.toString().padLeft(2, '0')}', style: const TextStyle(fontSize: 15)), const Spacer(), const Icon(Icons.calendar_today, size: 20, color: Color(0xFF9E9E9E))]))),
const SizedBox(height: 16),
GestureDetector(onTap: _pickDuration, child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration(border: Border.all(color: const Color(0xFFE0E0E0)), borderRadius: BorderRadius.circular(12)), child: Row(children: [Text(_duration, style: const TextStyle(fontSize: 15)), const Spacer(), const Icon(Icons.keyboard_arrow_down, size: 20, color: Color(0xFF9E9E9E))]))),
const SizedBox(height: 32),
SizedBox(width: double.infinity, height: 50, child: ElevatedButton(
onPressed: () {},
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF635BFF), foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(25))),
child: const Text('新增用药', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
)),
const SizedBox(height: 20),
]),
), ),
); );
} }
void _pickFrequency() async { Widget _buildCard(int index) {
final options = ['每日1次', '每日2次', '每日3次', '每周1次', '按需服用']; final item = _items[index];
final count = _timeCount(item.frequency);
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFEEEEEE)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'药品 ${index + 1}',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Color(0xFF635BFF),
),
),
if (_items.length > 1)
GestureDetector(
onTap: () => _removeItem(index),
child: const Icon(Icons.close, size: 18, color: Color(0xFFBDBDBD)),
),
],
),
const SizedBox(height: 8),
Divider(height: 1, color: const Color(0xFFF0F0F0)),
const SizedBox(height: 8),
// Name
_buildLabel('药品名称'),
const SizedBox(height: 4),
TextField(
controller: _nameCtrls[index],
style: const TextStyle(fontSize: 14),
decoration: _inputDecoration('请输入药品名称'),
),
const SizedBox(height: 8),
// Dosage
_buildLabel('剂量'),
const SizedBox(height: 4),
TextField(
controller: _doseCtrls[index],
style: const TextStyle(fontSize: 14),
decoration: _inputDecoration('100mg'),
),
const SizedBox(height: 8),
// Frequency
_buildLabel('服用频率'),
const SizedBox(height: 4),
GestureDetector(
onTap: () => _pickFrequency(index),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE0E0E0)),
color: const Color(0xFFFAFAFA),
),
child: Row(
children: [
Text(item.frequency, style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A))),
const Spacer(),
const Icon(Icons.keyboard_arrow_down, size: 20, color: Color(0xFF9E9E9E)),
],
),
),
),
const SizedBox(height: 8),
// Times (dynamic)
if (count > 0) ...[
_buildLabel('服药时间'),
const SizedBox(height: 4),
Wrap(
spacing: 8,
runSpacing: 6,
children: List.generate(count, (t) => _buildTimePicker(index, t)),
),
if (item.frequency == '每周1次') ...[
const SizedBox(height: 8),
_buildLabel('选择星期'),
const SizedBox(height: 4),
GestureDetector(
onTap: () => _pickWeekday(index),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE0E0E0)),
color: const Color(0xFFFAFAFA),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(_weekdays[item.weekday - 1], style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A))),
const SizedBox(width: 4),
const Icon(Icons.keyboard_arrow_down, size: 18, color: Color(0xFF9E9E9E)),
],
),
),
),
],
const SizedBox(height: 8),
],
// Start date
_buildLabel('开始日期'),
const SizedBox(height: 4),
GestureDetector(
onTap: () => _pickDate(index, isStart: true),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE0E0E0)),
color: const Color(0xFFFAFAFA),
),
child: Row(
children: [
Text(
'${item.startDate.year}-${item.startDate.month.toString().padLeft(2, '0')}-${item.startDate.day.toString().padLeft(2, '0')}',
style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)),
),
const Spacer(),
const Icon(Icons.calendar_today, size: 18, color: Color(0xFF9E9E9E)),
],
),
),
),
const SizedBox(height: 8),
// End date (optional)
_buildLabel('结束日期(可选)'),
const SizedBox(height: 4),
GestureDetector(
onTap: () => _pickDate(index, isStart: false),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE0E0E0)),
color: const Color(0xFFFAFAFA),
),
child: Row(
children: [
Text(
item.endDate != null
? '${item.endDate!.year}-${item.endDate!.month.toString().padLeft(2, '0')}-${item.endDate!.day.toString().padLeft(2, '0')}'
: '不设置',
style: TextStyle(
fontSize: 14,
color: item.endDate != null ? const Color(0xFF1A1A1A) : const Color(0xFFBDBDBD),
),
),
const Spacer(),
GestureDetector(
onTap: item.endDate != null ? () => setState(() => item.endDate = null) : null,
child: Icon(
item.endDate != null ? Icons.close : Icons.calendar_today,
size: 18,
color: const Color(0xFF9E9E9E),
),
),
],
),
),
),
],
),
);
}
Widget _buildLabel(String text) {
return Text(
text,
style: const TextStyle(fontSize: 12, color: Color(0xFF757575)),
);
}
Widget _buildTimePicker(int itemIndex, int timeIndex) {
final item = _items[itemIndex];
final time = item.times[timeIndex];
return GestureDetector(
onTap: () => _pickTime(itemIndex, timeIndex),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE0E0E0)),
color: const Color(0xFFFAFAFA),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.access_time, size: 16, color: Color(0xFF635BFF)),
const SizedBox(width: 6),
Text(
time.format(context),
style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)),
),
],
),
),
);
}
Widget _buildAddButton() {
return SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _addItem,
icon: const Icon(Icons.add, size: 18),
label: const Text('添加', style: TextStyle(fontSize: 14)),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF635BFF),
side: const BorderSide(color: Color(0xFFD5D1FF)),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
backgroundColor: const Color(0xFFF5F3FF),
),
),
);
}
InputDecoration _inputDecoration(String hint) {
return InputDecoration(
hintText: hint,
hintStyle: const TextStyle(color: Color(0xFFBDBDBD), fontSize: 14),
filled: true,
fillColor: const Color(0xFFFAFAFA),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
isDense: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFFE0E0E0)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFFE0E0E0)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFF635BFF)),
),
);
}
void _pickFrequency(int index) async {
final selected = await showModalBottomSheet<String>( final selected = await showModalBottomSheet<String>(
context: context, context: context,
builder: (ctx) => SafeArea(child: Column(mainAxisSize: MainAxisSize.min, children: options.map((o) => ListTile(title: Text(o), onTap: () => Navigator.pop(ctx, o))).toList())), builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: _frequencies
.map((f) => ListTile(
title: Text(f),
onTap: () => Navigator.pop(ctx, f),
))
.toList(),
),
),
); );
if (selected != null && mounted) setState(() => _frequency = selected); if (selected != null && mounted) {
setState(() {
final item = _items[index];
item.frequency = selected;
final newCount = _timeCount(selected);
if (newCount > 0 && item.times.length != newCount) {
item.times = List.generate(
newCount,
(i) => TimeOfDay(hour: 8 + i * 4, minute: 0),
);
}
});
}
} }
void _pickTime() async { void _pickWeekday(int index) async {
final time = await showTimePicker(context: context, initialTime: TimeOfDay.now()); final item = _items[index];
if (time != null && mounted) setState(() => _time = time.format(context)); final selected = await showModalBottomSheet<int>(
}
void _pickDate() async {
final date = await showDatePicker(context: context, firstDate: DateTime(2020), lastDate: DateTime(2030), initialDate: _startDate);
if (date != null && mounted) setState(() => _startDate = date);
}
void _pickDuration() async {
final options = ['长期服用', '7天', '14天', '30天', '90天'];
final selected = await showModalBottomSheet<String>(
context: context, context: context,
builder: (ctx) => SafeArea(child: Column(mainAxisSize: MainAxisSize.min, children: options.map((o) => ListTile(title: Text(o), onTap: () => Navigator.pop(ctx, o))).toList())), builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: List.generate(7, (i) {
return ListTile(
title: Text(_weekdays[i]),
selected: item.weekday == i + 1,
onTap: () => Navigator.pop(ctx, i + 1),
);
}),
),
),
); );
if (selected != null && mounted) setState(() => _duration = selected); if (selected != null && mounted) {
setState(() => _items[index].weekday = selected);
}
}
void _pickTime(int itemIndex, int timeIndex) async {
final item = _items[itemIndex];
final time = await showTimePicker(
context: context,
initialTime: item.times[timeIndex],
);
if (time != null && mounted) {
setState(() => item.times[timeIndex] = time);
}
}
void _pickDate(int index, {required bool isStart}) async {
final item = _items[index];
final initial = isStart ? item.startDate : (item.endDate ?? DateTime.now());
final date = await showDatePicker(
context: context,
firstDate: DateTime(2020),
lastDate: DateTime(2030),
initialDate: initial,
);
if (date != null && mounted) {
setState(() {
if (isStart) {
item.startDate = date;
} else {
item.endDate = date;
}
});
}
} }
} }

View File

@@ -589,16 +589,16 @@ class StaticTextPage extends ConsumerWidget {
final contents = { final contents = {
'privacy': '''## 隐私政策 'privacy': '''## 隐私政策
**更新日期2026年1月1日** 更新日期2026年1月1日
### 一、信息收集 ### 一、信息收集
我们收集以下类型的信息: 我们收集以下类型的信息:
- **账户信息**:手机号、昵称、头像(您主动提供) - 账户信息:手机号、昵称、头像(您主动提供)
- **健康数据**:血压、心率、血糖、血氧、体重等健康指标记录 - 健康数据:血压、心率、血糖、血氧、体重等健康指标记录
- **用药信息**:药品名称、剂量、服药时间等用药计划数据 - 用药信息:药品名称、剂量、服药时间等用药计划数据
- **饮食记录**:通过拍照或手动录入的饮食数据 - 饮食记录:通过拍照或手动录入的饮食数据
- **设备信息**:设备型号、操作系统版本(用于适配优化) - 设备信息:设备型号、操作系统版本(用于适配优化)
- **日志信息**App 使用情况、崩溃报告 - 日志信息App 使用情况、崩溃报告
### 二、信息使用 ### 二、信息使用
我们使用您的信息用于以下目的: 我们使用您的信息用于以下目的:
@@ -631,19 +631,19 @@ class StaticTextPage extends ConsumerWidget {
电话400-xxx-xxxx''', 电话400-xxx-xxxx''',
'about': '''## 关于健康管家 'about': '''## 关于健康管家
**版本**v1.0.0 (Build 20260101) 版本v1.0.0 (Build 20260101)
### 产品介绍 ### 产品介绍
健康管家是一款面向心脏术后康复患者的私人 AI 健康管理应用。以对话为核心交互方式,患者可以通过自然语言记录健康数据、获取饮食运动建议、管理用药、解读检查报告。 健康管家是一款面向心脏术后康复患者的私人 AI 健康管理应用。以对话为核心交互方式,患者可以通过自然语言记录健康数据、获取饮食运动建议、管理用药、解读检查报告。
### 核心功能 ### 核心功能
- **AI 智能问诊**:基于大语言模型的健康咨询服务 - AI 智能问诊:基于大语言模型的健康咨询服务
- **健康数据管理**:血压、心率、血糖、血氧、体重的记录与趋势分析 - 健康数据管理:血压、心率、血糖、血氧、体重的记录与趋势分析
- **智能用药管理**AI 解析处方,自动生成用药计划和提醒 - 智能用药管理AI 解析处方,自动生成用药计划和提醒
- **饮食识别分析**:拍照即可识别食物种类、估算热量营养素 - 饮食识别分析:拍照即可识别食物种类、估算热量营养素
- **报告智能解读**上传检查报告AI 自动提取指标并预解读 - 报告智能解读上传检查报告AI 自动提取指标并预解读
- **运动计划管理**:制定和追踪每日运动目标 - 运动计划管理:制定和追踪每日运动目标
- **在线医生问诊**:与签约医生进行远程咨询 - 在线医生问诊:与签约医生进行远程咨询
### 开发团队 ### 开发团队
由专业医疗团队与 AI 技术团队联合打造。 由专业医疗团队与 AI 技术团队联合打造。

View File

@@ -9,8 +9,14 @@ class SettingsPage extends ConsumerWidget {
@override Widget build(BuildContext context, WidgetRef ref) { @override Widget build(BuildContext context, WidgetRef ref) {
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF8F7FF), backgroundColor: const Color(0xFFF8F7FF),
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => popRoute(ref)),
title: const Text('设置', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
centerTitle: true,
),
body: SafeArea(child: SingleChildScrollView(padding: const EdgeInsets.only(bottom: 30), child: Column(children: [ body: SafeArea(child: SingleChildScrollView(padding: const EdgeInsets.only(bottom: 30), child: Column(children: [
Container(width: double.infinity, padding: const EdgeInsets.all(24), decoration: const BoxDecoration(color: Colors.white, borderRadius: BorderRadius.only(bottomLeft: Radius.circular(24), bottomRight: Radius.circular(24))), child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [Text('9:41', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.grey[800])), Row(children: [Icon(Icons.wifi, size: 18, color: Colors.grey[700]), const SizedBox(width: 4), Icon(Icons.battery_full, size: 18, color: Colors.grey[700])]),])),
const SizedBox(height: 12), const SizedBox(height: 12),
_SetItem(icon: Icons.notifications_outlined, title: '消息通知', onTap: () => pushRoute(ref, 'notificationPrefs')), _SetItem(icon: Icons.notifications_outlined, title: '消息通知', onTap: () => pushRoute(ref, 'notificationPrefs')),
_SetItem(icon: Icons.medication_outlined, title: '用药提醒', subtitle: 'mmHg / mmol/L', onTap: () => pushRoute(ref, 'medications')), _SetItem(icon: Icons.medication_outlined, title: '用药提醒', subtitle: 'mmHg / mmol/L', onTap: () => pushRoute(ref, 'medications')),

View File

@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_provider.dart'; import 'auth_provider.dart';
import 'data_providers.dart'; import 'data_providers.dart';
@@ -94,7 +95,7 @@ final conversationListProvider = FutureProvider<List<ConversationItem>>((ref) as
); );
}).toList(); }).toList();
} catch (_) { } catch (_) {
return _mockConversations; return [];
} }
}); });
@@ -110,30 +111,6 @@ ActiveAgent _parseAgent(String? type) {
} }
} }
final _mockConversations = [
ConversationItem(
id: '1',
title: '用药咨询',
lastMessage: '阿司匹林应该什么时候吃?',
updatedAt: DateTime.now().subtract(const Duration(hours: 2)),
agent: ActiveAgent.medication,
),
ConversationItem(
id: '2',
title: '血压偏高',
lastMessage: '血压145/90需要注意什么',
updatedAt: DateTime.now().subtract(const Duration(hours: 5)),
agent: ActiveAgent.health,
),
ConversationItem(
id: '3',
title: '饮食建议',
lastMessage: '今天吃了米饭和红烧肉',
updatedAt: DateTime.now().subtract(const Duration(days: 1)),
agent: ActiveAgent.diet,
),
];
class ChatNotifier extends Notifier<ChatState> { class ChatNotifier extends Notifier<ChatState> {
StreamSubscription<Map<String, dynamic>>? _subscription; StreamSubscription<Map<String, dynamic>>? _subscription;
@@ -142,7 +119,7 @@ class ChatNotifier extends Notifier<ChatState> {
void setAgent(ActiveAgent a) { void setAgent(ActiveAgent a) {
_subscription?.cancel(); _subscription?.cancel();
state = state.activeAgent == a ? const ChatState() : ChatState(activeAgent: a); state = state.copyWith(activeAgent: a);
} }
void insertAgentWelcome(ActiveAgent agent) { void insertAgentWelcome(ActiveAgent agent) {
@@ -156,6 +133,49 @@ class ChatNotifier extends Notifier<ChatState> {
)]); )]);
} }
Future<void> sendImage(String imagePath, String text) async {
final file = File(imagePath);
if (!await file.exists()) return;
// 先显示用户消息(本地显示图片路径)
final userMsg = ChatMessage(
id: '${DateTime.now().millisecondsSinceEpoch}',
role: 'user',
content: text.isNotEmpty ? text : '[图片]',
createdAt: DateTime.now(),
metadata: {'localImagePath': imagePath},
);
state = state.copyWith(messages: [...state.messages, userMsg]);
// 异步上传图片
String? uploadedUrl;
try {
final api = ref.read(apiClientProvider);
uploadedUrl = await api.uploadFile('/api/upload', file);
} catch (_) {
// 上传失败:保留本地路径,仍然可以本地显示
}
// 更新消息元数据(上传成功则替换为远程 URL
final finalUrl = uploadedUrl ?? imagePath;
final updatedMsgs = state.messages.toList();
final idx = updatedMsgs.indexWhere((m) => m.id == userMsg.id);
if (idx >= 0) {
updatedMsgs[idx] = ChatMessage(
id: userMsg.id,
role: 'user',
content: userMsg.content,
createdAt: userMsg.createdAt,
metadata: {'imageUrl': finalUrl},
);
state = state.copyWith(messages: updatedMsgs);
}
// 将图片 URL 作为消息内容发送给 AI
final msgWithImage = text.isNotEmpty ? '$text\n[图片已上传]' : '[图片已上传]';
await _sendToAI(msgWithImage);
}
Future<void> sendMessage(String text) async { Future<void> sendMessage(String text) async {
if (text.trim().isEmpty || state.isStreaming) return; if (text.trim().isEmpty || state.isStreaming) return;
@@ -168,6 +188,10 @@ class ChatNotifier extends Notifier<ChatState> {
state = state.copyWith( state = state.copyWith(
messages: [...state.messages, userMsg], isStreaming: true); messages: [...state.messages, userMsg], isStreaming: true);
await _sendToAI(text);
}
Future<void> _sendToAI(String text) async {
final aiMsg = ChatMessage( final aiMsg = ChatMessage(
id: '${DateTime.now().millisecondsSinceEpoch}_ai', id: '${DateTime.now().millisecondsSinceEpoch}_ai',
role: 'assistant', role: 'assistant',
@@ -175,6 +199,8 @@ class ChatNotifier extends Notifier<ChatState> {
createdAt: DateTime.now(), createdAt: DateTime.now(),
); );
state = state.copyWith(isStreaming: true);
try { try {
final token = await ref.read(apiClientProvider).accessToken; final token = await ref.read(apiClientProvider).accessToken;
if (token == null) { if (token == null) {

View File

@@ -53,9 +53,37 @@ final medicationReminderProvider = FutureProvider<List<Map<String, dynamic>>>((r
/// 医生列表 Provider /// 医生列表 Provider
final doctorListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async { final doctorListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
final service = ref.watch(consultationServiceProvider); final service = ref.watch(consultationServiceProvider);
return service.getDoctors(); try {
return await service.getDoctors().timeout(const Duration(seconds: 8));
} catch (_) {
return _fallbackDoctors;
}
}); });
const _fallbackDoctors = [
{
'id': 'doc_1',
'name': '张医生',
'title': '主任医师',
'department': '心内科',
'introduction': '擅长冠心病、高血压术后管理20年临床经验',
},
{
'id': 'doc_2',
'name': '李医生',
'title': '副主任医师',
'department': '内分泌科',
'introduction': '擅长糖尿病、甲状腺疾病管理15年临床经验',
},
{
'id': 'doc_3',
'name': '王医生',
'title': '主治医师',
'department': '营养科',
'introduction': '擅长术后营养指导、饮食方案制定10年临床经验',
},
];
/// 问诊配额 Provider /// 问诊配额 Provider
final consultationQuotaProvider = FutureProvider<Map<String, dynamic>>((ref) async { final consultationQuotaProvider = FutureProvider<Map<String, dynamic>>((ref) async {
final service = ref.watch(consultationServiceProvider); final service = ref.watch(consultationServiceProvider);
@@ -65,5 +93,18 @@ final consultationQuotaProvider = FutureProvider<Map<String, dynamic>>((ref) asy
/// 当前运动计划 Provider /// 当前运动计划 Provider
final currentExercisePlanProvider = FutureProvider<Map<String, dynamic>?>((ref) async { final currentExercisePlanProvider = FutureProvider<Map<String, dynamic>?>((ref) async {
final service = ref.watch(exerciseServiceProvider); final service = ref.watch(exerciseServiceProvider);
return service.getCurrentPlan(); try {
return await service.getCurrentPlan().timeout(const Duration(seconds: 8));
} catch (_) {
return null;
}
}); });
/// 拍照/相册直接触发(无需跳转页面)
final cameraActionProvider = NotifierProvider<CameraActionNotifier, String?>(CameraActionNotifier.new);
class CameraActionNotifier extends Notifier<String?> {
@override String? build() => null;
void trigger(String action) => state = action;
void clear() => state = null;
}

View File

@@ -17,6 +17,7 @@ class HealthDrawer extends ConsumerWidget {
final conversations = ref.watch(conversationListProvider); final conversations = ref.watch(conversationListProvider);
return Drawer( return Drawer(
width: MediaQuery.of(context).size.width * 0.8,
child: SafeArea( child: SafeArea(
child: ListView( child: ListView(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
@@ -42,9 +43,6 @@ class HealthDrawer extends ConsumerWidget {
], ],
), ),
), ),
_DrawerItem(icon: Icons.settings, label: '设置', onTap: () => pushRoute(ref, 'settings')),
const Divider(),
// 健康概览——接真实数据 // 健康概览——接真实数据
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
@@ -61,6 +59,7 @@ class HealthDrawer extends ConsumerWidget {
_HealthMetricChip(icon: Icons.monitor_heart, label: '心率', value: _metricText(data['HeartRate'], ''), onTap: () => pushRoute(ref, 'trend', params: {'type': 'heart_rate'})), _HealthMetricChip(icon: Icons.monitor_heart, label: '心率', value: _metricText(data['HeartRate'], ''), onTap: () => pushRoute(ref, 'trend', params: {'type': 'heart_rate'})),
_HealthMetricChip(icon: Icons.bloodtype, label: '血糖', value: _metricText(data['Glucose'], ''), onTap: () => pushRoute(ref, 'trend', params: {'type': 'glucose'})), _HealthMetricChip(icon: Icons.bloodtype, label: '血糖', value: _metricText(data['Glucose'], ''), onTap: () => pushRoute(ref, 'trend', params: {'type': 'glucose'})),
_HealthMetricChip(icon: Icons.air, label: '血氧', value: _metricText(data['SpO2'], '%'), onTap: () => pushRoute(ref, 'trend', params: {'type': 'spo2'})), _HealthMetricChip(icon: Icons.air, label: '血氧', value: _metricText(data['SpO2'], '%'), onTap: () => pushRoute(ref, 'trend', params: {'type': 'spo2'})),
_HealthMetricChip(icon: Icons.monitor_weight, label: '体重', value: _metricText(data['Weight'], 'kg'), onTap: () => pushRoute(ref, 'trend', params: {'type': 'weight'})),
], ],
), ),
), ),
@@ -75,6 +74,7 @@ class HealthDrawer extends ConsumerWidget {
const _HealthMetricChip(icon: Icons.monitor_heart, label: '心率', value: '--'), const _HealthMetricChip(icon: Icons.monitor_heart, label: '心率', value: '--'),
const _HealthMetricChip(icon: Icons.bloodtype, label: '血糖', value: '--'), const _HealthMetricChip(icon: Icons.bloodtype, label: '血糖', value: '--'),
const _HealthMetricChip(icon: Icons.air, label: '血氧', value: '--'), const _HealthMetricChip(icon: Icons.air, label: '血氧', value: '--'),
const _HealthMetricChip(icon: Icons.monitor_weight, label: '体重', value: '--'),
], ],
), ),
), ),
@@ -116,12 +116,7 @@ class HealthDrawer extends ConsumerWidget {
), ),
const Divider(), const Divider(),
_DrawerItem(icon: Icons.logout, label: '退出登录', onTap: () async { _DrawerItem(icon: Icons.settings, label: '设置', onTap: () => pushRoute(ref, 'settings')),
final ok = await showDialog<bool>(context: context, builder: (ctx) => AlertDialog(
title: const Text('退出登录'), content: const Text('确定退出?'),
actions: [TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定'))]));
if (ok == true) { await ref.read(authProvider.notifier).logout(); goRoute(ref, 'login'); }
}),
], ],
), ),
), ),
@@ -189,22 +184,22 @@ class _ConversationItem extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return Container( return Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFF8F7FF), color: const Color(0xFFF8F7FF),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
child: ListTile( child: ListTile(
leading: Container( leading: Container(
width: 32, width: 28,
height: 32, height: 28,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFEDEBFF), color: const Color(0xFFEDEBFF),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
), ),
child: Icon(_getAgentIcon(item.agent), size: 16, color: const Color(0xFF635BFF)), child: Icon(_getAgentIcon(item.agent), size: 14, color: const Color(0xFF635BFF)),
), ),
title: Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), title: Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500)),
subtitle: Text(item.lastMessage, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 10, color: Colors.grey[500])), subtitle: Text(item.lastMessage, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 10, color: Colors.grey[500])),
trailing: Column( trailing: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -212,7 +207,7 @@ class _ConversationItem extends ConsumerWidget {
children: [ children: [
Text(_formatTime(item.updatedAt), style: const TextStyle(fontSize: 9, color: Color(0xFFCCCCCC))), Text(_formatTime(item.updatedAt), style: const TextStyle(fontSize: 9, color: Color(0xFFCCCCCC))),
PopupMenuButton<int>( PopupMenuButton<int>(
icon: const Icon(Icons.more_vert, size: 14, color: Color(0xFFCCCCCC)), icon: const Icon(Icons.more_vert, size: 12, color: Color(0xFFCCCCCC)),
itemBuilder: (_) => [ itemBuilder: (_) => [
const PopupMenuItem(value: 1, child: Text('继续聊')), const PopupMenuItem(value: 1, child: Text('继续聊')),
const PopupMenuItem(value: 2, child: Text('删除')), const PopupMenuItem(value: 2, child: Text('删除')),