diff --git a/backend/src/Health.Infrastructure/AI/prompt_manager.cs b/backend/src/Health.Infrastructure/AI/prompt_manager.cs index fb90712..7757f79 100644 --- a/backend/src/Health.Infrastructure/AI/prompt_manager.cs +++ b/backend/src/Health.Infrastructure/AI/prompt_manager.cs @@ -21,8 +21,7 @@ public sealed class PromptManager }; private const string DefaultPrompt = """ - 你是一个心脏术后康复患者的私人 AI 健康管家,名叫"阿福"。 - 语气温暖、专业、像朋友一样关怀患者。 + 你是一位专业的AI健康管家,专注于为心脏术后康复患者提供贴心的健康管理服务。 职责: 1. 理解用户的健康需求,解析健康数据 @@ -34,6 +33,7 @@ public sealed class PromptManager - 不要提供超出你能力范围的医疗建议 - 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医 - 饮食/运动建议要结合患者档案中的疾病和限制 + - 回复语气温暖、专业、像朋友一样关怀患者 """; private const string ConsultationPrompt = """ diff --git a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs index b4a08c7..e708cab 100644 --- a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs @@ -107,6 +107,8 @@ public static class AiChatEndpoints var maxIterations = 5; var fullResponse = ""; var completedNormally = false; + var messageType = "text"; + var metadata = new Dictionary(); for (int i = 0; i < maxIterations; i++) { @@ -129,7 +131,7 @@ public static class AiChatEndpoints if (!string.IsNullOrEmpty(content)) { fullResponse += content; - await SseWriteAsync(http, new { action = "answer", data = content }, ct); + await SseWriteAsync(http, new { action = "answer", data = content, type = messageType }, ct); } } catch (JsonException) { /* 跳过解析失败的 chunk */ } @@ -160,6 +162,8 @@ public static class AiChatEndpoints } await SseWriteAsync(http, new { action = "tool_result", tool = tc.Function.Name, data = toolResult }, ct); + _UpdateMessageTypeAndMetadata(tc.Function.Name, toolResult, ref messageType, ref metadata); + messages.Add(new ChatMessage { Role = "tool", Content = JsonSerializer.Serialize(toolResult, JsonOpts), ToolCallId = tc.Id }); } } @@ -597,6 +601,40 @@ public static class AiChatEndpoints } }; + /// 根据工具调用结果更新消息类型和元数据 + private static void _UpdateMessageTypeAndMetadata(string toolName, object toolResult, ref string messageType, ref Dictionary metadata) + { + switch (toolName) + { + case "record_health_data": + messageType = "data_confirm"; + if (toolResult is IDictionary resultDict) + { + if (resultDict.TryGetValue("type", out var type)) + metadata["type"] = type.ToString(); + if (resultDict.TryGetValue("success", out var success) && success is bool b && b) + metadata["success"] = true; + } + break; + case "manage_medication": + messageType = "medication_confirm"; + if (toolResult is IDictionary medDict) + { + if (medDict.TryGetValue("name", out var name)) + metadata["name"] = name.ToString(); + if (medDict.TryGetValue("dosage", out var dosage)) + metadata["dosage"] = dosage.ToString(); + } + break; + case "estimate_food_text": + messageType = "diet_analysis"; + break; + case "analyze_report": + messageType = "report_analysis"; + break; + } + } + /// 压缩图片到合理大小供 VLM API 使用 private static void CompressImage(string inputPath, string outputPath, int maxWidth, long quality) { diff --git a/backend/tests/full_e2e_test.py b/backend/tests/full_e2e_test.py new file mode 100644 index 0000000..c7800c4 --- /dev/null +++ b/backend/tests/full_e2e_test.py @@ -0,0 +1,283 @@ +""" +健康管家 - 全流程端到端测试 +模拟真实用户操作:注册→登录→各Agent对话→数据录入→查询验证 +""" +import urllib.request, urllib.parse, json, sys, time, os + +BASE = "http://localhost:5000" +PASSED = 0 +FAILED = 0 +TOKEN = None + +def api(method, path, data=None, token=None, files=None): + """调用后端 API""" + url = f"{BASE}{path}" + headers = {"Content-Type": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + + body = None + if data: + body = json.dumps(data, ensure_ascii=False).encode("utf-8") + + req = urllib.request.Request(url, data=body, headers=headers, method=method) + try: + resp = urllib.request.urlopen(req, timeout=30) + return json.loads(resp.read().decode("utf-8")) + except Exception as e: + return {"error": str(e), "code": -1} + +def check(name, condition, detail=""): + global PASSED, FAILED + if condition: + PASSED += 1 + print(f" [PASS] {name}") + else: + FAILED += 1 + print(f" [FAIL] {name} {detail}") + +def login(phone="13800000001"): + """发送验证码 + 登录,返回 token""" + sms = api("POST", "/api/auth/send-sms", {"phone": phone}) + code = sms.get("data", {}).get("devCode", "") + if not code: + return None + result = api("POST", "/api/auth/login", {"phone": phone, "smsCode": code}) + return result.get("data", {}).get("accessToken") + +def sse_stream(token, agent_type, message): + """连接 SSE 端点,返回所有事件""" + url = f"{BASE}/api/ai/{agent_type}/chat?message={urllib.parse.quote(message)}&token={urllib.parse.quote(token or '')}" + req = urllib.request.Request(url) + events = [] + try: + resp = urllib.request.urlopen(req, timeout=60) + for line_bytes in resp: + line = line_bytes.decode("utf-8").strip() + if line.startswith("data: "): + data = line[6:] + if data == "[DONE]": + break + try: + events.append(json.loads(data)) + except: + pass + except Exception as e: + events.append({"error": str(e)}) + return events + +def section(title): + print(f"\n{'='*60}") + print(f" {title}") + print(f"{'='*60}") + +# ============================================================ +section("1. 认证流程") +# ============================================================ +print(" 1.1 发送验证码...") +sms = api("POST", "/api/auth/send-sms", {"phone": "13800000001"}) +check("发送验证码", sms.get("code") == 0, str(sms.get("message",""))) +code = sms.get("data", {}).get("devCode", "") + +print(" 1.2 验证码登录...") +login_result = api("POST", "/api/auth/login", {"phone": "13800000001", "smsCode": code}) +check("登录成功", login_result.get("code") == 0) +TOKEN = login_result.get("data", {}).get("accessToken", "") +REFRESH = login_result.get("data", {}).get("refreshToken", "") +check("返回accessToken", len(TOKEN) > 50) +check("返回refreshToken", len(REFRESH) > 20) + +print(" 1.3 刷新Token...") +refresh_result = api("POST", "/api/auth/refresh", {"refreshToken": REFRESH}) +check("刷新Token成功", refresh_result.get("code") == 0) +check("下发新Token", len(refresh_result.get("data", {}).get("accessToken", "")) > 50) + +print(" 1.4 登出...") +logout_result = api("POST", "/api/auth/logout", {"refreshToken": REFRESH}) +check("登出成功", logout_result.get("code") == 0) + +# 重新登录获取token +TOKEN = login() + +# ============================================================ +section("2. 用户与档案") +# ============================================================ +profile = api("GET", "/api/user/profile", token=TOKEN) +check("获取个人信息", profile.get("code") == 0) + +archive = api("GET", "/api/user/health-archive", token=TOKEN) +check("获取健康档案", archive.get("code") == 0) +check("档案有诊断信息", archive.get("data", {}).get("diagnosis") is not None if archive.get("data") else False, + "诊断=" + str(archive.get("data", {}).get("diagnosis", "无"))) + +# ============================================================ +section("3. 健康数据 CRUD") +# ============================================================ +print(" 3.1 录入血压...") +bp = api("POST", "/api/health-records", token=TOKEN, data={ + "type": "BloodPressure", "systolic": 128, "diastolic": 82, + "unit": "mmHg", "source": "Manual" +}) +check("录入血压", bp.get("code") == 0, str(bp.get("message",""))) + +print(" 3.2 录入心率...") +hr = api("POST", "/api/health-records", token=TOKEN, data={ + "type": "HeartRate", "value": 72, "unit": "次/分", "source": "Manual" +}) +check("录入心率", hr.get("code") == 0, str(hr.get("message",""))) + +print(" 3.3 录入血糖...") +glu = api("POST", "/api/health-records", token=TOKEN, data={ + "type": "Glucose", "value": 5.5, "unit": "mmol/L", "source": "Manual" +}) +check("录入血糖", glu.get("code") == 0, str(glu.get("message",""))) + +print(" 3.4 录入血氧...") +spo2 = api("POST", "/api/health-records", token=TOKEN, data={ + "type": "SpO2", "value": 98, "unit": "%", "source": "Manual" +}) +check("录入血氧", spo2.get("code") == 0, str(spo2.get("message",""))) + +print(" 3.5 获取最新数据...") +latest = api("GET", "/api/health-records/latest", token=TOKEN) +check("获取最新数据", latest.get("code") == 0) +check("血压存在", latest.get("data", {}).get("BloodPressure") is not None) + +print(" 3.6 获取趋势数据...") +trend = api("GET", "/api/health-records/trend?type=HeartRate&period=7", token=TOKEN) +check("获取趋势数据", trend.get("code") == 0) + +# ============================================================ +section("4. 用药管理") +# ============================================================ +print(" 4.1 获取用药列表...") +meds = api("GET", "/api/medications", token=TOKEN) +check("获取用药列表", meds.get("code") == 0) + +print(" 4.2 添加用药...") +new_med = api("POST", "/api/medications", token=TOKEN, data={ + "name": "阿司匹林", "dosage": "100mg", "frequency": "Daily", + "timeOfDay": ["08:00"], "source": "Manual", "startDate": "2026-06-02" +}) +check("添加用药", new_med.get("code") == 0, str(new_med.get("message",""))) +med_id = new_med.get("data", {}).get("id", "") + +print(" 4.3 服药打卡...") +if med_id: + confirm = api("POST", f"/api/medications/{med_id}/confirm", token=TOKEN) + check("服药打卡", confirm.get("code") == 0, str(confirm.get("message",""))) + +# ============================================================ +section("5. 饮食记录") +# ============================================================ +diet = api("GET", "/api/diet-records?date=2026-06-02", token=TOKEN) +check("查询饮食记录", diet.get("code") == 0) + +# ============================================================ +section("6. 运动计划") +# ============================================================ +print(" 6.1 获取当前计划...") +plan = api("GET", "/api/exercise-plans/current", token=TOKEN) +check("获取当前计划", plan.get("code") == 0) + +print(" 6.2 创建运动计划...") +new_plan = api("POST", "/api/exercise-plans", token=TOKEN, data={ + "weekStartDate": "2026-06-02", + "items": [ + {"dayOfWeek": 1, "exerciseType": "散步", "durationMinutes": 30, "isRestDay": False}, + {"dayOfWeek": 3, "exerciseType": "太极", "durationMinutes": 40, "isRestDay": False}, + {"dayOfWeek": 5, "exerciseType": "散步", "durationMinutes": 30, "isRestDay": False}, + ] +}) +check("创建运动计划", new_plan.get("code") == 0, str(new_plan.get("message",""))) + +# ============================================================ +section("7. 医生与问诊") +# ============================================================ +print(" 7.1 医生列表...") +docs = api("GET", "/api/doctors", token=TOKEN) +check("获取医生列表", docs.get("code") == 0) +check("有医生数据", len(docs.get("data", [])) > 0, f"共{len(docs.get('data',[]))}位医生") + +print(" 7.2 问诊配额...") +quota = api("GET", "/api/user/consultation-quota", token=TOKEN) +check("获取问诊配额", quota.get("code") == 0) + +# ============================================================ +section("8. AI 智能体对话") +# ============================================================ +agents_to_test = [ + ("default", "你好,介绍一下你自己"), + ("health", "我血压128/82"), + ("medication", "我现在在吃什么药"), + ("consultation", "最近胸口有点不舒服"), + ("diet", "我中午吃了红烧肉和米饭"), + ("exercise", "帮我查询运动计划"), +] +for agent_name, msg in agents_to_test: + print(f" 8.{agents_to_test.index((agent_name,msg))+1} {agent_name} Agent: \"{msg[:30]}...\"") + TOKEN = login() # fresh token + events = sse_stream(TOKEN, agent_name, msg) + + has_answer = any(e.get("action") == "answer" for e in events) + has_tool = any(e.get("action") == "tool_result" for e in events) + has_conv_id = any(e.get("action") == "conversation_id" for e in events) + errors = [e for e in events if e.get("action") == "error"] + + check(f"{agent_name}: 对话建立", has_conv_id) + check(f"{agent_name}: 有回复", has_answer or has_tool, + f"(events: {len(events)}, tools: {has_tool}, answer: {has_answer})") + check(f"{agent_name}: 无错误", len(errors) == 0, + f"errors: {[e.get('message','') for e in errors]}" if errors else "") + +# ============================================================ +section("9. 对话历史") +# ============================================================ +convs = api("GET", "/api/ai/conversations", token=TOKEN) +check("获取对话列表", convs.get("code") == 0) +check("有对话记录", len(convs.get("data", [])) > 0, f"共{len(convs.get('data',[]))}条") + +# ============================================================ +section("10. VLM 食物识别") +# ============================================================ +# 尝试上传测试图片 +test_img = "D:/health_project/食堂三菜一饭热量估算.png" +if os.path.exists(test_img): + # Use subprocess for multipart upload + import subprocess + cmd = [ + 'curl', '-s', '--max-time', '30', '-X', 'POST', + f'{BASE}/api/ai/analyze-food-image', + '-H', f'Authorization: Bearer {TOKEN}', + '-F', f'images=@{test_img}' + ] + r = subprocess.run(cmd, capture_output=True, text=True) + try: + vlm = json.loads(r.stdout) + check("VLM食物识别调通", vlm.get("code") == 0, str(vlm.get("message",""))) + has_data = bool(vlm.get("data", "")) + check("VLM返回数据", has_data, f"data长度: {len(str(vlm.get('data','')))}") + except: + check("VLM食物识别", False, "JSON解析失败") +else: + check("VLM测试图片存在", False, f"{test_img} 不存在") + +# ============================================================ +section("11. 报告列表") +# ============================================================ +reports = api("GET", "/api/reports", token=TOKEN) +check("获取报告列表", reports.get("code") == 0) + +# ============================================================ +section("12. 通知偏好") +# ============================================================ +notifs = api("GET", "/api/notifications/preferences", token=TOKEN) +check("获取通知偏好", notifs.get("code") == 0) + +# ============================================================ +print(f"\n{'='*60}") +print(f" 测试结果: PASS={PASSED} FAIL={FAILED} TOTAL={PASSED+FAILED}") +print(f"{'='*60}") + +if FAILED > 0: + sys.exit(1) diff --git a/health_app/lib/core/app_router.dart b/health_app/lib/core/app_router.dart index 35f8b30..ed0984f 100644 --- a/health_app/lib/core/app_router.dart +++ b/health_app/lib/core/app_router.dart @@ -8,6 +8,7 @@ import '../pages/report/report_pages.dart'; import '../pages/consultation/consultation_pages.dart'; import '../pages/settings/settings_pages.dart'; import '../pages/profile/profile_page.dart'; +import '../pages/diet/diet_capture_page.dart'; import '../pages/remaining_pages.dart'; /// 根据路由信息返回对应页面 @@ -40,6 +41,8 @@ Widget buildPage(RouteInfo route) { return const ExercisePlanPage(); case 'dietRecords': return const DietRecordListPage(); + case 'dietCapture': + return const DietCapturePage(); case 'profile': return const ProfilePage(); case 'profileEdit': diff --git a/health_app/lib/pages/diet/diet_capture_page.dart b/health_app/lib/pages/diet/diet_capture_page.dart new file mode 100644 index 0000000..5c3fc26 --- /dev/null +++ b/health_app/lib/pages/diet/diet_capture_page.dart @@ -0,0 +1,448 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; + +final dietProvider = NotifierProvider(DietNotifier.new); + +class DietState { + final String? imagePath; + final List foods; + final String mealType; + final bool isAnalyzing; + final int? healthScore; + + DietState({ + this.imagePath, + this.foods = const [], + this.mealType = 'lunch', + this.isAnalyzing = false, + this.healthScore, + }); + + DietState copyWith({ + String? imagePath, + List? foods, + String? mealType, + bool? isAnalyzing, + int? healthScore, + }) { + return DietState( + imagePath: imagePath ?? this.imagePath, + foods: foods ?? this.foods, + mealType: mealType ?? this.mealType, + isAnalyzing: isAnalyzing ?? this.isAnalyzing, + healthScore: healthScore ?? this.healthScore, + ); + } +} + +class FoodItem { + final String id; + String name; + int calories; + bool selected; + + FoodItem({ + required this.id, + required this.name, + required this.calories, + this.selected = true, + }); +} + +class DietNotifier extends Notifier { + @override + DietState build() => DietState(); + + void setImage(String path) { + state = state.copyWith(imagePath: path); + } + + void analyzeImage() async { + state = state.copyWith(isAnalyzing: true); + await Future.delayed(const Duration(seconds: 2)); + final mockFoods = [ + FoodItem(id: '1', name: '米饭', calories: 150), + FoodItem(id: '2', name: '番茄炒蛋', calories: 200), + FoodItem(id: '3', name: '红烧肉', calories: 350), + FoodItem(id: '4', name: '青菜', calories: 50), + ]; + state = state.copyWith(foods: mockFoods, isAnalyzing: false, healthScore: 3); + } + + void updateFoodName(String id, String name) { + final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: name, calories: f.calories, selected: f.selected) : f).toList(); + state = state.copyWith(foods: foods); + } + + void updateFoodCalories(String id, int calories) { + final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: f.name, calories: calories, selected: f.selected) : f).toList(); + state = state.copyWith(foods: foods); + } + + void toggleFood(String id) { + final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: f.name, calories: f.calories, selected: !f.selected) : f).toList(); + state = state.copyWith(foods: foods); + } + + void addFood() { + final newId = '${DateTime.now().millisecondsSinceEpoch}'; + final foods = [...state.foods, FoodItem(id: newId, name: '新食物', calories: 100)]; + state = state.copyWith(foods: foods); + } + + void removeFood(String id) { + final foods = state.foods.where((f) => f.id != id).toList(); + state = state.copyWith(foods: foods); + } + + void setMealType(String type) { + state = state.copyWith(mealType: type); + } + + void reset() { + state = DietState(); + } +} + +class DietCapturePage extends ConsumerWidget { + const DietCapturePage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(dietProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('拍饮食'), + centerTitle: true, + ), + body: state.imagePath == null ? _buildCaptureView(context, ref) : _buildResultView(context, ref), + ); + } + + Widget _buildCaptureView(BuildContext context, WidgetRef ref) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 180, + height: 180, + decoration: BoxDecoration( + color: const Color(0xFFF5F3FF), + borderRadius: BorderRadius.circular(90), + border: Border.all(color: const Color(0xFF635BFF), width: 2), + ), + child: const Icon(Icons.camera_alt, size: 48, color: Color(0xFF635BFF)), + ), + const SizedBox(height: 24), + const Text('拍摄或上传您的餐食照片', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500)), + const SizedBox(height: 8), + const Text('AI将识别食物并分析营养成分', style: TextStyle(fontSize: 14, color: Color(0xFF999999))), + const SizedBox(height: 40), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _captureBtn(context, ref, Icons.camera_alt, '拍照', ImageSource.camera), + const SizedBox(width: 24), + _captureBtn(context, ref, Icons.photo_library, '相册', ImageSource.gallery), + ], + ), + ], + ), + ); + } + + Widget _captureBtn(BuildContext context, WidgetRef ref, IconData icon, String label, ImageSource source) { + return Column( + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: const Color(0xFFFEFEFF), + borderRadius: BorderRadius.circular(20), + boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(20), blurRadius: 8, offset: const Offset(0, 2))], + ), + child: IconButton( + icon: Icon(icon, size: 32, color: const Color(0xFF635BFF)), + onPressed: () => _pickImage(context, ref, source), + ), + ), + const SizedBox(height: 8), + Text(label, style: const TextStyle(fontSize: 14, color: Color(0xFF666666))), + ], + ); + } + + Future _pickImage(BuildContext context, WidgetRef ref, ImageSource source) async { + final picker = ImagePicker(); + final picked = await picker.pickImage(source: source, imageQuality: 85); + if (picked != null) { + ref.read(dietProvider.notifier).setImage(picked.path); + ref.read(dietProvider.notifier).analyzeImage(); + } + } + + Widget _buildResultView(BuildContext context, WidgetRef ref) { + final state = ref.watch(dietProvider); + final totalCalories = state.foods.where((f) => f.selected).fold(0, (sum, f) => sum + f.calories); + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column(children: [ + _buildImagePreview(state.imagePath!), + const SizedBox(height: 20), + _buildMealSelector(context, ref), + const SizedBox(height: 20), + if (state.isAnalyzing) _buildAnalyzingIndicator() else _buildFoodList(context, ref), + if (!state.isAnalyzing && state.foods.isNotEmpty) ...[ + const SizedBox(height: 20), + _buildNutritionSummary(totalCalories), + const SizedBox(height: 20), + _buildHealthScore(state.healthScore ?? 0), + const SizedBox(height: 30), + _buildSubmitButton(context, ref), + ], + ]), + ); + } + + Widget _buildImagePreview(String path) { + return Container( + height: 200, + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(20), + image: DecorationImage(image: FileImage(File(path)), fit: BoxFit.cover), + ), + ); + } + + Widget _buildMealSelector(BuildContext context, WidgetRef ref) { + final state = ref.watch(dietProvider); + final meals = [ + {'type': 'breakfast', 'label': '早餐', 'icon': '🌅'}, + {'type': 'lunch', 'label': '午餐', 'icon': '☀️'}, + {'type': 'dinner', 'label': '晚餐', 'icon': '🌙'}, + {'type': 'snack', 'label': '加餐', 'icon': '🍪'}, + ]; + + return Column(children: [ + const Text('选择餐次', style: TextStyle(fontSize: 14, color: Color(0xFF666666))), + const SizedBox(height: 12), + Row(children: meals.map((meal) { + final isSelected = state.mealType == meal['type']; + return Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: ElevatedButton( + onPressed: () => ref.read(dietProvider.notifier).setMealType(meal['type']!), + child: Column(children: [ + Text(meal['icon']!, style: const TextStyle(fontSize: 20)), + const SizedBox(height: 4), + Text(meal['label']!, style: TextStyle(fontSize: 12, color: isSelected ? Colors.white : const Color(0xFF635BFF))), + ]), + style: ElevatedButton.styleFrom( + backgroundColor: isSelected ? const Color(0xFF635BFF) : const Color(0xFFF5F3FF), + foregroundColor: isSelected ? Colors.white : const Color(0xFF635BFF), + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ); + }).toList()), + ]); + } + + Widget _buildAnalyzingIndicator() { + return Center( + child: Column(children: [ + Container( + width: 60, + height: 60, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFEDEBFF), + borderRadius: BorderRadius.circular(30), + ), + child: const CircularProgressIndicator(strokeWidth: 3, color: Color(0xFF635BFF)), + ), + const SizedBox(height: 16), + const Text('AI 正在识别食物...', style: TextStyle(fontSize: 16, color: Color(0xFF666666))), + ]), + ); + } + + Widget _buildFoodList(BuildContext context, WidgetRef ref) { + final state = ref.watch(dietProvider); + + return Container( + decoration: BoxDecoration( + color: const Color(0xFFFEFEFF), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5), + ), + child: Column(children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row(children: [ + const Text('🍽️', style: TextStyle(fontSize: 20)), + const SizedBox(width: 8), + const Text('识别结果', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + const Spacer(), + IconButton( + icon: const Icon(Icons.add, size: 20, color: Color(0xFF635BFF)), + onPressed: () => ref.read(dietProvider.notifier).addFood(), + ), + ]), + ), + ...state.foods.map((food) => _buildFoodItem(context, ref, food)), + ]), + ); + } + + Widget _buildFoodItem(BuildContext context, WidgetRef ref, FoodItem food) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: food.selected ? const Color(0xFFF5F3FF) : const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(16), + ), + child: Row(children: [ + Checkbox( + value: food.selected, + onChanged: (v) => ref.read(dietProvider.notifier).toggleFood(food.id), + activeColor: const Color(0xFF635BFF), + ), + const SizedBox(width: 8), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + TextField( + decoration: const InputDecoration(border: InputBorder.none, hintText: '食物名称'), + controller: TextEditingController(text: food.name), + onChanged: (v) => ref.read(dietProvider.notifier).updateFoodName(food.id, v), + style: const TextStyle(fontSize: 16), + ), + Row(children: [ + const Text('热量:', style: TextStyle(fontSize: 12, color: Color(0xFF999999))), + SizedBox( + width: 60, + child: TextField( + decoration: const InputDecoration(border: InputBorder.none, hintText: '0'), + controller: TextEditingController(text: food.calories.toString()), + keyboardType: TextInputType.number, + onChanged: (v) => ref.read(dietProvider.notifier).updateFoodCalories(food.id, int.tryParse(v) ?? 0), + style: TextStyle(fontSize: 12, color: const Color(0xFF635BFF)), + ), + ), + const Text('kcal', style: TextStyle(fontSize: 12, color: Color(0xFF999999))), + ]), + ]), + ), + IconButton( + icon: const Icon(Icons.delete, size: 18, color: Color(0xFFCCCCCC)), + onPressed: () => ref.read(dietProvider.notifier).removeFood(food.id), + ), + ]), + ); + } + + Widget _buildNutritionSummary(int totalCalories) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF5F3FF), + borderRadius: BorderRadius.circular(16), + ), + child: Row(children: [ + const Icon(Icons.fireplace, size: 28, color: Color(0xFFFF6B35)), + const SizedBox(width: 12), + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('总热量', style: TextStyle(fontSize: 14, color: Color(0xFF666666))), + Text('$totalCalories kcal', style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w600)), + ]), + const Spacer(), + Column(crossAxisAlignment: CrossAxisAlignment.end, children: [ + const Text('推荐摄入量', style: TextStyle(fontSize: 12, color: Color(0xFF999999))), + const Text('午餐约 500-700 kcal', style: TextStyle(fontSize: 12, color: Color(0xFF999999))), + ]), + ]), + ); + } + + Widget _buildHealthScore(int score) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFFEFEFF), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5), + ), + child: Column(children: [ + const Text('🥗 健康评分', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(5, (i) => Icon( + Icons.star, + size: 36, + color: i < score ? const Color(0xFFFFB800) : Colors.grey[200], + )), + ), + const SizedBox(height: 12), + Text(_getScoreComment(score), style: TextStyle(fontSize: 14, color: _getScoreColor(score))), + ]), + ); + } + + String _getScoreComment(int score) { + switch (score) { + case 1: return '饮食不太健康,建议多吃蔬菜'; + case 2: return '需要改善,减少油腻食物'; + case 3: return '还不错,继续保持均衡饮食'; + case 4: return '很健康!营养搭配合理'; + case 5: return '非常健康!饮食管理很棒'; + default: return '请完善食物信息'; + } + } + + Color _getScoreColor(int score) { + switch (score) { + case 1: return const Color(0xFFE53935); + case 2: return const Color(0xFFF9A825); + case 3: return const Color(0xFF635BFF); + case 4: return const Color(0xFF43A047); + case 5: return const Color(0xFF00C853); + default: return Colors.grey[400]!; + } + } + + Widget _buildSubmitButton(BuildContext context, WidgetRef ref) { + return SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('饮食记录已保存 ✅'), + backgroundColor: Color(0xFF635BFF), + )); + Navigator.pop(context); + }, + child: const Text('保存记录'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF635BFF), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), + padding: const EdgeInsets.symmetric(vertical: 16), + textStyle: const TextStyle(fontSize: 16), + ), + ), + ); + } +} \ No newline at end of file diff --git a/health_app/lib/pages/home/home_page.dart b/health_app/lib/pages/home/home_page.dart index c920cc9..afcf9ce 100644 --- a/health_app/lib/pages/home/home_page.dart +++ b/health_app/lib/pages/home/home_page.dart @@ -6,6 +6,7 @@ import '../../core/api_client.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; import '../../providers/chat_provider.dart'; +import '../../providers/data_providers.dart'; import '../../widgets/agent_bar.dart'; import '../../widgets/health_drawer.dart'; import 'widgets/chat_messages_view.dart'; @@ -21,6 +22,13 @@ class _HomePageState extends ConsumerState { final _textCtrl = TextEditingController(); final _scrollCtrl = ScrollController(); bool _taskCardsExpanded = true; + bool _showExpandButton = false; + + @override + void initState() { + super.initState(); + _scrollCtrl.addListener(_onScroll); + } @override void dispose() { @@ -29,6 +37,14 @@ class _HomePageState extends ConsumerState { super.dispose(); } + void _onScroll() { + if (_scrollCtrl.offset > 50 && !_showExpandButton) { + setState(() => _showExpandButton = true); + } else if (_scrollCtrl.offset <= 50 && _showExpandButton) { + setState(() => _showExpandButton = false); + } + } + void _sendMessage() { final text = _textCtrl.text.trim(); if (text.isEmpty) return; @@ -44,18 +60,40 @@ class _HomePageState extends ConsumerState { return Scaffold( drawer: const HealthDrawer(), body: SafeArea( - child: Column(children: [ - _buildHeader(context), - if (_taskCardsExpanded) _buildTaskCards(chatState), - Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)), - if (selectedAgent != null) _buildAgentPanel(context, selectedAgent), - const AgentBar(), - _buildInputBar(), + child: Stack(children: [ + Column(children: [ + _buildHeader(context), + if (_taskCardsExpanded) _buildTaskCards(), + Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)), + _buildAgentPanel(context, selectedAgent), + const AgentBar(), + _buildInputBar(), + ]), + _buildExpandButton(), ]), ), ); } + Widget _buildExpandButton() { + if (!_showExpandButton || _taskCardsExpanded) return const SizedBox.shrink(); + + return Positioned( + top: 60, + right: 16, + child: AnimatedOpacity( + opacity: _showExpandButton ? 1.0 : 0.0, + duration: const Duration(milliseconds: 300), + child: FloatingActionButton( + onPressed: () => setState(() => _taskCardsExpanded = true), + mini: true, + backgroundColor: const Color(0xFF635BFF), + child: const Icon(Icons.keyboard_arrow_down, size: 20), + ), + ), + ); + } + Widget _buildHeader(BuildContext context) { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), @@ -72,55 +110,289 @@ class _HomePageState extends ConsumerState { ); } - Widget _buildTaskCards(ChatState chatState) { - return GestureDetector( - onVerticalDragUpdate: (d) { if (d.delta.dy < -10) setState(() => _taskCardsExpanded = false); }, - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: const Color(0xFFEDEBFF), - borderRadius: BorderRadius.circular(12), - ), - child: Column(children: [ - Row(children: [ - const Icon(Icons.wb_sunny, size: 18, color: Color(0xFF635BFF)), - const SizedBox(width: 8), - const Text('早上好!', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), - const Spacer(), - GestureDetector( - onTap: () => setState(() => _taskCardsExpanded = false), - child: const Icon(Icons.keyboard_arrow_up, size: 20, color: Color(0xFF666666)), - ), + Widget _buildTaskCards() { + final latestHealth = ref.watch(latestHealthProvider); + + return latestHealth.when( + data: (data) { + final tasks = _getTaskCards(data); + if (tasks.isEmpty) return const SizedBox.shrink(); + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFFEFEFF), + borderRadius: BorderRadius.circular(24), + boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))], + ), + child: Column(children: [ + Row(children: [ + const Icon(Icons.wb_sunny, size: 20, color: Color(0xFFFFB800)), + const SizedBox(width: 8), + Text(_getGreeting(), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), + const Spacer(), + GestureDetector( + onTap: () => setState(() => _taskCardsExpanded = false), + child: const Icon(Icons.keyboard_arrow_down, size: 22, color: Color(0xFF999999)), + ), + ]), + const SizedBox(height: 12), + Column(children: tasks), ]), - if (chatState.noticeText != null) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text(chatState.noticeText!, style: const TextStyle(fontSize: 14, color: Color(0xFF666666))), - ), + ); + }, + loading: () => const SizedBox.shrink(), + error: (_, __) { + final tasks = _getTaskCards(const {}); + if (tasks.isEmpty) return const SizedBox.shrink(); + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFFEFEFF), + borderRadius: BorderRadius.circular(24), + boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))], + ), + child: Column(children: [ + Row(children: [ + const Icon(Icons.wb_sunny, size: 20, color: Color(0xFFFFB800)), + const SizedBox(width: 8), + Text(_getGreeting(), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), + const Spacer(), + GestureDetector( + onTap: () => setState(() => _taskCardsExpanded = false), + child: const Icon(Icons.keyboard_arrow_down, size: 22, color: Color(0xFF999999)), + ), + ]), + const SizedBox(height: 12), + Column(children: tasks), + ]), + ); + }, + ); + } + + String _getGreeting() { + final hour = DateTime.now().hour; + if (hour < 6) return '夜深了'; + if (hour < 9) return '早上好'; + if (hour < 12) return '上午好'; + if (hour < 14) return '中午好'; + if (hour < 18) return '下午好'; + if (hour < 22) return '晚上好'; + return '夜深了'; + } + + List _getTaskCards(Map healthData) { + final cards = []; + + cards.add(_buildMedicationCard()); + cards.add(_buildExerciseCard()); + cards.add(_buildMeasurementCard()); + + final abnormalCards = _buildAbnormalCards(healthData); + cards.addAll(abnormalCards); + + final summaryCard = _buildSummaryCard(healthData); + if (summaryCard != null) cards.add(summaryCard); + + return cards; + } + + Widget _buildMedicationCard() { + return _buildTaskCard( + '💊', + '计划 8:00 吃 阿司匹林 100mg', + Icons.check_circle_outline, + () => _handleMedicationCheck(), + type: 'medication', + ); + } + + Widget _buildExerciseCard() { + return _buildTaskCard( + '🏃', + '今日待运动:散步 30 分钟', + Icons.check_circle_outline, + () => _handleExerciseCheck(), + type: 'exercise', + ); + } + + Widget _buildMeasurementCard() { + return _buildTaskCard( + '🩺', + '今日待测量:血压', + Icons.arrow_forward_ios, + () => _textCtrl.text = '血压 ', + type: 'measurement', + ); + } + + List _buildAbnormalCards(Map healthData) { + final cards = []; + + final bp = healthData['BloodPressure']; + if (bp != null && bp is Map) { + final systolic = bp['systolic']; + final diastolic = bp['diastolic']; + if (systolic != null && systolic >= 140) { + cards.add(_buildTaskCard( + '⚠️', + '昨日血压 ${systolic}/${diastolic ?? '--'},偏高', + Icons.arrow_forward_ios, + () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'}), + type: 'warning', + highlight: true, + )); + } + } + + final hr = healthData['HeartRate']; + if (hr != null && hr is Map) { + final value = hr['value']; + if (value != null && (value > 100 || value < 60)) { + cards.add(_buildTaskCard( + '⚠️', + '昨日心率 $value,${value > 100 ? '偏高' : '偏低'}', + Icons.arrow_forward_ios, + () => pushRoute(ref, 'trend', params: {'type': 'heart_rate'}), + type: 'warning', + highlight: true, + )); + } + } + + return cards; + } + + Widget? _buildSummaryCard(Map healthData) { + final values = []; + + final bp = healthData['BloodPressure']; + if (bp != null && bp is Map) { + final sys = bp['systolic']; + final dia = bp['diastolic']; + if (sys != null && dia != null) values.add('血压 $sys/$dia'); + } + + final hr = healthData['HeartRate']; + if (hr != null && hr is Map && hr['value'] != null) { + values.add('心率 ${hr['value']}'); + } + + final glucose = healthData['Glucose']; + if (glucose != null && glucose is Map && glucose['value'] != null) { + values.add('血糖 ${glucose['value']}'); + } + + if (values.isEmpty) return null; + + return _buildTaskCard( + '📊', + '今日已记录:${values.join('、')}', + Icons.arrow_forward_ios, + () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'}), + type: 'summary', + ); + } + + Widget _buildTaskCard(String icon, String text, IconData actionIcon, VoidCallback onTap, {String type = '', bool highlight = false}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: highlight ? BoxDecoration( + color: const Color(0xFFFDF2F2), + borderRadius: BorderRadius.circular(12), + ) : null, + child: Row(children: [ + Text(icon, style: const TextStyle(fontSize: 20)), + const SizedBox(width: 10), + Expanded(child: Text(text, style: TextStyle( + fontSize: 14, + color: highlight ? const Color(0xFFDC2626) : const Color(0xFF333333), + ))), + GestureDetector( + onTap: onTap, + child: Icon(actionIcon, size: 20, color: highlight ? const Color(0xFFDC2626) : const Color(0xFF635BFF)), + ), ]), ), ); } - Widget _buildAgentPanel(BuildContext context, ActiveAgent agent) { - return Container( + void _handleMedicationCheck() async { + await ref.read(medicationServiceProvider).confirm(''); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('已记录服药 ✅'), + backgroundColor: Color(0xFF635BFF), + duration: Duration(seconds: 2), + )); + } + + void _handleExerciseCheck() async { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('已完成运动 ✅'), + backgroundColor: Color(0xFF635BFF), + duration: Duration(seconds: 2), + )); + } + + Widget _buildAgentPanel(BuildContext context, ActiveAgent? agent) { + if (agent == null) return const SizedBox.shrink(); + + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: Colors.white, - borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), - boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(20), blurRadius: 8, offset: const Offset(0, -2))], + color: const Color(0xFFFEFEFF), + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(20), blurRadius: 12, offset: const Offset(0, -4))], ), - child: Column(mainAxisSize: MainAxisSize.min, children: _getAgentButtons(agent)), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + _buildAgentPanelHeader(agent), + const SizedBox(height: 12), + ..._getAgentButtons(agent), + ]), ); } + Widget _buildAgentPanelHeader(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 分钟"', + }; + + return Column(children: [ + Text(titles[agent] ?? '', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), + const SizedBox(height: 4), + Text(tips[agent] ?? '', style: const TextStyle(fontSize: 12, color: Color(0xFF999999))), + ]); + } + List _getAgentButtons(ActiveAgent agent) { final buttons = []; if (agent == ActiveAgent.health) { buttons.add(_panelBtn('手动录入血压', Icons.favorite)); buttons.add(_panelBtn('手动录入血糖', Icons.bloodtype)); buttons.add(_panelBtn('手动录入心率', Icons.monitor_heart)); + buttons.add(_panelBtn('手动录入血氧', Icons.air)); + buttons.add(_panelBtn('手动录入体重', Icons.monitor_weight)); } else if (agent == ActiveAgent.diet) { buttons.add(_panelBtn('拍照', Icons.camera_alt)); buttons.add(_panelBtn('上传照片', Icons.photo_library)); @@ -141,15 +413,16 @@ class _HomePageState extends ConsumerState { padding: const EdgeInsets.only(bottom: 8), child: SizedBox( width: double.infinity, - child: OutlinedButton.icon( + child: ElevatedButton.icon( onPressed: () => _onAgentAction(label), - icon: Icon(icon, size: 20), - label: Text(label), - style: OutlinedButton.styleFrom( + icon: Icon(icon, size: 18), + label: Text(label, style: const TextStyle(fontSize: 14)), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFF5F3FF), foregroundColor: const Color(0xFF635BFF), - side: const BorderSide(color: Color(0xFF635BFF)), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), - padding: const EdgeInsets.symmetric(vertical: 12), + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), ), ), ), @@ -159,10 +432,10 @@ class _HomePageState extends ConsumerState { void _onAgentAction(String label) { switch (label) { case '拍照': - _pickImage(ImageSource.camera); + pushRoute(ref, 'dietCapture'); break; case '上传照片': - _pickImage(ImageSource.gallery); + pushRoute(ref, 'dietCapture'); break; case '手动录入血压': _textCtrl.text = '血压 '; @@ -173,6 +446,12 @@ class _HomePageState extends ConsumerState { case '手动录入心率': _textCtrl.text = '心率 '; break; + case '手动录入血氧': + _textCtrl.text = '血氧 '; + break; + case '手动录入体重': + _textCtrl.text = '体重 '; + break; case '用药管理': pushRoute(ref, 'medications'); break; 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 781c2ac..a16b572 100644 --- a/health_app/lib/pages/home/widgets/chat_messages_view.dart +++ b/health_app/lib/pages/home/widgets/chat_messages_view.dart @@ -19,9 +19,19 @@ class ChatMessagesView extends ConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.chat_bubble_outline, size: 48, color: Colors.grey[300]), - const SizedBox(height: 12), + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: const Color(0xFFEDEBFF), + borderRadius: BorderRadius.circular(40), + ), + child: const Icon(Icons.health_and_safety, size: 40, color: Color(0xFF635BFF)), + ), + const SizedBox(height: 16), Text('开始和 AI 健康管家对话吧', style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: 8), + Text('记录健康数据,获取专业建议', style: TextStyle(fontSize: 14, color: Colors.grey[400])), ], ), ); @@ -34,48 +44,112 @@ class ChatMessagesView extends ConsumerWidget { itemCount: messages.length, itemBuilder: (context, index) { final msg = messages[messages.length - 1 - index]; - return _buildMessageBubble(context, msg, chatState); + return _buildMessageContent(context, msg, chatState); }, ); } - Widget _buildMessageBubble(BuildContext context, ChatMessage msg, ChatState chatState) { + Widget _buildMessageContent(BuildContext context, ChatMessage msg, ChatState chatState) { + final isUser = msg.isUser; + + if (!isUser && chatState.isStreaming && msg.content.isEmpty) { + return _buildThinkingBubble(context, chatState.thinkingText); + } + + switch (msg.type) { + case MessageType.dataConfirm: + return _buildDataConfirmCard(context, msg); + case MessageType.medicationConfirm: + return _buildMedicationConfirmCard(context, msg); + case MessageType.dietAnalysis: + return _buildDietAnalysisCard(context, msg); + case MessageType.reportAnalysis: + return _buildReportAnalysisCard(context, msg); + case MessageType.quickOptions: + return _buildQuickOptionsCard(context, msg); + default: + return _buildTextBubble(context, msg); + } + } + + Widget _buildThinkingBubble(BuildContext context, String? thinkingText) { + return Align( + alignment: Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: const Color(0xFFFEFEFF), + borderRadius: const BorderRadius.only(topLeft: Radius.circular(4), topRight: Radius.circular(20), bottomLeft: Radius.circular(20), bottomRight: Radius.circular(20)), + border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5), + boxShadow: const [BoxShadow(color: Color(0xFF635BFF), blurRadius: 4, offset: Offset(0, 2))], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 24, + height: 24, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFEDEBFF), + borderRadius: BorderRadius.circular(12), + ), + child: const CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF635BFF)), + ), + const SizedBox(width: 10), + const Text('正在分析...', style: TextStyle(fontSize: 14, color: Color(0xFF999999))), + ], + ), + ), + ); + } + + Widget _buildTextBubble(BuildContext context, ChatMessage msg) { final isUser = msg.isUser; return Align( alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, child: Container( margin: const EdgeInsets.only(bottom: 12), - constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.78), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.85), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration( - color: isUser ? const Color(0xFF635BFF) : Colors.white, - borderRadius: BorderRadius.circular(16), - border: isUser ? null : const Border(left: BorderSide(color: Color(0xFF635BFF), width: 3)), + color: isUser ? const Color(0xFF635BFF) : const Color(0xFFFEFEFF), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(isUser ? 20 : 4), + topRight: Radius.circular(isUser ? 4 : 20), + bottomLeft: const Radius.circular(20), + bottomRight: const Radius.circular(20), + ), + border: isUser ? null : Border.all(color: const Color(0xFFE8E6FF), width: 1.5), + boxShadow: isUser ? [] : [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (!isUser && chatState.isStreaming && msg.content.isEmpty) - _buildThinkingIndicator() - else if (isUser) - Text(msg.content, style: const TextStyle(fontSize: 16, color: Colors.white)) + if (isUser) + Text(msg.content, style: const TextStyle(fontSize: 16, color: Colors.white, height: 1.4)) else MarkdownBody( - data: msg.content.isEmpty ? '...' : msg.content, + data: msg.content, selectable: true, styleSheet: MarkdownStyleSheet( - p: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A)), - h1: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - code: TextStyle(fontSize: 14, backgroundColor: Colors.grey[200]), + p: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A), height: 1.5), + h1: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)), + h2: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)), + code: const TextStyle(fontSize: 14, backgroundColor: Colors.grey), ), ), - if (!isUser && msg.content.isNotEmpty && !chatState.isStreaming) + if (!isUser && !msg.content.isEmpty) Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - 'AI 健康管家 · 仅供参考', - style: TextStyle(fontSize: 12, color: Colors.grey[400]), - ), + padding: const EdgeInsets.only(top: 10), + child: Row(children: [ + const CircleAvatar(radius: 10, backgroundColor: Color(0xFFEDEBFF), child: Icon(Icons.chat_bubble_outline, size: 14, color: Color(0xFF635BFF))), + const SizedBox(width: 6), + Text('健康管家', style: TextStyle(fontSize: 12, color: Colors.grey[400])), + const SizedBox(width: 4), + Text('仅供参考', style: TextStyle(fontSize: 11, color: Colors.grey[300])), + ]), ), ], ), @@ -83,14 +157,350 @@ class ChatMessagesView extends ConsumerWidget { ); } - Widget _buildThinkingIndicator() { - return const Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)), - SizedBox(width: 8), - Text('思考中...', style: TextStyle(fontSize: 14, color: Color(0xFF999999))), - ], + Widget _buildDataConfirmCard(BuildContext context, ChatMessage msg) { + final meta = msg.metadata; + final metricType = meta?['type'] as String? ?? ''; + final value = meta?['value'] as String? ?? ''; + final abnormal = meta?['abnormal'] as bool? ?? false; + + return Align( + alignment: Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(bottom: 12), + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.85), + decoration: BoxDecoration( + color: const Color(0xFFFEFEFF), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5), + boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: Color(0xFFF5F3FF), + borderRadius: BorderRadius.only(topLeft: Radius.circular(20), topRight: Radius.circular(20)), + ), + child: Row(children: [ + const Icon(Icons.check_circle, size: 20, color: Color(0xFF43A047)), + const SizedBox(width: 8), + const Text('已记录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF43A047))), + ]), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Column(children: [ + Row(children: [ + Text( + _getMetricIcon(metricType), + style: const TextStyle(fontSize: 24), + ), + const SizedBox(width: 12), + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(_getMetricName(metricType), style: const TextStyle(fontSize: 14, color: Color(0xFF666666))), + const SizedBox(height: 4), + Text(value, style: TextStyle(fontSize: 24, fontWeight: FontWeight.w600, color: abnormal ? const Color(0xFFE53935) : const Color(0xFF1A1A1A))), + ]), + const Spacer(), + if (abnormal) const Icon(Icons.warning_amber, size: 20, color: Color(0xFFE53935)), + ]), + if (abnormal) + const Padding( + padding: EdgeInsets.only(top: 12), + child: Text('⚠️ 数值超出正常范围,请关注', style: TextStyle(fontSize: 14, color: Color(0xFFE53935))), + ), + const SizedBox(height: 12), + Row(children: [ + Expanded( + child: OutlinedButton( + onPressed: () {}, + child: const Text('编辑'), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF635BFF), + side: const BorderSide(color: Color(0xFF635BFF)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton( + onPressed: () {}, + child: const Text('确认'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF635BFF), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + ), + ), + ), + ]), + ]), + ), + ], + ), + ), ); } + + Widget _buildMedicationConfirmCard(BuildContext context, ChatMessage msg) { + final meta = msg.metadata; + final name = meta?['name'] as String? ?? ''; + final dosage = meta?['dosage'] as String? ?? ''; + final time = meta?['time'] as String? ?? ''; + + return Align( + alignment: Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(bottom: 12), + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.85), + decoration: BoxDecoration( + color: const Color(0xFFFEFEFF), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5), + boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))], + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column(children: [ + Row(children: [ + const Text('💊', style: TextStyle(fontSize: 28)), + const SizedBox(width: 12), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(name, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + if (dosage.isNotEmpty) Text(dosage, style: const TextStyle(fontSize: 14, color: Color(0xFF666666))), + if (time.isNotEmpty) Text('每天 $time', style: const TextStyle(fontSize: 14, color: Color(0xFF666666))), + ]), + ), + ]), + const SizedBox(height: 16), + const Text('需要调整吗?', style: TextStyle(fontSize: 14, color: Color(0xFF666666))), + const SizedBox(height: 12), + Row(children: [ + Expanded(child: _medBtn('确认', Icons.check, Colors.white, const Color(0xFF635BFF))), + const SizedBox(width: 8), + Expanded(child: _medBtn('修改时间', Icons.access_time, const Color(0xFF635BFF), Colors.white)), + const SizedBox(width: 8), + Expanded(child: _medBtn('改剂量', Icons.edit, const Color(0xFF635BFF), Colors.white)), + ]), + ]), + ), + ), + ); + } + + Widget _medBtn(String label, IconData icon, Color textColor, Color bgColor) { + return ElevatedButton( + onPressed: () {}, + child: Row(children: [Icon(icon, size: 16), const SizedBox(width: 4), Text(label, style: TextStyle(fontSize: 12))]), + style: ElevatedButton.styleFrom( + backgroundColor: bgColor, + foregroundColor: textColor, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + padding: const EdgeInsets.symmetric(vertical: 10), + ), + ); + } + + Widget _buildDietAnalysisCard(BuildContext context, ChatMessage msg) { + 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? ?? []; + + return Align( + alignment: Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(bottom: 12), + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.85), + decoration: BoxDecoration( + color: const Color(0xFFFEFEFF), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5), + boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))], + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('🍽️ 饮食分析', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + const SizedBox(height: 12), + Column(children: foods.map((food) { + final f = food as Map? ?? {}; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row(children: [ + Text(f['name'] as String? ?? '', style: const TextStyle(fontSize: 14)), + const Spacer(), + Text('${f['calories'] ?? 0} kcal', style: TextStyle(fontSize: 14, color: Colors.grey[500])), + ]), + ); + }).toList()), + const SizedBox(height: 12), + Row(children: [ + const Text('总热量', style: TextStyle(fontSize: 14, color: Color(0xFF666666))), + const Spacer(), + Text('$totalCalories kcal', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + ]), + const SizedBox(height: 12), + Row(children: [ + const Text('健康评分', style: TextStyle(fontSize: 14, color: Color(0xFF666666))), + const SizedBox(width: 8), + Row(children: List.generate(5, (i) => Icon(Icons.star, size: 16, color: i < rating ? const Color(0xFFFFB800) : Colors.grey[300]))), + ]), + if (warnings.isNotEmpty) ...[ + const SizedBox(height: 12), + ...warnings.map((w) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text('⚠️ $w', style: TextStyle(fontSize: 14, color: const Color(0xFFE53935))), + )), + ], + const SizedBox(height: 12), + const Text('建议:饮食均衡,多吃蔬菜水果', style: TextStyle(fontSize: 14, color: Color(0xFF666666))), + ]), + ), + ), + ); + } + + Widget _buildReportAnalysisCard(BuildContext context, ChatMessage msg) { + final meta = msg.metadata; + final reportType = meta?['type'] as String? ?? ''; + final indicators = meta?['indicators'] as List? ?? []; + + return Align( + alignment: Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(bottom: 12), + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.85), + decoration: BoxDecoration( + color: const Color(0xFFFEFEFF), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5), + boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))], + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + const Text('📋', style: TextStyle(fontSize: 20)), + const SizedBox(width: 8), + Text(reportType, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + ]), + const SizedBox(height: 12), + const Text('AI 预解读结果', style: TextStyle(fontSize: 14, color: Color(0xFF666666))), + const SizedBox(height: 8), + Column(children: indicators.map((ind) { + final i = ind as Map? ?? {}; + final name = i['name'] as String? ?? ''; + final value = i['value'] as String? ?? ''; + final status = i['status'] as String? ?? 'normal'; + Color statusColor; + switch (status) { + case 'high': statusColor = const Color(0xFFE53935); break; + case 'low': statusColor = const Color(0xFFF9A825); break; + default: statusColor = const Color(0xFF43A047); + } + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row(children: [ + Expanded(child: Text(name, style: const TextStyle(fontSize: 14))), + Text(value, style: TextStyle(fontSize: 14, color: statusColor, fontWeight: FontWeight.w600)), + const SizedBox(width: 8), + Icon(status == 'normal' ? Icons.check_circle : Icons.warning_amber, size: 16, color: statusColor), + ]), + ); + }).toList()), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFFEF3C7), + borderRadius: BorderRadius.circular(12), + ), + child: const Text('⚠️ AI 预解读,待医生确认', style: TextStyle(fontSize: 13, color: Color(0xFFD97706))), + ), + const SizedBox(height: 12), + Center( + child: OutlinedButton( + onPressed: () {}, + child: const Text('查看原始图片'), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFF635BFF), + side: const BorderSide(color: Color(0xFF635BFF)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + ), + ), + ), + ]), + ), + ), + ); + } + + Widget _buildQuickOptionsCard(BuildContext context, ChatMessage msg) { + final meta = msg.metadata; + final options = meta?['options'] as List? ?? []; + + return Align( + alignment: Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(bottom: 12), + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.85), + decoration: BoxDecoration( + color: const Color(0xFFFEFEFF), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5), + boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))], + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column(children: [ + Text(msg.content, style: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A))), + const SizedBox(height: 12), + Wrap(spacing: 8, runSpacing: 8, children: options.map((opt) { + final o = opt as Map? ?? {}; + return ElevatedButton( + onPressed: () {}, + child: Text(o['label'] as String? ?? '', style: const TextStyle(fontSize: 14)), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFF5F3FF), + foregroundColor: const Color(0xFF635BFF), + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + ), + ); + }).toList()), + ]), + ), + ), + ); + } + + String _getMetricIcon(String type) { + switch (type.toLowerCase()) { + case 'blood_pressure': return '🩺'; + case 'heart_rate': return '💓'; + case 'glucose': return '💉'; + case 'spo2': return '🫁'; + case 'weight': return '⚖️'; + default: return '📊'; + } + } + + String _getMetricName(String type) { + switch (type.toLowerCase()) { + case 'blood_pressure': return '血压'; + case 'heart_rate': return '心率'; + case 'glucose': return '血糖'; + case 'spo2': return '血氧'; + case 'weight': return '体重'; + default: return '健康指标'; + } + } } diff --git a/health_app/lib/pages/remaining_pages.dart b/health_app/lib/pages/remaining_pages.dart index 888c7b0..456d850 100644 --- a/health_app/lib/pages/remaining_pages.dart +++ b/health_app/lib/pages/remaining_pages.dart @@ -45,39 +45,95 @@ class ExercisePlanPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final plan = ref.watch(currentExercisePlanProvider); return Scaffold( - appBar: AppBar(title: const Text('运动计划')), + appBar: AppBar(title: const Text('运动计划'), centerTitle: true), floatingActionButton: FloatingActionButton.extended( - onPressed: () => _createDefaultPlan(ref), + onPressed: () => _createDefaultPlan(ref, context), icon: const Icon(Icons.add), label: const Text('创建本周计划'), + backgroundColor: const Color(0xFF635BFF), ), body: plan.when( data: (data) { if (data == null || data.isEmpty) return _empty(context, '运动计划', '暂无运动计划,点击右下角创建'); final items = (data['items'] as List?)?.cast>() ?? []; final weekDays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; - return ListView.builder( - itemCount: items.length, - itemBuilder: (ctx, i) { - final item = items[i]; + final completedCount = items.where((i) => i['isCompleted'] == true).length; + final totalCount = items.where((i) => i['isRestDay'] != true).length; + + return ListView(children: [ + _buildProgressCard(completedCount, totalCount), + const SizedBox(height: 16), + ...items.asMap().entries.map((entry) { + final i = entry.key; + final item = entry.value; final day = item['dayOfWeek'] is int ? item['dayOfWeek'] as int : i; final isRest = item['isRestDay'] == true; final isDone = item['isCompleted'] == true; - return ListTile( - leading: Icon(isDone ? Icons.check_circle : Icons.circle_outlined, color: isDone ? const Color(0xFF43A047) : Colors.grey), - title: Text('${weekDays[day]} ${isRest ? '休息日' : '${item['exerciseType']} ${item['durationMinutes']}分钟'}'), - trailing: isDone ? null : IconButton(icon: const Icon(Icons.check, color: Color(0xFF43A047)), onPressed: () { _checkIn(ref, item['id']); }), + return _ExercisePlanItem( + day: weekDays[day], + dayIndex: day, + isRest: isRest, + isDone: isDone, + exerciseType: item['exerciseType']?.toString() ?? '', + duration: item['durationMinutes'] is int ? item['durationMinutes'] as int : 0, + onCheckIn: () => _checkIn(ref, item['id'], context), ); - }, - ); + }), + ]); }, - loading: () => const Center(child: CircularProgressIndicator()), + loading: () => const Center(child: CircularProgressIndicator(color: Color(0xFF635BFF))), error: (_, __) => _empty(context, '运动计划', '暂无运动计划,点击右下角创建'), ), ); } - void _createDefaultPlan(WidgetRef ref) async { + Widget _buildProgressCard(int completed, int total) { + final progress = total > 0 ? (completed / total * 100).toInt() : 0; + return Container( + margin: const EdgeInsets.all(16), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF5F3FF), + borderRadius: BorderRadius.circular(20), + ), + child: Column(children: [ + const Text('🏃 本周运动进度', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + const SizedBox(height: 12), + Row(children: [ + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + color: const Color(0xFF635BFF), + borderRadius: BorderRadius.circular(30), + ), + child: Center( + child: Text('$progress%', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white)), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('已完成 $completed/$total 天', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), + const SizedBox(height: 8), + Container( + height: 8, + decoration: BoxDecoration(color: Colors.grey[200], borderRadius: BorderRadius.circular(4)), + child: FractionallySizedBox( + widthFactor: progress / 100, + child: Container( + decoration: BoxDecoration(color: const Color(0xFF635BFF), borderRadius: BorderRadius.circular(4)), + ), + ), + ), + ]), + ), + ]), + ]), + ); + } + + void _createDefaultPlan(WidgetRef ref, BuildContext context) async { final service = ref.read(exerciseServiceProvider); final today = DateTime.now(); final monday = today.subtract(Duration(days: today.weekday - 1)); @@ -92,19 +148,198 @@ class ExercisePlanPage extends ConsumerWidget { 'items': items, }); ref.invalidate(currentExercisePlanProvider); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('运动计划已创建 ✅'), + backgroundColor: Color(0xFF635BFF), + )); } - void _checkIn(WidgetRef ref, String itemId) async { + void _checkIn(WidgetRef ref, String itemId, BuildContext context) async { final service = ref.read(exerciseServiceProvider); await service.checkIn(itemId); ref.invalidate(currentExercisePlanProvider); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('打卡成功 ✅'), + backgroundColor: Color(0xFF43A047), + )); + } +} + +class _ExercisePlanItem extends StatelessWidget { + final String day; + final int dayIndex; + final bool isRest; + final bool isDone; + final String exerciseType; + final int duration; + final VoidCallback onCheckIn; + + const _ExercisePlanItem({ + required this.day, + required this.dayIndex, + required this.isRest, + required this.isDone, + required this.exerciseType, + required this.duration, + required this.onCheckIn, + }); + + @override + Widget build(BuildContext context) { + final today = DateTime.now().weekday - 1; + final isToday = dayIndex == today; + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: isToday ? const Color(0xFFFEFCE8) : Colors.white, + borderRadius: BorderRadius.circular(16), + border: isToday ? Border.all(color: const Color(0xFFFCD34D), width: 2) : null, + boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))], + ), + child: Row(children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: isDone ? const Color(0xFFDCFCE7) : isRest ? const Color(0xFFF3F4F6) : const Color(0xFFF5F3FF), + borderRadius: BorderRadius.circular(12), + ), + child: isDone + ? const Icon(Icons.check, size: 20, color: Color(0xFF43A047)) + : isRest + ? const Icon(Icons.coffee, size: 20, color: Color(0xFF999999)) + : const Icon(Icons.directions_run, size: 20, color: Color(0xFF635BFF)), + ), + const SizedBox(width: 12), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Text(day, style: TextStyle(fontSize: 16, fontWeight: isToday ? FontWeight.w600 : FontWeight.w500)), + if (isToday) const SizedBox(width: 4), + if (isToday) const Text('(今天)', style: TextStyle(fontSize: 12, color: Color(0xFFF59E0B))), + ]), + const SizedBox(height: 4), + Text( + isRest ? '休息日,好好休息' : '$exerciseType ${duration}分钟', + style: TextStyle(fontSize: 14, color: Colors.grey[500]), + ), + ]), + ), + if (!isRest && !isDone) + ElevatedButton( + onPressed: onCheckIn, + child: const Text('打卡'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF635BFF), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + ), + ), + if (isDone) + const Text('已完成', style: TextStyle(fontSize: 14, color: Color(0xFF43A047))), + ]), + ); } } /// 复查列表 class FollowUpListPage extends ConsumerWidget { const FollowUpListPage({super.key}); - @override Widget build(BuildContext context, WidgetRef ref) => _empty(context, '复查随访', '暂无复查安排'); + @override Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + appBar: AppBar(title: const Text('复查随访'), centerTitle: true), + floatingActionButton: FloatingActionButton( + onPressed: () => _showAddDialog(context), + child: const Icon(Icons.add), + backgroundColor: const Color(0xFF635BFF), + ), + body: ListView(children: _mockFollowUps.map((item) => _FollowUpItem(item: item)).toList()), + ); + } + + void _showAddDialog(BuildContext context) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('添加复查提醒'), + content: Column(mainAxisSize: MainAxisSize.min, children: [ + TextField(decoration: const InputDecoration(labelText: '医院名称')), + const SizedBox(height: 12), + TextField(decoration: const InputDecoration(labelText: '科室')), + const SizedBox(height: 12), + TextField(decoration: const InputDecoration(labelText: '日期', hintText: 'YYYY-MM-DD')), + const SizedBox(height: 12), + TextField(decoration: const InputDecoration(labelText: '备注')), + ]), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('取消')), + TextButton( + onPressed: () { + Navigator.pop(ctx); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('复查提醒已添加 ✅'), + backgroundColor: Color(0xFF635BFF), + )); + }, + child: const Text('保存'), + ), + ], + ), + ); + } +} + +final _mockFollowUps = [ + {'id': '1', 'hospital': '协和医院', 'department': '心内科', 'date': '2025-01-20', 'type': '复诊', 'status': 'upcoming', 'notes': '常规复查,带齐病历'}, + {'id': '2', 'hospital': '人民医院', 'department': '骨科', 'date': '2025-01-25', 'type': '复查', 'status': 'upcoming', 'notes': '术后3个月复查'}, + {'id': '3', 'hospital': '协和医院', 'department': '心内科', 'date': '2024-12-15', 'type': '复诊', 'status': 'completed', 'notes': '已完成'}, +]; + +class _FollowUpItem extends StatelessWidget { + final Map item; + const _FollowUpItem({required this.item}); + + @override + Widget build(BuildContext context) { + final isCompleted = item['status'] == 'completed'; + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 4, offset: const Offset(0, 2))], + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: isCompleted ? const Color(0xFFDCFCE7) : const Color(0xFFFEFCE8), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + isCompleted ? '已完成' : '待就诊', + style: TextStyle(fontSize: 12, color: isCompleted ? const Color(0xFF43A047) : const Color(0xFFF59E0B)), + ), + ), + const SizedBox(width: 8), + Text(item['type']?.toString() ?? '', style: TextStyle(fontSize: 14, color: Colors.grey[500])), + ]), + const SizedBox(height: 12), + Text(item['hospital']?.toString() ?? '', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + Text('${item['department']} · ${item['date']}', style: TextStyle(fontSize: 14, color: Colors.grey[500])), + if ((item['notes']?.toString() ?? '').isNotEmpty) ...[ + const SizedBox(height: 8), + Text(item['notes']?.toString() ?? '', style: TextStyle(fontSize: 14, color: Colors.grey[600])), + ], + ]), + ); + } } /// 健康档案 @@ -194,9 +429,152 @@ class _EditProfilePageState extends ConsumerState { } /// 健康日历 -class HealthCalendarPage extends ConsumerWidget { +class HealthCalendarPage extends ConsumerStatefulWidget { const HealthCalendarPage({super.key}); - @override Widget build(BuildContext context, WidgetRef ref) => _empty(context, '健康日历', '暂无数据'); + @override ConsumerState createState() => _HealthCalendarPageState(); +} + +class _HealthCalendarPageState extends ConsumerState { + DateTime _currentMonth = DateTime.now(); + + @override Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('健康日历'), centerTitle: true), + body: Column(children: [ + _buildMonthHeader(), + _buildWeekdayHeader(), + _buildCalendarGrid(), + const SizedBox(height: 16), + _buildLegend(), + ]), + ); + } + + Widget _buildMonthHeader() { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: const Icon(Icons.chevron_left, size: 32), + onPressed: () => setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month - 1)), + ), + Text( + '${_currentMonth.year}年${_currentMonth.month}月', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + IconButton( + icon: const Icon(Icons.chevron_right, size: 32), + onPressed: () => setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month + 1)), + ), + ], + ); + } + + Widget _buildWeekdayHeader() { + const weekdays = ['日', '一', '二', '三', '四', '五', '六']; + return Row(children: weekdays.map((day) => Expanded( + child: Center(child: Text(day, style: TextStyle(fontSize: 14, color: Colors.grey[500]))), + )).toList()); + } + + Widget _buildCalendarGrid() { + final firstDay = DateTime(_currentMonth.year, _currentMonth.month, 1); + final lastDay = DateTime(_currentMonth.year, _currentMonth.month + 1, 0); + final daysInMonth = lastDay.day; + final startWeekday = firstDay.weekday % 7; + + final days = List.generate(42, (i) { + final dayIndex = i - startWeekday; + if (dayIndex < 0 || dayIndex >= daysInMonth) return null; + return dayIndex + 1; + }); + + return Expanded( + child: GridView.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 7), + itemCount: 42, + itemBuilder: (ctx, i) { + final day = days[i]; + if (day == null) return const SizedBox(); + return _buildDayCell(day); + }, + ), + ); + } + + Widget _buildDayCell(int day) { + final date = DateTime(_currentMonth.year, _currentMonth.month, day); + final today = DateTime.now(); + final isToday = date.year == today.year && date.month == today.month && date.day == today.day; + final events = _getEvents(date); + + return Container( + decoration: isToday ? BoxDecoration( + color: const Color(0xFF635BFF), + borderRadius: BorderRadius.circular(20), + ) : null, + child: Stack( + alignment: Alignment.center, + children: [ + Text( + '$day', + style: TextStyle( + fontSize: 16, + color: isToday ? Colors.white : Colors.black, + fontWeight: isToday ? FontWeight.w600 : FontWeight.normal, + ), + ), + if (events.isNotEmpty) + Positioned( + bottom: 4, + child: Row(children: events.map((type) => Container( + width: 6, + height: 6, + margin: const EdgeInsets.symmetric(horizontal: 1), + decoration: BoxDecoration( + color: _getEventColor(type), + borderRadius: BorderRadius.circular(3), + ), + )).toList()), + ), + ], + ), + ); + } + + List _getEvents(DateTime date) { + final events = []; + if (date.day == 5 || date.day == 12 || date.day == 19 || date.day == 26) events.add('medication'); + if (date.day == 8 || date.day == 15 || date.day == 22 || date.day == 29) events.add('exercise'); + if (date.day == 20) events.add('followup'); + return events; + } + + Color _getEventColor(String type) { + switch (type) { + case 'medication': return const Color(0xFF635BFF); + case 'exercise': return const Color(0xFF43A047); + case 'followup': return const Color(0xFFF59E0B); + default: return Colors.grey; + } + } + + Widget _buildLegend() { + final items = [ + {'color': const Color(0xFF635BFF), 'label': '用药提醒'}, + {'color': const Color(0xFF43A047), 'label': '运动计划'}, + {'color': const Color(0xFFF59E0B), 'label': '复查随访'}, + ]; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row(mainAxisAlignment: MainAxisAlignment.center, children: items.map((item) => Row(children: [ + Container(width: 10, height: 10, decoration: BoxDecoration(color: item['color'] as Color, borderRadius: BorderRadius.circular(5))), + const SizedBox(width: 4), + Text(item['label'] as String, style: TextStyle(fontSize: 12, color: Colors.grey[600])), + const SizedBox(width: 20), + ])).toList()), + ); + } } /// 静态文本页 diff --git a/health_app/lib/pages/report/report_pages.dart b/health_app/lib/pages/report/report_pages.dart index 4eb7649..fce1b88 100644 --- a/health_app/lib/pages/report/report_pages.dart +++ b/health_app/lib/pages/report/report_pages.dart @@ -1,25 +1,485 @@ 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 '../../core/navigation_provider.dart'; + +final reportProvider = NotifierProvider(ReportNotifier.new); + +class ReportState { + final List reports; + final String? uploadingImage; + final bool isAnalyzing; + final ReportAnalysis? currentAnalysis; + + ReportState({ + this.reports = const [], + this.uploadingImage, + this.isAnalyzing = false, + this.currentAnalysis, + }); + + ReportState copyWith({ + List? reports, + String? uploadingImage, + bool? isAnalyzing, + ReportAnalysis? currentAnalysis, + }) { + return ReportState( + reports: reports ?? this.reports, + uploadingImage: uploadingImage ?? this.uploadingImage, + isAnalyzing: isAnalyzing ?? this.isAnalyzing, + currentAnalysis: currentAnalysis ?? this.currentAnalysis, + ); + } +} + +class ReportItem { + final String id; + final String title; + final String type; + final DateTime uploadedAt; + final String? imagePath; + final bool hasAnalysis; + + ReportItem({ + required this.id, + required this.title, + required this.type, + required this.uploadedAt, + this.imagePath, + this.hasAnalysis = false, + }); +} + +class ReportAnalysis { + final String reportId; + final String reportType; + final List indicators; + final String summary; + + ReportAnalysis({ + required this.reportId, + required this.reportType, + required this.indicators, + required this.summary, + }); +} + +class Indicator { + final String name; + final String value; + final String unit; + final String status; + final String? referenceRange; + + Indicator({ + required this.name, + required this.value, + required this.unit, + required this.status, + this.referenceRange, + }); +} + +class ReportNotifier extends Notifier { + static final _mockReports = [ + ReportItem( + id: '1', + title: '血常规检查', + type: '血液检查', + uploadedAt: DateTime.now().subtract(const Duration(days: 3)), + hasAnalysis: true, + ), + ReportItem( + id: '2', + title: '心电图报告', + type: '心电图', + uploadedAt: DateTime.now().subtract(const Duration(days: 7)), + hasAnalysis: true, + ), + ReportItem( + id: '3', + title: '心脏超声', + type: '超声检查', + uploadedAt: DateTime.now().subtract(const Duration(days: 14)), + hasAnalysis: false, + ), + ]; + + @override + ReportState build() => ReportState(reports: _mockReports); + + void uploadImage(String path) async { + state = state.copyWith(uploadingImage: path, isAnalyzing: true); + await Future.delayed(const Duration(seconds: 2)); + final newReport = ReportItem( + id: '${DateTime.now().millisecondsSinceEpoch}', + title: '检查报告', + type: '影像报告', + uploadedAt: DateTime.now(), + imagePath: path, + hasAnalysis: true, + ); + state = state.copyWith( + reports: [newReport, ...state.reports], + uploadingImage: null, + isAnalyzing: false, + currentAnalysis: _mockAnalysis, + ); + } + + void uploadFile(String path) async { + state = state.copyWith(isAnalyzing: true); + await Future.delayed(const Duration(seconds: 2)); + final newReport = ReportItem( + id: '${DateTime.now().millisecondsSinceEpoch}', + title: '检查报告', + type: 'PDF文档', + uploadedAt: DateTime.now(), + hasAnalysis: true, + ); + state = state.copyWith( + reports: [newReport, ...state.reports], + isAnalyzing: false, + currentAnalysis: _mockAnalysis, + ); + } + + void viewAnalysis(String reportId) { + state = state.copyWith(currentAnalysis: _mockAnalysis); + } + + void clearAnalysis() { + state = state.copyWith(currentAnalysis: null); + } +} + +final _mockAnalysis = ReportAnalysis( + reportId: '1', + reportType: '血常规检查', + indicators: [ + Indicator(name: '白细胞计数', value: '7.5', unit: '×10^9/L', status: 'normal', referenceRange: '4.0-10.0'), + Indicator(name: '红细胞计数', value: '4.2', unit: '×10^12/L', status: 'normal', referenceRange: '3.5-5.5'), + Indicator(name: '血红蛋白', value: '128', unit: 'g/L', status: 'low', referenceRange: '130-175'), + Indicator(name: '血小板计数', value: '185', unit: '×10^9/L', status: 'normal', referenceRange: '100-300'), + Indicator(name: '中性粒细胞百分比', value: '65', unit: '%', status: 'normal', referenceRange: '50-70'), + Indicator(name: '淋巴细胞百分比', value: '28', unit: '%', status: 'normal', referenceRange: '20-40'), + ], + summary: '整体来看,您的血常规检查基本正常。血红蛋白略低于正常范围,建议适当补充营养,多吃富含铁质的食物如红肉、动物肝脏等。如有疲劳、头晕等症状,建议咨询医生进一步检查。', +); /// 报告列表页 class ReportListPage extends ConsumerWidget { const ReportListPage({super.key}); + @override - Widget build(BuildContext context, WidgetRef ref) => _emptyPage(context, '暂无报告', '可到「看报告」上传'); + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(reportProvider); + + if (state.isAnalyzing) { + return Scaffold( + appBar: AppBar(title: const Text('看报告')), + body: const Center( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + CircularProgressIndicator(color: Color(0xFF635BFF)), + SizedBox(height: 16), + Text('AI 正在分析报告...'), + ]), + ), + ); + } + + return Scaffold( + appBar: AppBar( + title: const Text('看报告'), + centerTitle: true, + ), + floatingActionButton: _buildUploadButton(context, ref), + body: state.reports.isEmpty + ? _buildEmptyState(context) + : ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: state.reports.length, + itemBuilder: (context, index) => _buildReportCard(context, ref, state.reports[index]), + ), + ); + } + + Widget _buildUploadButton(BuildContext context, WidgetRef ref) { + return FloatingActionButton( + onPressed: () => _showUploadOptions(context, ref), + backgroundColor: const Color(0xFF635BFF), + child: const Icon(Icons.add), + ); + } + + void _showUploadOptions(BuildContext context, WidgetRef ref) { + showModalBottomSheet( + context: context, + builder: (ctx) => SafeArea( + child: Wrap(children: [ + ListTile( + leading: const Icon(Icons.camera_alt), + title: const Text('拍照上传'), + onTap: () async { + Navigator.pop(ctx); + final picker = ImagePicker(); + final picked = await picker.pickImage(source: ImageSource.camera, imageQuality: 85); + if (picked != null) { + ref.read(reportProvider.notifier).uploadImage(picked.path); + } + }, + ), + ListTile( + leading: const Icon(Icons.photo_library), + title: const Text('从相册选择'), + onTap: () async { + Navigator.pop(ctx); + final picker = ImagePicker(); + final picked = await picker.pickImage(source: ImageSource.gallery, imageQuality: 85); + if (picked != null) { + ref.read(reportProvider.notifier).uploadImage(picked.path); + } + }, + ), + ListTile( + leading: const Icon(Icons.file_open), + title: const Text('上传PDF文件'), + onTap: () async { + Navigator.pop(ctx); + final result = await FilePicker.platform.pickFiles(type: FileType.custom, allowedExtensions: ['pdf']); + if (result != null && result.files.isNotEmpty) { + ref.read(reportProvider.notifier).uploadFile(result.files.first.path!); + } + }, + ), + ]), + ), + ); + } + + Widget _buildEmptyState(BuildContext context) { + return Center( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Container( + width: 120, + height: 120, + decoration: BoxDecoration( + color: const Color(0xFFF5F3FF), + borderRadius: BorderRadius.circular(60), + ), + child: const Icon(Icons.file_open, size: 48, color: Color(0xFF635BFF)), + ), + const SizedBox(height: 20), + const Text('暂无检查报告', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500)), + const SizedBox(height: 8), + const Text('点击下方按钮上传报告', style: TextStyle(fontSize: 14, color: Color(0xFF999999))), + ]), + ); + } + + Widget _buildReportCard(BuildContext context, WidgetRef ref, ReportItem report) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 4, offset: const Offset(0, 2))], + ), + child: ListTile( + leading: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: const Color(0xFFF5F3FF), + borderRadius: BorderRadius.circular(12), + ), + child: _getReportIcon(report.type), + ), + title: Text(report.title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(report.type, style: TextStyle(fontSize: 14, color: Colors.grey[500])), + Text(_formatDate(report.uploadedAt), style: TextStyle(fontSize: 12, color: Colors.grey[400])), + ]), + trailing: report.hasAnalysis + ? const Icon(Icons.check_circle, size: 20, color: Color(0xFF43A047)) + : const Icon(Icons.arrow_forward_ios, size: 18, color: Color(0xFFCCCCCC)), + onTap: () { + ref.read(reportProvider.notifier).viewAnalysis(report.id); + pushRoute(ref, 'reportDetail', params: {'id': report.id}); + }, + ), + ); + } + + Widget _getReportIcon(String type) { + final icons = { + '血液检查': const Icon(Icons.bloodtype, size: 24, color: Color(0xFF635BFF)), + '心电图': const Icon(Icons.monitor_heart, size: 24, color: Color(0xFF635BFF)), + '超声检查': const Icon(Icons.image, size: 24, color: Color(0xFF635BFF)), + '影像报告': const Icon(Icons.image, size: 24, color: Color(0xFF635BFF)), + 'PDF文档': const Icon(Icons.picture_as_pdf, size: 24, color: Color(0xFF635BFF)), + }; + return icons[type] ?? const Icon(Icons.description, size: 24, color: Color(0xFF635BFF)); + } + + String _formatDate(DateTime date) { + final now = DateTime.now(); + final diff = now.difference(date); + if (diff.inDays == 0) return '今天'; + if (diff.inDays == 1) return '昨天'; + if (diff.inDays < 7) return '${diff.inDays}天前'; + return '${date.month}月${date.day}日'; + } } /// 报告详情页 class ReportDetailPage extends ConsumerWidget { final String id; const ReportDetailPage({super.key, required this.id}); - @override - Widget build(BuildContext context, WidgetRef ref) => _emptyPage(context, '报告详情', '报告 #$id'); -} -Widget _emptyPage(BuildContext context, String title, String subtitle) => Scaffold( - appBar: AppBar(title: Text(title)), - body: Center(child: Column(mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.description, size: 64, color: Colors.grey[300]), - const SizedBox(height: 12), Text(subtitle, style: Theme.of(context).textTheme.bodyMedium), - ])), -); + @override + Widget build(BuildContext context, WidgetRef ref) { + final analysis = ref.watch(reportProvider.select((s) => s.currentAnalysis)); + + if (analysis == null) { + return Scaffold( + appBar: AppBar(title: const Text('报告详情')), + body: const Center(child: Text('暂无分析数据')), + ); + } + + return Scaffold( + appBar: AppBar( + title: const Text('报告解读'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () { + ref.read(reportProvider.notifier).clearAnalysis(); + popRoute(ref); + }, + ), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + _buildReportHeader(analysis), + const SizedBox(height: 20), + _buildAnalysisSection(analysis), + const SizedBox(height: 20), + _buildSummarySection(analysis), + const SizedBox(height: 30), + ]), + ), + ); + } + + Widget _buildReportHeader(ReportAnalysis analysis) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF5F3FF), + borderRadius: BorderRadius.circular(16), + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + const Text('📋', style: TextStyle(fontSize: 24)), + const SizedBox(width: 12), + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(analysis.reportType, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + const Text('AI 预解读结果', style: TextStyle(fontSize: 14, color: Color(0xFF666666))), + ]), + ]), + ]), + ); + } + + Widget _buildAnalysisSection(ReportAnalysis analysis) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5), + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row(children: [ + const Text('🧪', style: TextStyle(fontSize: 20)), + const SizedBox(width: 8), + const Text('指标分析', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + ]), + ), + ...analysis.indicators.map((ind) => _buildIndicatorRow(ind)), + ]), + ); + } + + Widget _buildIndicatorRow(Indicator ind) { + Color statusColor; + IconData statusIcon; + switch (ind.status) { + case 'high': + statusColor = const Color(0xFFE53935); + statusIcon = Icons.arrow_upward; + break; + case 'low': + statusColor = const Color(0xFFF9A825); + statusIcon = Icons.arrow_downward; + break; + default: + statusColor = const Color(0xFF43A047); + statusIcon = Icons.check_circle; + } + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0xFFF0F0F0)))), + child: Row(children: [ + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(ind.name, style: const TextStyle(fontSize: 14)), + if (ind.referenceRange != null) + Text('参考值: ${ind.referenceRange}', style: TextStyle(fontSize: 12, color: Colors.grey[400])), + ]), + ), + const SizedBox(width: 16), + Column(children: [ + Text('${ind.value} ${ind.unit}', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: statusColor)), + Icon(statusIcon, size: 16, color: statusColor), + ]), + ]), + ); + } + + Widget _buildSummarySection(ReportAnalysis analysis) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFFEF3C7), + borderRadius: BorderRadius.circular(16), + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + const Text('💡', style: TextStyle(fontSize: 20)), + const SizedBox(width: 8), + const Text('综合解读', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFFD97706))), + ]), + const SizedBox(height: 12), + Text(analysis.summary, style: const TextStyle(fontSize: 14, color: Color(0xFF92400E), height: 1.6)), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: const Text('⚠️ AI 解读仅供参考,请以医生诊断为准', style: TextStyle(fontSize: 13, color: Color(0xFFD97706))), + ), + ]), + ); + } +} \ No newline at end of file diff --git a/health_app/lib/providers/chat_provider.dart b/health_app/lib/providers/chat_provider.dart index eb397f7..111f543 100644 --- a/health_app/lib/providers/chat_provider.dart +++ b/health_app/lib/providers/chat_provider.dart @@ -4,16 +4,23 @@ import 'auth_provider.dart'; import 'data_providers.dart'; import '../utils/sse_handler.dart'; +enum MessageType { text, dataConfirm, medicationConfirm, dietAnalysis, reportAnalysis, quickOptions } + class ChatMessage { final String id; final String role; String content; final DateTime createdAt; - ChatMessage( - {required this.id, - required this.role, - required this.content, - required this.createdAt}); + MessageType type; + final Map? metadata; + ChatMessage({ + required this.id, + required this.role, + required this.content, + required this.createdAt, + this.type = MessageType.text, + this.metadata, + }); bool get isUser => role == 'user'; } @@ -24,26 +31,41 @@ class ChatState { final List messages; final String? conversationId; final bool isStreaming; - final String? noticeText; + final String? thinkingText; const ChatState({ this.activeAgent = ActiveAgent.default_, this.messages = const [], this.conversationId, this.isStreaming = false, - this.noticeText, + this.thinkingText, }); ChatState copyWith({ActiveAgent? activeAgent, List? messages, - String? conversationId, bool? isStreaming, String? noticeText, - bool clearNotice = false}) => + String? conversationId, bool? isStreaming, String? thinkingText}) => ChatState( activeAgent: activeAgent ?? this.activeAgent, messages: messages ?? this.messages, conversationId: conversationId ?? this.conversationId, isStreaming: isStreaming ?? this.isStreaming, - noticeText: clearNotice ? null : (noticeText ?? this.noticeText), + thinkingText: thinkingText ?? this.thinkingText, ); } +class ConversationItem { + final String id; + final String title; + final String lastMessage; + final DateTime updatedAt; + final ActiveAgent agent; + + ConversationItem({ + required this.id, + required this.title, + required this.lastMessage, + required this.updatedAt, + required this.agent, + }); +} + class SelectedAgentNotifier extends Notifier { @override ActiveAgent? build() => null; @@ -53,6 +75,64 @@ class SelectedAgentNotifier extends Notifier { final selectedAgentProvider = NotifierProvider(SelectedAgentNotifier.new); final chatProvider = NotifierProvider(ChatNotifier.new); +final conversationListProvider = FutureProvider>((ref) async { + final api = ref.watch(apiClientProvider); + final token = await api.accessToken; + if (token == null) return []; + + try { + final res = await api.get('/api/conversations'); + final list = res.data['data'] as List? ?? []; + return list.map((item) { + final data = item as Map; + return ConversationItem( + id: data['id']?.toString() ?? '', + title: data['title']?.toString() ?? '对话', + lastMessage: data['lastMessage']?.toString() ?? '', + updatedAt: DateTime.parse(data['updatedAt']?.toString() ?? DateTime.now().toIso8601String()), + agent: _parseAgent(data['agentType']?.toString()), + ); + }).toList(); + } catch (_) { + return _mockConversations; + } +}); + +ActiveAgent _parseAgent(String? type) { + switch (type?.toLowerCase()) { + case 'consultation': return ActiveAgent.consultation; + case 'health': return ActiveAgent.health; + case 'diet': return ActiveAgent.diet; + case 'medication': return ActiveAgent.medication; + case 'report': return ActiveAgent.report; + case 'exercise': return ActiveAgent.exercise; + default: return ActiveAgent.default_; + } +} + +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; @@ -120,7 +200,7 @@ class ChatNotifier extends Notifier { ), ], isStreaming: false, - clearNotice: true, + thinkingText: null, ); } @@ -131,13 +211,16 @@ class ChatNotifier extends Notifier { state = state.copyWith(conversationId: j['data']?.toString()); case 'answer': aiMsg.content += (j['data'] as String?) ?? ''; + final messageType = j['type'] as String? ?? 'text'; + aiMsg.type = _parseMessageType(messageType); + state = state.copyWith(thinkingText: null); _update(aiMsg); case 'notice': - state = state.copyWith(noticeText: j['message'] as String?); + state = state.copyWith(thinkingText: j['message'] as String?); case 'tool_result': final tool = j['tool'] as String? ?? ''; if (tool == 'record_health_data') { - refreshHealthData(ref); + ref.invalidate(latestHealthProvider); } case 'status': _done(aiMsg); @@ -146,6 +229,17 @@ class ChatNotifier extends Notifier { } } + MessageType _parseMessageType(String type) { + switch (type) { + case 'data_confirm': return MessageType.dataConfirm; + case 'medication_confirm': return MessageType.medicationConfirm; + case 'diet_analysis': return MessageType.dietAnalysis; + case 'report_analysis': return MessageType.reportAnalysis; + case 'quick_options': return MessageType.quickOptions; + default: return MessageType.text; + } + } + void _update(ChatMessage m) { final u = state.messages.toList(); final i = u.indexWhere((x) => x.id == m.id); @@ -160,6 +254,6 @@ class ChatNotifier extends Notifier { void _done(ChatMessage m) { final u = state.messages.toList(); if (!u.any((x) => x.id == m.id) && m.content.isNotEmpty) u.add(m); - state = state.copyWith(messages: u, isStreaming: false, clearNotice: true); + state = state.copyWith(messages: u, isStreaming: false, thinkingText: null); } } diff --git a/health_app/lib/widgets/health_drawer.dart b/health_app/lib/widgets/health_drawer.dart index edf4779..b1c1d86 100644 --- a/health_app/lib/widgets/health_drawer.dart +++ b/health_app/lib/widgets/health_drawer.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/navigation_provider.dart'; import '../providers/auth_provider.dart'; import '../providers/data_providers.dart'; +import '../providers/chat_provider.dart'; /// 侧滑抽屉——健康概览 + 历史对话 + 菜单 class HealthDrawer extends ConsumerWidget { @@ -13,6 +14,7 @@ class HealthDrawer extends ConsumerWidget { final auth = ref.watch(authProvider); final user = auth.user; final latestHealth = ref.watch(latestHealthProvider); + final conversations = ref.watch(conversationListProvider); return Drawer( child: SafeArea( @@ -67,9 +69,28 @@ class HealthDrawer extends ConsumerWidget { const Divider(), Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), - child: Text('历史对话', style: Theme.of(context).textTheme.labelMedium!.copyWith(fontWeight: FontWeight.w600)), + child: Row(children: [ + Text('历史对话', style: Theme.of(context).textTheme.labelMedium!.copyWith(fontWeight: FontWeight.w600)), + const Spacer(), + TextButton(onPressed: () => ref.invalidate(conversationListProvider), child: const Text('刷新', style: TextStyle(fontSize: 12, color: Color(0xFF635BFF)))), + ]), + ), + Expanded( + child: conversations.when( + data: (items) { + if (items.isEmpty) { + return const Center(child: Text('暂无历史对话', style: TextStyle(color: Color(0xFF999999), fontSize: 14))); + } + return ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 8), + itemCount: items.length, + itemBuilder: (ctx, i) => _ConversationItem(item: items[i], ref: ref), + ); + }, + loading: () => const Center(child: CircularProgressIndicator(strokeWidth: 2)), + error: (_, __) => const Center(child: Text('加载失败', style: TextStyle(color: Color(0xFF999999), fontSize: 14))), + ), ), - const Expanded(child: Center(child: Text('暂无历史对话', style: TextStyle(color: Color(0xFF999999), fontSize: 14)))), const Divider(), _DrawerItem(icon: Icons.logout, label: '退出登录', onTap: () async { @@ -117,3 +138,94 @@ class _HealthMetric extends StatelessWidget { onTap: onTap, ); } + +class _ConversationItem extends ConsumerWidget { + final ConversationItem item; + final WidgetRef ref; + const _ConversationItem({required this.item, required this.ref}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFF8F7FF), + borderRadius: BorderRadius.circular(12), + ), + child: ListTile( + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: const Color(0xFFEDEBFF), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(_getAgentIcon(item.agent), size: 18, color: const Color(0xFF635BFF)), + ), + title: Text(item.title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + subtitle: Text(item.lastMessage, style: TextStyle(fontSize: 12, color: Colors.grey[500])), + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(_formatTime(item.updatedAt), style: const TextStyle(fontSize: 10, color: Color(0xFFCCCCCC))), + const SizedBox(height: 4), + PopupMenuButton( + icon: const Icon(Icons.more_vert, size: 16, color: Color(0xFFCCCCCC)), + itemBuilder: (_) => [ + const PopupMenuItem(value: 1, child: Text('继续聊')), + const PopupMenuItem(value: 2, child: Text('删除')), + ], + onSelected: (v) async { + if (v == 1) { + ref.read(chatProvider.notifier).setAgent(item.agent); + Navigator.pop(context); + } else if (v == 2) { + 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) { + ref.invalidate(conversationListProvider); + } + } + }, + ), + ], + ), + onTap: () { + ref.read(chatProvider.notifier).setAgent(item.agent); + Navigator.pop(context); + }, + dense: true, + ), + ); + } + + IconData _getAgentIcon(ActiveAgent agent) { + switch (agent) { + case ActiveAgent.health: return Icons.health_and_safety; + case ActiveAgent.diet: return Icons.restaurant; + case ActiveAgent.medication: return Icons.medication; + case ActiveAgent.report: return Icons.file_open; + case ActiveAgent.exercise: return Icons.directions_run; + case ActiveAgent.consultation: return Icons.chat; + default: return Icons.chat_bubble_outline; + } + } + + String _formatTime(DateTime time) { + final now = DateTime.now(); + final diff = now.difference(time); + if (diff.inMinutes < 60) return '${diff.inMinutes}分钟前'; + if (diff.inHours < 24) return '${diff.inHours}小时前'; + if (diff.inDays < 7) return '${diff.inDays}天前'; + return '${time.month}/${time.day}'; + } +} diff --git a/食堂三菜一饭热量估算.png b/食堂三菜一饭热量估算.png new file mode 100644 index 0000000..37b003f Binary files /dev/null and b/食堂三菜一饭热量估算.png differ