fix: 图片发送/医生加载/运动超时/用药黑屏/服药打卡
- sendImage: 本地预览→上传→远程URL替换 - doctorListProvider: 8s超时+mock医生fallback - currentExercisePlanProvider: 8s超时→显示空状态 - 用药编辑: try-catch防黑屏+刷新列表 - 服药打卡: 接入后端confirm()接口
This commit is contained in:
BIN
health_app/flutter_01.png
Normal file
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
BIN
health_app/flutter_02.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 558 KiB |
@@ -1,3 +1,4 @@
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'local_database.dart';
|
||||
|
||||
@@ -54,6 +55,19 @@ class ApiClient {
|
||||
Future<Response> delete(String path) async {
|
||||
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 刷新
|
||||
|
||||
@@ -2,6 +2,7 @@ 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 'dart:io';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/chat_provider.dart';
|
||||
@@ -18,6 +19,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
final _textCtrl = TextEditingController();
|
||||
final _scrollCtrl = ScrollController();
|
||||
bool _taskCardsExpanded = true;
|
||||
String? _pickedImagePath;
|
||||
double _lastScrollOffset = 0;
|
||||
DateTime? _lastCollapseTime;
|
||||
bool _exerciseDone = false;
|
||||
@@ -55,11 +57,16 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
|
||||
void _sendMessage() {
|
||||
final text = _textCtrl.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
final imagePath = _pickedImagePath;
|
||||
if (text.isEmpty && imagePath == null) return;
|
||||
_textCtrl.clear();
|
||||
setState(() => _taskCardsExpanded = false);
|
||||
setState(() { _taskCardsExpanded = false; _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);
|
||||
@@ -67,6 +74,16 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
final user = auth.user;
|
||||
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(
|
||||
drawer: const HealthDrawer(),
|
||||
backgroundColor: const Color(0xFFF8F7FF),
|
||||
@@ -130,20 +147,20 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
);
|
||||
}
|
||||
|
||||
// 折叠状态:只显示一行可点击的标题栏
|
||||
// 折叠状态:与展开态容器完全相同,只保留标题行
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _taskCardsExpanded = true),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
const Text('今日任务', style: TextStyle(fontSize: 13, color: Color(0xFF635BFF))),
|
||||
const SizedBox(width: 4),
|
||||
const Text('▾', style: TextStyle(fontSize: 12, color: Color(0xFF635BFF))),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 6, offset: const Offset(0, 1))]),
|
||||
child: Row(children: [
|
||||
const Text('今日任务', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||
const Spacer(),
|
||||
const Text('展开 ▾', style: TextStyle(fontSize: 12, color: Color(0xFF635BFF))),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -301,27 +318,11 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
dateLabel = '$diff天后';
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
return _taskRow(
|
||||
icon: Icons.event_available,
|
||||
label: '📋 $dateLabel ${followUp['hospital']} ${followUp['department']} ${followUp['type']}',
|
||||
status: 'pending',
|
||||
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,
|
||||
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))),
|
||||
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),
|
||||
@@ -381,11 +382,14 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
final notifier = ref.read(selectedAgentProvider.notifier);
|
||||
final newAgent = isActive ? null : agent;
|
||||
notifier.select(newAgent);
|
||||
if (newAgent != null) {
|
||||
ref.read(chatProvider.notifier).setAgent(newAgent);
|
||||
ref.read(chatProvider.notifier).insertAgentWelcome(newAgent);
|
||||
if (isActive) {
|
||||
notifier.select(null);
|
||||
} else {
|
||||
notifier.select(agent);
|
||||
ref.read(chatProvider.notifier).setAgent(agent);
|
||||
if (_welcomedAgents.add(agent)) {
|
||||
ref.read(chatProvider.notifier).insertAgentWelcome(agent);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
@@ -414,83 +418,60 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
// 智能体胶囊栏(常驻,高度36)
|
||||
_buildAgentBar(selectedAgent),
|
||||
|
||||
// 输入框(紧凑)
|
||||
// 图片预览(有选中图片时显示)
|
||||
if (_pickedImagePath != null) _buildImagePreview(),
|
||||
|
||||
// 输入框
|
||||
_buildCompactInputBar(context),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _buildCompactAgentPanel(ActiveAgent agent) {
|
||||
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 分钟"'};
|
||||
|
||||
Widget _buildImagePreview() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
decoration: BoxDecoration(color: Colors.white, border: Border(top: BorderSide(color: const 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])),
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
|
||||
decoration: const BoxDecoration(color: Colors.white, border: Border(top: BorderSide(color: Color(0xFFEEEEEE)))),
|
||||
child: Row(children: [
|
||||
Stack(children: [
|
||||
ClipRRect(
|
||||
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 SizedBox(height: 4),
|
||||
SingleChildScrollView(scrollDirection: Axis.horizontal, child: Row(children: _getAgentButtons(agent))),
|
||||
]));
|
||||
const Spacer(),
|
||||
Text('点击发送上传图片', style: TextStyle(fontSize: 12, color: Colors.grey[500])),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompactInputBar(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
decoration: BoxDecoration(color: Colors.white, border: Border(top: BorderSide(color: const Color(0xFFEEEEEE)))),
|
||||
child: Row(children: [
|
||||
IconButton(icon: const Icon(Icons.attach_file, size: 20, color: Color(0xFF666666)), onPressed: () => _showAttachmentPicker(context), padding: const EdgeInsets.all(4)),
|
||||
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())),
|
||||
IconButton(icon: const Icon(Icons.send, size: 20, color: Color(0xFF635BFF)), onPressed: _sendMessage, padding: const EdgeInsets.all(4)),
|
||||
IconButton(icon: const Icon(Icons.attach_file, size: 24, color: Color(0xFF666666)), onPressed: () => _showAttachmentPicker(context)),
|
||||
Expanded(child: TextField(
|
||||
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(),
|
||||
)),
|
||||
IconButton(icon: const Icon(Icons.send, size: 24, color: Color(0xFF635BFF)), onPressed: _sendMessage),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _getAgentButtons(ActiveAgent agent) {
|
||||
switch (agent) {
|
||||
case ActiveAgent.health: return [_agentBtn('录入血压', Icons.favorite), _agentBtn('录入血糖', Icons.bloodtype), _agentBtn('录入心率', Icons.monitor_heart), _agentBtn('录入血氧', Icons.air), _agentBtn('录入体重', Icons.monitor_weight)];
|
||||
case ActiveAgent.diet: return [_agentBtn('拍照识别', Icons.camera_alt), _agentBtn('上传照片', Icons.photo_library)];
|
||||
case ActiveAgent.medication: return [_agentBtn('用药管理', Icons.medication), _agentBtn('用药提醒', Icons.alarm)];
|
||||
case ActiveAgent.consultation: return [_agentBtn('找医生', Icons.person_search)];
|
||||
case ActiveAgent.exercise: return [_agentBtn('本周计划', Icons.calendar_view_week), _agentBtn('新建计划', Icons.add_circle_outline)];
|
||||
default: return [];
|
||||
}
|
||||
}
|
||||
|
||||
Widget _agentBtn(String label, IconData icon) {
|
||||
return Padding(
|
||||
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 {
|
||||
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; _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) {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/navigation_provider.dart';
|
||||
import '../../../providers/chat_provider.dart';
|
||||
import '../../../providers/data_providers.dart';
|
||||
|
||||
/// 对话消息列表
|
||||
class ChatMessagesView extends ConsumerWidget {
|
||||
@@ -44,14 +47,14 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, 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;
|
||||
|
||||
if (!isUser && chatState.isStreaming && msg.content.isEmpty) {
|
||||
@@ -60,7 +63,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
|
||||
switch (msg.type) {
|
||||
case MessageType.agentWelcome:
|
||||
return _buildAgentWelcomeCard(context, msg, chatState.activeAgent);
|
||||
return _buildAgentWelcomeCard(context, ref, msg, chatState.activeAgent);
|
||||
case MessageType.dataConfirm:
|
||||
return _buildDataConfirmCard(context, msg);
|
||||
case MessageType.medicationConfirm:
|
||||
@@ -80,7 +83,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
// 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 actions = agent.actions;
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
@@ -164,7 +167,9 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
child: Wrap(
|
||||
spacing: 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(
|
||||
onTap: () {},
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
onTap: () {
|
||||
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(
|
||||
width: ((screenWidth - 72) / (a.isWide ? 2 : 3)) - 10,
|
||||
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 — 增强版数据确认卡片
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
@@ -523,11 +588,6 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
final meta = msg.metadata;
|
||||
final foods = meta?['foods'] as List? ?? [];
|
||||
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? ?? '饮食均衡,多吃蔬菜水果,减少高油高糖食物摄入。';
|
||||
|
||||
return Align(
|
||||
@@ -548,158 +608,75 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(colors: [Color(0xFFFFF8E1), Color(0xFFFFF3E0)]),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
decoration: const BoxDecoration(gradient: LinearGradient(colors: [Color(0xFFFFF8E1), Color(0xFFFFF3E0)])),
|
||||
child: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
Text('🍽️ ', style: TextStyle(fontSize: 18)),
|
||||
Text('饮食分析结果', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Color(0xFF1A1A2E))),
|
||||
],
|
||||
]),
|
||||
),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 总热量大号数字
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
// ── 总热量(仅 >0 时显示) ──
|
||||
if (totalCalories > 0) ...[
|
||||
Center(child: Column(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),
|
||||
|
||||
// 三大营养素圆环指示
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
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))),
|
||||
// ── 识别食物列表 ──
|
||||
if (foods.isNotEmpty) ...[
|
||||
const Text('识别结果', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||
const SizedBox(height: 10),
|
||||
...foods.map((food) {
|
||||
final f = food as Map? ?? {};
|
||||
final fCal = (f['calories'] ?? 0) as num;
|
||||
final fPct = totalCalories > 0 ? (fCal / totalCalories * 100).clamp(0.0, 100.0) : 0.0;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(f['name'] as String? ?? '', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)),
|
||||
const Spacer(),
|
||||
Text('${fCal.toInt()} kcal', style: const TextStyle(fontSize: 12, color: Color(0xFF888888))),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
child: LinearProgressIndicator(
|
||||
value: fPct / 100,
|
||||
minHeight: 5,
|
||||
backgroundColor: const Color(0xFFF0EEFF),
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(Color(0xFFFFB74D)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
final f = food is Map ? food : <String, dynamic>{};
|
||||
final name = f['name'] as String? ?? '';
|
||||
final calories = f['calories'] as num? ?? 0;
|
||||
final portion = f['portion'] as String?;
|
||||
final nutrients = f['nutrients'] as String?;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(color: const Color(0xFFFAFAFA), borderRadius: BorderRadius.circular(10), border: Border.all(color: const Color(0xFFF0F0F0))),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
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))),
|
||||
]),
|
||||
if (portion != null && portion.isNotEmpty) Padding(padding: const EdgeInsets.only(top: 4), child: Text(portion, style: TextStyle(fontSize: 12, color: Colors.grey[500]))),
|
||||
if (nutrients != null && nutrients.isNotEmpty) Padding(padding: const EdgeInsets.only(top: 2), child: Text(nutrients, style: TextStyle(fontSize: 11, color: Colors.grey[500]))),
|
||||
]),
|
||||
);
|
||||
}),
|
||||
|
||||
// 健康评分
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
const Text('健康评分', style: TextStyle(fontSize: 13, color: Color(0xFF666666))),
|
||||
const SizedBox(width: 8),
|
||||
...List.generate(5, (i) => Padding(
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _nutrientRing(String label, double pct, Color fgColor, Color bgColor) {
|
||||
return Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
] else ...[
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: bgColor),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(color: const Color(0xFFF5F3FF), borderRadius: BorderRadius.circular(12)),
|
||||
child: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
Icon(Icons.hourglass_empty, size: 18, color: Color(0xFF999999)),
|
||||
SizedBox(width: 8),
|
||||
Text('正在分析食物中...', style: TextStyle(fontSize: 14, color: Color(0xFF999999))),
|
||||
]),
|
||||
),
|
||||
SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: CircularProgressIndicator(
|
||||
value: pct.clamp(0.0, 100.0) / 100,
|
||||
strokeWidth: 5,
|
||||
backgroundColor: Colors.transparent,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(fgColor),
|
||||
],
|
||||
|
||||
// ── 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))),
|
||||
),
|
||||
]),
|
||||
),
|
||||
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))),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -972,6 +949,10 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
|
||||
Widget _buildTextBubble(BuildContext context, ChatMessage msg) {
|
||||
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(
|
||||
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(
|
||||
@@ -992,6 +973,18 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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)
|
||||
Text(msg.content, style: const TextStyle(fontSize: 16, color: Colors.white, height: 1.4))
|
||||
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) {
|
||||
return switch (agent) {
|
||||
ActiveAgent.health => (Icons.favorite_border, '记数据', '录入血压、血糖、心率等日常指标'),
|
||||
@@ -1134,41 +1169,36 @@ class _AgentAction {
|
||||
final String label;
|
||||
final IconData icon;
|
||||
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>>{
|
||||
ActiveAgent.health: [
|
||||
_AgentAction(label: '录入血压', icon: Icons.monitor_heart_outlined),
|
||||
_AgentAction(label: '录入血糖', icon: Icons.bloodtype_outlined),
|
||||
_AgentAction(label: '录入心率', icon: Icons.favorite_border),
|
||||
_AgentAction(label: '录入血氧', icon: Icons.air_outlined),
|
||||
_AgentAction(label: '录入体重', icon: Icons.monitor_weight_outlined),
|
||||
_AgentAction(label: '录入血压', icon: Icons.monitor_heart_outlined, route: 'trend'),
|
||||
_AgentAction(label: '录入血糖', icon: Icons.bloodtype_outlined, route: 'trend'),
|
||||
_AgentAction(label: '录入心率', icon: Icons.favorite_border, route: 'trend'),
|
||||
_AgentAction(label: '录入血氧', icon: Icons.air_outlined, route: 'trend'),
|
||||
_AgentAction(label: '录入体重', icon: Icons.monitor_weight_outlined, route: 'trend'),
|
||||
],
|
||||
ActiveAgent.diet: [
|
||||
_AgentAction(label: '拍照识别', icon: Icons.camera_alt_outlined, isWide: true),
|
||||
_AgentAction(label: '上传照片', icon: Icons.photo_library_outlined, isWide: true),
|
||||
_AgentAction(label: '看舌答', icon: Icons.face_retouching_natural_outlined, isWide: true),
|
||||
_AgentAction(label: '测肤质', icon: Icons.palette_outlined, isWide: true),
|
||||
_AgentAction(label: '拍照识别', icon: Icons.camera_alt_outlined, isWide: true, route: 'camera'),
|
||||
_AgentAction(label: '上传照片', icon: Icons.photo_library_outlined, isWide: true, route: 'gallery'),
|
||||
],
|
||||
ActiveAgent.medication: [
|
||||
_AgentAction(label: '用药管理', icon: Icons.medication_liquid_outlined, isWide: true),
|
||||
_AgentAction(label: '用药提醒', icon: Icons.alarm_outlined, 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),
|
||||
_AgentAction(label: '用药管理', icon: Icons.medication_liquid_outlined, isWide: true, route: 'medications'),
|
||||
_AgentAction(label: '服药打卡', icon: Icons.check_circle_outline, isWide: true),
|
||||
],
|
||||
ActiveAgent.consultation: [],
|
||||
ActiveAgent.report: [
|
||||
_AgentAction(label: '上传报告', icon: Icons.upload_file_outlined, isWide: true),
|
||||
_AgentAction(label: '查看历史', icon: Icons.history_outlined, isWide: true),
|
||||
_AgentAction(label: '上传报告', icon: Icons.upload_file_outlined, isWide: true, route: 'reports'),
|
||||
_AgentAction(label: '查看历史', icon: Icons.history_outlined, isWide: true, route: 'reports'),
|
||||
],
|
||||
ActiveAgent.exercise: [
|
||||
_AgentAction(label: '本周计划', icon: Icons.calendar_month_outlined, isWide: true),
|
||||
_AgentAction(label: '新建计划', icon: Icons.add_task_outlined, isWide: true),
|
||||
_AgentAction(label: '今日打卡', icon: Icons.fact_check_outlined, isWide: true),
|
||||
_AgentAction(label: '本周计划', icon: Icons.calendar_month_outlined, isWide: true, route: 'exercisePlan'),
|
||||
_AgentAction(label: '新建计划', icon: Icons.add_task_outlined, isWide: true, route: 'exercisePlan'),
|
||||
_AgentAction(label: '今日打卡', icon: Icons.fact_check_outlined, isWide: true, route: 'exercisePlan'),
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -1,98 +1,528 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.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 {
|
||||
final String? medicationId;
|
||||
const MedicationEditPage({super.key, this.medicationId});
|
||||
|
||||
@override ConsumerState<MedicationEditPage> createState() => _MedicationEditPageState();
|
||||
@override
|
||||
ConsumerState<MedicationEditPage> createState() => _MedicationEditPageState();
|
||||
}
|
||||
|
||||
class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
||||
final _nameCtrl = TextEditingController(text: '阿司匹林肠溶片');
|
||||
final _dosageCtrl = TextEditingController(text: '100mg');
|
||||
String _frequency = '每日1次';
|
||||
String _time = '08:00';
|
||||
DateTime _startDate = DateTime.now();
|
||||
String _duration = '长期服用';
|
||||
final _items = <_MedicationItem>[];
|
||||
final _nameCtrls = <TextEditingController>[];
|
||||
final _doseCtrls = <TextEditingController>[];
|
||||
|
||||
@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(
|
||||
backgroundColor: Colors.white,
|
||||
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(color: Color(0xFF1A1A1A), fontWeight: FontWeight.w600)),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.chevron_left),
|
||||
onPressed: () => popRoute(ref),
|
||||
),
|
||||
title: const Text(
|
||||
'添加用药',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF1A1A1A),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存成功 ✅'), backgroundColor: Color(0xFF635BFF)));
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('保存', style: TextStyle(color: Color(0xFF635BFF), fontWeight: FontWeight.w600)),
|
||||
onPressed: _onSave,
|
||||
child: const Text(
|
||||
'保存',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF635BFF),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('药品信息', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
...List.generate(_items.length, (i) => _buildCard(i)),
|
||||
const SizedBox(height: 12),
|
||||
TextField(controller: _nameCtrl, decoration: InputDecoration(hintText: '请输入药品名称', filled: true, fillColor: Colors.grey[50], border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none))),
|
||||
const SizedBox(height: 16),
|
||||
TextField(controller: _dosageCtrl, decoration: InputDecoration(hintText: '如:100mg', filled: true, fillColor: Colors.grey[50], border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none))),
|
||||
const SizedBox(height: 24),
|
||||
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),
|
||||
]),
|
||||
_buildAddButton(),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _pickFrequency() async {
|
||||
final options = ['每日1次', '每日2次', '每日3次', '每周1次', '按需服用'];
|
||||
Widget _buildCard(int index) {
|
||||
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>(
|
||||
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 {
|
||||
final time = await showTimePicker(context: context, initialTime: TimeOfDay.now());
|
||||
if (time != null && mounted) setState(() => _time = time.format(context));
|
||||
}
|
||||
|
||||
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>(
|
||||
void _pickWeekday(int index) async {
|
||||
final item = _items[index];
|
||||
final selected = await showModalBottomSheet<int>(
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -589,16 +589,16 @@ class StaticTextPage extends ConsumerWidget {
|
||||
final contents = {
|
||||
'privacy': '''## 隐私政策
|
||||
|
||||
**更新日期:2026年1月1日**
|
||||
更新日期:2026年1月1日
|
||||
|
||||
### 一、信息收集
|
||||
我们收集以下类型的信息:
|
||||
- **账户信息**:手机号、昵称、头像(您主动提供)
|
||||
- **健康数据**:血压、心率、血糖、血氧、体重等健康指标记录
|
||||
- **用药信息**:药品名称、剂量、服药时间等用药计划数据
|
||||
- **饮食记录**:通过拍照或手动录入的饮食数据
|
||||
- **设备信息**:设备型号、操作系统版本(用于适配优化)
|
||||
- **日志信息**:App 使用情况、崩溃报告
|
||||
- 账户信息:手机号、昵称、头像(您主动提供)
|
||||
- 健康数据:血压、心率、血糖、血氧、体重等健康指标记录
|
||||
- 用药信息:药品名称、剂量、服药时间等用药计划数据
|
||||
- 饮食记录:通过拍照或手动录入的饮食数据
|
||||
- 设备信息:设备型号、操作系统版本(用于适配优化)
|
||||
- 日志信息:App 使用情况、崩溃报告
|
||||
|
||||
### 二、信息使用
|
||||
我们使用您的信息用于以下目的:
|
||||
@@ -631,19 +631,19 @@ class StaticTextPage extends ConsumerWidget {
|
||||
电话:400-xxx-xxxx''',
|
||||
'about': '''## 关于健康管家
|
||||
|
||||
**版本**:v1.0.0 (Build 20260101)
|
||||
版本:v1.0.0 (Build 20260101)
|
||||
|
||||
### 产品介绍
|
||||
健康管家是一款面向心脏术后康复患者的私人 AI 健康管理应用。以对话为核心交互方式,患者可以通过自然语言记录健康数据、获取饮食运动建议、管理用药、解读检查报告。
|
||||
|
||||
### 核心功能
|
||||
- **AI 智能问诊**:基于大语言模型的健康咨询服务
|
||||
- **健康数据管理**:血压、心率、血糖、血氧、体重的记录与趋势分析
|
||||
- **智能用药管理**:AI 解析处方,自动生成用药计划和提醒
|
||||
- **饮食识别分析**:拍照即可识别食物种类、估算热量营养素
|
||||
- **报告智能解读**:上传检查报告,AI 自动提取指标并预解读
|
||||
- **运动计划管理**:制定和追踪每日运动目标
|
||||
- **在线医生问诊**:与签约医生进行远程咨询
|
||||
- AI 智能问诊:基于大语言模型的健康咨询服务
|
||||
- 健康数据管理:血压、心率、血糖、血氧、体重的记录与趋势分析
|
||||
- 智能用药管理:AI 解析处方,自动生成用药计划和提醒
|
||||
- 饮食识别分析:拍照即可识别食物种类、估算热量营养素
|
||||
- 报告智能解读:上传检查报告,AI 自动提取指标并预解读
|
||||
- 运动计划管理:制定和追踪每日运动目标
|
||||
- 在线医生问诊:与签约医生进行远程咨询
|
||||
|
||||
### 开发团队
|
||||
由专业医疗团队与 AI 技术团队联合打造。
|
||||
|
||||
@@ -9,8 +9,14 @@ class SettingsPage extends ConsumerWidget {
|
||||
@override Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Scaffold(
|
||||
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: [
|
||||
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),
|
||||
_SetItem(icon: Icons.notifications_outlined, title: '消息通知', onTap: () => pushRoute(ref, 'notificationPrefs')),
|
||||
_SetItem(icon: Icons.medication_outlined, title: '用药提醒', subtitle: 'mmHg / mmol/L', onTap: () => pushRoute(ref, 'medications')),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'auth_provider.dart';
|
||||
import 'data_providers.dart';
|
||||
@@ -94,7 +95,7 @@ final conversationListProvider = FutureProvider<List<ConversationItem>>((ref) as
|
||||
);
|
||||
}).toList();
|
||||
} 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> {
|
||||
StreamSubscription<Map<String, dynamic>>? _subscription;
|
||||
|
||||
@@ -142,7 +119,7 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
|
||||
void setAgent(ActiveAgent a) {
|
||||
_subscription?.cancel();
|
||||
state = state.activeAgent == a ? const ChatState() : ChatState(activeAgent: a);
|
||||
state = state.copyWith(activeAgent: a);
|
||||
}
|
||||
|
||||
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 {
|
||||
if (text.trim().isEmpty || state.isStreaming) return;
|
||||
|
||||
@@ -168,6 +188,10 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
state = state.copyWith(
|
||||
messages: [...state.messages, userMsg], isStreaming: true);
|
||||
|
||||
await _sendToAI(text);
|
||||
}
|
||||
|
||||
Future<void> _sendToAI(String text) async {
|
||||
final aiMsg = ChatMessage(
|
||||
id: '${DateTime.now().millisecondsSinceEpoch}_ai',
|
||||
role: 'assistant',
|
||||
@@ -175,6 +199,8 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
state = state.copyWith(isStreaming: true);
|
||||
|
||||
try {
|
||||
final token = await ref.read(apiClientProvider).accessToken;
|
||||
if (token == null) {
|
||||
|
||||
@@ -53,9 +53,37 @@ final medicationReminderProvider = FutureProvider<List<Map<String, dynamic>>>((r
|
||||
/// 医生列表 Provider
|
||||
final doctorListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
|
||||
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
|
||||
final consultationQuotaProvider = FutureProvider<Map<String, dynamic>>((ref) async {
|
||||
final service = ref.watch(consultationServiceProvider);
|
||||
@@ -65,5 +93,18 @@ final consultationQuotaProvider = FutureProvider<Map<String, dynamic>>((ref) asy
|
||||
/// 当前运动计划 Provider
|
||||
final currentExercisePlanProvider = FutureProvider<Map<String, dynamic>?>((ref) async {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ class HealthDrawer extends ConsumerWidget {
|
||||
final conversations = ref.watch(conversationListProvider);
|
||||
|
||||
return Drawer(
|
||||
width: MediaQuery.of(context).size.width * 0.8,
|
||||
child: SafeArea(
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
@@ -42,9 +43,6 @@ class HealthDrawer extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
_DrawerItem(icon: Icons.settings, label: '设置', onTap: () => pushRoute(ref, 'settings')),
|
||||
const Divider(),
|
||||
|
||||
// 健康概览——接真实数据
|
||||
Padding(
|
||||
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.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.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.bloodtype, 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(),
|
||||
_DrawerItem(icon: Icons.logout, label: '退出登录', onTap: () async {
|
||||
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'); }
|
||||
}),
|
||||
_DrawerItem(icon: Icons.settings, label: '设置', onTap: () => pushRoute(ref, 'settings')),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -189,22 +184,22 @@ class _ConversationItem extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8F7FF),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: ListTile(
|
||||
leading: Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEDEBFF),
|
||||
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])),
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -212,7 +207,7 @@ class _ConversationItem extends ConsumerWidget {
|
||||
children: [
|
||||
Text(_formatTime(item.updatedAt), style: const TextStyle(fontSize: 9, color: Color(0xFFCCCCCC))),
|
||||
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: (_) => [
|
||||
const PopupMenuItem(value: 1, child: Text('继续聊')),
|
||||
const PopupMenuItem(value: 2, child: Text('删除')),
|
||||
|
||||
Reference in New Issue
Block a user