From 16d1d3d30556c63c2830e750b21d90a312305ca0 Mon Sep 17 00:00:00 2001 From: MingNian <1281442923@qq.com> Date: Mon, 8 Jun 2026 16:21:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=94=A8=E8=8D=AF=E6=89=93=E5=8D=A1?= =?UTF-8?q?=E5=85=A8=E9=9D=A2=E9=87=8D=E6=9E=84=20-=20=E6=8C=89=E6=AC=A1?= =?UTF-8?q?=E8=BF=BD=E8=B8=AA=20+=20=E7=8B=AC=E7=AB=8B=E6=89=93=E5=8D=A1?= =?UTF-8?q?=E9=A1=B5=20+=20=E4=BB=BB=E5=8A=A1=E5=8C=BA=E6=B1=87=E6=80=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端用药提醒改为按次粒度(每个服药时间独立判断) - 新增 per-dose 打卡/取消打卡 API - 新增 MedicationCheckInPage 独立打卡页(每药每时间单独toggle) - 用药列表删底部弹窗(编辑/停药),点药品直接进该药打卡页 - 任务区用药汇总为一行(过期待服已服) - 支持隔天(EveryOtherDay)/每周频率判断 - 删历史对话功能(ConversationItem + conversationListProvider + UI) --- CLAUDE.md | 1 + .../src/Health.Domain/Enums/health_enums.cs | 9 +- .../AgentHandlers/exercise_agent_handler.cs | 21 +- .../AgentHandlers/medication_agent_handler.cs | 45 ++++- .../AI/prompt_manager.cs | 10 + .../Endpoints/ai_chat_endpoints.cs | 38 +++- .../Endpoints/medication_endpoints.cs | 143 ++++++++++++-- health_app/lib/app.dart | 11 ++ health_app/lib/core/app_router.dart | 3 + .../home/widgets/chat_messages_view.dart | 184 ++++++++++-------- .../medication/medication_checkin_page.dart | 142 ++++++++++++++ .../medication/medication_list_page.dart | 54 +---- health_app/lib/pages/remaining_pages.dart | 1 + health_app/lib/providers/chat_provider.dart | 54 +---- health_app/lib/widgets/health_drawer.dart | 161 --------------- health_app/pubspec.lock | 13 ++ health_app/pubspec.yaml | 2 + 17 files changed, 519 insertions(+), 373 deletions(-) create mode 100644 health_app/lib/pages/medication/medication_checkin_page.dart diff --git a/CLAUDE.md b/CLAUDE.md index 6c3197c..bc76eb0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,7 @@ -前端用sqlite存储信息,不用shared_preferences - 用 Riverpod 管理所有状态和页面跳转 +- com.datalumina.YYA applicationId用这个,这个是有要求的 ## 运行与测试 - 测试时自行启动后端,通过浏览器 DevTools 发送请求验证 diff --git a/backend/src/Health.Domain/Enums/health_enums.cs b/backend/src/Health.Domain/Enums/health_enums.cs index ee68547..fb3e5ff 100644 --- a/backend/src/Health.Domain/Enums/health_enums.cs +++ b/backend/src/Health.Domain/Enums/health_enums.cs @@ -58,11 +58,12 @@ public enum MedicationLogStatus /// public enum MedicationFrequency { - Daily, // 每天一次 - TwiceDaily, // 每天两次 + Daily, // 每天一次 + TwiceDaily, // 每天两次 ThreeTimesDaily, // 每天三次 - Weekly, // 每周 - AsNeeded // 必要时 + EveryOtherDay, // 隔天一次 + Weekly, // 每周 + AsNeeded // 必要时 } /// diff --git a/backend/src/Health.Infrastructure/AI/AgentHandlers/exercise_agent_handler.cs b/backend/src/Health.Infrastructure/AI/AgentHandlers/exercise_agent_handler.cs index 8213346..03cee8d 100644 --- a/backend/src/Health.Infrastructure/AI/AgentHandlers/exercise_agent_handler.cs +++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/exercise_agent_handler.cs @@ -9,8 +9,17 @@ public static class ExerciseAgentHandler { Function = new() { - Name = "manage_exercise", Description = "运动计划管理", - Parameters = new { type = "object", properties = new { action = new { type = "string" } }, required = new[] { "action" } } + Name = "manage_exercise", Description = "运动计划管理(创建/查询/打卡)", + Parameters = new { type = "object", properties = new { + action = new { type = "string", description = "create/query/checkin" }, + week_start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" }, + items = new { type = "array", description = "每天的运动项目", items = new { type = "object", properties = new { + day_of_week = new { type = "integer", description = "0=周日 1=周一...6=周六" }, + exercise_type = new { type = "string", description = "运动类型,如散步/慢跑/游泳/太极" }, + duration_minutes = new { type = "integer", description = "时长(分钟)" }, + is_rest_day = new { type = "boolean", description = "是否休息日" } + }}} + }, required = new[] { "action" } } } }; @@ -48,7 +57,13 @@ public static class ExerciseAgentHandler } db.ExercisePlans.Add(plan); await db.SaveChangesAsync(); - return new { success = true, plan_id = plan.Id }; + var firstItem = plan.Items.FirstOrDefault(); + return new { + success = true, plan_id = plan.Id, + exercise_type = firstItem?.ExerciseType ?? "运动", + duration_minutes = firstItem?.DurationMinutes ?? 30, + day_count = plan.Items.Count, + }; case "checkin": var itemId = args.TryGetProperty("item_id", out var iid) ? iid.GetGuid() : Guid.Empty; diff --git a/backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs b/backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs index 7c31d5c..1268f4f 100644 --- a/backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs +++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs @@ -9,8 +9,16 @@ public static class MedicationAgentHandler { Function = new() { - Name = "manage_medication", Description = "用药管理", - Parameters = new { type = "object", properties = new { action = new { type = "string" }, name = new { type = "string" }, dosage = new { type = "string" } }, required = new[] { "action" } } + Name = "manage_medication", Description = "用药管理(创建/查询/确认服药)", + Parameters = new { type = "object", properties = new { + action = new { type = "string", description = "create/query/confirm" }, + name = new { type = "string", description = "药品名称" }, + dosage = new { type = "string", description = "剂量,如 100mg" }, + frequency = new { type = "string", description = "频率,如 Daily/EveryOtherDay/Weekly" }, + time_of_day = new { type = "array", items = new { type = "string" }, description = "服药时间,如 [\"08:00\",\"20:00\"]" }, + duration_days = new { type = "integer", description = "服用天数" }, + start_date = new { type = "string", description = "开始日期 yyyy-MM-dd" }, + }, required = new[] { "action" } } } }; @@ -40,16 +48,43 @@ public static class MedicationAgentHandler private static async Task CreateMedication(AppDbContext db, Guid userId, JsonElement args) { + var name = args.TryGetProperty("name", out var n) ? n.GetString()! : ""; + var dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null; + var frequencyStr = args.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily"; + var frequency = Enum.TryParse(frequencyStr, out var fr) ? fr : MedicationFrequency.Daily; + + List times = []; + if (args.TryGetProperty("time_of_day", out var tod) && tod.ValueKind == JsonValueKind.Array) + { + foreach (var t in tod.EnumerateArray()) + { + if (TimeOnly.TryParse(t.GetString(), out var parsed)) times.Add(parsed); + } + } + + var durationDays = args.TryGetProperty("duration_days", out var dd) ? dd.GetInt32() : 0; + var startDateStr = args.TryGetProperty("start_date", out var sd) && sd.GetString() is string sds ? sds : null; + var med = new Medication { Id = Guid.NewGuid(), UserId = userId, - Name = args.TryGetProperty("name", out var n) ? n.GetString()! : "", - Dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null, + Name = name, Dosage = dosage, Frequency = frequency, + TimeOfDay = times.Count > 0 ? times : [new TimeOnly(8, 0)], Source = MedicationSource.AiEntry, IsActive = true, + StartDate = DateOnly.TryParse(startDateStr, out var parsedDate) ? parsedDate : DateOnly.FromDateTime(DateTime.Now), + EndDate = durationDays > 0 ? DateOnly.FromDateTime(DateTime.Now).AddDays(durationDays) : null, }; db.Medications.Add(med); await db.SaveChangesAsync(); - return new { success = true, medication_id = med.Id, med.Name }; + + var timeLabels = times.Count > 0 ? string.Join(", ", times.Select(t => t.ToString("HH:mm"))) : "08:00"; + return new { + success = true, medication_id = med.Id, + name = med.Name, dosage = med.Dosage, + frequency = med.Frequency.ToString(), time = timeLabels, + start_date = med.StartDate?.ToString("yyyy-MM-dd"), + duration_days = durationDays, + }; } private static async Task QueryMedications(AppDbContext db, Guid userId) diff --git a/backend/src/Health.Infrastructure/AI/prompt_manager.cs b/backend/src/Health.Infrastructure/AI/prompt_manager.cs index 46ef573..e378adb 100644 --- a/backend/src/Health.Infrastructure/AI/prompt_manager.cs +++ b/backend/src/Health.Infrastructure/AI/prompt_manager.cs @@ -132,6 +132,16 @@ public sealed class PromptManager 5. 健康档案查询/修改 → 调用 check_archive / manage_archive 6. 历史数据查询 → 调用 query_health_records + 运动计划参数格式(manage_exercise): + - action="create", week_start_date="今天日期(YYYY-MM-DD)" + - items: [{"day_of_week":0-6(周日=0),"exercise_type":"散步","duration_minutes":30,"is_rest_day":false}, ...] + - 未说持续多久则默认创建7天 + - 用户说"坚持10天"则创建10天,说"到6月20日"则算天数 + - items 数量必须等于持续天数 + + 用药管理参数格式(manage_medication): + - action="create", name="药名", dosage="剂量", frequency="Daily", time_of_day=["08:00","20:00"], duration_days=7 + 规则: - 用户可能一句话涉及多个领域,全部处理 - 数值明确+指标明确→直接录入,不要追问 diff --git a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs index e9507f7..d51cfd2 100644 --- a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs @@ -132,7 +132,7 @@ public static class AiChatEndpoints if (!string.IsNullOrEmpty(content)) { fullResponse += content; - await SseWriteAsync(http, new { action = "answer", data = content, type = messageType }, ct); + await SseWriteAsync(http, new { action = "answer", data = content, type = messageType, metadata = metadata.Count > 0 ? metadata : null }, ct); } } catch (JsonException) { /* 跳过解析失败的 chunk */ } @@ -338,11 +338,12 @@ public static class AiChatEndpoints AgentType.Report => ReportAgentHandler.Tools, AgentType.Exercise => ExerciseAgentHandler.Tools, AgentType.Unified => [ - ..HealthDataAgentHandler.Tools, - ..DietAgentHandler.Tools, - ..MedicationAgentHandler.Tools, - ..ExerciseAgentHandler.Tools, - ..CommonAgentHandler.Tools, + HealthDataAgentHandler.RecordHealthDataTool, + CommonAgentHandler.QueryHealthRecordsTool, + DietAgentHandler.EstimateFoodTool, + MedicationAgentHandler.ManageMedicationTool, + ExerciseAgentHandler.ManageExerciseTool, + CommonAgentHandler.CheckArchiveTool, ], _ => CommonAgentHandler.Tools, }; @@ -442,6 +443,31 @@ public static class AiChatEndpoints metadata["name"] = name.ToString(); if (resultDict.TryGetValue("dosage", out var dosage)) metadata["dosage"] = dosage.ToString(); + if (resultDict.TryGetValue("time", out var time)) + metadata["time"] = time.ToString(); + if (resultDict.TryGetValue("frequency", out var freq)) + metadata["frequency"] = freq.ToString(); + if (resultDict.TryGetValue("duration_days", out var dd2) && dd2 is int d2) + metadata["duration_days"] = d2; + if (resultDict.TryGetValue("start_date", out var sd)) + metadata["startDate"] = sd.ToString(); + } + break; + case "manage_exercise": + messageType = "data_confirm"; + if (resultDict != null) + { + metadata["type"] = "exercise"; + metadata["value"] = ""; + if (resultDict.TryGetValue("exercise_type", out var et)) + metadata["value"] = et.ToString(); + if (resultDict.TryGetValue("duration_minutes", out var dm) && dm is int mins) + metadata["unit"] = $"每天{mins}分钟"; + if (resultDict.TryGetValue("day_count", out var dc) && dc is int days) + metadata["durationDays"] = days; + if (resultDict.TryGetValue("success", out var es) && es is bool eb && eb) + metadata["success"] = true; + metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm"); } break; case "estimate_food_text": diff --git a/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs b/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs index 1adcc8d..78cf002 100644 --- a/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs @@ -108,32 +108,141 @@ public static class MedicationEndpoints var userId = GetUserId(http); var beijingNow = DateTime.UtcNow.AddHours(8); var now = TimeOnly.FromDateTime(beijingNow); - var windowFuture = now.AddHours(2); // 接下来2小时 - var windowPast = now.AddHours(-1); // 过去1小时内(过了还没吃就该提醒) + var today = DateOnly.FromDateTime(beijingNow); + var todayUtc = DateTime.SpecifyKind(beijingNow.Date, DateTimeKind.Utc); + // 只查活跃且在有效期内的药 var meds = await db.Medications - .Where(m => m.UserId == userId && m.IsActive && m.TimeOfDay != null) + .Where(m => m.UserId == userId && m.IsActive && m.TimeOfDay != null + && m.StartDate <= today && (m.EndDate == null || m.EndDate >= today)) .ToListAsync(ct); - // 同时包含:未来2小时内 + 过去1小时内(防止过了时间就不提醒) - var due = meds.Where(m => m.TimeOfDay!.Any(t => - InTimeWindow(t, now, windowFuture) || InTimeWindow(t, windowPast, now) - )).ToList(); + // 查询今天所有服药记录 + var logs = await db.MedicationLogs + .Where(l => l.UserId == userId && l.ConfirmedAt >= todayUtc) + .ToListAsync(ct); - var todayStart = DateTime.SpecifyKind(beijingNow.Date, DateTimeKind.Utc); - var dueMeds = new List(); - foreach (var m in due) + // 按次生成提醒 + var reminders = new List(); + foreach (var m in meds) { - var logged = await db.MedicationLogs.AnyAsync(l => - l.MedicationId == m.Id - && l.CreatedAt >= todayStart - && l.Status == MedicationLogStatus.Taken, ct); - if (!logged) - dueMeds.Add(new { m.Id, m.Name, m.Dosage, m.TimeOfDay }); + // 处理隔天/每周的逻辑 + var dosesToday = GetDosesForToday(m, today); + if (!dosesToday) continue; + + foreach (var t in m.TimeOfDay!) + { + var scheduledTime = t; + var doseTimeBeijing = today.ToDateTime(scheduledTime); + + // 查该药该时间是否已打卡 + var alreadyLogged = logs.Any(l => + l.MedicationId == m.Id && l.ScheduledTime == scheduledTime); + + string status; + if (alreadyLogged) + { + var log = logs.First(l => l.MedicationId == m.Id && l.ScheduledTime == scheduledTime); + status = log.Status == MedicationLogStatus.Taken ? "taken" : "skipped"; + } + else if (scheduledTime <= now.AddMinutes(-30)) + { + status = "overdue"; // 过了30分钟还没吃 + } + else if (scheduledTime <= now.AddHours(3)) + { + status = "upcoming"; // 未来3小时内 + } + else + { + continue; // 太远,不显示 + } + + reminders.Add(new + { + m.Id, m.Name, m.Dosage, + scheduledTime = scheduledTime.ToString("HH:mm"), + status, + }); + } } - return Results.Ok(new { code = 0, data = dueMeds, message = (string?)null }); + // overdue 在前,upcoming 在后,taken 在最后 + reminders = reminders.OrderBy(r => + ((dynamic)r).status == "overdue" ? 0 : + ((dynamic)r).status == "upcoming" ? 1 : 2 + ).ToList(); + + return Results.Ok(new { code = 0, data = reminders, message = (string?)null }); }); + + // 按次打卡 + group.MapPost("/{id:guid}/confirm-dose", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) => + { + var userId = GetUserId(http); + var med = await db.Medications.FirstOrDefaultAsync(m => m.Id == id && m.UserId == userId, ct); + if (med == null) return Results.Ok(new { code = 404, message = "药品不存在" }); + + using var reader = new System.IO.StreamReader(http.Request.Body); + var body = await reader.ReadToEndAsync(ct); + var json = System.Text.Json.JsonDocument.Parse(body); + var timeStr = json.RootElement.GetProperty("scheduledTime").GetString()!; + var statusStr = json.RootElement.TryGetProperty("status", out var st) ? st.GetString() : "taken"; + var scheduledTime = TimeOnly.Parse(timeStr); + var status = statusStr == "taken" ? MedicationLogStatus.Taken : MedicationLogStatus.Skipped; + + // 防止重复打卡 + var todayUtc = DateTime.SpecifyKind(DateTime.UtcNow.AddHours(8).Date, DateTimeKind.Utc); + var existing = await db.MedicationLogs.AnyAsync(l => + l.MedicationId == id && l.UserId == userId + && l.ScheduledTime == scheduledTime && l.ConfirmedAt >= todayUtc, ct); + if (existing) return Results.Ok(new { code = 0, data = new { success = false, message = "已打卡" }, message = (string?)null }); + + db.MedicationLogs.Add(new MedicationLog + { + Id = Guid.NewGuid(), MedicationId = id, UserId = userId, + Status = status, ScheduledTime = scheduledTime, ConfirmedAt = DateTime.UtcNow, + }); + await db.SaveChangesAsync(ct); + return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); + }); + + // 取消打卡(删掉对应 log) + group.MapDelete("/{id:guid}/confirm-dose/{time}", async (Guid id, string time, HttpContext http, AppDbContext db, CancellationToken ct) => + { + var userId = GetUserId(http); + var scheduledTime = TimeOnly.Parse(time); + var todayUtc = DateTime.SpecifyKind(DateTime.UtcNow.AddHours(8).Date, DateTimeKind.Utc); + var log = await db.MedicationLogs + .Where(l => l.MedicationId == id && l.UserId == userId + && l.ScheduledTime == scheduledTime && l.ConfirmedAt >= todayUtc) + .FirstOrDefaultAsync(ct); + if (log != null) { db.MedicationLogs.Remove(log); await db.SaveChangesAsync(ct); } + return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); + }); + } + + /// + /// 判断今天是否该吃药(处理隔天/每周频率) + /// + private static bool GetDosesForToday(Medication m, DateOnly today) + { + if (m.Frequency == MedicationFrequency.Daily) return true; + if (m.Frequency == MedicationFrequency.TwiceDaily) return true; + if (m.Frequency == MedicationFrequency.ThreeTimesDaily) return true; + if (m.Frequency == MedicationFrequency.AsNeeded) return true; + + var startDate = m.StartDate ?? today; + if (m.Frequency == MedicationFrequency.EveryOtherDay) + { + var daysSinceStart = today.DayNumber - startDate.DayNumber; + return daysSinceStart % 2 == 0; + } + if (m.Frequency == MedicationFrequency.Weekly) + { + return today.DayOfWeek == startDate.DayOfWeek; + } + return true; } private static bool InTimeWindow(TimeOnly t, TimeOnly start, TimeOnly end) => diff --git a/health_app/lib/app.dart b/health_app/lib/app.dart index 4919482..238c747 100644 --- a/health_app/lib/app.dart +++ b/health_app/lib/app.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'core/app_router.dart'; import 'core/app_theme.dart'; @@ -14,6 +15,16 @@ class HealthApp extends ConsumerWidget { title: '健康管家', debugShowCheckedModeBanner: false, theme: AppTheme.lightTheme, + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [ + Locale('zh', 'CN'), + Locale('zh'), + ], + locale: const Locale('zh'), home: const _RootNavigator(), ); } diff --git a/health_app/lib/core/app_router.dart b/health_app/lib/core/app_router.dart index 706b204..137358c 100644 --- a/health_app/lib/core/app_router.dart +++ b/health_app/lib/core/app_router.dart @@ -4,6 +4,7 @@ import '../pages/auth/login_page.dart'; import '../pages/home/home_page.dart'; import '../pages/chart/trend_page.dart'; import '../pages/medication/medication_list_page.dart'; +import '../pages/medication/medication_checkin_page.dart'; import '../pages/medication/medication_edit_page.dart'; import '../pages/report/report_pages.dart'; import '../pages/report/ai_analysis_page.dart'; @@ -30,6 +31,8 @@ Widget buildPage(RouteInfo route) { return const HealthCalendarPage(); case 'medications': return const MedicationListPage(); + case 'medCheckIn': + return MedicationCheckInPage(medId: params['id']); case 'medicationEdit': return const MedicationEditPage(); case 'reports': 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 fa4cac4..85d76d6 100644 --- a/health_app/lib/pages/home/widgets/chat_messages_view.dart +++ b/health_app/lib/pages/home/widgets/chat_messages_view.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/navigation_provider.dart'; +import '../../../providers/auth_provider.dart'; import '../../../providers/chat_provider.dart'; import '../../../providers/data_providers.dart'; @@ -64,7 +65,7 @@ class ChatMessagesView extends ConsumerWidget { case MessageType.dataConfirm: return _buildDataConfirmCard(context, ref, msg); case MessageType.medicationConfirm: - return _buildMedicationConfirmCard(context, msg); + return _buildMedicationConfirmCard(context, ref, msg); case MessageType.dietAnalysis: return _buildDietAnalysisCard(context, msg); case MessageType.reportAnalysis: @@ -282,7 +283,6 @@ class ChatMessagesView extends ConsumerWidget { // ═══════════════════════════════════════════════════════════ Widget _buildDataConfirmCard(BuildContext context, WidgetRef ref, ChatMessage msg) { - if (msg.confirmed) return const SizedBox.shrink(); final meta = msg.metadata; final metricType = meta?['type'] as String? ?? ''; final value = meta?['value'] as String? ?? ''; @@ -294,7 +294,7 @@ class ChatMessagesView extends ConsumerWidget { alignment: Alignment.centerLeft, child: Container( margin: const EdgeInsets.only(bottom: 12), - constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.88), + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.92), decoration: BoxDecoration( color: const Color(0xFFFFFFFF), borderRadius: BorderRadius.circular(20), @@ -305,9 +305,10 @@ class ChatMessagesView extends ConsumerWidget { Container( width: double.infinity, padding: const EdgeInsets.symmetric(vertical: 10), decoration: const BoxDecoration(gradient: LinearGradient(colors: [Color(0xFF4CAF50), Color(0xFF43A047)])), - child: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.check_circle, size: 18, color: Colors.white), SizedBox(width: 6), - Text('数据已记录', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.white)), + child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + const Icon(Icons.info_outline, size: 18, color: Colors.white), const SizedBox(width: 6), + Text(metricType == 'exercise' ? '请确认运动计划' : '请确认录入', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.white)), ]), ), Padding(padding: const EdgeInsets.all(18), child: Column(children: [ @@ -335,16 +336,32 @@ class ChatMessagesView extends ConsumerWidget { child: const Row(children: [Icon(Icons.warning_amber_rounded, size: 18, color: Color(0xFFE53935)), SizedBox(width: 8), Expanded(child: Text('数值偏高,建议关注', style: TextStyle(fontSize: 13, color: Color(0xFFE53935), fontWeight: FontWeight.w500)))])), ], const SizedBox(height: 18), - Row(children: [ - Expanded(child: _cardFilledBtn('确认', Icons.check, onTap: () { - msg.confirmed = true; - ref.invalidate(latestHealthProvider); - final notifier = ref.read(chatProvider.notifier); - notifier.markNeedsRebuild(); - })), - const SizedBox(width: 8), - Expanded(child: _cardOutlineBtn('查看详情', Icons.trending_up_outlined, onTap: () => pushRoute(ref, 'trend', params: {'type': metricType}))), - ]), + if (msg.confirmed) + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: null, + icon: const Icon(Icons.check_circle, size: 18), + label: const Text('录入成功'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF43A047), + foregroundColor: Colors.white, + disabledBackgroundColor: const Color(0xFF43A047), + disabledForegroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ) + else + Row(children: [ + Expanded(child: _cardFilledBtn('确认录入', Icons.check, onTap: () { + msg.confirmed = true; + ref.invalidate(latestHealthProvider); + final notifier = ref.read(chatProvider.notifier); + notifier.markNeedsRebuild(); + })), + ]), ])), ]), ), @@ -355,19 +372,19 @@ class ChatMessagesView extends ConsumerWidget { // 3. MedicationConfirmCard — 增强版用药确认卡片 // ═══════════════════════════════════════════════════════════ - Widget _buildMedicationConfirmCard(BuildContext context, ChatMessage msg) { + Widget _buildMedicationConfirmCard(BuildContext context, WidgetRef ref, ChatMessage msg) { final meta = msg.metadata; final name = meta?['name'] as String? ?? ''; final dosage = meta?['dosage'] as String? ?? ''; final time = meta?['time'] as String? ?? ''; final frequency = meta?['frequency'] as String? ?? ''; - final remaining = meta?['remaining'] as double? ?? 0.65; + final duration = meta?['duration_days'] as int?; return Align( alignment: Alignment.centerLeft, child: Container( margin: const EdgeInsets.only(bottom: 12), - constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.88), + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.92), decoration: BoxDecoration( color: const Color(0xFFFFFFFF), borderRadius: BorderRadius.circular(20), @@ -417,38 +434,36 @@ class ChatMessagesView extends ConsumerWidget { child: Column( children: [ // 服药时间 & 频率 - _medInfoRow(Icons.schedule_outlined, time.isNotEmpty ? '服药时间:$time' : '待设置'), - if (frequency.isNotEmpty) _medInfoRow(Icons.repeat, '频率:$frequency'), - - // 剩余药量进度条 - const SizedBox(height: 14), - Row( - children: [ - const Text('剩余药量', style: TextStyle(fontSize: 13, color: Color(0xFF666666))), - const Spacer(), - Text('${(remaining * 100).toInt()}%', style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF8B9CF7))), - ], - ), - const SizedBox(height: 8), - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: LinearProgressIndicator( - value: remaining, - minHeight: 8, - backgroundColor: const Color(0xFFEFEDFF), - valueColor: const AlwaysStoppedAnimation(Color(0xFF8B9CF7)), - ), - ), - - // 操作按钮 - const SizedBox(height: 18), - Row(children: [ - Expanded(child: _cardFilledBtn('确认服药', Icons.check_circle_outline)), - const SizedBox(width: 8), - Expanded(child: _cardOutlineBtn('跳过', Icons.skip_next)), - const SizedBox(width: 8), - Expanded(child: _cardOutlineBtn('设置提醒', Icons.notifications_none_outlined)), - ]), + _medInfoRow(Icons.schedule_outlined, time.isNotEmpty ? '服药时间:$time' : ''), + if (frequency.isNotEmpty) _medInfoRow(Icons.repeat, '频率:${_freqLabel(frequency)}'), + if (duration != null && duration > 0) + _medInfoRow(Icons.calendar_today_outlined, '服用天数:$duration 天'), + const SizedBox(height: 16), + if (msg.confirmed) + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: null, + icon: const Icon(Icons.check_circle, size: 18), + label: const Text('录入成功'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF43A047), + foregroundColor: Colors.white, + disabledBackgroundColor: const Color(0xFF43A047), + disabledForegroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ) + else + SizedBox(width: double.infinity, child: _cardFilledBtn('确认录入', Icons.check_circle_outlined, + onTap: () { + msg.confirmed = true; + ref.invalidate(medicationListProvider); + final notifier = ref.read(chatProvider.notifier); + notifier.markNeedsRebuild(); + })), const SizedBox(height: 8), ], ), @@ -472,6 +487,15 @@ class ChatMessagesView extends ConsumerWidget { ); } + String _freqLabel(String freq) { + switch (freq.toLowerCase()) { + case 'daily': return '每天'; + case 'everyotherday': return '隔天'; + case 'weekly': return '每周'; + default: return freq; + } + } + // ═══════════════════════════════════════════════════════════ // 4. DietAnalysisCard — 增强版饮食分析卡片 // ═══════════════════════════════════════════════════════════ @@ -486,7 +510,7 @@ class ChatMessagesView extends ConsumerWidget { alignment: Alignment.centerLeft, child: Container( margin: const EdgeInsets.only(bottom: 12), - constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.88), + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.92), decoration: BoxDecoration( color: const Color(0xFFFFFFFF), borderRadius: BorderRadius.circular(20), @@ -578,7 +602,7 @@ class ChatMessagesView extends ConsumerWidget { alignment: Alignment.centerLeft, child: Container( margin: const EdgeInsets.only(bottom: 12), - constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.88), + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.92), decoration: BoxDecoration( color: const Color(0xFFFFFFFF), borderRadius: BorderRadius.circular(20), @@ -758,7 +782,7 @@ class ChatMessagesView extends ConsumerWidget { alignment: Alignment.centerLeft, child: Container( margin: const EdgeInsets.only(bottom: 12), - constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.88), + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.92), decoration: BoxDecoration( color: const Color(0xFFFFFFFF), borderRadius: BorderRadius.circular(20), @@ -857,7 +881,7 @@ class ChatMessagesView extends ConsumerWidget { children: [ // 文字内容 if (isUser) - Text(msg.content, style: const TextStyle(fontSize: 16, color: Colors.white, height: 1.4)) + SelectableText(msg.content, style: const TextStyle(fontSize: 16, color: Colors.white, height: 1.4)) else MarkdownBody( data: msg.content, @@ -994,6 +1018,7 @@ class ChatMessagesView extends ConsumerWidget { case 'glucose': return 'mmol/L'; case 'spo2': return '%'; case 'weight': return 'kg'; + case 'exercise': return '分钟/天'; default: return ''; } } @@ -1005,6 +1030,7 @@ class ChatMessagesView extends ConsumerWidget { case 'glucose': return '💉'; case 'spo2': return '🫁'; case 'weight': return '⚖️'; + case 'exercise': return '🏃'; default: return '📊'; } } @@ -1016,13 +1042,14 @@ class ChatMessagesView extends ConsumerWidget { case 'glucose': return '血糖'; case 'spo2': return '血氧'; case 'weight': return '体重'; + case 'exercise': return '运动计划'; default: return '健康指标'; } } static void _medicationCheckIn(WidgetRef ref, BuildContext context) async { try { - final service = ref.read(medicationServiceProvider); + final api = ref.read(apiClientProvider); final reminders = await ref.read(medicationReminderProvider.future); if (reminders.isEmpty) { if (!context.mounted) return; @@ -1032,7 +1059,10 @@ class ChatMessagesView extends ConsumerWidget { return; } for (final m in reminders) { - await service.confirm(m['id']?.toString() ?? ''); + final id = m['id']?.toString() ?? ''; + final time = m['scheduledTime']?.toString() ?? ''; + if (id.isEmpty) continue; + await api.post('/api/medications/$id/confirm-dose', data: {'scheduledTime': time, 'status': 'taken'}); } ref.invalidate(medicationReminderProvider); if (!context.mounted) return; @@ -1050,6 +1080,7 @@ class ChatMessagesView extends ConsumerWidget { } } + static _AgentColors _agentColors(ActiveAgent agent) { return switch (agent) { ActiveAgent.consultation => _AgentColors( @@ -1166,34 +1197,27 @@ class ChatMessagesView extends ConsumerWidget { ].join(' · '), status: 'done', onTap: () => pushRoute(ref, 'trend'))); } - // 2. 用药提醒 + // 2. 用药提醒 — 一行汇总 reminders.whenOrNull(data: (meds) { - for (final m in meds) { - final name = m['name'] ?? ''; - final dosage = m['dosage'] ?? ''; - final times = (m['timeOfDay'] as List?)?.map((t) => t.toString().substring(0, 5)).join(', ') ?? ''; - // 判断该用药时间是否已过 - bool overdue = false; - if (m['timeOfDay'] is List) { - final nowTime = TimeOfDay.fromDateTime(now); - for (final t in (m['timeOfDay'] as List)) { - final parts = t.toString().split(':'); - final h = int.tryParse(parts[0]) ?? 0; - final min = int.tryParse(parts.length > 1 ? parts[1] : '0') ?? 0; - if (nowTime.hour > h || (nowTime.hour == h && nowTime.minute > min)) { - overdue = true; - break; - } - } - } + final overdue = meds.where((m) => m['status'] == 'overdue').length; + final upcoming = meds.where((m) => m['status'] == 'upcoming').length; + final taken = meds.where((m) => m['status'] == 'taken').length; + final total = overdue + upcoming + taken; + if (total > 0) { + final parts = []; + if (overdue > 0) parts.add('$overdue项过期'); + if (upcoming > 0) parts.add('$upcoming项待服'); + if (taken > 0) parts.add('$taken项已服'); tasks.add(_taskRow( - context, Icons.medication_rounded, '$name $dosage ($times)', - status: overdue ? 'overdue' : 'pending', - onTap: () {}, + context, Icons.medication_rounded, '用药打卡 ($total项)', + trailing: parts.join(' · '), + status: overdue > 0 ? 'overdue' : upcoming > 0 ? 'pending' : 'done', + onTap: () => pushRoute(ref, 'medCheckIn'), )); } }); - if (tasks.length <= (allNull ? 0 : 1)) { + final hasMeds = reminders.asData?.value != null && (reminders.asData!.value).isNotEmpty; + if (!hasMeds) { tasks.add(_taskRow(context, Icons.medication_rounded, '暂无用药提醒', status: 'pending')); } diff --git a/health_app/lib/pages/medication/medication_checkin_page.dart b/health_app/lib/pages/medication/medication_checkin_page.dart new file mode 100644 index 0000000..b61dd06 --- /dev/null +++ b/health_app/lib/pages/medication/medication_checkin_page.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../providers/auth_provider.dart'; +import '../../providers/data_providers.dart'; + +class MedicationCheckInPage extends ConsumerStatefulWidget { + final String? medId; // 可选:指定药品,null 显示全部 + const MedicationCheckInPage({super.key, this.medId}); + @override ConsumerState createState() => _MedicationCheckInPageState(); +} + +class _MedicationCheckInPageState extends ConsumerState { + Future>>? _future; + + @override void initState() { super.initState(); _load(); } + void _load() => setState(() { _future = ref.read(medicationReminderProvider.future); }); + + Future _toggleDose(String medId, String time, bool taken) async { + try { + final api = ref.read(apiClientProvider); + if (taken) { + // 取消打卡:删掉对应 log + await api.delete('/api/medications/$medId/confirm-dose/$time'); + } else { + // 打卡 + await api.post('/api/medications/$medId/confirm-dose', data: {'scheduledTime': time, 'status': 'taken'}); + } + ref.invalidate(medicationReminderProvider); + ref.invalidate(medicationListProvider); + _load(); + } catch (_) {} + } + + @override Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF8F9FC), + appBar: AppBar(title: const Text('服药打卡'), centerTitle: true), + body: FutureBuilder>>( + future: _future, + builder: (ctx, snap) { + if (snap.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator(color: Color(0xFF8B9CF7))); + } + final reminders = snap.data ?? []; + if (reminders.isEmpty) { + return Center( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.check_circle_outline, size: 64, color: Colors.grey[300]), + const SizedBox(height: 12), + const Text('今天所有用药已打卡', style: TextStyle(fontSize: 16, color: Color(0xFF999999))), + ]), + ); + } + + // 如果指定了 medId,只显示该药品 + var filtered = reminders; + if (widget.medId != null) { + filtered = reminders.where((r) => r['id']?.toString() == widget.medId).toList(); + } + + // 按药品分组 + final grouped = >>{}; + for (final r in filtered) { + final name = r['name']?.toString() ?? '药品'; + grouped.putIfAbsent(name, () => []).add(r); + } + + return RefreshIndicator( + onRefresh: () async => _load(), + child: ListView( + padding: const EdgeInsets.all(14), + children: grouped.entries.map((entry) { + final medName = entry.key; + final doses = entry.value; + final dosage = doses.first['dosage']?.toString() ?? ''; + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))], + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Container( + width: 40, height: 40, + decoration: BoxDecoration(color: const Color(0xFFFFF3E0), borderRadius: BorderRadius.circular(10)), + child: const Center(child: Text('💊', style: TextStyle(fontSize: 20))), + ), + const SizedBox(width: 12), + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(medName, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)), + if (dosage.isNotEmpty) Text(dosage, style: const TextStyle(fontSize: 13, color: Color(0xFF888888))), + ]), + ]), + const SizedBox(height: 14), + ...doses.map((d) { + final time = d['scheduledTime']?.toString() ?? ''; + final status = d['status']?.toString() ?? 'pending'; + final isTaken = status == 'taken'; + final medId = d['id']?.toString() ?? ''; + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row(children: [ + Icon( + isTaken ? Icons.check_circle : Icons.radio_button_unchecked, + size: 20, + color: isTaken ? const Color(0xFF43A047) : const Color(0xFFCCCCCC), + ), + const SizedBox(width: 10), + Text(time, style: TextStyle( + fontSize: 15, fontWeight: FontWeight.w500, + color: isTaken ? const Color(0xFF999999) : const Color(0xFF333333), + )), + const Spacer(), + GestureDetector( + onTap: () => _toggleDose(medId, time, isTaken), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: isTaken ? const Color(0xFFDCFCE7) : const Color(0xFF8B9CF7), + borderRadius: BorderRadius.circular(20), + ), + child: Text(isTaken ? '已打卡' : '打卡', + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, + color: isTaken ? const Color(0xFF43A047) : Colors.white)), + ), + ), + ]), + ); + }), + ]), + ); + }).toList(), + ), + ); + }, + ), + ); + } +} diff --git a/health_app/lib/pages/medication/medication_list_page.dart b/health_app/lib/pages/medication/medication_list_page.dart index b64e12a..fdfedc8 100644 --- a/health_app/lib/pages/medication/medication_list_page.dart +++ b/health_app/lib/pages/medication/medication_list_page.dart @@ -13,51 +13,10 @@ class _MedicationListPageState extends ConsumerState { @override void initState() { super.initState(); _load(); } void _load() { setState(() { _future = ref.read(medicationServiceProvider).getMedications(_filter); }); } - Future _checkIn(String id) async { - await ref.read(medicationServiceProvider).confirm(id); - ref.invalidate(medicationReminderProvider); - _load(); - } Future _delete(String id) async { await ref.read(medicationServiceProvider).deleteMedication(id); _load(); } - Future _toggleActive(Map m) async { - await ref.read(medicationServiceProvider).update(m['id']?.toString() ?? '', {'isActive': !(m['isActive'] == true), 'name': m['name'] ?? '', 'dosage': m['dosage'] ?? '', 'frequency': 'Daily'}); - _load(); - } - - void _showDetail(Map m) { - final times = (m['timeOfDay'] as List?)?.map((t) => t.toString().substring(0, 5)).join('、') ?? ''; - final isActive = m['isActive'] == true; - showModalBottomSheet(context: context, shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))), builder: (ctx) => Padding(padding: const EdgeInsets.all(20), child: Column(mainAxisSize: MainAxisSize.min, children: [ - Row(children: [ - Container(width: 48, height: 48, decoration: BoxDecoration(color: isActive ? const Color(0xFFEDEAFF) : const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(14)), child: Icon(Icons.medication_outlined, size: 24, color: isActive ? const Color(0xFF6C5CE7) : Colors.grey)), - const SizedBox(width: 14), - Expanded(child: Text(m['name']?.toString() ?? '', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700))), - if (!isActive) const Text(' (已停用)', style: TextStyle(color: Color(0xFF999999))), - ]), - const SizedBox(height: 16), - if (m['dosage']?.toString().isNotEmpty == true) _dRow(ctx, '剂量', m['dosage'].toString()), - _dRow(ctx, '服用时间', times), - _dRow(ctx, '频率', '每天${(m['timeOfDay'] as List?)?.length ?? 0}次'), - if (m['startDate'] != null) _dRow(ctx, '开始', m['startDate'].toString().substring(0, 10)), - if (m['endDate'] != null) _dRow(ctx, '结束', m['endDate'].toString().substring(0, 10)), - if (m['notes']?.toString().isNotEmpty == true) _dRow(ctx, '备注', m['notes'].toString()), - const SizedBox(height: 20), - Row(children: [ - Expanded(child: OutlinedButton(onPressed: () { Navigator.pop(ctx); pushRoute(ref, 'medicationEdit', params: {'id': m['id']?.toString() ?? ''}); }, child: const Text('编辑'))), - const SizedBox(width: 12), - Expanded(child: OutlinedButton(onPressed: () { Navigator.pop(ctx); _toggleActive(m); }, child: Text(isActive ? '停药' : '恢复服用'), style: OutlinedButton.styleFrom(foregroundColor: isActive ? const Color(0xFFFF9800) : const Color(0xFF43A047)))), - ]), - const SizedBox(height: 8), - ]))); - } - - Widget _dRow(BuildContext ctx, String label, String value) => Padding(padding: const EdgeInsets.only(bottom: 8), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox(width: 72, child: Text(label, style: const TextStyle(fontSize: 14, color: Color(0xFF888888)))), - Expanded(child: Text(value, style: const TextStyle(fontSize: 14))), - ])); @override Widget build(BuildContext context) { return Scaffold( @@ -81,12 +40,11 @@ class _MedicationListPageState extends ConsumerState { if (i == list.length) return const SizedBox(height: 80); final m = list[i]; final times = (m['timeOfDay'] as List?)?.map((t) => t.toString().substring(0, 5)).join(' ') ?? ''; - final todayTaken = m['todayTaken'] == true; final isActive = m['isActive'] == true; return _SwipeDeleteTile( key: Key(m['id']?.toString() ?? '$i'), onDelete: () => _delete(m['id']?.toString() ?? ''), - onTap: () => _showDetail(m), + onTap: () => pushRoute(ref, 'medCheckIn', params: {'id': m['id']?.toString() ?? ''}), child: Container( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), padding: const EdgeInsets.all(14), @@ -101,16 +59,8 @@ class _MedicationListPageState extends ConsumerState { ]), const SizedBox(height: 2), Text('${m['dosage'] ?? ''} $times', style: const TextStyle(fontSize: 13, color: Color(0xFF888888))), - if (m['notes']?.toString().isNotEmpty == true) - Text(m['notes'].toString(), style: const TextStyle(fontSize: 12, color: Color(0xFFBBBBBB)), maxLines: 1, overflow: TextOverflow.ellipsis), ])), - if (isActive) GestureDetector( - onTap: () => _checkIn(m['id']?.toString() ?? ''), - child: Container(width: 40, height: 40, - decoration: BoxDecoration(color: todayTaken ? const Color(0xFFDCFCE7) : const Color(0xFFEDEAFF), borderRadius: BorderRadius.circular(12)), - child: Icon(todayTaken ? Icons.check_circle : Icons.check_circle_outline, size: 24, color: todayTaken ? const Color(0xFF43A047) : const Color(0xFFBBBBBB)), - ), - ), + const Icon(Icons.chevron_right, size: 20, color: Color(0xFFCCCCCC)), ]), ), ); diff --git a/health_app/lib/pages/remaining_pages.dart b/health_app/lib/pages/remaining_pages.dart index 249fcb0..249c754 100644 --- a/health_app/lib/pages/remaining_pages.dart +++ b/health_app/lib/pages/remaining_pages.dart @@ -150,6 +150,7 @@ class _ExercisePlanPageState extends ConsumerState { return ListView.builder(padding: const EdgeInsets.all(12), itemCount: plans.length, itemBuilder: (ctx, i) { final p = plans[i]; final items = (p['items'] as List?)?.cast>() ?? []; + if (items.isEmpty) return const SizedBox.shrink(); final total = items.length; final done = items.where((it) => it['isCompleted'] == true).length; // 基于weekStartDate推算今天对应的条目索引 diff --git a/health_app/lib/providers/chat_provider.dart b/health_app/lib/providers/chat_provider.dart index 8d46ab2..96da5d9 100644 --- a/health_app/lib/providers/chat_provider.dart +++ b/health_app/lib/providers/chat_provider.dart @@ -13,7 +13,7 @@ class ChatMessage { String content; final DateTime createdAt; MessageType type; - final Map? metadata; + Map? metadata; bool confirmed; ChatMessage({ required this.id, @@ -53,22 +53,6 @@ class ChatState { ); } -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; @@ -78,28 +62,6 @@ 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/ai/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 []; - } -}); ActiveAgent _parseAgent(String? type) { switch (type?.toLowerCase()) { @@ -138,6 +100,8 @@ class ChatNotifier extends Notifier { } void setAgent(ActiveAgent a) { + // 流式回复中忽略胶囊切换,防止状态混乱 + if (state.isStreaming) return; _subscription?.cancel(); state = state.copyWith(activeAgent: a); ref.read(selectedAgentProvider.notifier).select(a); @@ -190,16 +154,12 @@ class ChatNotifier extends Notifier { ); }).toList(); - // 从对话列表中找到对应的 agent 类型 - final convList = ref.read(conversationListProvider).value ?? []; - final conv = convList.firstWhere((c) => c.id == convId, orElse: () => ConversationItem(id: convId, title: '', lastMessage: '', updatedAt: DateTime.now(), agent: ActiveAgent.default_)); - state = state.copyWith( messages: messages, conversationId: convId, - activeAgent: conv.agent, + activeAgent: ActiveAgent.default_, ); - ref.read(selectedAgentProvider.notifier).select(conv.agent); + ref.read(selectedAgentProvider.notifier).select(ActiveAgent.default_); } catch (_) {} } @@ -331,6 +291,10 @@ class ChatNotifier extends Notifier { aiMsg.content += (j['data'] as String?) ?? ''; final messageType = j['type'] as String? ?? 'text'; aiMsg.type = _parseMessageType(messageType); + // 从 SSE 中获取元数据(用药/健康数据的确认信息) + if (j['metadata'] is Map) { + aiMsg.metadata = Map.from(j['metadata']); + } state = state.copyWith(thinkingText: null); _update(aiMsg); case 'notice': diff --git a/health_app/lib/widgets/health_drawer.dart b/health_app/lib/widgets/health_drawer.dart index 8d60b29..1a1fb33 100644 --- a/health_app/lib/widgets/health_drawer.dart +++ b/health_app/lib/widgets/health_drawer.dart @@ -15,8 +15,6 @@ 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( width: MediaQuery.of(context).size.width * 0.82, backgroundColor: const Color(0xFFFAFBFE), @@ -163,48 +161,6 @@ class HealthDrawer extends ConsumerWidget { const SizedBox(height: 10), - // ════════════ 历史对话区 ════════════ - _SectionCard( - color: Colors.white, - gradientColors: null, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 4), - child: Row(children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: const Color(0xFF4D96FF).withAlpha(15), - borderRadius: BorderRadius.circular(6), - ), - child: const Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.history_rounded, size: 13, color: Color(0xFF4D96FF)), - SizedBox(width: 4), - Text('历史对话', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF4D96FF))), - ]), - ), - const Spacer(), - GestureDetector( - onTap: () async { - final api = ref.read(apiClientProvider); - for (final c in conversations.value ?? []) { - try { await api.delete('/api/ai/conversations/${c.id}'); } catch (_) {} - } - ref.invalidate(conversationListProvider); - }, - child: const Padding(padding: EdgeInsets.all(4), child: Text('清理', style: TextStyle(fontSize: 11, color: Color(0xFFAAAAAA)))), - ), - ]), - ), - _buildConversationList(context, ref, conversations), - ], - ), - ), - - const SizedBox(height: 10), - // ════════════ 设置区 ════════════ _SectionCard( color: const Color(0xFFF5F5F7), @@ -255,55 +211,6 @@ class HealthDrawer extends ConsumerWidget { return metric.toString(); } - Widget _buildConversationList(BuildContext context, WidgetRef ref, AsyncValue> conversations) { - return conversations.when( - data: (items) { - if (items.isEmpty) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: Center( - child: Text('暂无历史对话', style: TextStyle(color: Color(0xFFBBBBBB), fontSize: 13)), - ), - ); - } - return Padding( - padding: const EdgeInsets.fromLTRB(8, 6, 8, 14), - child: Column( - mainAxisSize: MainAxisSize.min, - children: items.map((item) => _ConversationItem( - item: item, - onTap: () { - ref.read(chatProvider.notifier).loadConversation(item.id); - Navigator.of(context).pop(); - }, - onDelete: () async { - try { - await ref.read(apiClientProvider).delete('/api/ai/conversations/${item.id}'); - ref.invalidate(conversationListProvider); - } catch (_) {} - }, - )).toList(), - ), - ); - }, - loading: () => const Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: Center( - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF4D96FF)), - ), - ), - ), - error: (Object err, StackTrace st) => const Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: Center( - child: Text('加载失败', style: TextStyle(color: Color(0xFFBBBBBB), fontSize: 13)), - ), - ), - ); - } } // ═══════════════════════════════════════════════════════════════ @@ -429,71 +336,3 @@ class _FeatureChip extends StatelessWidget { } } -// ═══════════════════════════════════════════════════════════════ -// 历史对话项 -// ═══════════════════════════════════════════════════════════════ - -class _ConversationItem extends StatelessWidget { - final ConversationItem item; - final VoidCallback? onTap; - final VoidCallback? onDelete; - - const _ConversationItem({required this.item, this.onTap, this.onDelete}); - - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.symmetric(vertical: 2), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: const Color(0xFFEEEEEE)), - ), - child: ListTile( - onTap: onTap, - contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2), - leading: Container( - width: 32, height: 32, - decoration: BoxDecoration( - color: const Color(0xFFEDEAFF), - borderRadius: BorderRadius.circular(8), - ), - child: Icon(_getAgentIcon(item.agent), size: 15, color: const Color(0xFF6C5CE7)), - ), - title: Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF333333))), - subtitle: Text(item.lastMessage, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 11, color: Colors.grey[500])), - trailing: GestureDetector( - onTap: onDelete, - child: const Padding( - padding: EdgeInsets.all(8), - child: Icon(Icons.close, size: 16, color: Color(0xFFBBBBBB)), - ), - ), - dense: true, - visualDensity: VisualDensity.compact, - ), - ); - } - - IconData _getAgentIcon(ActiveAgent agent) { - return switch (agent) { - ActiveAgent.health => Icons.health_and_safety_outlined, - ActiveAgent.diet => Icons.restaurant_outlined, - ActiveAgent.medication => Icons.medication_outlined, - ActiveAgent.report => Icons.description_outlined, - ActiveAgent.exercise => Icons.directions_run_outlined, - ActiveAgent.consultation => Icons.chat_bubble_outline, - _ => 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/health_app/pubspec.lock b/health_app/pubspec.lock index f9a998c..d6f3ec1 100644 --- a/health_app/pubspec.lock +++ b/health_app/pubspec.lock @@ -238,6 +238,11 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" flutter_markdown: dependency: "direct main" description: @@ -376,6 +381,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + intl: + dependency: transitive + description: + name: intl + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + url: "https://pub.dev" + source: hosted + version: "0.20.2" io: dependency: transitive description: diff --git a/health_app/pubspec.yaml b/health_app/pubspec.yaml index b167381..28fdf13 100644 --- a/health_app/pubspec.yaml +++ b/health_app/pubspec.yaml @@ -9,6 +9,8 @@ environment: dependencies: flutter: sdk: flutter + flutter_localizations: + sdk: flutter # 状态管理 flutter_riverpod: ^3.2.0