diff --git a/health_app/flutter_01.png b/health_app/flutter_01.png new file mode 100644 index 0000000..b47e9f2 Binary files /dev/null and b/health_app/flutter_01.png differ diff --git a/health_app/flutter_02.png b/health_app/flutter_02.png new file mode 100644 index 0000000..e6d759b Binary files /dev/null and b/health_app/flutter_02.png differ diff --git a/health_app/lib/core/api_client.dart b/health_app/lib/core/api_client.dart index 152b106..53abc4c 100644 --- a/health_app/lib/core/api_client.dart +++ b/health_app/lib/core/api_client.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'package:dio/dio.dart'; import 'local_database.dart'; @@ -54,6 +55,19 @@ class ApiClient { Future delete(String path) async { return _dio.delete(path); } + + /// 上传文件(multipart),返回文件 URL + Future 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 刷新 diff --git a/health_app/lib/pages/home/home_page.dart b/health_app/lib/pages/home/home_page.dart index 7cceefb..1fa456c 100644 --- a/health_app/lib/pages/home/home_page.dart +++ b/health_app/lib/pages/home/home_page.dart @@ -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 { final _textCtrl = TextEditingController(); final _scrollCtrl = ScrollController(); bool _taskCardsExpanded = true; + String? _pickedImagePath; double _lastScrollOffset = 0; DateTime? _lastCollapseTime; bool _exerciseDone = false; @@ -55,10 +57,15 @@ class _HomePageState extends ConsumerState { 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); - ref.read(chatProvider.notifier).sendMessage(text); + 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) { @@ -67,6 +74,16 @@ class _HomePageState extends ConsumerState { 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,19 +147,19 @@ class _HomePageState extends ConsumerState { ); } - // 折叠状态:只显示一行可点击的标题栏 + // 折叠状态:与展开态容器完全相同,只保留标题行 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 { dateLabel = '$diff天后'; } - return Padding( - padding: const EdgeInsets.only(bottom: 10), - child: GestureDetector( - behavior: HitTestBehavior.opaque, - 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)), - )), - ]), - ), + return _taskRow( + icon: Icons.event_available, + label: '📋 $dateLabel ${followUp['hospital']} ${followUp['department']} ${followUp['type']}', + status: 'pending', + onTap: () => pushRoute(ref, 'followups'), ); } @@ -340,7 +341,7 @@ class _HomePageState extends ConsumerState { 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 { 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 { // 智能体胶囊栏(常驻,高度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])), - ]), - 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)))), + padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), + decoration: const BoxDecoration(color: Colors.white, border: Border(top: BorderSide(color: 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)), + 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 Spacer(), + Text('点击发送上传图片', style: TextStyle(fontSize: 12, color: Colors.grey[500])), ]), ); } - List _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)), - ), + Widget _buildCompactInputBar(BuildContext context) { + return Container( + 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: 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), + ]), ); } - 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 _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) { diff --git a/health_app/lib/pages/home/widgets/chat_messages_view.dart b/health_app/lib/pages/home/widgets/chat_messages_view.dart index 0b7cd46..fc1bd80 100644 --- a/health_app/lib/pages/home/widgets/chat_messages_view.dart +++ b/health_app/lib/pages/home/widgets/chat_messages_view.dart @@ -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 _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 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,120 +608,71 @@ 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: [ - Text('🍽️ ', style: TextStyle(fontSize: 18)), - Text('饮食分析结果', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Color(0xFF1A1A2E))), - ], - ), + 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: [ - Text('$totalCalories', style: const TextStyle(fontSize: 36, fontWeight: FontWeight.w800, color: Color(0xFFFF8F00))), - const Text('千卡 (kcal)', style: TextStyle(fontSize: 12, color: Color(0xFFAAAAAA))), - ], - ), - ), + 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(0xFFFFB74D)), - ), - ), - ], - ), + final f = food is Map ? food : {}; + 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))), - ], + ] else ...[ + Container( + 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))), + ]), ), - - // 警告 - 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(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 — 增强版报告分析卡片 // ═══════════════════════════════════════════════════════════ @@ -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.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'), ], }; diff --git a/health_app/lib/pages/medication/medication_edit_page.dart b/health_app/lib/pages/medication/medication_edit_page.dart index af1c40c..305fe5f 100644 --- a/health_app/lib/pages/medication/medication_edit_page.dart +++ b/health_app/lib/pages/medication/medication_edit_page.dart @@ -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 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 createState() => _MedicationEditPageState(); + @override + ConsumerState createState() => _MedicationEditPageState(); } class _MedicationEditPageState extends ConsumerState { - 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 = []; + final _doseCtrls = []; - @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))), - 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), - ]), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...List.generate(_items.length, (i) => _buildCard(i)), + const SizedBox(height: 12), + _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( 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( + void _pickWeekday(int index) async { + final item = _items[index]; + final selected = await showModalBottomSheet( 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; + } + }); + } } } diff --git a/health_app/lib/pages/remaining_pages.dart b/health_app/lib/pages/remaining_pages.dart index 013ba69..119d8a0 100644 --- a/health_app/lib/pages/remaining_pages.dart +++ b/health_app/lib/pages/remaining_pages.dart @@ -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 技术团队联合打造。 diff --git a/health_app/lib/pages/settings/settings_pages.dart b/health_app/lib/pages/settings/settings_pages.dart index be9ff37..afd29ad 100644 --- a/health_app/lib/pages/settings/settings_pages.dart +++ b/health_app/lib/pages/settings/settings_pages.dart @@ -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')), diff --git a/health_app/lib/providers/chat_provider.dart b/health_app/lib/providers/chat_provider.dart index ce41622..e8fe403 100644 --- a/health_app/lib/providers/chat_provider.dart +++ b/health_app/lib/providers/chat_provider.dart @@ -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>((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 { StreamSubscription>? _subscription; @@ -142,7 +119,7 @@ class ChatNotifier extends Notifier { 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 { )]); } + Future 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 sendMessage(String text) async { if (text.trim().isEmpty || state.isStreaming) return; @@ -168,6 +188,10 @@ class ChatNotifier extends Notifier { state = state.copyWith( messages: [...state.messages, userMsg], isStreaming: true); + await _sendToAI(text); + } + + Future _sendToAI(String text) async { final aiMsg = ChatMessage( id: '${DateTime.now().millisecondsSinceEpoch}_ai', role: 'assistant', @@ -175,6 +199,8 @@ class ChatNotifier extends Notifier { createdAt: DateTime.now(), ); + state = state.copyWith(isStreaming: true); + try { final token = await ref.read(apiClientProvider).accessToken; if (token == null) { diff --git a/health_app/lib/providers/data_providers.dart b/health_app/lib/providers/data_providers.dart index 02eb6e6..a89cdf9 100644 --- a/health_app/lib/providers/data_providers.dart +++ b/health_app/lib/providers/data_providers.dart @@ -53,9 +53,37 @@ final medicationReminderProvider = FutureProvider>>((r /// 医生列表 Provider final doctorListProvider = FutureProvider>>((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>((ref) async { final service = ref.watch(consultationServiceProvider); @@ -65,5 +93,18 @@ final consultationQuotaProvider = FutureProvider>((ref) asy /// 当前运动计划 Provider final currentExercisePlanProvider = FutureProvider?>((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.new); + +class CameraActionNotifier extends Notifier { + @override String? build() => null; + void trigger(String action) => state = action; + void clear() => state = null; +} diff --git a/health_app/lib/widgets/health_drawer.dart b/health_app/lib/widgets/health_drawer.dart index 51d2469..348015d 100644 --- a/health_app/lib/widgets/health_drawer.dart +++ b/health_app/lib/widgets/health_drawer.dart @@ -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(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( - 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('删除')),