diff --git a/backend/src/Health.Application/AI/AiConversationContracts.cs b/backend/src/Health.Application/AI/AiConversationContracts.cs index a43eb24..42bb634 100644 --- a/backend/src/Health.Application/AI/AiConversationContracts.cs +++ b/backend/src/Health.Application/AI/AiConversationContracts.cs @@ -28,12 +28,15 @@ public sealed record OpenConversationResult( public interface IAiConversationService { Task OpenAsync(Guid userId, Guid? conversationId, AgentType agentType, string firstMessage, CancellationToken ct); - Task AddUserMessageAsync(Guid conversationId, string content, CancellationToken ct); + Task AddUserMessageAsync(Guid conversationId, string content, string? metadataJson, CancellationToken ct); Task AddAssistantMessageAsync(Guid conversationId, string content, CancellationToken ct); + Task AddAssistantMessageAsync(Guid conversationId, string content, string? metadataJson, CancellationToken ct); Task> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct); Task> ListAsync(Guid userId, CancellationToken ct); Task> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct); + Task UpdateSummaryAsync(Guid conversationId, string summary, CancellationToken ct); Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct); + Task DeleteAllAsync(Guid userId, CancellationToken ct); } public interface IAiConversationRepository diff --git a/backend/src/Health.Application/AI/AiConversationService.cs b/backend/src/Health.Application/AI/AiConversationService.cs index 72ddbf8..539bf1e 100644 --- a/backend/src/Health.Application/AI/AiConversationService.cs +++ b/backend/src/Health.Application/AI/AiConversationService.cs @@ -5,6 +5,7 @@ namespace Health.Application.AI; public sealed class AiConversationService(IAiConversationRepository conversations) : IAiConversationService { + private const int HistoryConversationLimit = 7; private readonly IAiConversationRepository _conversations = conversations; public async Task OpenAsync( @@ -33,14 +34,18 @@ public sealed class AiConversationService(IAiConversationRepository conversation }; await _conversations.AddConversationAsync(conversation, ct); await _conversations.SaveChangesAsync(ct); + await PruneHistoryAsync(userId, ct); return new OpenConversationResult(true, true, conversation.Id); } - public async Task AddUserMessageAsync(Guid conversationId, string content, CancellationToken ct) => - await AddMessageAsync(conversationId, MessageRole.User, content, updateSummary: false, ct); + public async Task AddUserMessageAsync(Guid conversationId, string content, string? metadataJson, CancellationToken ct) => + await AddMessageAsync(conversationId, MessageRole.User, content, updateSummary: false, metadataJson, ct); public async Task AddAssistantMessageAsync(Guid conversationId, string content, CancellationToken ct) => - await AddMessageAsync(conversationId, MessageRole.Assistant, content, updateSummary: true, ct); + await AddMessageAsync(conversationId, MessageRole.Assistant, content, updateSummary: true, metadataJson: null, ct); + + public async Task AddAssistantMessageAsync(Guid conversationId, string content, string? metadataJson, CancellationToken ct) => + await AddMessageAsync(conversationId, MessageRole.Assistant, content, updateSummary: true, metadataJson, ct); public async Task> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct) { @@ -51,7 +56,7 @@ public sealed class AiConversationService(IAiConversationRepository conversation public async Task> ListAsync(Guid userId, CancellationToken ct) { var conversations = await _conversations.ListAsync(userId, ct); - return conversations.Select(ToDto).ToList(); + return conversations.Take(HistoryConversationLimit).Select(ToDto).ToList(); } public async Task> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct) @@ -63,6 +68,17 @@ public sealed class AiConversationService(IAiConversationRepository conversation return messages.Select(ToMessageDto).ToList(); } + public async Task UpdateSummaryAsync(Guid conversationId, string summary, CancellationToken ct) + { + var conversation = await _conversations.GetAsync(conversationId, ct) + ?? throw new InvalidOperationException("会话不存在"); + var normalized = summary.Trim(); + if (string.IsNullOrWhiteSpace(normalized)) return; + + conversation.Summary = normalized.Length > 24 ? normalized[..24] : normalized; + await _conversations.SaveChangesAsync(ct); + } + public async Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct) { var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct); @@ -72,7 +88,19 @@ public sealed class AiConversationService(IAiConversationRepository conversation await _conversations.SaveChangesAsync(ct); } - private async Task AddMessageAsync(Guid conversationId, MessageRole role, string content, bool updateSummary, CancellationToken ct) + public async Task DeleteAllAsync(Guid userId, CancellationToken ct) + { + var list = await _conversations.ListAsync(userId, ct); + foreach (var conv in list) + { + _conversations.Delete(conv); + } + if (list.Count > 0) + await _conversations.SaveChangesAsync(ct); + return list.Count; + } + + private async Task AddMessageAsync(Guid conversationId, MessageRole role, string content, bool updateSummary, string? metadataJson, CancellationToken ct) { var conversation = await _conversations.GetAsync(conversationId, ct) ?? throw new InvalidOperationException("会话不存在"); @@ -82,6 +110,7 @@ public sealed class AiConversationService(IAiConversationRepository conversation ConversationId = conversationId, Role = role, Content = content, + MetadataJson = metadataJson, CreatedAt = DateTime.UtcNow, }, ct); @@ -90,6 +119,18 @@ public sealed class AiConversationService(IAiConversationRepository conversation if (updateSummary) conversation.Summary = content.Length > 100 ? content[..100] : content; await _conversations.SaveChangesAsync(ct); + await PruneHistoryAsync(conversation.UserId, ct); + } + + private async Task PruneHistoryAsync(Guid userId, CancellationToken ct) + { + var list = await _conversations.ListAsync(userId, ct); + var expired = list.Skip(HistoryConversationLimit).ToList(); + if (expired.Count == 0) return; + + foreach (var conversation in expired) + _conversations.Delete(conversation); + await _conversations.SaveChangesAsync(ct); } private static AiConversationDto ToDto(Conversation conversation) => new( diff --git a/backend/src/Health.Application/AI/AttachmentContextContracts.cs b/backend/src/Health.Application/AI/AttachmentContextContracts.cs new file mode 100644 index 0000000..f81124f --- /dev/null +++ b/backend/src/Health.Application/AI/AttachmentContextContracts.cs @@ -0,0 +1,20 @@ +namespace Health.Application.AI; + +/// +/// 用户消息附带的图片/PDF 解析结果。 +/// 既用作 LLM 上下文拼装,也持久化到 ConversationMessage.MetadataJson。 +/// +public sealed record AttachmentContext( + string Kind, // "image" 或 "pdf" + string? Category, // 仅图片:food / report / wound / drug / chart / other + string? FileName, // PDF 文件原名 + string Summary, // 简短摘要,用于持久化和历史回顾 + string LlmContent); // 完整文本,拼到当前轮 LLM user content 前 + +public interface IAttachmentContextBuilder +{ + /// + /// 根据 imageUrl 或 pdfUrl 构建附件上下文。两个都为空返回 null。 + /// + Task BuildAsync(string? imageUrl, string? pdfUrl, CancellationToken ct); +} diff --git a/backend/src/Health.Application/Exercises/ExerciseService.cs b/backend/src/Health.Application/Exercises/ExerciseService.cs index 943a9f1..e6e25f1 100644 --- a/backend/src/Health.Application/Exercises/ExerciseService.cs +++ b/backend/src/Health.Application/Exercises/ExerciseService.cs @@ -97,6 +97,10 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe { var item = await _exercises.GetOwnedItemAsync(userId, itemId, ct); if (item == null) return null; + if (item.ScheduledDate != BeijingToday()) + throw new ValidationException("只能打卡今天的运动任务"); + if (item.IsRestDay) + throw new ValidationException("休息日无需打卡"); item.IsCompleted = !item.IsCompleted; item.CompletedAt = item.IsCompleted ? DateTime.UtcNow : null; item.Plan.UpdatedAt = DateTime.UtcNow; @@ -108,6 +112,10 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe { var item = await _exercises.GetOwnedItemAsync(userId, itemId, ct); if (item == null) return false; + if (item.ScheduledDate != BeijingToday()) + throw new ValidationException("只能打卡今天的运动任务"); + if (item.IsRestDay) + throw new ValidationException("休息日无需打卡"); item.IsCompleted = true; item.CompletedAt = DateTime.UtcNow; item.Plan.UpdatedAt = DateTime.UtcNow; diff --git a/backend/src/Health.Application/Reports/ReportService.cs b/backend/src/Health.Application/Reports/ReportService.cs index 1bd443b..56bd14b 100644 --- a/backend/src/Health.Application/Reports/ReportService.cs +++ b/backend/src/Health.Application/Reports/ReportService.cs @@ -8,10 +8,10 @@ public sealed class ReportService( IReportFileStorage fileStorage, IReportAnalysisQueue queue) : IReportService { - private const long MaxReportImageBytes = 10 * 1024 * 1024; + private const long MaxReportFileBytes = 20 * 1024 * 1024; private static readonly HashSet AllowedExtensions = new(StringComparer.OrdinalIgnoreCase) { - ".jpg", ".jpeg", ".png", ".webp" + ".jpg", ".jpeg", ".png", ".webp", ".pdf" }; private readonly IReportRepository _reports = reports; @@ -35,12 +35,12 @@ public sealed class ReportService( if (file.Length <= 0) return Fail(400, "未上传文件"); - if (file.Length > MaxReportImageBytes) - return Fail(400, "报告图片不能超过 10MB,请压缩后重新上传"); + if (file.Length > MaxReportFileBytes) + return Fail(400, "报告文件不能超过 20MB,请压缩后重新上传"); var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); if (!AllowedExtensions.Contains(ext)) - return Fail(400, "目前仅支持上传 JPG、PNG、WEBP 格式的报告图片"); + return Fail(400, "目前支持上传 JPG、PNG、WEBP 图片或 PDF 报告"); var storedFile = await _fileStorage.SaveAsync(file, ext, ct); var report = new Report @@ -48,7 +48,7 @@ public sealed class ReportService( Id = Guid.NewGuid(), UserId = userId, FileUrl = storedFile.FileUrl, - FileType = ReportFileType.Image, + FileType = ext == ".pdf" ? ReportFileType.Pdf : ReportFileType.Image, Category = ReportCategory.Other, Status = ReportStatus.Analyzing, AiSummary = null, diff --git a/backend/src/Health.Infrastructure/AI/AttachmentContextBuilder.cs b/backend/src/Health.Infrastructure/AI/AttachmentContextBuilder.cs new file mode 100644 index 0000000..f35f1e1 --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/AttachmentContextBuilder.cs @@ -0,0 +1,179 @@ +using System.Text.Json; +using Health.Application.AI; +using Microsoft.Extensions.Logging; +using UglyToad.PdfPig; + +namespace Health.Infrastructure.AI; + +public sealed class AttachmentContextBuilder( + VisionClient vision, + ILogger logger) : IAttachmentContextBuilder +{ + private const int MaxPdfChars = 6000; + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + private readonly VisionClient _vision = vision; + private readonly ILogger _logger = logger; + + public async Task BuildAsync(string? imageUrl, string? pdfUrl, CancellationToken ct) + { + if (!string.IsNullOrWhiteSpace(imageUrl)) + return await BuildImageAsync(imageUrl!, ct); + if (!string.IsNullOrWhiteSpace(pdfUrl)) + return await BuildPdfAsync(pdfUrl!, ct); + return null; + } + + // ── 图片:调 VLM 输出结构化 JSON ── + private async Task BuildImageAsync(string imageUrl, CancellationToken ct) + { + var filePath = ResolveLocalPath(imageUrl); + if (filePath == null || !File.Exists(filePath)) + { + _logger.LogWarning("Image file not found for {Url}", imageUrl); + return new AttachmentContext("image", null, null, "图片暂时无法读取", "[图片附件读取失败,请描述图片内容]"); + } + + var bytes = await File.ReadAllBytesAsync(filePath, ct); + var mime = Path.GetExtension(filePath).ToLowerInvariant() switch + { + ".png" => "image/png", + ".webp" => "image/webp", + ".heic" => "image/heic", + _ => "image/jpeg", + }; + var dataUrl = $"data:{mime};base64,{Convert.ToBase64String(bytes)}"; + + var prompt = """ + 识别图片内容,输出 JSON: + {"category":"food|report|wound|drug|chart|other","summary":"1-2句话描述图片","details":["关键信息1","关键信息2"]} + + 分类标准: + - food:任何食物、饮品、餐食 + - report:医学检查报告、化验单、影像报告(X光/CT/MRI/B超截图等) + - wound:伤口、术后切口、皮肤异常 + - drug:药品包装、药品说明书 + - chart:心电图、监护仪截图等 + - other:其他 + + details 给出最有价值的细节(食物种类、报告关键值、伤口部位等)。 + 只输出 JSON 本身,不要任何前后缀。 + """; + + string? raw = null; + try + { + var resp = await _vision.VisionAsync(prompt, [dataUrl], userText: null, maxTokens: 1024, ct: ct); + raw = resp.Choices?.FirstOrDefault()?.Message?.Content; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "VLM call failed for image {Url}", imageUrl); + } + + if (string.IsNullOrWhiteSpace(raw)) + return new AttachmentContext("image", null, null, "图片识别失败", "[图片附件识别失败,请简要描述图片内容]"); + + try + { + var cleaned = StripCodeFence(raw.Trim()); + using var doc = JsonDocument.Parse(cleaned); + var root = doc.RootElement; + var category = GetString(root, "category") ?? "other"; + var summary = GetString(root, "summary") ?? "图片内容"; + var details = root.TryGetProperty("details", out var d) && d.ValueKind == JsonValueKind.Array + ? string.Join("、", d.EnumerateArray().Select(x => x.GetString()).Where(s => !string.IsNullOrWhiteSpace(s))) + : ""; + + var compact = string.IsNullOrEmpty(details) + ? $"图片识别:{summary}" + : $"图片识别:{summary}({details})"; + var llmContent = $"[图片识别(类别 {category}):{summary}{(string.IsNullOrEmpty(details) ? "" : $",包含 {details}")}]"; + + return new AttachmentContext("image", category, null, compact, llmContent); + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Failed to parse VLM JSON: {Raw}", raw); + // 兜底:原文也能用 + return new AttachmentContext("image", null, null, $"图片识别:{raw[..Math.Min(raw.Length, 80)]}", $"[图片识别:{raw}]"); + } + } + + // ── PDF:PdfPig 抽取文本 ── + private Task BuildPdfAsync(string pdfUrl, CancellationToken ct) + { + var filePath = ResolveLocalPath(pdfUrl); + var fileName = Path.GetFileName(pdfUrl); + if (filePath == null || !File.Exists(filePath)) + { + _logger.LogWarning("PDF file not found for {Url}", pdfUrl); + return Task.FromResult(new AttachmentContext( + "pdf", null, fileName, "PDF 文件读取失败", + "[PDF 附件读取失败,请描述文档内容]")); + } + + try + { + using var pdf = PdfDocument.Open(filePath); + var sb = new System.Text.StringBuilder(); + foreach (var page in pdf.GetPages()) + { + sb.AppendLine(page.Text); + if (sb.Length > MaxPdfChars) break; + } + var text = sb.ToString().Trim(); + + if (string.IsNullOrWhiteSpace(text)) + { + return Task.FromResult(new AttachmentContext( + "pdf", null, fileName, "PDF 内容为空或扫描件", + "[PDF 看起来是扫描件或图片版,无法提取文字。请提示用户改用拍照上传,或简要描述报告内容]")); + } + + var truncated = text.Length > MaxPdfChars ? text[..MaxPdfChars] + "...(已截断)" : text; + var summary = $"PDF:{fileName}"; + var llmContent = $"[用户上传 PDF 「{fileName}」,文档内容如下]\n{truncated}"; + + return Task.FromResult(new AttachmentContext("pdf", null, fileName, summary, llmContent)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to parse PDF {Url}", pdfUrl); + return Task.FromResult(new AttachmentContext( + "pdf", null, fileName, "PDF 解析异常", + "[PDF 解析失败,请提示用户文件可能损坏或加密]")); + } + } + + private static string? ResolveLocalPath(string url) + { + // url 形如 "/uploads/{guid}.{ext}"。处理 base URL 前缀也兼容。 + var idx = url.IndexOf("/uploads/", StringComparison.Ordinal); + if (idx < 0) return null; + var relative = url[(idx + "/uploads/".Length)..]; + // 去掉可能的 query string + var q = relative.IndexOf('?'); + if (q >= 0) relative = relative[..q]; + return Path.Combine(Directory.GetCurrentDirectory(), "uploads", relative); + } + + private static string StripCodeFence(string raw) + { + var t = raw.Trim(); + if (t.StartsWith("```")) + { + var firstNewline = t.IndexOf('\n'); + if (firstNewline > 0) t = t[(firstNewline + 1)..]; + if (t.EndsWith("```")) t = t[..^3]; + } + return t.Trim(); + } + + private static string? GetString(JsonElement el, string name) => + el.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; +} diff --git a/backend/src/Health.Infrastructure/AI/prompt_manager.cs b/backend/src/Health.Infrastructure/AI/prompt_manager.cs index 6ce4c95..b510c1c 100644 --- a/backend/src/Health.Infrastructure/AI/prompt_manager.cs +++ b/backend/src/Health.Infrastructure/AI/prompt_manager.cs @@ -23,9 +23,21 @@ public sealed class PromptManager _ => DefaultPrompt }; - return $"{prompt}\n\n{MedicalBoundaryRules}"; + return $"{prompt}\n\n{MedicalBoundaryRules}\n\n{SmartLinkRules}"; } + private const string SmartLinkRules = """ + 智能跳转链接(按需嵌入): + - 当用户明确询问食物的 热量/卡路里 时,在回答的自然位置嵌入 Markdown 链接 [热量分析](app://diet),引导用户使用专门的饮食拍照分析功能。 + - 当用户上传医学检查报告、化验单、影像报告、出院小结等需要专业解读时,在回答末尾嵌入 [报告分析](app://report),引导上传到报告分析功能获得详细解读。 + - 当用户询问血压、血糖、血氧等需要长期追踪的设备测量场景时,可嵌入 [蓝牙设备](app://device)。 + + 硬性约束: + - 每条回复最多嵌入一个跳转链接,且必须在合适语境下自然提及,不要堆砌。 + - 仅"问热量/卡路里"才用 app://diet;"营养、能不能吃、是否健康"类问题正常回答,禁止插入饮食跳转链接。 + - 链接的 Markdown 文本只能用上面列出的 4 个固定文案,不要自创其他文案。 + """; + private const string MedicalBoundaryRules = """ 医疗边界(必须遵守): - 你的定位是患者端 AI 健康解释与预问诊助手,不是医生,不能替代医生诊断、处方或治疗决策。 diff --git a/backend/src/Health.Infrastructure/Diets/DietImageAnalyzer.cs b/backend/src/Health.Infrastructure/Diets/DietImageAnalyzer.cs index 2cc9583..97faa83 100644 --- a/backend/src/Health.Infrastructure/Diets/DietImageAnalyzer.cs +++ b/backend/src/Health.Infrastructure/Diets/DietImageAnalyzer.cs @@ -31,9 +31,38 @@ public sealed class DietImageAnalyzer(VisionClient vision) : IDietImageAnalyzer } var prompt = """ - 识别图片中的食物和饮品,返回JSON数组: - [{"name":"名称","portion":"份量","calories":热量}] - 只返回JSON,不要其他内容。 + 你是营养师助手。请识别图片中所有食物和饮品,估算每项的具体克数或毫升数,输出 JSON 数组。 + + ## 无食物判定(必读) + 如果图片中完全没有可食用的食物或饮品(例如是风景、人物、宠物、文档、屏幕截图、纯背景、空盘子等),**直接输出空数组 []**,不要硬编造食物。 + + ## 输出格式 + [{"name":"食物名","portion":"具体克数","calories":数字}] + + ## 份量估算依据(容器与参照物对照表) + - 标准饭碗(口径11cm):满碗米饭≈200g、满碗面条≈250g、满碗粥≈250g、半碗≈100g + - 标准汤碗(口径14cm):满碗汤≈400ml、半碗≈200ml + - 标准盘子(口径23cm):满盘菜≈300g、半盘≈150g + - 普通玻璃杯:满杯水/饮料≈250ml、纸杯≈200ml + - 鸡蛋1个≈50g、馒头1个≈100g、包子1个≈80g、饺子1个≈15g + - 香蕉1根≈100g、苹果1个≈200g、橘子1个≈120g + - 肉块巴掌大小≈80-120g、鸡腿1个≈100g、鱼1条(中等)≈300g + + ## 输出示例(务必模仿此风格) + 正确:{"name":"米饭","portion":"约200g","calories":230} + 正确:{"name":"红烧肉","portion":"约150g","calories":420} + 正确:{"name":"豆浆","portion":"约250ml","calories":80} + 错误:{"name":"米饭","portion":"一碗","calories":230} ← portion 缺克数 + 错误:{"name":"汤","portion":"一份","calories":80} ← portion 缺毫升数 + 错误:{"name":"水果","portion":"少许","calories":50} ← portion 是模糊词 + + ## 硬性要求 + 1. 图片若无食物,输出 [] 即可。不要硬凑食物。 + 2. portion 字段必须包含阿拉伯数字 + 单位(g 或 ml)。不允许任何只有量词没有克数的回答。 + 3. 即使图片不够清楚,也要根据可见容器/参照物给出估算,可加"约"或区间("约80-120g")。 + 4. calories 是数字(千卡),不带单位、不带引号。 + + 只输出 JSON 数组本身,不要任何前缀、后缀、代码块标记或说明文字。 """; var response = await _vision.VisionAsync(prompt, imageUrls, userText: null, maxTokens: 8192, ct: ct); var result = response.Choices?.FirstOrDefault()?.Message?.Content ?? "[]"; diff --git a/backend/src/Health.Infrastructure/Health.Infrastructure.csproj b/backend/src/Health.Infrastructure/Health.Infrastructure.csproj index 511c462..f8fffa8 100644 --- a/backend/src/Health.Infrastructure/Health.Infrastructure.csproj +++ b/backend/src/Health.Infrastructure/Health.Infrastructure.csproj @@ -7,11 +7,13 @@ + + diff --git a/backend/src/Health.Infrastructure/Reports/ReportAnalysisService.cs b/backend/src/Health.Infrastructure/Reports/ReportAnalysisService.cs index 3685d4e..e36549a 100644 --- a/backend/src/Health.Infrastructure/Reports/ReportAnalysisService.cs +++ b/backend/src/Health.Infrastructure/Reports/ReportAnalysisService.cs @@ -2,6 +2,7 @@ using Health.Application.Reports; using Health.Application.Notifications; using Health.Infrastructure.AI; using Microsoft.Extensions.Logging; +using UglyToad.PdfPig; namespace Health.Infrastructure.Reports; @@ -17,6 +18,7 @@ public sealed class ReportAnalysisService( private readonly DeepSeekClient _llm = llm; private readonly IUserNotificationProducer _notifications = notifications; private readonly ILogger _logger = logger; + private const int MaxPdfChars = 8000; public async Task AnalyzeAsync(ReportAnalysisJob job, CancellationToken ct) { @@ -72,6 +74,9 @@ public sealed class ReportAnalysisService( private async Task ExtractIndicatorsAsync(string filePath, CancellationToken ct) { + if (Path.GetExtension(filePath).Equals(".pdf", StringComparison.OrdinalIgnoreCase)) + return await ExtractIndicatorsFromPdfAsync(filePath, ct); + var imageUrl = await GetLocalImageUrl(filePath, ct); if (imageUrl == null) throw new InvalidOperationException("报告图片文件不存在或无法读取"); @@ -112,6 +117,66 @@ public sealed class ReportAnalysisService( return json ?? throw new InvalidOperationException("报告识别结果格式异常"); } + private async Task ExtractIndicatorsFromPdfAsync(string filePath, CancellationToken ct) + { + var text = ExtractPdfText(filePath); + if (string.IsNullOrWhiteSpace(text)) + { + return JsonDocument.Parse(""" + {"isReport": false, "reason": "这份 PDF 可能是扫描件或图片版,当前无法直接提取文字。请上传清晰图片,或后续启用 PDF 页转图片后再用 VLM 识别。"} + """).RootElement; + } + + var prompt = $$""" + 你是一名医学检验报告分析专家。下面是用户上传 PDF 中提取出的文字。 + + PDF 文本: + {{text}} + + 第一步:判断这到底是不是一份医学检验报告。 + 如果不是,直接返回: + {"isReport": false, "reason": "这不是医学报告,原因:..."} + + 如果是医学报告,提取所有检测指标,返回严格 JSON 格式(不要 markdown 包裹): + { + "isReport": true, + "reportType": "BloodTest/Biochemistry/Ecg/Ultrasound/Discharge/Other", + "indicators": [ + {"name": "指标名称", "value": "数值", "unit": "单位", "referenceRange": "参考范围", "status": "normal/high/low"} + ] + } + + 注意: + - 只提取报告里真实出现的指标 + - status 判断:数值在参考范围内=normal,超出上限=high,低于下限=low + - 不确定的参考范围不要编造,可留空 + """; + + var messages = new List + { + new() { Role = "system", Content = "你是医学报告结构化抽取助手,只输出严格 JSON。" }, + new() { Role = "user", Content = prompt } + }; + + var response = await _llm.ChatAsync(messages, maxTokens: 1800, temperature: 0.1f, ct: ct); + var content = response.Choices?.FirstOrDefault()?.Message?.Content ?? ""; + var json = TryParseJson(content); + return json ?? throw new InvalidOperationException("PDF 报告解析结果格式异常"); + } + + private static string ExtractPdfText(string filePath) + { + using var pdf = PdfDocument.Open(filePath); + var sb = new System.Text.StringBuilder(); + foreach (var page in pdf.GetPages()) + { + sb.AppendLine(page.Text); + if (sb.Length > MaxPdfChars) break; + } + var text = sb.ToString().Trim(); + return text.Length > MaxPdfChars ? text[..MaxPdfChars] : text; + } + private async Task GenerateSummaryAsync(string indicatorsJson, CancellationToken ct) { var prompt = $""" diff --git a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs index 436b8db..45ff64e 100644 --- a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs @@ -28,6 +28,8 @@ public static class AiChatEndpoints app.MapGet("/api/ai/{agentType}/chat", async ( string message, string? conversationId, + string? imageUrl, + string? pdfUrl, string token, string agentType, HttpContext http, @@ -37,6 +39,7 @@ public static class AiChatEndpoints IAiToolExecutionService toolExecution, IAiWriteConfirmationStore confirmations, IAiConversationService conversations, + IAttachmentContextBuilder attachments, IPatientContextService patientContexts, CancellationToken ct) => { @@ -85,7 +88,24 @@ public static class AiChatEndpoints if (opened.Created) await SseWriteAsync(http, new { action = "conversation_id", data = activeConversationId.ToString() }, ct); - await conversations.AddUserMessageAsync(activeConversationId, message, ct); + // 附件解析(图片走 VLM、PDF 走 PdfPig),结果同时拼 LLM 上下文 + 持久化到 user message metadata + var attachment = await attachments.BuildAsync(imageUrl, pdfUrl, ct); + string? userMessageMetadataJson = null; + if (attachment != null) + { + userMessageMetadataJson = JsonSerializer.Serialize(new + { + kind = attachment.Kind, + category = attachment.Category, + fileName = attachment.FileName, + summary = attachment.Summary, + imageUrl, + pdfUrl, + }, JsonOpts); + await SseWriteAsync(http, new { action = "attachment", data = new { attachment.Kind, attachment.Category, attachment.Summary } }, ct); + } + + await conversations.AddUserMessageAsync(activeConversationId, message, userMessageMetadataJson, ct); var urgentWarning = DetectUrgentRisk(message); if (!string.IsNullOrEmpty(urgentWarning)) @@ -99,6 +119,7 @@ public static class AiChatEndpoints """; await conversations.AddAssistantMessageAsync(activeConversationId, urgentResponse, ct); + await TryUpdateConversationSummaryAsync(conversations, llmClient, userId.Value, activeConversationId, ct); await SseWriteAsync(http, new { action = "notice", message = "检测到可能的危险信号,优先给出就医提醒" }, ct); await SseWriteAsync(http, new { action = "answer", data = urgentResponse, type = "text" }, ct); @@ -131,16 +152,46 @@ public static class AiChatEndpoints new() { Role = "system", Content = enhancedSystem }, }; - // 加载历史对话(最近 10 条) + // 加载历史对话(最近 12 条) var history = await conversations.GetRecentMessagesAsync(activeConversationId, 12, ct); foreach (var h in history) { - messages.Add(new ChatMessage + var role = h.Role == MessageRole.User.ToString() ? "user" : "assistant"; + var content = h.Content; + + // 历史 user message 如果带过附件,把附件摘要拼回 content,让 AI 能回忆起来 + if (role == "user" && !string.IsNullOrWhiteSpace(h.MetadataJson)) { - Role = h.Role == MessageRole.User.ToString() ? "user" : "assistant", - Content = h.Content, - }); + try + { + using var meta = JsonDocument.Parse(h.MetadataJson); + if (meta.RootElement.TryGetProperty("summary", out var sumEl) && + sumEl.ValueKind == JsonValueKind.String) + { + var sum = sumEl.GetString(); + if (!string.IsNullOrWhiteSpace(sum) && + // 跳过本轮:本轮 user message 会在下面单独拼 LlmContent,避免重复 + h.Content != message) + { + content = $"[历史附件回忆:{sum}]\n{content}"; + } + } + } + catch (JsonException) { /* 忽略损坏的 metadata */ } + } + + messages.Add(new ChatMessage { Role = role, Content = content }); + } + + // 本轮如果带了附件,把完整识别结果拼到最后一条 user message 前 + if (attachment != null && messages.Count > 0 && messages[^1].Role == "user") + { + messages[^1] = new ChatMessage + { + Role = "user", + Content = $"{attachment.LlmContent}\n{messages[^1].Content}", + }; } // Tool Calling 循环 @@ -213,7 +264,16 @@ public static class AiChatEndpoints // 保存 AI 回复 if (!string.IsNullOrEmpty(fullResponse)) - await conversations.AddAssistantMessageAsync(activeConversationId, fullResponse, ct); + { + string? assistantMetadataJson = null; + if (metadata.Count > 0 || messageType != "text") + { + metadata["messageType"] = messageType; + assistantMetadataJson = JsonSerializer.Serialize(metadata, JsonOpts); + } + await conversations.AddAssistantMessageAsync(activeConversationId, fullResponse, assistantMetadataJson, ct); + await TryUpdateConversationSummaryAsync(conversations, llmClient, userId.Value, activeConversationId, ct); + } await SseWriteAsync(http, new { action = "status", data = completedNormally ? "done" : "error" }, ct); await http.Response.WriteAsync("data: [DONE]\n\n", ct); @@ -265,6 +325,16 @@ public static class AiChatEndpoints return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); }); + // 一键清空当前用户的全部对话 + app.MapDelete("/api/ai/conversations", async (HttpContext http, IAiConversationService conversations, CancellationToken ct) => + { + var userId = GetUserId(http); + if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401); + + var count = await conversations.DeleteAllAsync(userId.Value, ct); + return Results.Ok(new { code = 0, data = new { deleted = count }, message = (string?)null }); + }); + app.MapPost("/api/ai/analyze-food-image", async ( HttpRequest httpRequest, HttpContext http, @@ -317,6 +387,116 @@ public static class AiChatEndpoints catch (Exception) { return null; } } + private static async Task TryUpdateConversationSummaryAsync( + IAiConversationService conversations, + DeepSeekClient llmClient, + Guid userId, + Guid conversationId, + CancellationToken ct) + { + try + { + var messages = await conversations.GetMessagesAsync(userId, conversationId, ct); + var transcript = BuildSummaryTranscript(messages); + if (string.IsNullOrWhiteSpace(transcript)) return; + + var prompt = $""" + 请把下面这次健康助手会话压缩成一个“对话记录标题”。 + + 要求: + - 只输出标题,不要解释 + - 8到18个汉字 + - 优先保留用户真正要解决的问题、对象、最终动作或结论 + - 像列表标题,不要像完整句子 + - 不要写“本次会话、用户咨询、健康建议、进行分析、相关问题” + - 如果是上传图片/PDF,标题要体现附件内容和目的 + + 示例: + - 早餐热量识别 + - 血压偏高记录 + - 服药时间调整 + - 报告指标解读 + - 跑步计划打卡 + + 会话内容: + {transcript} + """; + + var response = await llmClient.ChatAsync( + [ + new ChatMessage { Role = "system", Content = "你是对话标题生成助手,只输出短标题。" }, + new ChatMessage { Role = "user", Content = prompt }, + ], + maxTokens: 120, + temperature: 0.2f, + ct: ct); + + var summary = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim(); + if (string.IsNullOrWhiteSpace(summary)) return; + + summary = Regex.Replace(summary, @"\s+", ""); + summary = summary.Trim(' ', '。', '.', '"', '"', '\'', '“', '”', ':', ':'); + summary = TrimSummaryPrefix(summary); + await conversations.UpdateSummaryAsync(conversationId, summary, ct); + } + catch + { + // 摘要只是历史列表体验增强,失败不能影响主对话。 + } + } + + private static string BuildSummaryTranscript(IReadOnlyList messages) + { + var sb = new System.Text.StringBuilder(); + foreach (var message in messages.TakeLast(24)) + { + var role = message.Role == MessageRole.User.ToString() ? "用户" : "AI"; + var content = message.Content?.Trim() ?? ""; + + if (message.Role == MessageRole.User.ToString() && + !string.IsNullOrWhiteSpace(message.MetadataJson)) + { + try + { + using var meta = JsonDocument.Parse(message.MetadataJson); + if (meta.RootElement.TryGetProperty("summary", out var sumEl) && + sumEl.ValueKind == JsonValueKind.String) + { + var attachmentSummary = sumEl.GetString(); + if (!string.IsNullOrWhiteSpace(attachmentSummary)) + content = $"[附件:{attachmentSummary}] {content}"; + } + } + catch (JsonException) { } + } + + if (string.IsNullOrWhiteSpace(content)) continue; + if (content.Length > 800) content = content[..800] + "..."; + sb.Append(role).Append(":").AppendLine(content); + if (sb.Length > 6000) break; + } + + return sb.ToString(); + } + + private static string TrimSummaryPrefix(string summary) + { + var prefixes = new[] + { + "本次会话", + "用户咨询", + "用户询问", + "健康建议", + "相关问题", + }; + foreach (var prefix in prefixes) + { + if (summary.StartsWith(prefix, StringComparison.Ordinal)) + return summary[prefix.Length..].Trim(' ', ':', ':', ',', ','); + } + return summary; + } + // ── Agent / Tool 调度 ── /// diff --git a/backend/src/Health.WebApi/Program.cs b/backend/src/Health.WebApi/Program.cs index 67f0bb4..0de5756 100644 --- a/backend/src/Health.WebApi/Program.cs +++ b/backend/src/Health.WebApi/Program.cs @@ -123,6 +123,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/backend/tests/Health.Tests/application_service_tests.cs b/backend/tests/Health.Tests/application_service_tests.cs index 6c802fd..76f2f35 100644 --- a/backend/tests/Health.Tests/application_service_tests.cs +++ b/backend/tests/Health.Tests/application_service_tests.cs @@ -3,6 +3,7 @@ using Health.Application.Calendars; using Health.Application.HealthArchives; using Health.Application.HealthRecords; using Health.Application.Exercises; +using Health.Domain; using Health.Domain.Entities; using Health.Domain.Enums; @@ -167,6 +168,26 @@ public sealed class ApplicationServiceTests Assert.Equal(2, current.Items.Count); } + [Fact] + public async Task ExercisePlan_CheckInRejectsNonTodayItem() + { + var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); + var repository = new FakeExerciseRepository(); + var plan = new ExercisePlan + { + Id = Guid.NewGuid(), UserId = Guid.NewGuid(), StartDate = today.AddDays(-1), EndDate = today, ReminderTime = new TimeOnly(19, 0), + }; + var item = new ExercisePlanItem + { + Id = Guid.NewGuid(), Plan = plan, ScheduledDate = today.AddDays(-1), ExerciseType = "散步", DurationMinutes = 30, + }; + plan.Items.Add(item); + repository.SetPlan(plan); + var service = new ExerciseService(repository); + + await Assert.ThrowsAsync(() => service.ToggleCheckInAsync(plan.UserId, item.Id, CancellationToken.None)); + } + private sealed class FakeHealthArchiveRepository(HealthArchive? archive) : IHealthArchiveRepository { public HealthArchive? Archive { get; private set; } = archive; diff --git a/health_app/lib/app.dart b/health_app/lib/app.dart index d30428f..b68d372 100644 --- a/health_app/lib/app.dart +++ b/health_app/lib/app.dart @@ -29,9 +29,14 @@ class HealthApp extends ConsumerWidget { locale: const Locale('zh'), home: const _RootNavigator(), // 注入 ShadTheme + 启动闸门:Splash 盖在最上层,直到首页今日健康卡数据就绪 + // 外层包一层 GestureDetector:点击任意空白处取消输入框焦点(全 app 生效) builder: (context, child) => ShadTheme( data: AppTheme.shadTheme, - child: _BootGate(child: child!), + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => FocusManager.instance.primaryFocus?.unfocus(), + child: _BootGate(child: child!), + ), ), ); } diff --git a/health_app/lib/core/api_client.dart b/health_app/lib/core/api_client.dart index 66342c9..072ae1f 100644 --- a/health_app/lib/core/api_client.dart +++ b/health_app/lib/core/api_client.dart @@ -6,7 +6,7 @@ import 'local_database.dart'; /// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。 const String baseUrl = String.fromEnvironment( 'API_BASE_URL', - defaultValue: 'http://10.4.232.251:5000', + defaultValue: 'http://192.168.10.139:5000', ); class ApiException implements Exception { diff --git a/health_app/lib/core/app_router.dart b/health_app/lib/core/app_router.dart index 0b9a782..7fdf0f6 100644 --- a/health_app/lib/core/app_router.dart +++ b/health_app/lib/core/app_router.dart @@ -13,6 +13,7 @@ import '../pages/consultation/consultation_pages.dart'; import '../pages/settings/settings_pages.dart'; import '../pages/settings/notification_prefs_page.dart'; import '../pages/notifications/notification_center_page.dart'; +import '../pages/history/conversation_history_page.dart'; import '../pages/profile/profile_page.dart'; import '../pages/diet/diet_capture_page.dart'; import '../pages/device/device_scan_page.dart'; @@ -67,7 +68,7 @@ Widget buildPage(RouteInfo route, WidgetRef ref) { case 'exercisePlan': return const ExercisePlanPage(); case 'exercisePlanDetail': - return ExercisePlanDetailPage(id: params['id'] ?? ''); + return const ExercisePlanPage(); case 'exerciseCreate': return const ExercisePlanCreatePage(); case 'dietRecords': @@ -102,6 +103,8 @@ Widget buildPage(RouteInfo route, WidgetRef ref) { return const NotificationPrefsPage(); case 'notifications': return const NotificationCenterPage(); + case 'conversationHistory': + return const ConversationHistoryPage(); case 'staticText': return StaticTextPage(type: params['type']!); case 'dietDetail': diff --git a/health_app/lib/core/navigation_provider.dart b/health_app/lib/core/navigation_provider.dart index 997c400..e49388b 100644 --- a/health_app/lib/core/navigation_provider.dart +++ b/health_app/lib/core/navigation_provider.dart @@ -1,3 +1,4 @@ +import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; /// 路由信息 @@ -39,17 +40,25 @@ final currentRouteProvider = Provider((ref) { return stack.last; }); +/// 路由切换前清理键盘焦点,避免返回页面时键盘自动弹起 +void _dismissKeyboard() { + FocusManager.instance.primaryFocus?.unfocus(); +} + /// 跳转(替换整个栈) void goRoute(WidgetRef ref, String name, {Map params = const {}}) { + _dismissKeyboard(); ref.read(routeStackProvider.notifier).replace(name, params: params); } /// 推入新页面 void pushRoute(WidgetRef ref, String name, {Map params = const {}}) { + _dismissKeyboard(); ref.read(routeStackProvider.notifier).push(name, params: params); } /// 返回上一页 void popRoute(WidgetRef ref) { + _dismissKeyboard(); ref.read(routeStackProvider.notifier).pop(); } diff --git a/health_app/lib/pages/consultation/consultation_pages.dart b/health_app/lib/pages/consultation/consultation_pages.dart index d38e75d..c3df791 100644 --- a/health_app/lib/pages/consultation/consultation_pages.dart +++ b/health_app/lib/pages/consultation/consultation_pages.dart @@ -143,7 +143,7 @@ class _DoctorChatPageState extends ConsumerState { margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.82, + maxWidth: MediaQuery.sizeOf(context).width * 0.82, ), decoration: BoxDecoration( color: isUser ? AppTheme.primary : const Color(0xFFFEFEFF), diff --git a/health_app/lib/pages/device/device_management_page.dart b/health_app/lib/pages/device/device_management_page.dart index 45a1392..c446a59 100644 --- a/health_app/lib/pages/device/device_management_page.dart +++ b/health_app/lib/pages/device/device_management_page.dart @@ -359,6 +359,7 @@ class _DeviceManagementPageState extends ConsumerState final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), title: const Text('解绑设备'), content: Text('确定解绑这台${device.type.label}吗?'), actions: [ @@ -388,29 +389,33 @@ class _DevicesHeader extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 14), + padding: const EdgeInsets.fromLTRB(18, 18, 18, 18), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: AppColors.border), - boxShadow: AppColors.cardShadowLight, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: AppColors.borderLight, width: 1.1), + boxShadow: [AppTheme.shadowLight], ), child: Row( children: [ Container( - width: 42, - height: 42, + width: 50, + height: 50, decoration: BoxDecoration( - color: const Color(0xFFEFF6FF), - borderRadius: BorderRadius.circular(8), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF60A5FA), Color(0xFF8B5CF6)], + ), + borderRadius: BorderRadius.circular(16), ), child: const Icon( Icons.bluetooth_audio_rounded, - color: Color(0xFF2563EB), - size: 23, + color: Colors.white, + size: 27, ), ), - const SizedBox(width: 12), + const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -418,17 +423,17 @@ class _DevicesHeader extends StatelessWidget { const Text( '已绑定设备', style: TextStyle( - fontSize: 16, + fontSize: 19, fontWeight: FontWeight.w900, color: AppColors.textPrimary, ), ), - const SizedBox(height: 3), + const SizedBox(height: 4), Text( '$deviceCount 台设备', style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, + fontSize: 13, + fontWeight: FontWeight.w800, color: AppColors.textSecondary, ), ), @@ -466,24 +471,24 @@ class _DeviceSection extends StatelessWidget { child: Row( children: [ Container( - width: 30, - height: 30, + width: 34, + height: 34, decoration: BoxDecoration( - color: const Color(0xFFF8FAFC), - borderRadius: BorderRadius.circular(8), + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(11), border: Border.all(color: AppColors.borderLight), ), child: Icon( type.icon, - size: 17, + size: 19, color: const Color(0xFF2563EB), ), ), - const SizedBox(width: 9), + const SizedBox(width: 10), Text( type.label, style: const TextStyle( - fontSize: 15, + fontSize: 16, fontWeight: FontWeight.w900, color: AppColors.textPrimary, ), @@ -530,10 +535,10 @@ class _BoundDeviceTile extends StatelessWidget { final accent = connected ? AppColors.success : AppColors.textHint; return AnimatedContainer( duration: const Duration(milliseconds: 180), - padding: const EdgeInsets.fromLTRB(14, 12, 8, 12), + padding: const EdgeInsets.fromLTRB(16, 15, 10, 15), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(18), border: Border.all( color: connected ? AppColors.success : AppColors.border, width: connected ? 1.4 : 1.1, @@ -552,17 +557,18 @@ class _BoundDeviceTile extends StatelessWidget { children: [ AnimatedContainer( duration: const Duration(milliseconds: 180), - width: 42, - height: 42, + width: 50, + height: 50, decoration: BoxDecoration( color: connected ? AppColors.successLight : const Color(0xFFF8FAFC), - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.borderLight), ), - child: Icon(device.type.icon, color: accent, size: 22), + child: Icon(device.type.icon, color: accent, size: 25), ), - const SizedBox(width: 13), + const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -572,16 +578,16 @@ class _BoundDeviceTile extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - fontSize: 15, + fontSize: 18, fontWeight: FontWeight.w800, color: AppColors.textPrimary, ), ), - const SizedBox(height: 3), + const SizedBox(height: 4), Text( '最近同步:${_formatLastSync(device.lastSyncAt)}', style: const TextStyle( - fontSize: 12, + fontSize: 13, fontWeight: FontWeight.w700, color: AppColors.textHint, ), @@ -592,7 +598,7 @@ class _BoundDeviceTile extends StatelessWidget { if (connected) Container( margin: const EdgeInsets.only(right: 4), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: AppColors.successLight, borderRadius: BorderRadius.circular(999), @@ -600,7 +606,8 @@ class _BoundDeviceTile extends StatelessWidget { child: const Text( '已连接', style: TextStyle( - fontSize: 11, + fontSize: 12, + height: 1, fontWeight: FontWeight.w900, color: AppColors.success, ), @@ -638,32 +645,33 @@ class _EmptyDevices extends StatelessWidget { Widget build(BuildContext context) { return Container( width: double.infinity, - padding: const EdgeInsets.fromLTRB(24, 42, 24, 42), + padding: const EdgeInsets.fromLTRB(24, 46, 24, 46), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: AppColors.borderLight), + borderRadius: BorderRadius.circular(22), + border: Border.all(color: AppColors.borderLight, width: 1.1), + boxShadow: [AppTheme.shadowLight], ), child: Column( children: [ Container( - width: 62, - height: 62, + width: 72, + height: 72, decoration: BoxDecoration( color: const Color(0xFFEFF6FF), - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(22), ), child: const Icon( Icons.bluetooth_disabled_rounded, color: Color(0xFF2563EB), - size: 31, + size: 36, ), ), - const SizedBox(height: 16), + const SizedBox(height: 18), const Text( '还没有绑定设备', style: TextStyle( - fontSize: 18, + fontSize: 19, fontWeight: FontWeight.w900, color: AppColors.textPrimary, ), @@ -673,7 +681,7 @@ class _EmptyDevices extends StatelessWidget { '右上角添加设备', textAlign: TextAlign.center, style: TextStyle( - fontSize: 13, + fontSize: 14, fontWeight: FontWeight.w700, color: AppColors.textSecondary, ), diff --git a/health_app/lib/pages/device/device_scan_page.dart b/health_app/lib/pages/device/device_scan_page.dart index 8222173..435b8aa 100644 --- a/health_app/lib/pages/device/device_scan_page.dart +++ b/health_app/lib/pages/device/device_scan_page.dart @@ -393,16 +393,32 @@ class _DeviceResultTile extends StatelessWidget { final type = HealthBleService.deviceTypeFromScan(result); final name = _deviceNameOf(result, type); return Container( - padding: const EdgeInsets.all(14), + padding: const EdgeInsets.all(15), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.borderLight), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border, width: 1.1), + boxShadow: [AppTheme.shadowLight], ), child: Row( children: [ - Icon(type?.icon ?? Icons.bluetooth_rounded, color: AppColors.primary), - const SizedBox(width: 12), + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: AppColors.primary.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: AppColors.primary.withValues(alpha: 0.10), + ), + ), + child: Icon( + type?.icon ?? Icons.bluetooth_rounded, + color: AppColors.primary, + size: 25, + ), + ), + const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -410,27 +426,41 @@ class _DeviceResultTile extends StatelessWidget { Text( type?.label ?? '健康设备', style: const TextStyle( - fontSize: 15, + fontSize: 18, fontWeight: FontWeight.w800, color: AppColors.textPrimary, ), ), - const SizedBox(height: 3), + const SizedBox(height: 4), Text( name, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - fontSize: 12, + fontSize: 14, color: AppColors.textHint, ), ), ], ), ), - FilledButton( - onPressed: connecting ? null : onConnect, - child: Text(connecting ? '连接中' : '连接'), + const SizedBox(width: 10), + SizedBox( + height: 38, + child: FilledButton( + onPressed: connecting ? null : onConnect, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + textStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + child: Text(connecting ? '连接中' : '连接'), + ), ), ], ), diff --git a/health_app/lib/pages/diet/diet_capture_page.dart b/health_app/lib/pages/diet/diet_capture_page.dart index 0012b14..1ab2804 100644 --- a/health_app/lib/pages/diet/diet_capture_page.dart +++ b/health_app/lib/pages/diet/diet_capture_page.dart @@ -358,7 +358,7 @@ class _DietCapturePageState extends ConsumerState { Widget _buildResultView(BuildContext context, WidgetRef ref) { final state = ref.watch(dietProvider); - final screenW = MediaQuery.of(context).size.width; + final screenW = MediaQuery.sizeOf(context).width; return SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), @@ -388,16 +388,15 @@ class _DietCapturePageState extends ConsumerState { const SizedBox(height: 16), if (state.isAnalyzing) _buildAnalyzing(state) + else if (state.foods.isEmpty) + _buildNoFoodHint() else ...[ - if (state.foods.isNotEmpty) ...[ - _buildFoodList(ref), + _buildFoodList(ref), + const SizedBox(height: 16), + _buildNutritionCard(ref), + if (state.commentary != null && state.commentary!.isNotEmpty) ...[ const SizedBox(height: 16), - _buildNutritionCard(ref), - if (state.commentary != null && - state.commentary!.isNotEmpty) ...[ - const SizedBox(height: 16), - _buildAiCommentary(state.commentary!), - ], + _buildAiCommentary(state.commentary!), ], const SizedBox(height: 20), _buildSaveButton(), @@ -498,6 +497,43 @@ class _DietCapturePageState extends ConsumerState { ); } + Widget _buildNoFoodHint() { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: 36, horizontal: 18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: AppColors.borderLight), + boxShadow: AppColors.cardShadowLight, + ), + child: Column( + children: [ + const Icon( + Icons.image_not_supported_outlined, + size: 44, + color: AppColors.textHint, + ), + const SizedBox(height: 14), + const Text( + '未识别到食物', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 6), + const Text( + '请重新拍摄或选择含有食物的清晰照片', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, color: AppColors.textSecondary), + ), + ], + ), + ); + } + // ─────────── 食物列表 ─────────── Widget _buildFoodList(WidgetRef ref) { final state = ref.watch(dietProvider); diff --git a/health_app/lib/pages/doctor/doctor_followup_edit_page.dart b/health_app/lib/pages/doctor/doctor_followup_edit_page.dart index acf4508..bda9510 100644 --- a/health_app/lib/pages/doctor/doctor_followup_edit_page.dart +++ b/health_app/lib/pages/doctor/doctor_followup_edit_page.dart @@ -186,9 +186,7 @@ class _DoctorFollowUpEditPageState ); if (d != null) setState(() => _date = d); }, - child: _pickerBox( - '${_date.year} ${_date.month.toString().padLeft(2, '0')} ${_date.day.toString().padLeft(2, '0')}', - ), + child: _pickerBox(_displayDate(_date)), ), ), const SizedBox(width: 12), @@ -298,3 +296,7 @@ final _fupDetailForEdit = FutureProvider.family?, String>(( orElse: () => null, ); }); + +String _displayDate(DateTime date) { + return '${date.year}/${date.month.toString().padLeft(2, '0')}/${date.day.toString().padLeft(2, '0')}'; +} diff --git a/health_app/lib/pages/history/conversation_history_page.dart b/health_app/lib/pages/history/conversation_history_page.dart new file mode 100644 index 0000000..cc2576f --- /dev/null +++ b/health_app/lib/pages/history/conversation_history_page.dart @@ -0,0 +1,352 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/app_colors.dart'; +import '../../core/app_theme.dart'; +import '../../core/navigation_provider.dart'; +import '../../providers/chat_provider.dart'; +import '../../providers/conversation_history_provider.dart'; + +/// 对话历史完整列表页。 +/// - 左滑删除(真删,删后 ScaffoldMessenger 提示) +/// - 顶部"清空全部"按钮(确认弹窗 → 调 deleteAll) +/// - 点击一条 → loadConversation → popRoute 回首页 +class ConversationHistoryPage extends ConsumerWidget { + const ConversationHistoryPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(conversationHistoryProvider); + + return GradientScaffold( + appBar: AppBar( + backgroundColor: Colors.white.withValues(alpha: 0.9), + title: const Text('最近 7 次对话'), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => popRoute(ref), + ), + actions: [ + TextButton( + onPressed: () => _confirmClearAll(context, ref), + child: const Text( + '清空', + style: TextStyle( + color: AppColors.error, + fontWeight: FontWeight.w800, + ), + ), + ), + ], + ), + body: RefreshIndicator( + onRefresh: () => + ref.read(conversationHistoryProvider.notifier).refresh(), + child: async.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => _ErrorBlock( + message: '历史记录加载失败', + onRetry: () => + ref.read(conversationHistoryProvider.notifier).refresh(), + ), + data: (list) => list.isEmpty + ? const _EmptyHint() + : ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 24), + itemCount: list.length, + separatorBuilder: (_, index) => const SizedBox(height: 10), + itemBuilder: (context, index) { + final item = list[index]; + return _HistoryTile( + item: item, + onTap: () => _openConversation(ref, item.id), + onDelete: () => _deleteOne(context, ref, item.id), + ); + }, + ), + ), + ), + ); + } + + Future _openConversation(WidgetRef ref, String id) async { + await ref.read(chatProvider.notifier).loadConversation(id); + popRoute(ref); + } + + Future _deleteOne( + BuildContext context, + WidgetRef ref, + String id, + ) async { + try { + await ref.read(conversationHistoryProvider.notifier).deleteOne(id); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('已删除'), duration: Duration(seconds: 1)), + ); + } + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('删除失败,请稍后重试'), + backgroundColor: AppColors.error, + ), + ); + } + } + } + + Future _confirmClearAll(BuildContext context, WidgetRef ref) async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('清空全部历史'), + content: const Text('清空后无法恢复,确认继续?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('取消'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + style: TextButton.styleFrom(foregroundColor: AppColors.error), + child: const Text('清空'), + ), + ], + ), + ); + if (ok != true) return; + try { + final count = await ref + .read(conversationHistoryProvider.notifier) + .clearAll(); + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('已清空 $count 条对话'))); + } + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('清空失败,请稍后重试'), + backgroundColor: AppColors.error, + ), + ); + } + } + } +} + +class _HistoryTile extends StatelessWidget { + final ConversationListItem item; + final VoidCallback onTap; + final VoidCallback onDelete; + const _HistoryTile({ + required this.item, + required this.onTap, + required this.onDelete, + }); + + @override + Widget build(BuildContext context) { + return Dismissible( + key: ValueKey(item.id), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 22), + decoration: BoxDecoration( + color: AppColors.error, + borderRadius: BorderRadius.circular(14), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.delete_outline, color: Colors.white), + SizedBox(width: 6), + Text( + '删除', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ), + confirmDismiss: (_) async { + onDelete(); + return true; + }, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.borderLight), + boxShadow: AppColors.cardShadowLight, + ), + child: Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.forum_rounded, + color: Color(0xFF2563EB), + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _displaySummary(item), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 3), + if (_displayOriginalQuestion(item).isNotEmpty) ...[ + Text( + '首问:${_displayOriginalQuestion(item)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.textHint, + ), + ), + const SizedBox(height: 4), + ], + Text( + '${item.messageCount} 条 · ${_relativeTime(item.updatedAt)}', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.textHint, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + static String _displayTitle(ConversationListItem item) { + final s = item.summary?.trim(); + if (s != null && s.isNotEmpty) return s; + final t = item.title?.trim(); + if (t != null && t.isNotEmpty) return t; + return '未命名对话'; + } + + static String _displaySummary(ConversationListItem item) { + final s = item.summary?.trim(); + if (s != null && s.isNotEmpty) return s.replaceAll(RegExp(r'\s+'), ' '); + return _displayTitle(item); + } + + static String _displayOriginalQuestion(ConversationListItem item) { + final t = item.title?.trim(); + if (t == null || t.isEmpty) return ''; + final summary = item.summary?.trim(); + if (summary != null && summary.isNotEmpty && summary != t) { + return t.replaceAll(RegExp(r'\s+'), ' '); + } + return ''; + } + + static String _relativeTime(DateTime time) { + final now = DateTime.now(); + final local = time.toLocal(); + final diff = now.difference(local); + if (diff.inMinutes < 1) return '刚刚'; + if (diff.inHours < 1) return '${diff.inMinutes} 分钟前'; + if (diff.inDays < 1) return '${diff.inHours} 小时前'; + if (diff.inDays < 7) return '${diff.inDays} 天前'; + return '${local.year}/${local.month}/${local.day}'; + } +} + +class _EmptyHint extends StatelessWidget { + const _EmptyHint(); + + @override + Widget build(BuildContext context) { + return ListView( + children: const [ + SizedBox(height: 120), + Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.forum_outlined, size: 56, color: AppColors.textHint), + SizedBox(height: 14), + Text( + '暂无历史对话', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + SizedBox(height: 6), + Text( + '在首页和 AI 健康助手聊聊吧', + style: TextStyle(fontSize: 13, color: AppColors.textSecondary), + ), + ], + ), + ), + ], + ); + } +} + +class _ErrorBlock extends StatelessWidget { + final String message; + final VoidCallback onRetry; + const _ErrorBlock({required this.message, required this.onRetry}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.wifi_off_rounded, + size: 56, + color: AppColors.textHint, + ), + const SizedBox(height: 12), + Text(message, style: const TextStyle(color: AppColors.textSecondary)), + const SizedBox(height: 14), + OutlinedButton(onPressed: onRetry, child: const Text('重试')), + ], + ), + ); + } +} diff --git a/health_app/lib/pages/home/home_page.dart b/health_app/lib/pages/home/home_page.dart index 5e12485..8c941c9 100644 --- a/health_app/lib/pages/home/home_page.dart +++ b/health_app/lib/pages/home/home_page.dart @@ -21,7 +21,8 @@ class HomePage extends ConsumerStatefulWidget { ConsumerState createState() => _HomePageState(); } -class _HomePageState extends ConsumerState { +class _HomePageState extends ConsumerState + with WidgetsBindingObserver { final _textCtrl = TextEditingController(); final _scrollCtrl = ScrollController(); final _focusNode = FocusNode(); @@ -32,6 +33,7 @@ class _HomePageState extends ConsumerState { @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addPostFrameCallback( (_) => ref.invalidate(notificationUnreadCountProvider), ); @@ -41,8 +43,19 @@ class _HomePageState extends ConsumerState { ); } + @override + void didChangeMetrics() { + // 键盘动画期间每帧都会回调,让列表底部始终贴住输入区上沿 + if (!_focusNode.hasFocus) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scrollCtrl.hasClients) return; + _scrollCtrl.jumpTo(_scrollCtrl.position.maxScrollExtent); + }); + } + @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _notificationTimer?.cancel(); _textCtrl.dispose(); _scrollCtrl.dispose(); @@ -102,16 +115,18 @@ class _HomePageState extends ConsumerState { return Scaffold( backgroundColor: const Color(0xFFFFFCFF), drawer: const HealthDrawer(), - drawerEdgeDragWidth: MediaQuery.of(context).size.width * 0.35, + drawerEdgeDragWidth: MediaQuery.sizeOf(context).width * 0.65, body: AppBackground( safeArea: true, child: Column( children: [ _buildHeader(user), Expanded( - child: ChatMessagesView( - scrollCtrl: _scrollCtrl, - messages: chatState.messages, + child: RepaintBoundary( + child: ChatMessagesView( + scrollCtrl: _scrollCtrl, + messages: chatState.messages, + ), ), ), _buildBottomBar(context), @@ -472,17 +487,29 @@ class _HomePageState extends ConsumerState { ), ListTile( leading: const Icon( - Icons.file_open_outlined, + Icons.picture_as_pdf_outlined, color: AppColors.primary, ), - title: const Text('传文件'), + title: const Text('上传 PDF'), onTap: () async { Navigator.pop(ctx); - final result = await FilePicker.platform.pickFiles(); - if (result != null && result.files.isNotEmpty) { - _textCtrl.text = '[文件已选择] ${result.files.first.name}'; - if (mounted) setState(() {}); - } + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['pdf'], + withData: false, + ); + if (result == null || result.files.isEmpty) return; + final pdfFile = result.files.first; + final path = pdfFile.path; + if (path == null || path.isEmpty) return; + // 上传 + 让 AI 看 PDF 内容 + await ref.read(chatProvider.notifier).sendPdf( + path, + pdfFile.name, + _textCtrl.text.trim(), + ); + _textCtrl.clear(); + if (mounted) setState(() {}); }, ), ], 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 0437b99..cb0ad85 100644 --- a/health_app/lib/pages/home/widgets/chat_messages_view.dart +++ b/health_app/lib/pages/home/widgets/chat_messages_view.dart @@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import '../../../core/app_colors.dart'; import '../../../core/app_theme.dart'; +import '../../../core/api_client.dart' show baseUrl; import '../../../core/navigation_provider.dart'; import '../../../providers/chat_provider.dart'; import '../../../providers/data_providers.dart'; @@ -88,10 +89,7 @@ class ChatMessagesView extends ConsumerWidget { ), ), const SizedBox(height: 16), - Text( - '开始和小脉健康对话吧', - style: Theme.of(context).textTheme.bodyMedium, - ), + Text('开始和小脉健康对话吧', style: Theme.of(context).textTheme.bodyMedium), const SizedBox(height: 8), const Text( '记录健康数据,获取专业建议', @@ -138,7 +136,7 @@ class ChatMessagesView extends ConsumerWidget { msg.content.trim().isEmpty) { return _buildThinkingBubble(context, chatState.thinkingText); } - return _buildTextBubble(context, msg, chatState); + return _buildTextBubble(context, ref, msg, chatState); } } @@ -154,7 +152,7 @@ class ChatMessagesView extends ConsumerWidget { ) { final info = _agentInfo(agent); final actions = agent.actions; - final screenWidth = MediaQuery.of(context).size.width; + final screenWidth = MediaQuery.sizeOf(context).width; final agentColors = _agentColors(agent); final artworkPath = _agentArtworkPath(agent); @@ -467,7 +465,7 @@ class ChatMessagesView extends ConsumerWidget { ) { final meta = msg.metadata ?? {}; final backendType = meta['type'] as String? ?? ''; - final screenWidth = MediaQuery.of(context).size.width; + final screenWidth = MediaQuery.sizeOf(context).width; // 识别是哪种数据类型 final isMedication = @@ -501,23 +499,17 @@ class ChatMessagesView extends ConsumerWidget { final time = meta['time'] as String? ?? ''; if (time.isNotEmpty) { final timeValue = meta['服药时间'] as String? ?? time; - fields.add( - _ConfirmField(label: '服药时间', value: timeValue), - ); + fields.add(_ConfirmField(label: '服药时间', value: timeValue)); } final frequency = meta['frequency'] as String? ?? ''; if (frequency.isNotEmpty) { final freqValue = meta['频率'] as String? ?? _freqLabel(frequency); - fields.add( - _ConfirmField(label: '频率', value: freqValue), - ); + fields.add(_ConfirmField(label: '频率', value: freqValue)); } final duration = meta['duration_days'] as int?; if (duration != null && duration > 0) { final durationValue = meta['服用天数'] as String? ?? '$duration 天'; - fields.add( - _ConfirmField(label: '服用天数', value: durationValue), - ); + fields.add(_ConfirmField(label: '服用天数', value: durationValue)); } } else if (isExercise) { // 运动计划 — 主展示区大号显示运动名,字段列时长/频率/天数 @@ -558,17 +550,13 @@ class ChatMessagesView extends ConsumerWidget { } final intensity = (meta['intensity'] ?? meta['运动强度'] ?? '').toString(); if (intensity.isNotEmpty) { - fields.add( - _ConfirmField(label: '运动强度', value: intensity), - ); + fields.add(_ConfirmField(label: '运动强度', value: intensity)); } final calories = (meta['calories'] ?? meta['calorie'] ?? meta['消耗热量'] ?? '') .toString(); if (calories.isNotEmpty) { - fields.add( - _ConfirmField(label: '消耗热量', value: '$calories kcal'), - ); + fields.add(_ConfirmField(label: '消耗热量', value: '$calories kcal')); } } else { // 健康指标 — 主展示区已显示指标+数值+单位,详情只列额外信息 @@ -580,15 +568,11 @@ class ChatMessagesView extends ConsumerWidget { if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt); final timeValue = meta['记录时间'] as String? ?? recordTime; - fields.add( - _ConfirmField(label: '记录时间', value: timeValue), - ); + fields.add(_ConfirmField(label: '记录时间', value: timeValue)); final note = meta['note'] as String? ?? ''; if (note.isNotEmpty) { final noteValue = meta['备注'] as String? ?? note; - fields.add( - _ConfirmField(label: '备注', value: noteValue), - ); + fields.add(_ConfirmField(label: '备注', value: noteValue)); } } @@ -911,9 +895,9 @@ class ChatMessagesView extends ConsumerWidget { .read(chatProvider.notifier) .confirmMessage(msg.id); if (error != null && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(error)), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error))); } }, borderRadius: BorderRadius.circular(18), @@ -999,16 +983,15 @@ class ChatMessagesView extends ConsumerWidget { alignment: Alignment.centerLeft, child: Container( margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), + padding: const EdgeInsets.all(1.4), decoration: BoxDecoration( - color: AppColors.cardBackground, + gradient: AppColors.actionOutlineGradient, borderRadius: const BorderRadius.only( topLeft: Radius.circular(4), topRight: Radius.circular(20), bottomLeft: Radius.circular(20), bottomRight: Radius.circular(20), ), - border: Border.all(color: AppColors.border, width: 1.5), boxShadow: [ BoxShadow( color: AppTheme.primary.withAlpha(12), @@ -1017,28 +1000,40 @@ class ChatMessagesView extends ConsumerWidget { ), ], ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 26, - height: 26, - padding: const EdgeInsets.all(5), - decoration: BoxDecoration( - color: AppTheme.primaryLight, - borderRadius: BorderRadius.circular(13), - ), - child: const CircularProgressIndicator( - strokeWidth: 2.2, - color: AppTheme.primary, - ), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), + decoration: const BoxDecoration( + color: AppColors.cardBackground, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(3), + topRight: Radius.circular(18.6), + bottomLeft: Radius.circular(18.6), + bottomRight: Radius.circular(18.6), ), - const SizedBox(width: 10), - Text( - thinkingText?.isNotEmpty == true ? thinkingText! : '正在分析...', - style: const TextStyle(fontSize: 17, color: AppColors.textHint), - ), - ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 26, + height: 26, + padding: const EdgeInsets.all(5), + decoration: BoxDecoration( + color: AppTheme.primaryLight, + borderRadius: BorderRadius.circular(13), + ), + child: const CircularProgressIndicator( + strokeWidth: 2.2, + color: AppTheme.primary, + ), + ), + const SizedBox(width: 10), + Text( + thinkingText?.isNotEmpty == true ? thinkingText! : '正在分析...', + style: const TextStyle(fontSize: 17, color: AppColors.textHint), + ), + ], + ), ), ), ); @@ -1046,153 +1041,228 @@ class ChatMessagesView extends ConsumerWidget { Widget _buildTextBubble( BuildContext context, + WidgetRef ref, ChatMessage msg, ChatState? chatState, ) { final isUser = msg.isUser; final imageUrl = msg.metadata?['imageUrl'] as String?; final localPath = msg.metadata?['localImagePath'] as String?; + final pdfFileName = + msg.metadata?['pdfFileName']?.toString() ?? + msg.metadata?['fileName']?.toString(); final hasImage = imageUrl != null || localPath != null; + final hasPdf = pdfFileName != null && pdfFileName.isNotEmpty; 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.82, + maxWidth: MediaQuery.sizeOf(context).width * 0.82, ), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - decoration: BoxDecoration( - color: isUser ? Colors.white : AppColors.cardBackground, - 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: Border.all( - color: isUser ? const Color(0xFFE8E4FF) : AppColors.border, - width: isUser ? 1 : 1.5, - ), - boxShadow: isUser - ? [] - : [ + padding: EdgeInsets.all(isUser ? 0 : 1.4), + decoration: isUser + ? null + : BoxDecoration( + gradient: AppColors.actionOutlineGradient, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(4), + topRight: Radius.circular(20), + bottomLeft: Radius.circular(20), + bottomRight: Radius.circular(20), + ), + boxShadow: [ BoxShadow( color: AppTheme.primary.withAlpha(12), blurRadius: 10, offset: const Offset(0, 3), ), ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 文字内容 - if (isUser) - SelectableText( - msg.content, - style: const TextStyle( - fontSize: 19, - color: AppColors.textPrimary, - height: 1.5, - ), - ) - else - MarkdownBody( - data: _cleanAiText(msg.content), - selectable: true, - styleSheet: MarkdownStyleSheet( - p: const TextStyle( + ), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: isUser ? AppTheme.primary : AppColors.cardBackground, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(isUser ? 18.6 : 3), + topRight: Radius.circular(isUser ? 3 : 18.6), + bottomLeft: const Radius.circular(18.6), + bottomRight: const Radius.circular(18.6), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 文字内容 + if (isUser) + SelectableText( + msg.content, + style: const TextStyle( fontSize: 19, - color: AppColors.textPrimary, + color: Colors.white, height: 1.5, ), - strong: const TextStyle( - fontSize: 19, - color: AppColors.textPrimary, - height: 1.5, - fontWeight: FontWeight.w700, - ), - h1: const TextStyle( - fontSize: 21, - color: AppColors.textPrimary, - fontWeight: FontWeight.w700, - ), - h2: const TextStyle( - fontSize: 20, - color: AppColors.textPrimary, - fontWeight: FontWeight.w700, - ), - ), - ), - - // 图片缩略图(在文字下方) - if (hasImage) - Padding( - padding: const EdgeInsets.only(top: 8), - child: GestureDetector( - onTap: () => _showFullImage(context, localPath ?? imageUrl), - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: ConstrainedBox( - constraints: const BoxConstraints( - maxWidth: 160, - maxHeight: 120, - ), - child: localPath != null - ? Image.file(File(localPath), fit: BoxFit.cover) - : imageUrl != null - ? Image.network( - imageUrl, - fit: BoxFit.cover, - errorBuilder: (_, e, s) => Container( - width: 80, - height: 60, - decoration: BoxDecoration( - color: AppColors.backgroundSecondary, - borderRadius: BorderRadius.circular(8), - ), - child: const Icon( - Icons.image, - size: 28, - color: AppColors.textHint, - ), - ), - ) - : const SizedBox.shrink(), + ) + else + MarkdownBody( + data: _cleanAiText(msg.content), + selectable: true, + onTapLink: (text, href, title) => + _handleMarkdownLink(context, ref, href), + styleSheet: MarkdownStyleSheet( + p: const TextStyle( + fontSize: 19, + color: AppColors.textPrimary, + height: 1.5, ), + a: const TextStyle( + fontSize: 19, + color: AppColors.primary, + fontWeight: FontWeight.w700, + decoration: TextDecoration.underline, + decorationColor: AppColors.primary, + ), + strong: const TextStyle( + fontSize: 19, + color: AppColors.textPrimary, + height: 1.5, + fontWeight: FontWeight.w700, + ), + h1: const TextStyle( + fontSize: 22, + color: AppColors.textPrimary, + height: 1.35, + fontWeight: FontWeight.w800, + ), + h2: const TextStyle( + fontSize: 21, + color: AppColors.textPrimary, + height: 1.4, + fontWeight: FontWeight.w800, + ), + h3: const TextStyle( + fontSize: 20, + color: AppColors.textPrimary, + height: 1.4, + fontWeight: FontWeight.w800, + ), + listBullet: const TextStyle( + fontSize: 24, + color: AppColors.textPrimary, + height: 1.2, + fontWeight: FontWeight.w900, + ), + listBulletPadding: const EdgeInsets.only(right: 8), ), ), - ), - if (!isUser && msg.content.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 10), - child: Row( - children: [ - const CircleAvatar( - radius: 10, - backgroundColor: AppTheme.primaryLight, - child: Icon( - Icons.chat_bubble_outline, - size: 17, - color: AppTheme.primary, + // 图片缩略图(在文字下方) + if (hasImage) + Padding( + padding: const EdgeInsets.only(top: 8), + child: GestureDetector( + onTap: () => _showFullImage(context, localPath ?? imageUrl), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 160, + maxHeight: 120, + ), + child: localPath != null + ? Image.file(File(localPath), fit: BoxFit.cover) + : imageUrl != null + ? Image.network( + _mediaUrl(imageUrl), + fit: BoxFit.cover, + errorBuilder: (_, e, s) => Container( + width: 80, + height: 60, + decoration: BoxDecoration( + color: AppColors.backgroundSecondary, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.image, + size: 28, + color: AppColors.textHint, + ), + ), + ) + : const SizedBox.shrink(), ), ), - const SizedBox(width: 6), - const Text( - '小脉健康', - style: TextStyle(fontSize: 15, color: AppColors.textHint), - ), - const SizedBox(width: 4), - const Text( - '仅供参考', - style: TextStyle(fontSize: 14, color: AppColors.textHint), - ), - ], + ), ), - ), - ], + + if (hasPdf) + Container( + margin: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + decoration: BoxDecoration( + color: isUser + ? Colors.white.withValues(alpha: 0.16) + : AppColors.backgroundSecondary, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.picture_as_pdf_outlined, + size: 18, + color: isUser ? Colors.white : AppColors.error, + ), + const SizedBox(width: 6), + Flexible( + child: Text( + pdfFileName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: isUser + ? Colors.white + : AppColors.textPrimary, + ), + ), + ), + ], + ), + ), + + if (!isUser && msg.content.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 10), + child: Row( + children: [ + const CircleAvatar( + radius: 10, + backgroundColor: AppTheme.primaryLight, + child: Icon( + Icons.chat_bubble_outline, + size: 17, + color: AppTheme.primary, + ), + ), + const SizedBox(width: 6), + const Text( + '内容由 AI 生成', + style: TextStyle( + fontSize: 14, + color: AppColors.textHint, + ), + ), + ], + ), + ), + ], + ), ), ), ); @@ -1204,6 +1274,7 @@ class ChatMessagesView extends ConsumerWidget { static void _showFullImage(BuildContext context, String? path) { if (path == null) return; + final resolvedPath = _mediaUrl(path); showDialog( context: context, builder: (ctx) => Dialog( @@ -1216,8 +1287,10 @@ class ChatMessagesView extends ConsumerWidget { borderRadius: BorderRadius.circular(12), child: InteractiveViewer( child: path.startsWith('http') - ? Image.network(path, fit: BoxFit.contain) - : Image.file(File(path), fit: BoxFit.contain), + ? Image.network(resolvedPath, fit: BoxFit.contain) + : resolvedPath.startsWith('http') + ? Image.network(resolvedPath, fit: BoxFit.contain) + : Image.file(File(resolvedPath), fit: BoxFit.contain), ), ), Positioned( @@ -1241,6 +1314,12 @@ class ChatMessagesView extends ConsumerWidget { ); } + static String _mediaUrl(String path) { + if (path.startsWith('http://') || path.startsWith('https://')) return path; + if (path.startsWith('/uploads/')) return '$baseUrl$path'; + return path; + } + /// 清理 AI 返回文本中的异常占位符(Markdown 格式交给 MarkdownBody 渲染) static String _cleanAiText(String text) { var t = text; @@ -1255,6 +1334,37 @@ class ChatMessagesView extends ConsumerWidget { return t.trim(); } + /// 处理 AI 回复里的 markdown 链接点击: + /// - app://diet → 触发拍照/相册选择,跳到饮食拍照流程 + /// - app://report → 跳到报告列表(用户可在那里上传新报告) + /// - app://device → 跳到蓝牙设备页 + /// - 其他 app://xxx → 当作 route name 直接跳 + static void _handleMarkdownLink( + BuildContext context, + WidgetRef ref, + String? href, + ) { + if (href == null || href.isEmpty) return; + final uri = Uri.tryParse(href); + if (uri == null) return; + + if (uri.scheme == 'app') { + switch (uri.host) { + case 'diet': + ref.read(chatProvider.notifier).triggerAgent(ActiveAgent.diet, '拍饮食'); + break; + case 'report': + pushRoute(ref, 'reports'); + break; + case 'device': + pushRoute(ref, 'devices'); + break; + default: + if (uri.host.isNotEmpty) pushRoute(ref, uri.host); + } + } + } + String _freqLabel(String freq) { switch (freq.toLowerCase()) { case 'daily': @@ -1493,8 +1603,11 @@ class ChatMessagesView extends ConsumerWidget { } if (hr is Map && hr['value'] is num) { final v = (hr['value'] as num).toDouble(); - if (v > 100) abnormals.add('心率 ${v.toStringAsFixed(0)} 偏快'); - else if (v < 60) abnormals.add('心率 ${v.toStringAsFixed(0)} 偏慢'); + if (v > 100) { + abnormals.add('心率 ${v.toStringAsFixed(0)} 偏快'); + } else if (v < 60) { + abnormals.add('心率 ${v.toStringAsFixed(0)} 偏慢'); + } } if (bs is Map && bs['value'] is num) { final v = (bs['value'] as num).toDouble(); @@ -1554,7 +1667,8 @@ class ChatMessagesView extends ConsumerWidget { if (plan != null) { final items = (plan['items'] as List?)?.cast>() ?? []; - final today = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; + final today = + '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; final todayItem = items.cast?>().firstWhere( (i) => i?['scheduledDate']?.toString() == today, orElse: () => null, @@ -1631,7 +1745,7 @@ class ChatMessagesView extends ConsumerWidget { } // ── 3. 用药打卡 ── - // 只显示已过期(漏服)的药;用颜色区分严重程度;没漏服则整行不显示 + // 漏服时突出提醒;未到服药时间时也保留一行轻提示,避免今日健康缺少用药状态。 const medIconColor = Color(0xFFEC4899); const medIconBg = Color(0xFFFCE7F3); reminders.whenOrNull( @@ -1639,7 +1753,21 @@ class ChatMessagesView extends ConsumerWidget { final overdueMeds = meds .where((m) => m['status'] == 'overdue') .toList(); - if (overdueMeds.isEmpty) return; + if (overdueMeds.isEmpty) { + tasks.add( + _taskRow( + context, + Icons.medication_rounded, + '用药', + trailing: meds.isEmpty ? '暂无用药安排' : '暂时不用吃药', + status: 'done', + iconColor: medIconColor, + iconBg: medIconBg, + onTap: () => pushRoute(ref, 'medCheckIn'), + ), + ); + return; + } // 按过期时长排序——先把最严重的放前面 final now = DateTime.now(); @@ -1658,8 +1786,7 @@ class ChatMessagesView extends ConsumerWidget { ? 0 : now.difference(scheduled).inMinutes / 60.0; return (m, overdueHours); - }).toList() - ..sort((a, b) => b.$2.compareTo(a.$2)); + }).toList()..sort((a, b) => b.$2.compareTo(a.$2)); // 取最严重那一项作为代表,其他用"等"省略 final first = withDelta.first; @@ -1969,10 +2096,7 @@ class _AgentAction { class _ConfirmField { final String label; final String value; - const _ConfirmField({ - required this.label, - required this.value, - }); + const _ConfirmField({required this.label, required this.value}); } final _agentActions = >{ diff --git a/health_app/lib/pages/medication/medication_checkin_page.dart b/health_app/lib/pages/medication/medication_checkin_page.dart index 00286f6..58720a7 100644 --- a/health_app/lib/pages/medication/medication_checkin_page.dart +++ b/health_app/lib/pages/medication/medication_checkin_page.dart @@ -1,150 +1,680 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + import '../../core/app_colors.dart'; import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../providers/auth_provider.dart'; import '../../providers/data_providers.dart'; +import '../../widgets/app_empty_state.dart'; import '../../widgets/app_error_state.dart'; class MedicationCheckInPage extends ConsumerStatefulWidget { - final String? medId; // 可选:指定药品,null 显示全部 + final String? medId; + const MedicationCheckInPage({super.key, this.medId}); - @override ConsumerState createState() => _MedicationCheckInPageState(); + + @override + ConsumerState createState() => + _MedicationCheckInPageState(); } class _MedicationCheckInPageState extends ConsumerState { - Future>>? _future; + static const _medBlue = Color(0xFF60A5FA); + static const _medViolet = Color(0xFF8B5CF6); + static const _softGreen = Color(0xFFEAF8EF); - @override void initState() { super.initState(); _load(); } - void _load() => setState(() { _future = ref.read(medicationReminderProvider.future); }); + Future>>? _future; + final Set _busyDoses = {}; + + @override + void initState() { + super.initState(); + _load(); + } + + void _load() { + setState(() { + _future = ref.read(medicationReminderProvider.future); + }); + } + + Future _refresh() async { + ref.invalidate(medicationReminderProvider); + _load(); + await _future; + } Future _toggleDose(String medId, String time, bool taken) async { + final doseKey = '$medId-$time'; + if (_busyDoses.contains(doseKey)) return; + + setState(() => _busyDoses.add(doseKey)); 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'}); + await api.post( + '/api/medications/$medId/confirm-dose', + data: {'scheduledTime': time, 'status': 'taken'}, + ); } ref.invalidate(medicationReminderProvider); ref.invalidate(medicationListProvider); _load(); - } catch (e) { debugPrint('[MedCheckIn] 打卡失败: $e'); } + } catch (e) { + debugPrint('[MedCheckIn] 打卡失败: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('打卡失败,请稍后重试'), + backgroundColor: AppTheme.error, + ), + ); + } + } finally { + if (mounted) setState(() => _busyDoses.remove(doseKey)); + } } - @override Widget build(BuildContext context) { + @override + Widget build(BuildContext context) { return GradientScaffold( - appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('服药打卡'), centerTitle: true), + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => popRoute(ref), + ), + title: const Text('服药打卡'), + centerTitle: true, + ), body: FutureBuilder>>( future: _future, builder: (ctx, snap) { - if (snap.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator(color: AppTheme.primary)); + if (snap.connectionState == ConnectionState.waiting && + !snap.hasData) { + return const Center( + child: CircularProgressIndicator(color: AppTheme.primary), + ); } if (snap.hasError) { return AppErrorState(title: '用药信息加载失败', onRetry: _load); } - final reminders = snap.data ?? []; + + final reminders = _filterReminders(snap.data ?? []); if (reminders.isEmpty) { - return Center( - child: Column(mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.check_circle_outline, size: 64, color: AppTheme.textHint), - const SizedBox(height: 12), - const Text('今天所有用药已打卡', style: TextStyle(fontSize: 19, color: AppColors.textSecondary)), - ]), + return RefreshIndicator( + onRefresh: _refresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: const [ + SizedBox(height: 92), + AppEmptyState( + icon: Icons.task_alt_outlined, + title: '今天的用药已完成', + subtitle: '下拉可刷新最新用药提醒', + ), + ], + ), ); } - // 如果指定了 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); - } + final grouped = _groupByMedication(reminders); + final total = reminders.length; + final taken = reminders.where(_isTaken).length; + final pending = total - taken; return RefreshIndicator( - onRefresh: () async => _load(), + onRefresh: _refresh, 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: AppTheme.surface, - borderRadius: BorderRadius.circular(AppTheme.rLg), - boxShadow: [AppTheme.shadowCard], + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 28), + children: [ + _CheckInSummary(total: total, taken: taken, pending: pending), + const SizedBox(height: 14), + ...grouped.entries.map( + (entry) => _MedicationDoseCard( + name: entry.key, + doses: entry.value, + busyDoses: _busyDoses, + onToggle: _toggleDose, ), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - Container( - width: 40, height: 40, - decoration: BoxDecoration(color: AppColors.warningLight, borderRadius: BorderRadius.circular(10)), - child: const Center(child: Text('💊', style: TextStyle(fontSize: 23))), - ), - const SizedBox(width: 12), - Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(medName, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w700)), - if (dosage.isNotEmpty) Text(dosage, style: const TextStyle(fontSize: 16, color: AppColors.textSecondary)), - ]), - ]), - 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: 23, - color: isTaken ? AppTheme.success : AppTheme.border, - ), - const SizedBox(width: 10), - Text(time, style: TextStyle( - fontSize: 18, fontWeight: FontWeight.w500, - color: isTaken ? AppTheme.textSub : AppTheme.text, - )), - const Spacer(), - GestureDetector( - onTap: () => _toggleDose(medId, time, isTaken), - child: Container( - width: 40, height: 40, - decoration: BoxDecoration( - color: isTaken ? AppColors.successLight : AppColors.cardInner, - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - isTaken ? Icons.check_circle : Icons.check_circle_outline, - size: 28, - color: isTaken ? AppTheme.success : AppColors.textHint, - ), - ), - ), - ]), - ); - }), - ]), - ); - }).toList(), + ), + ], ), ); }, ), ); } + + List> _filterReminders( + List> reminders, + ) { + if (widget.medId == null) return reminders; + return reminders.where((r) => r['id']?.toString() == widget.medId).toList(); + } + + Map>> _groupByMedication( + List> reminders, + ) { + final grouped = >>{}; + for (final reminder in reminders) { + final name = reminder['name']?.toString().trim(); + grouped + .putIfAbsent(name?.isNotEmpty == true ? name! : '未命名药品', () { + return >[]; + }) + .add(reminder); + } + + for (final doses in grouped.values) { + doses.sort( + (a, b) => _formatTime( + a['scheduledTime']?.toString() ?? '', + ).compareTo(_formatTime(b['scheduledTime']?.toString() ?? '')), + ); + } + return grouped; + } +} + +class _CheckInSummary extends StatelessWidget { + final int total; + final int taken; + final int pending; + + const _CheckInSummary({ + required this.total, + required this.taken, + required this.pending, + }); + + @override + Widget build(BuildContext context) { + final progress = total == 0 ? 0.0 : taken / total; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border, width: 1.1), + boxShadow: [AppTheme.shadowLight], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + _MedicationCheckInPageState._medBlue, + _MedicationCheckInPageState._medViolet, + ], + ), + borderRadius: BorderRadius.circular(14), + ), + child: const Icon( + Icons.medication_outlined, + color: Colors.white, + size: 24, + ), + ), + const SizedBox(width: 12), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '今日服药进度', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + SizedBox(height: 2), + Text( + '按提醒时间逐项确认,保持用药节奏', + style: TextStyle( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + Text( + '${(progress * 100).round()}%', + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w900, + color: _MedicationCheckInPageState._medViolet, + ), + ), + ], + ), + const SizedBox(height: 16), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + minHeight: 9, + value: progress, + backgroundColor: AppColors.cardInner, + valueColor: const AlwaysStoppedAnimation( + _MedicationCheckInPageState._medBlue, + ), + ), + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: _SummaryStat( + label: '待完成', + value: '$pending', + icon: Icons.schedule_outlined, + color: AppColors.warning, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _SummaryStat( + label: '已打卡', + value: '$taken', + icon: Icons.check_circle_outline, + color: AppTheme.success, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _SummaryStat( + label: '总次数', + value: '$total', + icon: Icons.format_list_numbered_outlined, + color: _MedicationCheckInPageState._medViolet, + ), + ), + ], + ), + ], + ), + ); + } +} + +class _SummaryStat extends StatelessWidget { + final String label; + final String value; + final IconData icon; + final Color color; + + const _SummaryStat({ + required this.label, + required this.value, + required this.icon, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Container( + constraints: const BoxConstraints(minHeight: 58), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: color.withValues(alpha: 0.14)), + ), + child: Row( + children: [ + Icon(icon, color: color, size: 17), + const SizedBox(width: 7), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _MedicationDoseCard extends StatelessWidget { + final String name; + final List> doses; + final Set busyDoses; + final Future Function(String medId, String time, bool taken) onToggle; + + const _MedicationDoseCard({ + required this.name, + required this.doses, + required this.busyDoses, + required this.onToggle, + }); + + @override + Widget build(BuildContext context) { + final dosage = doses.first['dosage']?.toString().trim() ?? ''; + final takenCount = doses.where(_isTaken).length; + final allTaken = takenCount == doses.length; + + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: allTaken + ? AppTheme.success.withValues(alpha: 0.26) + : AppColors.border, + width: 1.1, + ), + boxShadow: [AppTheme.shadowLight], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: allTaken + ? _MedicationCheckInPageState._softGreen + : AppColors.infoLight, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.borderLight), + ), + child: Icon( + allTaken ? Icons.done_all_rounded : Icons.medication_liquid, + color: allTaken + ? AppTheme.success + : _MedicationCheckInPageState._medBlue, + size: 24, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + if (dosage.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + dosage, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14, + color: AppColors.textSecondary, + ), + ), + ], + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5), + decoration: BoxDecoration( + color: allTaken + ? _MedicationCheckInPageState._softGreen + : AppColors.cardInner, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + '$takenCount/${doses.length}', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w800, + color: allTaken + ? AppTheme.success + : AppColors.textSecondary, + ), + ), + ), + ], + ), + const SizedBox(height: 14), + for (var i = 0; i < doses.length; i++) ...[ + _DoseRow( + dose: doses[i], + isLast: i == doses.length - 1, + isBusy: busyDoses.contains( + '${doses[i]['id']?.toString() ?? ''}-${doses[i]['scheduledTime']?.toString() ?? ''}', + ), + onToggle: onToggle, + ), + ], + ], + ), + ); + } +} + +class _DoseRow extends StatelessWidget { + final Map dose; + final bool isLast; + final bool isBusy; + final Future Function(String medId, String time, bool taken) onToggle; + + const _DoseRow({ + required this.dose, + required this.isLast, + required this.isBusy, + required this.onToggle, + }); + + @override + Widget build(BuildContext context) { + final time = dose['scheduledTime']?.toString() ?? ''; + final medId = dose['id']?.toString() ?? ''; + final isTaken = _isTaken(dose); + final displayTime = _formatTime(time); + final statusColor = isTaken ? AppTheme.success : AppColors.warning; + + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SizedBox( + width: 28, + child: Column( + children: [ + Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: isTaken ? AppTheme.success : Colors.white, + shape: BoxShape.circle, + border: Border.all( + color: isTaken ? AppTheme.success : AppColors.border, + width: 2, + ), + ), + child: isTaken + ? const Icon(Icons.check, size: 14, color: Colors.white) + : null, + ), + if (!isLast) + Expanded( + child: Container( + width: 2, + margin: const EdgeInsets.symmetric(vertical: 4), + color: isTaken + ? AppTheme.success.withValues(alpha: 0.34) + : AppColors.borderLight, + ), + ), + ], + ), + ), + const SizedBox(width: 10), + Expanded( + child: Padding( + padding: EdgeInsets.only(bottom: isLast ? 0 : 12), + child: Container( + padding: const EdgeInsets.fromLTRB(12, 10, 10, 10), + decoration: BoxDecoration( + color: isTaken + ? _MedicationCheckInPageState._softGreen + : const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isTaken + ? AppTheme.success.withValues(alpha: 0.18) + : AppColors.borderLight, + ), + ), + child: Row( + children: [ + Icon(Icons.access_time, size: 18, color: statusColor), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + displayTime, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w800, + color: isTaken ? AppTheme.textSub : AppTheme.text, + ), + ), + const SizedBox(height: 1), + Text( + isTaken ? '已完成打卡' : '等待确认服用', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: isTaken + ? AppTheme.success + : AppColors.textSecondary, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + SizedBox( + height: 36, + child: FilledButton.icon( + onPressed: isBusy || medId.isEmpty || time.isEmpty + ? null + : () => onToggle(medId, time, isTaken), + style: FilledButton.styleFrom( + backgroundColor: isTaken + ? Colors.white + : _MedicationCheckInPageState._medBlue, + foregroundColor: isTaken + ? AppTheme.success + : Colors.white, + disabledBackgroundColor: AppColors.cardInner, + disabledForegroundColor: AppColors.textHint, + padding: const EdgeInsets.symmetric(horizontal: 12), + minimumSize: const Size(0, 36), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide( + color: isTaken + ? AppTheme.success.withValues(alpha: 0.34) + : Colors.transparent, + ), + ), + ), + icon: isBusy + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.textHint, + ), + ) + : Icon( + isTaken + ? Icons.undo_rounded + : Icons.check_rounded, + size: 17, + ), + label: Text( + isTaken ? '撤销' : '打卡', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + ); + } +} + +bool _isTaken(Map dose) { + return dose['status']?.toString().toLowerCase() == 'taken'; +} + +String _formatTime(String raw) { + final value = raw.trim(); + if (value.length >= 5 && value[2] == ':') return value.substring(0, 5); + final parsed = DateTime.tryParse(value); + if (parsed == null) return value; + final hour = parsed.hour.toString().padLeft(2, '0'); + final minute = parsed.minute.toString().padLeft(2, '0'); + return '$hour:$minute'; } diff --git a/health_app/lib/pages/medication/medication_edit_page.dart b/health_app/lib/pages/medication/medication_edit_page.dart index 5141300..05aaa17 100644 --- a/health_app/lib/pages/medication/medication_edit_page.dart +++ b/health_app/lib/pages/medication/medication_edit_page.dart @@ -139,7 +139,10 @@ class _MedicationEditPageState extends ConsumerState { Widget build(BuildContext context) { return GradientScaffold( appBar: AppBar( - leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => popRoute(ref), + ), title: Text(widget.id != null ? '编辑用药' : '添加用药'), ), body: ListView( @@ -164,7 +167,13 @@ class _MedicationEditPageState extends ConsumerState { _label('每日服药次数'), const SizedBox(height: 8), GestureDetector( onTap: () async { - final n = await showAppCountPicker(context, initialValue: _timesPerDay, min: 1, max: 4, label: ' 次'); + final n = await showAppCountPicker( + context, + initialValue: _timesPerDay, + min: 1, + max: 4, + label: ' 次', + ); if (n != null) _updateTimes(n); }, child: Container( @@ -333,7 +342,7 @@ class _MedicationEditPageState extends ConsumerState { border: Border.all(color: AppColors.border), ), child: Text( - '${val.year} ${val.month.toString().padLeft(2, '0')} ${val.day.toString().padLeft(2, '0')}', + _displayDate(val), style: const TextStyle(fontSize: 18), ), ), @@ -370,7 +379,7 @@ class _MedicationEditPageState extends ConsumerState { child: Row( children: [ Text( - val != null ? '${val.year} ${val.month.toString().padLeft(2, '0')} ${val.day.toString().padLeft(2, '0')}' : '不设置', + val != null ? _displayDate(val) : '不设置', style: TextStyle( fontSize: 18, color: val != null ? null : AppTheme.textHint, @@ -393,3 +402,7 @@ class _MedicationEditPageState extends ConsumerState { ], ); } + +String _displayDate(DateTime date) { + return '${date.year}/${date.month.toString().padLeft(2, '0')}/${date.day.toString().padLeft(2, '0')}'; +} diff --git a/health_app/lib/pages/medication/medication_list_page.dart b/health_app/lib/pages/medication/medication_list_page.dart index aefe903..47ce3fb 100644 --- a/health_app/lib/pages/medication/medication_list_page.dart +++ b/health_app/lib/pages/medication/medication_list_page.dart @@ -75,11 +75,12 @@ class _MedicationListPageState extends ConsumerState { padding: const EdgeInsets.fromLTRB(16, 12, 16, 88), children: [ EnterpriseHeader( - title: '用药管理', + title: '今日用药概览', subtitle: '集中管理服药计划、剂量和每日打卡状态', icon: Icons.medication_outlined, color: _medBlue, accent: _medCyan, + showIcon: false, stats: [ EnterpriseStat( label: '药品总数', diff --git a/health_app/lib/pages/notifications/notification_center_page.dart b/health_app/lib/pages/notifications/notification_center_page.dart index 84ae408..d69a05b 100644 --- a/health_app/lib/pages/notifications/notification_center_page.dart +++ b/health_app/lib/pages/notifications/notification_center_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import '../../core/app_colors.dart'; +import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart'; import '../../providers/data_providers.dart'; import '../../services/in_app_notification_service.dart'; @@ -20,6 +21,7 @@ class _NotificationCenterPageState InAppNotificationHistory? _history; bool _loading = true; bool _markingAll = false; + bool _showEarlier = false; String? _error; InAppNotificationService get _service => @@ -47,12 +49,11 @@ class _NotificationCenterPageState }); ref.invalidate(notificationUnreadCountProvider); } catch (error) { - if (mounted) { - setState(() { - _error = '$error'; - _loading = false; - }); - } + if (!mounted) return; + setState(() { + _error = '$error'; + _loading = false; + }); } } @@ -146,7 +147,7 @@ class _NotificationCenterPageState final unread = _history?.unreadCount ?? 0; return Scaffold( - backgroundColor: Colors.white, + backgroundColor: const Color(0xFFF6F8FC), appBar: AppBar( backgroundColor: Colors.white, elevation: 0, @@ -160,45 +161,32 @@ class _NotificationCenterPageState '通知中心', style: TextStyle( fontSize: 19, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, color: AppColors.textPrimary, ), ), centerTitle: true, actions: [ if (unread > 0) - Padding( - padding: const EdgeInsets.only(right: 4), - child: TextButton( - onPressed: _markingAll ? null : _markAllRead, - style: TextButton.styleFrom( - foregroundColor: AppColors.textPrimary, - padding: const EdgeInsets.symmetric(horizontal: 10), - ), - child: _markingAll - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: AppColors.textPrimary, - ), - ) - : const Text( - '全部已读', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - ), + TextButton( + onPressed: _markingAll ? null : _markAllRead, + child: _markingAll + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text( + '全部已读', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, ), - ), + ), ), IconButton( tooltip: '通知设置', - icon: const Icon( - Icons.settings_outlined, - color: AppColors.textPrimary, - ), + icon: const Icon(Icons.settings_outlined), onPressed: () => pushRoute(ref, 'notificationPrefs'), ), const SizedBox(width: 4), @@ -206,16 +194,14 @@ class _NotificationCenterPageState ), body: RefreshIndicator( onRefresh: _load, - color: const Color(0xFF6366F1), + color: AppColors.primary, child: _buildBody(items, unread), ), ); } Widget _buildBody(List items, int unread) { - if (_loading) { - return const Center(child: CircularProgressIndicator()); - } + if (_loading) return const Center(child: CircularProgressIndicator()); if (_error != null) { return _MessageState( icon: LucideIcons.wifiOff, @@ -250,7 +236,6 @@ class _NotificationCenterPageState padding: const EdgeInsets.fromLTRB(16, 14, 16, 32), children: [ if (unread > 0) _UnreadHint(unreadCount: unread), - const SizedBox(height: 8), if (today.isNotEmpty) ...[ const _SectionTitle('今天'), const SizedBox(height: 10), @@ -258,9 +243,14 @@ class _NotificationCenterPageState ], if (earlier.isNotEmpty) ...[ if (today.isNotEmpty) const SizedBox(height: 18), - const _SectionTitle('更早'), + _CollapsibleSectionTitle( + text: '更早', + count: earlier.length, + expanded: _showEarlier, + onTap: () => setState(() => _showEarlier = !_showEarlier), + ), const SizedBox(height: 10), - ...earlier.map(_buildDismissible), + if (_showEarlier) ...earlier.map(_buildDismissible), ], ], ); @@ -275,12 +265,8 @@ class _NotificationCenterPageState alignment: Alignment.centerRight, padding: const EdgeInsets.only(right: 26), decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [AppColors.error.withValues(alpha: 0.6), AppColors.error], - ), - borderRadius: BorderRadius.circular(28), + color: AppColors.error, + borderRadius: BorderRadius.circular(16), ), child: const Row( mainAxisSize: MainAxisSize.min, @@ -292,106 +278,157 @@ class _NotificationCenterPageState style: TextStyle( color: Colors.white, fontSize: 15, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, ), ), ], ), ), onDismissed: (_) => _delete(item), - child: _NotificationCard(item: item, onTap: () => _open(item)), + child: SizedBox( + height: 98, + child: _NotificationCard(item: item, onTap: () => _open(item)), + ), ), ); } -/// 顶部一行精致提示——只在有未读时显示,不抢眼 class _UnreadHint extends StatelessWidget { final int unreadCount; + const _UnreadHint({required this.unreadCount}); @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.fromLTRB(0, 4, 0, 12), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - color: const Color(0xFFFFFBEB), - borderRadius: BorderRadius.circular(999), - border: Border.all(color: const Color(0xFFFDE68A)), - ), - child: Row( - children: [ - Container( - width: 8, - height: 8, - decoration: const BoxDecoration( - color: Color(0xFFF97316), - shape: BoxShape.circle, - ), + Widget build(BuildContext context) => Container( + margin: const EdgeInsets.fromLTRB(0, 2, 0, 14), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + decoration: BoxDecoration( + color: const Color(0xFFFFFBEB), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFFDE68A)), + ), + child: Row( + children: [ + const Icon(LucideIcons.bellRing, size: 18, color: Color(0xFFF97316)), + const SizedBox(width: 9), + Text( + '你有 $unreadCount 条未读消息', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w800, + color: Color(0xFF92400E), ), - const SizedBox(width: 8), - Text( - '你有 $unreadCount 条未读消息', - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w800, - color: Color(0xFF92400E), - ), - ), - ], - ), - ); - } + ), + ], + ), + ); } class _SectionTitle extends StatelessWidget { final String text; + const _SectionTitle(this.text); @override - Widget build(BuildContext context) => Container( - margin: const EdgeInsets.fromLTRB(0, 12, 0, 2), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: const Color(0xFFF8FAFC), - borderRadius: BorderRadius.circular(999), - border: Border.all(color: const Color(0xFFE5E7EB)), - ), + Widget build(BuildContext context) => _SectionShell( child: Row( children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - gradient: AppColors.doctorGradient, - borderRadius: BorderRadius.circular(99), - ), + const Icon( + LucideIcons.calendarDays, + size: 18, + color: AppColors.primary, ), const SizedBox(width: 8), Text( text, style: const TextStyle( fontSize: 15, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w900, color: AppColors.textPrimary, ), ), - const SizedBox(width: 10), - Expanded( - child: Container( - height: 1, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - const Color(0xFFE5E7EB), - const Color(0xFFE5E7EB).withValues(alpha: 0), - ], - ), - ), - ), + ], + ), + ); +} + +class _SectionShell extends StatelessWidget { + final Widget child; + + const _SectionShell({required this.child}); + + @override + Widget build(BuildContext context) => Container( + margin: const EdgeInsets.fromLTRB(0, 6, 0, 2), + padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: AppColors.borderLight), + boxShadow: [ + BoxShadow( + color: const Color(0xFF101828).withValues(alpha: 0.035), + blurRadius: 10, + offset: const Offset(0, 4), ), ], ), + child: child, + ); +} + +class _CollapsibleSectionTitle extends StatelessWidget { + final String text; + final int count; + final bool expanded; + final VoidCallback onTap; + + const _CollapsibleSectionTitle({ + required this.text, + required this.count, + required this.expanded, + required this.onTap, + }); + + @override + Widget build(BuildContext context) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + child: _SectionShell( + child: Row( + children: [ + const Icon( + LucideIcons.history, + size: 18, + color: AppColors.textSecondary, + ), + const SizedBox(width: 8), + Text( + '$text · $count', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w900, + color: AppColors.textPrimary, + ), + ), + const Spacer(), + Text( + expanded ? '收起' : '展开', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w800, + color: AppColors.textSecondary, + ), + ), + const SizedBox(width: 8), + Icon( + expanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down, + size: 22, + color: AppColors.textSecondary, + ), + ], + ), + ), ); } @@ -406,80 +443,113 @@ class _NotificationCard extends StatelessWidget { final visual = _NotificationVisual.of(item); return Material( color: Colors.white, - borderRadius: BorderRadius.circular(28), - elevation: 0, + borderRadius: BorderRadius.circular(16), child: InkWell( onTap: onTap, - borderRadius: BorderRadius.circular(28), + borderRadius: BorderRadius.circular(16), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + height: 98, decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(28), + borderRadius: BorderRadius.circular(16), border: Border.all( color: item.isRead - ? const Color(0xFFF1F5F9) - : visual.color.withValues(alpha: 0.24), - width: 1, + ? AppColors.borderLight + : visual.color.withValues(alpha: 0.32), + width: 1.1, ), - boxShadow: [ - BoxShadow( - color: const Color(0xFF0F172A).withValues(alpha: 0.08), - blurRadius: 22, - offset: const Offset(0, 10), - ), - BoxShadow( - color: visual.color.withValues(alpha: item.isRead ? 0 : 0.08), - blurRadius: 18, - offset: const Offset(0, 8), - ), - ], + boxShadow: [AppTheme.shadowLight], ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, + child: Stack( children: [ - // 图标 - Stack( - clipBehavior: Clip.none, - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: visual.lightColor, - borderRadius: BorderRadius.circular(18), - border: Border.all( - color: visual.color.withValues(alpha: 0.12), - ), - ), - child: Icon(visual.icon, size: 23, color: visual.color), - ), - if (!item.isRead) - Positioned( - top: -2, - right: -2, - child: Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: const Color(0xFFEF4444), - shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 1.5), - ), - ), - ), - ], - ), - const SizedBox(width: 13), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + Padding( + padding: const EdgeInsets.fromLTRB(14, 13, 12, 13), + child: Row( children: [ - Row( + Stack( + clipBehavior: Clip.none, children: [ - Expanded( - child: Text( + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + visual.color.withValues(alpha: 0.18), + visual.lightColor, + ], + ), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: visual.color.withValues(alpha: 0.22), + ), + ), + child: Icon( + visual.icon, + size: 24, + color: visual.color, + ), + ), + if (!item.isRead) + Positioned( + top: -2, + right: -2, + child: Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: AppColors.error, + shape: BoxShape.circle, + border: Border.all( + color: Colors.white, + width: 1.5, + ), + ), + ), + ), + ], + ), + const SizedBox(width: 13), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 7, + vertical: 3, + ), + decoration: BoxDecoration( + color: visual.lightColor, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + visual.label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w800, + color: visual.color, + ), + ), + ), + const SizedBox(width: 8), + Text( + _formatTime(item.createdAt), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textHint, + ), + ), + ], + ), + const SizedBox(height: 5), + Text( item.title, maxLines: 1, overflow: TextOverflow.ellipsis, @@ -489,54 +559,35 @@ class _NotificationCard extends StatelessWidget { ? FontWeight.w700 : FontWeight.w800, color: AppColors.textPrimary, - height: 1.3, + height: 1.2, ), ), - ), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: const Color(0xFFF8FAFC), - borderRadius: BorderRadius.circular(999), - ), - child: Text( - _formatTime(item.createdAt), + const SizedBox(height: 4), + Text( + item.message, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, + fontSize: 14, + height: 1.25, + fontWeight: FontWeight.w500, color: AppColors.textSecondary, ), ), - ), - ], - ), - const SizedBox(height: 6), - Text( - item.message, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 14, - height: 1.5, - fontWeight: FontWeight.w500, - color: AppColors.textSecondary, + ], ), ), + if (item.actionType != null) ...[ + const SizedBox(width: 6), + const Icon( + Icons.chevron_right_rounded, + size: 22, + color: AppColors.textSecondary, + ), + ], ], ), ), - if (item.actionType != null) ...[ - const SizedBox(width: 4), - const Icon( - Icons.chevron_right_rounded, - size: 22, - color: AppColors.textSecondary, - ), - ], ], ), ), @@ -555,8 +606,8 @@ class _NotificationCard extends StatelessWidget { local.day == now.day) { return time; } - if (local.year == now.year) return '${local.month}月${local.day}日 $time'; - return '${local.year}年${local.month}月${local.day}日 $time'; + if (local.year == now.year) return '${local.month}/${local.day} $time'; + return '${local.year}/${local.month}/${local.day} $time'; } } @@ -573,40 +624,38 @@ class _NotificationVisual { case 'exercise': return const _NotificationVisual( LucideIcons.activity, - Color(0xFF60A5FA), - Color(0xFFEFF6FF), + Color(0xFF16A34A), + Color(0xFFEAF8EF), '运动', ); case 'health': - if (item.severity == 'critical') { - return const _NotificationVisual( - LucideIcons.triangleAlert, - Color(0xFFEF4444), - Color(0xFFFEE2E2), - '紧急', - ); - } - return const _NotificationVisual( - LucideIcons.heartPulse, - Color(0xFFF59E0B), - Color(0xFFFEF3C7), - '健康', - ); + return item.severity == 'critical' + ? const _NotificationVisual( + LucideIcons.triangleAlert, + Color(0xFFEF4444), + Color(0xFFFEE2E2), + '紧急', + ) + : const _NotificationVisual( + LucideIcons.heartPulse, + Color(0xFFF59E0B), + Color(0xFFFEF3C7), + '健康', + ); case 'report': - if (item.severity == 'error') { - return const _NotificationVisual( - LucideIcons.fileWarning, - Color(0xFFEF4444), - Color(0xFFFEE2E2), - '报告', - ); - } - return const _NotificationVisual( - LucideIcons.fileCheck2, - Color(0xFF8B5CF6), - Color(0xFFEDE9FE), - '报告', - ); + return item.severity == 'error' + ? const _NotificationVisual( + LucideIcons.fileWarning, + Color(0xFFEF4444), + Color(0xFFFEE2E2), + '报告', + ) + : const _NotificationVisual( + LucideIcons.fileCheck2, + Color(0xFF8B5CF6), + Color(0xFFEDE9FE), + '报告', + ); default: return const _NotificationVisual( LucideIcons.pill, @@ -642,15 +691,9 @@ class _MessageState extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 36), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(24), - border: Border.all(color: const Color(0xFFE9EEF5)), - boxShadow: [ - BoxShadow( - color: const Color(0xFF101828).withValues(alpha: 0.04), - blurRadius: 14, - offset: const Offset(0, 4), - ), - ], + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.borderLight), + boxShadow: [AppTheme.shadowLight], ), child: Column( children: [ @@ -658,14 +701,10 @@ class _MessageState extends StatelessWidget { width: 72, height: 72, decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFFEEF2FF), Color(0xFFF5F3FF)], - ), - borderRadius: BorderRadius.circular(22), + color: AppColors.primaryLight, + borderRadius: BorderRadius.circular(18), ), - child: Icon(icon, size: 32, color: const Color(0xFF6366F1)), + child: Icon(icon, size: 32, color: AppColors.primary), ), const SizedBox(height: 20), Text( @@ -681,7 +720,7 @@ class _MessageState extends StatelessWidget { message, textAlign: TextAlign.center, style: const TextStyle( - fontSize: 13, + fontSize: 14, height: 1.5, color: AppColors.textSecondary, ), @@ -691,7 +730,7 @@ class _MessageState extends StatelessWidget { FilledButton( onPressed: onPressed, style: FilledButton.styleFrom( - backgroundColor: const Color(0xFF6366F1), + backgroundColor: AppColors.primary, padding: const EdgeInsets.symmetric( horizontal: 28, vertical: 12, diff --git a/health_app/lib/pages/profile/profile_page.dart b/health_app/lib/pages/profile/profile_page.dart index 4ea9ebc..f4844ec 100644 --- a/health_app/lib/pages/profile/profile_page.dart +++ b/health_app/lib/pages/profile/profile_page.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; + import '../../core/app_colors.dart'; import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; -import '../../widgets/enterprise_widgets.dart'; class ProfilePage extends ConsumerWidget { const ProfilePage({super.key}); @@ -18,102 +18,33 @@ class ProfilePage extends ConsumerWidget { return GradientScaffold( appBar: AppBar( - backgroundColor: Colors.white.withValues(alpha: 0.86), leading: IconButton( icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 19), onPressed: () => popRoute(ref), ), - title: const Text( - '个人信息', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800), - ), + title: const Text('个人信息'), centerTitle: true, ), body: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(20, 18, 20, 34), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - EnterpriseHeader( - title: name, - subtitle: phone.isNotEmpty ? phone : '未绑定手机', - icon: Icons.person_rounded, - color: const Color(0xFF38BDF8), - accent: const Color(0xFF8B5CF6), - stats: const [ - EnterpriseStat( - label: '账号状态', - value: '已登录', - icon: Icons.verified_user_rounded, - ), - EnterpriseStat( - label: '健康资料', - value: '可维护', - icon: Icons.folder_shared_rounded, - ), - ], - trailing: _AvatarBadge(avatarUrl: user?.avatarUrl), - ), - const SizedBox(height: 18), - _InfoPanel( - children: [ - _InfoRow( - icon: Icons.badge_rounded, - label: '姓名', - value: name, - colors: const [Color(0xFF7DD3FC), Color(0xFF38BDF8)], - ), - const _SoftDivider(), - _InfoRow( - icon: Icons.phone_iphone_rounded, - label: '手机号', - value: phone.isNotEmpty ? phone : '未绑定手机', - colors: const [Color(0xFFFBCFE8), Color(0xFFF472B6)], - ), - const _SoftDivider(), - _InfoRow( - icon: Icons.folder_shared_rounded, - label: '健康档案', - value: '查看和维护基础健康资料', - colors: const [Color(0xFFC4B5FD), Color(0xFF8B5CF6)], - onTap: () => pushRoute(ref, 'healthArchive'), - ), - ], - ), - const SizedBox(height: 18), - _InfoPanel( - children: [ - _InfoRow( - icon: Icons.verified_user_rounded, - label: '隐私保护', - value: '健康数据仅用于个人健康管理', - colors: const [Color(0xFFFFB4A2), Color(0xFFFB7185)], - ), - ], - ), - const SizedBox(height: 28), - OutlinedButton.icon( - onPressed: () => _logout(context, ref), - icon: const Icon(Icons.logout_rounded, size: 19), - label: const Text('退出登录'), - style: OutlinedButton.styleFrom( - foregroundColor: AppColors.error, - side: BorderSide( - color: AppColors.error.withValues(alpha: 0.34), - ), - minimumSize: const Size.fromHeight(52), - textStyle: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w800, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(18), - ), - ), - ), - ], - ), + child: ListView( + padding: const EdgeInsets.fromLTRB(18, 16, 18, 32), + children: [ + _AccountCard( + name: name, + phone: phone.isNotEmpty ? phone : '未绑定手机号', + avatarUrl: user?.avatarUrl, + ), + const SizedBox(height: 14), + _ActionTile( + icon: Icons.folder_shared_outlined, + title: '健康档案', + subtitle: '维护个人资料、病史、手术和过敏信息', + color: const Color(0xFF8B5CF6), + onTap: () => pushRoute(ref, 'healthArchive'), + ), + const SizedBox(height: 14), + _LogoutButton(onPressed: () => _logout(context, ref)), + ], ), ), ); @@ -124,7 +55,7 @@ class ProfilePage extends ConsumerWidget { context: context, builder: (ctx) => AlertDialog( shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppTheme.rXl), + borderRadius: BorderRadius.circular(AppTheme.rLg), ), title: const Text('退出登录'), content: const Text('确定退出当前账号?'), @@ -147,99 +78,126 @@ class ProfilePage extends ConsumerWidget { } } -class _AvatarBadge extends StatelessWidget { +class _AccountCard extends StatelessWidget { + final String name; + final String phone; final String? avatarUrl; - const _AvatarBadge({required this.avatarUrl}); - @override - Widget build(BuildContext context) { - return Container( - width: 50, - height: 50, - padding: const EdgeInsets.all(2), - decoration: BoxDecoration( - color: const Color(0xFFF8FAFC), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.border, width: 1.1), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(14), - child: avatarUrl != null - ? Image.network(avatarUrl!, fit: BoxFit.cover) - : const ColoredBox( - color: Colors.white, - child: Icon( - Icons.person_rounded, - color: Color(0xFF38BDF8), - size: 30, - ), - ), - ), - ); - } -} - -class _InfoPanel extends StatelessWidget { - final List children; - const _InfoPanel({required this.children}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(18), - border: Border.all(color: AppColors.borderLight), - boxShadow: AppColors.cardShadowLight, - ), - child: Column(children: children), - ); - } -} - -class _InfoRow extends StatelessWidget { - final IconData icon; - final String label; - final String value; - final List colors; - final VoidCallback? onTap; - const _InfoRow({ - required this.icon, - required this.label, - required this.value, - required this.colors, - this.onTap, + const _AccountCard({ + required this.name, + required this.phone, + required this.avatarUrl, }); @override - Widget build(BuildContext context) { - return InkWell( + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border, width: 1.1), + boxShadow: [AppTheme.shadowLight], + ), + child: Row( + children: [ + _Avatar(avatarUrl: avatarUrl), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 5), + Text( + phone, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 16, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ); +} + +class _Avatar extends StatelessWidget { + final String? avatarUrl; + + const _Avatar({required this.avatarUrl}); + + @override + Widget build(BuildContext context) => Container( + width: 58, + height: 58, + decoration: BoxDecoration( + color: AppColors.iconBg, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: AppColors.borderLight), + ), + clipBehavior: Clip.antiAlias, + child: avatarUrl != null && avatarUrl!.isNotEmpty + ? Image.network(avatarUrl!, fit: BoxFit.cover) + : const Icon( + Icons.person_rounded, + color: AppColors.blueMeasure, + size: 34, + ), + ); +} + +class _ActionTile extends StatelessWidget { + final IconData icon; + final String title; + final String subtitle; + final Color color; + final VoidCallback onTap; + + const _ActionTile({ + required this.icon, + required this.title, + required this.subtitle, + required this.color, + required this.onTap, + }); + + @override + Widget build(BuildContext context) => Material( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + child: InkWell( onTap: onTap, - borderRadius: BorderRadius.circular(14), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 12), + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.all(15), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border, width: 1.1), + boxShadow: [AppTheme.shadowLight], + ), child: Row( children: [ Container( - width: 42, - height: 42, + width: 46, + height: 46, decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: colors, - ), - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: colors.last.withValues(alpha: 0.18), - blurRadius: 12, - offset: const Offset(0, 5), - ), - ], + color: color.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(14), ), - child: Icon(icon, color: Colors.white, size: 22), + child: Icon(icon, color: color, size: 24), ), const SizedBox(width: 13), Expanded( @@ -247,45 +205,54 @@ class _InfoRow extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - label, + title, style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - color: AppColors.textHint, + fontSize: 17, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, ), ), const SizedBox(height: 4), Text( - value, + subtitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w800, - color: AppColors.textPrimary, + fontSize: 14, + color: AppColors.textSecondary, ), ), ], ), ), - if (onTap != null) - const Icon( - Icons.arrow_forward_ios_rounded, - size: 16, - color: AppColors.textHint, - ), + const Icon( + Icons.chevron_right_rounded, + size: 22, + color: AppColors.textHint, + ), ], ), ), - ); - } + ), + ); } -class _SoftDivider extends StatelessWidget { - const _SoftDivider(); +class _LogoutButton extends StatelessWidget { + final VoidCallback onPressed; + + const _LogoutButton({required this.onPressed}); @override - Widget build(BuildContext context) { - return const Divider(height: 1, color: AppColors.divider); - } + Widget build(BuildContext context) => OutlinedButton.icon( + onPressed: onPressed, + icon: const Icon(Icons.logout_rounded, size: 19), + label: const Text('退出登录'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.error, + side: BorderSide(color: AppColors.error.withValues(alpha: 0.34)), + minimumSize: const Size.fromHeight(52), + textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + ); } diff --git a/health_app/lib/pages/remaining_pages.dart b/health_app/lib/pages/remaining_pages.dart index 3b9866c..9d4596e 100644 --- a/health_app/lib/pages/remaining_pages.dart +++ b/health_app/lib/pages/remaining_pages.dart @@ -9,7 +9,6 @@ import '../providers/data_providers.dart'; import '../widgets/common_widgets.dart'; import '../widgets/app_error_state.dart'; import '../widgets/app_future_view.dart'; -import '../widgets/enterprise_widgets.dart'; /// 饮食记录列表(趋势+日历+胶囊详情+侧滑编辑删除) class DietRecordListPage extends ConsumerStatefulWidget { @@ -688,7 +687,12 @@ class ExercisePlanPage extends ConsumerStatefulWidget { } class _ExercisePlanPageState extends ConsumerState { + static const _exerciseBlue = Color(0xFF60A5FA); + static const _exerciseViolet = Color(0xFF8B5CF6); + Future>>? _future; + final Set _busyItems = {}; + @override void initState() { super.initState(); @@ -699,10 +703,31 @@ class _ExercisePlanPageState extends ConsumerState { _future = ref.read(exerciseServiceProvider).getPlans(); }); - Future _checkIn(String id, String itemId) async { - await ref.read(exerciseServiceProvider).checkIn(itemId); - ref.invalidate(currentExercisePlanProvider); + Future _refresh() async { _load(); + await _future; + } + + Future _checkIn(String itemId) async { + if (itemId.isEmpty || _busyItems.contains(itemId)) return; + setState(() => _busyItems.add(itemId)); + try { + await ref.read(exerciseServiceProvider).checkIn(itemId); + ref.invalidate(currentExercisePlanProvider); + _load(); + } catch (e) { + debugPrint('[ExercisePlan] 打卡失败: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('只能打卡今天的运动任务'), + backgroundColor: AppTheme.error, + ), + ); + } + } finally { + if (mounted) setState(() => _busyItems.remove(itemId)); + } } Future _deletePlan(String id) async { @@ -716,13 +741,6 @@ class _ExercisePlanPageState extends ConsumerState { return '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; } - String _slashDate(String value) { - if (value.length >= 10) { - return value.substring(0, 10).replaceAll('-', '/'); - } - return value.replaceAll('-', '/'); - } - @override Widget build(BuildContext context) { return GradientScaffold( @@ -748,172 +766,52 @@ class _ExercisePlanPageState extends ConsumerState { errorTitle: '运动计划加载失败', onData: (ctx, plans) { if (plans.isEmpty) return _empty(context, '运动计划', '暂无计划,点击右下角新建'); - final totalItems = plans.fold(0, (sum, p) { - final items = (p['items'] as List?) ?? []; - return sum + items.length; - }); - final doneItems = plans.fold(0, (sum, p) { - final items = - (p['items'] as List?)?.cast>() ?? []; - return sum + items.where((it) => it['isCompleted'] == true).length; - }); - return ListView( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 88), - children: [ - EnterpriseHeader( - title: '运动计划', - subtitle: '按天追踪运动目标,保持稳定节奏', - icon: Icons.directions_run, - color: const Color(0xFF60A5FA), - accent: const Color(0xFF8B5CF6), - stats: [ - EnterpriseStat( - label: '计划数', - value: '${plans.length} 个', - icon: Icons.view_week_outlined, - ), - EnterpriseStat( - label: '总完成', - value: '$doneItems/$totalItems 天', - icon: Icons.check_circle_outline, - ), - ], - ), - const SizedBox(height: 10), - ...List.generate(plans.length, (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; - final startDate = p['startDate']?.toString() ?? ''; - final endDate = p['endDate']?.toString() ?? ''; - final todayKey = _todayKey(); - final todayItem = items.cast>().firstWhere( - (it) => it['scheduledDate']?.toString() == todayKey, - orElse: () => {}, - ); - final hasTodayItem = todayItem.isNotEmpty; - final todayDone = todayItem['isCompleted'] == true; - final exerciseName = items.isNotEmpty - ? (items.first['exerciseType']?.toString() ?? '运动') - : '运动'; + final todayKey = _todayKey(); + final todayItems = plans + .expand( + (p) => + ((p['items'] as List?)?.cast>() ?? + >[]), + ) + .where((item) => item['scheduledDate']?.toString() == todayKey) + .toList(); + final todayDone = todayItems + .where((item) => item['isCompleted'] == true) + .length; - return SwipeDeleteTile( - key: Key(p['id']?.toString() ?? '$i'), - onDelete: () => _deletePlan(p['id']?.toString() ?? ''), - margin: const EdgeInsets.symmetric(vertical: 4), - onTap: () => pushRoute( - ref, - 'exercisePlanDetail', - params: {'id': p['id']?.toString() ?? ''}, - ), - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, + return RefreshIndicator( + onRefresh: _refresh, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 88), + children: [ + _ExerciseCheckInSummary( + planCount: plans.length, + totalToday: todayItems.length, + doneToday: todayDone, + ), + const SizedBox(height: 10), + ...List.generate(plans.length, (i) { + final p = plans[i]; + final items = + (p['items'] as List?)?.cast>() ?? []; + if (items.isEmpty) return const SizedBox.shrink(); + + return SwipeDeleteTile( + key: Key(p['id']?.toString() ?? '$i'), + onDelete: () => _deletePlan(p['id']?.toString() ?? ''), + margin: const EdgeInsets.symmetric(vertical: 6), + child: _ExercisePlanOverviewCard( + plan: p, + items: items, + todayKey: todayKey, + busyItems: _busyItems, + onCheckIn: _checkIn, ), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.border, width: 1.1), - boxShadow: [AppTheme.shadowLight], - ), - child: Row( - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: const Color(0xFFEFF6FF), - borderRadius: BorderRadius.circular(10), - ), - child: const Icon( - Icons.directions_run, - size: 21, - color: Color(0xFF60A5FA), - ), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - exerciseName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w800, - ), - ), - ), - const SizedBox(width: 8), - Text( - '$done/$total 天', - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - color: Color(0xFF60A5FA), - ), - ), - ], - ), - const SizedBox(height: 4), - Text( - '${_slashDate(startDate)} 至 ${_slashDate(endDate)} · ${items.first['durationMinutes'] ?? 0}分钟/天', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 13, - color: AppColors.textSecondary, - ), - ), - ], - ), - ), - const SizedBox(width: 8), - GestureDetector( - onTap: hasTodayItem - ? () => _checkIn( - p['id']?.toString() ?? '', - todayItem['id']?.toString() ?? '', - ) - : null, - child: Container( - width: 34, - height: 34, - decoration: BoxDecoration( - color: todayDone - ? AppColors.successLight - : AppColors.cardInner, - borderRadius: BorderRadius.circular(10), - ), - child: Icon( - todayDone - ? Icons.check_circle - : Icons.check_circle_outline, - size: 24, - color: todayDone - ? AppTheme.success - : AppColors.textHint, - ), - ), - ), - ], - ), - ), - ); - }), - ], + ); + }), + ], + ), ); }, ), @@ -921,6 +819,448 @@ class _ExercisePlanPageState extends ConsumerState { } } +class _ExercisePlanOverviewCard extends StatelessWidget { + final Map plan; + final List> items; + final String todayKey; + final Set busyItems; + final Future Function(String itemId) onCheckIn; + + const _ExercisePlanOverviewCard({ + required this.plan, + required this.items, + required this.todayKey, + required this.busyItems, + required this.onCheckIn, + }); + + @override + Widget build(BuildContext context) { + final sortedItems = [...items] + ..sort( + (a, b) => (a['scheduledDate']?.toString() ?? '').compareTo( + b['scheduledDate']?.toString() ?? '', + ), + ); + final total = sortedItems.length; + final done = sortedItems.where((it) => it['isCompleted'] == true).length; + final progress = total == 0 ? 0.0 : done / total; + final todayItem = sortedItems.firstWhere( + (it) => it['scheduledDate']?.toString() == todayKey, + orElse: () => {}, + ); + final exerciseName = _exerciseSummary(sortedItems); + final startDate = plan['startDate']?.toString() ?? ''; + final endDate = plan['endDate']?.toString() ?? ''; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border, width: 1.1), + boxShadow: [AppTheme.shadowLight], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + _ExercisePlanPageState._exerciseBlue, + _ExercisePlanPageState._exerciseViolet, + ], + ), + borderRadius: BorderRadius.circular(14), + ), + child: const Icon( + Icons.directions_run, + color: Colors.white, + size: 24, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + exerciseName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 19, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 3), + Text( + '${_slashDate(startDate)} - ${_slashDate(endDate)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 16, + color: AppTheme.textSub, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: BoxDecoration( + color: AppColors.infoLight, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: const Color(0xFFBFDBFE)), + ), + child: Text( + '$done/$total 天', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w800, + color: _ExercisePlanPageState._exerciseBlue, + ), + ), + ), + ], + ), + const SizedBox(height: 14), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + minHeight: 8, + value: progress.clamp(0.0, 1.0), + backgroundColor: AppColors.cardInner, + valueColor: const AlwaysStoppedAnimation( + _ExercisePlanPageState._exerciseBlue, + ), + ), + ), + const SizedBox(height: 14), + _ExerciseTodayInlineTask( + item: todayItem, + isBusy: busyItems.contains(todayItem['id']?.toString() ?? ''), + onCheckIn: onCheckIn, + ), + ], + ), + ); + } + + String _exerciseSummary(List> source) { + final names = source + .where((item) => item['isRestDay'] != true) + .map((item) => item['exerciseType']?.toString() ?? '') + .where((name) => name.isNotEmpty) + .toSet() + .toList(); + if (names.isEmpty) return '运动计划'; + if (names.length == 1) return names.first; + return '${names.first}等 ${names.length} 项'; + } +} + +class _ExerciseTodayInlineTask extends StatelessWidget { + final Map item; + final bool isBusy; + final Future Function(String itemId) onCheckIn; + + const _ExerciseTodayInlineTask({ + required this.item, + required this.isBusy, + required this.onCheckIn, + }); + + @override + Widget build(BuildContext context) { + final hasItem = item.isNotEmpty; + final isCompleted = item['isCompleted'] == true; + final isRestDay = item['isRestDay'] == true; + final itemId = item['id']?.toString() ?? ''; + final name = item['exerciseType']?.toString() ?? '今日休息'; + final minutes = ((item['durationMinutes'] as num?)?.toInt() ?? 0); + final canCheckIn = hasItem && !isRestDay; + + return Container( + padding: const EdgeInsets.fromLTRB(12, 11, 10, 11), + decoration: BoxDecoration( + color: isCompleted ? AppColors.successLight : const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isCompleted + ? AppTheme.success.withValues(alpha: 0.18) + : AppColors.borderLight, + ), + ), + child: Row( + children: [ + Icon( + isCompleted ? Icons.check_circle : Icons.today_outlined, + size: 22, + color: isCompleted + ? AppTheme.success + : _ExercisePlanPageState._exerciseBlue, + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '今日任务', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 2), + Text( + !hasItem + ? '今天没有安排' + : (isRestDay ? '休息日' : '$name · $minutes 分钟'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + SizedBox( + height: 38, + child: FilledButton.icon( + onPressed: canCheckIn && !isBusy ? () => onCheckIn(itemId) : null, + style: FilledButton.styleFrom( + backgroundColor: isCompleted + ? Colors.white + : _ExercisePlanPageState._exerciseBlue, + foregroundColor: isCompleted ? AppTheme.success : Colors.white, + disabledBackgroundColor: AppColors.cardInner, + disabledForegroundColor: AppColors.textHint, + padding: const EdgeInsets.symmetric(horizontal: 12), + minimumSize: const Size(0, 38), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide( + color: isCompleted + ? AppTheme.success.withValues(alpha: 0.34) + : Colors.transparent, + ), + ), + ), + icon: isBusy + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.textHint, + ), + ) + : Icon( + isCompleted ? Icons.undo_rounded : Icons.check_rounded, + size: 17, + ), + label: Text( + !hasItem || isRestDay ? '今日无' : (isCompleted ? '撤销' : '打卡'), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + ], + ), + ); + } +} + +class _ExerciseCheckInSummary extends StatelessWidget { + final int planCount; + final int totalToday; + final int doneToday; + + const _ExerciseCheckInSummary({ + required this.planCount, + required this.totalToday, + required this.doneToday, + }); + + @override + Widget build(BuildContext context) { + final pending = totalToday - doneToday; + final progress = totalToday == 0 ? 0.0 : doneToday / totalToday; + + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border, width: 1.1), + boxShadow: [AppTheme.shadowLight], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '今日运动进度', + style: TextStyle( + fontSize: 19, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + SizedBox(height: 2), + Text( + '只允许打卡今天的运动任务', + style: TextStyle(fontSize: 16, color: AppTheme.textSub), + ), + ], + ), + ), + Text( + '${(progress * 100).round()}%', + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w900, + color: _ExercisePlanPageState._exerciseViolet, + ), + ), + ], + ), + const SizedBox(height: 16), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + minHeight: 9, + value: progress, + backgroundColor: AppColors.cardInner, + valueColor: const AlwaysStoppedAnimation( + _ExercisePlanPageState._exerciseBlue, + ), + ), + ), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: _ExerciseSummaryStat( + label: '待完成', + value: '$pending', + icon: Icons.schedule_outlined, + color: AppColors.warning, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _ExerciseSummaryStat( + label: '已打卡', + value: '$doneToday', + icon: Icons.check_circle_outline, + color: AppTheme.success, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _ExerciseSummaryStat( + label: '计划数', + value: '$planCount', + icon: Icons.view_week_outlined, + color: _ExercisePlanPageState._exerciseViolet, + ), + ), + ], + ), + ], + ), + ); + } +} + +class _ExerciseSummaryStat extends StatelessWidget { + final String label; + final String value; + final IconData icon; + final Color color; + + const _ExerciseSummaryStat({ + required this.label, + required this.value, + required this.icon, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Container( + constraints: const BoxConstraints(minHeight: 58), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: color.withValues(alpha: 0.14)), + ), + child: Row( + children: [ + Icon(icon, color: color, size: 17), + const SizedBox(width: 7), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + ], + ), + ); + } +} + class ExercisePlanDetailPage extends ConsumerStatefulWidget { final String id; const ExercisePlanDetailPage({super.key, required this.id}); @@ -933,6 +1273,7 @@ class ExercisePlanDetailPage extends ConsumerStatefulWidget { class _ExercisePlanDetailPageState extends ConsumerState { Future?>? _future; + final Set _busyItems = {}; static const _exerciseColor = Color(0xFF60A5FA); static const _exerciseDark = Color(0xFF7C5CFF); @@ -952,10 +1293,25 @@ class _ExercisePlanDetailPageState } Future _checkIn(String itemId) async { - if (itemId.isEmpty) return; - await ref.read(exerciseServiceProvider).checkIn(itemId); - ref.invalidate(currentExercisePlanProvider); - _load(); + if (itemId.isEmpty || _busyItems.contains(itemId)) return; + setState(() => _busyItems.add(itemId)); + try { + await ref.read(exerciseServiceProvider).checkIn(itemId); + ref.invalidate(currentExercisePlanProvider); + _load(); + } catch (e) { + debugPrint('[ExercisePlanDetail] 打卡失败: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('只能打卡今天的运动任务'), + backgroundColor: AppTheme.error, + ), + ); + } + } finally { + if (mounted) setState(() => _busyItems.remove(itemId)); + } } String _todayKey() { @@ -1017,6 +1373,9 @@ class _ExercisePlanDetailPageState const SizedBox(height: 12), _TodayExerciseCard( item: todayItem, + isBusy: _busyItems.contains( + todayItem['id']?.toString() ?? '', + ), onCheckIn: () => _checkIn(todayItem['id']?.toString() ?? ''), ), const SizedBox(height: 16), @@ -1040,7 +1399,11 @@ class _ExercisePlanDetailPageState ((item['durationMinutes'] as num?)?.toInt() ?? 0), isCompleted: item['isCompleted'] == true, isToday: item['scheduledDate']?.toString() == today, - onTap: () => _checkIn(item['id']?.toString() ?? ''), + isRestDay: item['isRestDay'] == true, + isBusy: _busyItems.contains(item['id']?.toString() ?? ''), + onTap: item['scheduledDate']?.toString() == today + ? () => _checkIn(item['id']?.toString() ?? '') + : null, ), ), ], @@ -1175,16 +1538,23 @@ class _ExerciseHeroCard extends StatelessWidget { class _TodayExerciseCard extends StatelessWidget { final Map item; + final bool isBusy; final VoidCallback onCheckIn; - const _TodayExerciseCard({required this.item, required this.onCheckIn}); + const _TodayExerciseCard({ + required this.item, + required this.isBusy, + required this.onCheckIn, + }); @override Widget build(BuildContext context) { final hasItem = item.isNotEmpty; final isDone = item['isCompleted'] == true; + final isRestDay = item['isRestDay'] == true; final name = item['exerciseType']?.toString() ?? '今日休息'; final minutes = ((item['durationMinutes'] as num?)?.toInt() ?? 0); + final canCheckIn = hasItem && !isRestDay; return Container( padding: const EdgeInsets.all(16), @@ -1241,19 +1611,47 @@ class _TodayExerciseCard extends StatelessWidget { ), ), if (hasItem) - GestureDetector( - onTap: onCheckIn, - child: Container( - width: 42, - height: 42, - decoration: BoxDecoration( - color: isDone ? AppColors.successLight : AppColors.cardInner, - borderRadius: BorderRadius.circular(13), + SizedBox( + height: 38, + child: FilledButton.icon( + onPressed: canCheckIn && !isBusy ? onCheckIn : null, + style: FilledButton.styleFrom( + backgroundColor: isDone + ? Colors.white + : _ExercisePlanDetailPageState._exerciseColor, + foregroundColor: isDone ? AppTheme.success : Colors.white, + disabledBackgroundColor: AppColors.cardInner, + disabledForegroundColor: AppColors.textHint, + padding: const EdgeInsets.symmetric(horizontal: 12), + minimumSize: const Size(0, 38), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide( + color: isDone + ? AppTheme.success.withValues(alpha: 0.34) + : Colors.transparent, + ), + ), ), - child: Icon( - isDone ? Icons.check_circle : Icons.check_circle_outline, - size: 28, - color: isDone ? AppTheme.success : AppColors.textHint, + icon: isBusy + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.textHint, + ), + ) + : Icon( + isDone ? Icons.undo_rounded : Icons.check_rounded, + size: 17, + ), + label: Text( + isRestDay ? '休息' : (isDone ? '撤销' : '打卡'), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w800, + ), ), ), ), @@ -1269,7 +1667,9 @@ class _ExerciseDayTile extends StatelessWidget { final int minutes; final bool isCompleted; final bool isToday; - final VoidCallback onTap; + final bool isRestDay; + final bool isBusy; + final VoidCallback? onTap; const _ExerciseDayTile({ required this.date, @@ -1277,11 +1677,16 @@ class _ExerciseDayTile extends StatelessWidget { required this.minutes, required this.isCompleted, required this.isToday, + required this.isRestDay, + required this.isBusy, required this.onTap, }); @override Widget build(BuildContext context) { + final canCheckIn = isToday && !isRestDay; + final subtitle = isRestDay ? '$date · 休息日' : '$date · $minutes 分钟'; + return Container( margin: const EdgeInsets.only(bottom: 8), decoration: BoxDecoration( @@ -1320,14 +1725,50 @@ class _ExerciseDayTile extends StatelessWidget { style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), ), subtitle: Text( - '$date · $minutes 分钟', + subtitle, style: const TextStyle(fontSize: 13, color: AppColors.textHint), ), - trailing: IconButton( - onPressed: onTap, - icon: Icon( - isCompleted ? Icons.check_circle : Icons.check_circle_outline, - color: isCompleted ? AppTheme.success : AppColors.textHint, + trailing: SizedBox( + height: 36, + child: FilledButton.icon( + onPressed: canCheckIn && !isBusy ? onTap : null, + style: FilledButton.styleFrom( + backgroundColor: isCompleted + ? Colors.white + : _ExercisePlanDetailPageState._exerciseColor, + foregroundColor: isCompleted ? AppTheme.success : Colors.white, + disabledBackgroundColor: AppColors.cardInner, + disabledForegroundColor: AppColors.textHint, + padding: const EdgeInsets.symmetric(horizontal: 10), + minimumSize: const Size(0, 36), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + side: BorderSide( + color: isCompleted + ? AppTheme.success.withValues(alpha: 0.34) + : Colors.transparent, + ), + ), + ), + icon: isBusy + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.textHint, + ), + ) + : Icon( + isCompleted ? Icons.undo_rounded : Icons.check_rounded, + size: 16, + ), + label: Text( + !isToday + ? '只读' + : (isRestDay ? '休息' : (isCompleted ? '撤销' : '打卡')), + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w800), + ), ), ), ), @@ -1420,7 +1861,10 @@ class _ExercisePlanCreatePageState ), padding: const EdgeInsets.symmetric(vertical: 14), ), - child: const Text('保存计划', style: TextStyle(fontSize: 19)), + child: const Text( + '保存计划', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700), + ), ), ), ], @@ -1439,7 +1883,7 @@ class _ExercisePlanCreatePageState children: [ Text( label, - style: TextStyle(fontSize: 17, color: AppColors.textSecondary), + style: TextStyle(fontSize: 14, color: AppColors.textSecondary), ), const SizedBox(height: 6), TextField( @@ -1461,7 +1905,7 @@ class _ExercisePlanCreatePageState ), ), ), - style: const TextStyle(fontSize: 17), + style: const TextStyle(fontSize: 16), ), ], ); @@ -1477,7 +1921,7 @@ class _ExercisePlanCreatePageState children: [ Text( label, - style: const TextStyle(fontSize: 17, color: AppColors.textSecondary), + style: const TextStyle(fontSize: 14, color: AppColors.textSecondary), ), const SizedBox(height: 6), GestureDetector( @@ -1498,8 +1942,8 @@ class _ExercisePlanCreatePageState borderRadius: BorderRadius.circular(12), ), child: Text( - '${val.year} ${val.month.toString().padLeft(2, '0')} ${val.day.toString().padLeft(2, '0')}', - style: const TextStyle(fontSize: 19), + _displayDate(val), + style: const TextStyle(fontSize: 16), ), ), ), @@ -1515,7 +1959,7 @@ class _ExercisePlanCreatePageState children: [ const Text( '每日提醒时间', - style: TextStyle(fontSize: 17, color: AppColors.textSecondary), + style: TextStyle(fontSize: 14, color: AppColors.textSecondary), ), const SizedBox(height: 6), GestureDetector( @@ -1535,7 +1979,7 @@ class _ExercisePlanCreatePageState color: Colors.white, borderRadius: BorderRadius.circular(12), ), - child: Text(value, style: const TextStyle(fontSize: 19)), + child: Text(value, style: const TextStyle(fontSize: 16)), ), ), ], @@ -1836,6 +2280,7 @@ class _HealthArchivePageState extends ConsumerState { void _addSurgery() => setState(() => _surgeries.add({'type': '', 'date': ''})); void _removeSurgery(int i) => setState(() => _surgeries.removeAt(i)); + void _setGender(String value) => setState(() => _genderCtrl.text = value); Future _save() async { setState(() => _saving = true); @@ -1953,12 +2398,26 @@ class _HealthArchivePageState extends ConsumerState { _Section('个人资料', Icons.person_outline, [ _F('姓名', _nameCtrl, hint: '请输入姓名'), const SizedBox(height: 12), - _F('性别', _genderCtrl, hint: '男/女'), - const SizedBox(height: 12), - _DateF( - '出生日期', - _birthDate, - () => _pickDate((v) => setState(() => _birthDate = v)), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 5, + child: _GenderF( + value: _genderCtrl.text, + onChanged: _setGender, + ), + ), + const SizedBox(width: 10), + Expanded( + flex: 6, + child: _DateF( + '出生日期', + _birthDate, + () => _pickDate((v) => setState(() => _birthDate = v)), + ), + ), + ], ), ]), const SizedBox(height: 12), @@ -2136,6 +2595,73 @@ class _F extends StatelessWidget { ); } +class _GenderF extends StatelessWidget { + final String value; + final ValueChanged onChanged; + + const _GenderF({required this.value, required this.onChanged}); + + @override + Widget build(BuildContext c) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '性别', + style: TextStyle(fontSize: 14, color: AppColors.textSecondary), + ), + const SizedBox(height: 6), + Container( + height: 48, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: AppColors.borderLight), + ), + child: Row( + children: [ + Expanded(child: _GenderChip('男', value == '男', onChanged)), + const SizedBox(width: 4), + Expanded(child: _GenderChip('女', value == '女', onChanged)), + ], + ), + ), + ], + ); + } +} + +class _GenderChip extends StatelessWidget { + final String label; + final bool selected; + final ValueChanged onChanged; + + const _GenderChip(this.label, this.selected, this.onChanged); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => onChanged(label), + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected ? const Color(0xFF2563EB) : Colors.transparent, + borderRadius: BorderRadius.circular(9), + ), + child: Text( + label, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w800, + color: selected ? Colors.white : AppColors.textSecondary, + ), + ), + ), + ); + } +} + class _F2 extends StatelessWidget { final String label; final String value; @@ -2210,7 +2736,7 @@ class _DateF extends StatelessWidget { child: Row( children: [ Text( - value.isEmpty ? '点击选择日期' : value.replaceAll('-', ' '), + value.isEmpty ? '点击选择日期' : _slashDate(value), style: TextStyle( fontSize: 16, color: value.isEmpty @@ -2261,7 +2787,7 @@ class _DateF2 extends StatelessWidget { child: Row( children: [ Text( - value.isEmpty ? (hint ?? '点击选择') : value.replaceAll('-', ' '), + value.isEmpty ? (hint ?? '点击选择') : _slashDate(value), style: TextStyle( fontSize: 13, color: value.isEmpty @@ -2283,6 +2809,16 @@ class _DateF2 extends StatelessWidget { ); } +String _slashDate(String value) { + final date = value.trim(); + if (date.length >= 10) return date.substring(0, 10).replaceAll('-', '/'); + return date.replaceAll('-', '/'); +} + +String _displayDate(DateTime date) { + return '${date.year}/${date.month.toString().padLeft(2, '0')}/${date.day.toString().padLeft(2, '0')}'; +} + class _AddBtn extends StatelessWidget { final VoidCallback onTap; const _AddBtn(this.onTap); @@ -2844,86 +3380,281 @@ class StaticTextPage extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final titles = {'privacy': '隐私协议', 'terms': '服务协议', 'about': '关于小脉健康'}; final contents = { - 'privacy': '''## 隐私政策 + 'privacy': '''## 小脉健康隐私政策 -更新日期:2026年1月1日 +更新日期:2026年6月29日 +生效日期:2026年6月29日 -### 一、信息收集 -我们收集以下类型的信息: -- 账户信息:手机号、昵称、头像(您主动提供) -- 健康数据:血压、心率、血糖、血氧、体重等健康指标记录 -- 用药信息:药品名称、剂量、服药时间等用药计划数据 -- 饮食记录:通过拍照或手动录入的饮食数据 -- 设备信息:设备型号、操作系统版本(用于适配优化) -- 日志信息:App 使用情况、崩溃报告 +小脉健康重视您的个人信息和健康数据保护。本政策说明我们在您使用小脉健康 App 及相关服务时,如何收集、使用、存储、共享和保护您的信息,以及您如何管理自己的信息。 -### 二、信息使用 -我们使用您的信息用于以下目的: -- 提供和改进健康管理服务 -- AI 健康分析和个性化建议 -- 用药提醒和复查通知推送 -- App 功能优化和问题修复 +请您在注册、登录和使用本应用前仔细阅读本政策。若您不同意本政策内容,请停止注册或使用相关服务。 -### 三、信息保护 -- 所有健康数据均采用 HTTPS 加密传输 -- 数据存储于安全服务器,采用行业标准的加密措施 -- 我们不会向任何第三方出售、出租或共享您的个人健康数据 -- 医生仅可查看其签约患者的数据,且需经过您的授权 +### 一、我们收集的信息 -### 四、信息保留 -- 对话记录保留 30 天后自动删除 -- 您可以随时删除自己的健康数据和对话记录 -- 账号注销后,所有数据将在 7 天内永久删除 +为向您提供健康管理、AI 健康咨询、报告解读、饮食识别、用药管理、运动计划、在线医生咨询和蓝牙设备同步等功能,我们会根据您使用的具体功能收集以下信息: -### 五、您的权利 -- 查看和导出您的个人数据 -- 修改不准确的个人信息 -- 删除不需要的数据 -- 注销账号并清除所有数据 -- 关闭推送通知 +1. 账号与身份信息 +- 手机号码:用于注册、登录、身份识别和账号安全验证。 +- 姓名或昵称、性别、出生日期、头像:用于完善个人资料和展示账号信息。 +- 医生选择或签约关系:用于向您提供医生咨询、报告审核和随访相关服务。 -### 六、联系我们 -如有任何关于隐私的问题,请联系: -邮箱:privacy@healthbutler.com -电话:400-xxx-xxxx''', - 'terms': '''## 服务协议 +2. 健康档案与健康记录 +- 健康档案:诊断信息、手术类型、手术日期、过敏史、饮食禁忌、慢性病史、家族史等您主动填写的信息。 +- 健康指标:血压、心率、血糖、血氧、体重、记录时间、记录来源等。 +- 用药信息:药品名称、剂量、频次、服药时间、提醒设置、服药打卡记录等。 +- 饮食记录:餐次、食物名称、份量、热量估算、饮食评分等。 +- 运动计划:运动项目、计划内容、完成情况等。 +- 检查报告:您上传的报告图片、报告文件、AI 识别出的指标、摘要、医生审核意见等。 +- 在线咨询和随访信息:您与医生或系统产生的咨询内容、咨询状态、随访内容和时间。 -更新日期:2026年6月26日 +3. 图片、相机、相册和文件信息 +- 当您使用“拍照上传报告”“拍照识别饮食”“聊天中发送图片”等功能时,我们会调用设备相机,用于拍摄检查报告、饮食图片或您希望 AI 辅助查看的图片。 +- 当您使用“从相册选择”“上传 PDF”等功能时,我们会读取您主动选择的图片或 PDF 文件,用于报告管理、饮食识别、AI 对话附件解析等功能。 +- 未经您主动选择或确认,我们不会读取您的相册其他内容,也不会持续访问您的相机。 -欢迎使用小脉健康。请您在注册、登录和使用本应用前,仔细阅读并理解本服务协议。您点击同意或继续使用本应用,即表示您已阅读、理解并同意本协议。 +4. 蓝牙设备信息 +- 当您使用蓝牙设备功能时,我们会扫描和连接附近支持的健康设备。目前代码中已实现血压计数据同步,设备模型中预留了血糖仪、体重秤、血氧仪类型,但当前实际支持的数据解析以血压计为主。 +- 我们会在本地保存已绑定设备的设备标识、设备名称、设备类型、服务 UUID、最近同步时间,以及用于避免重复同步的短期记录指纹。 +- 通过血压计同步时,我们会读取并记录收缩压、舒张压、脉搏、测量时间等数据。 + +5. 位置信息相关说明 +- Android 系统在部分版本中要求 App 申请定位权限后才允许进行蓝牙低功耗设备扫描。我们申请定位权限的目的,是用于发现和连接蓝牙健康设备。 +- 当前代码未将您的定位经纬度上传至服务器,也未用于地图、轨迹、广告或位置画像。 + +6. 设备、日志和网络信息 +- 设备型号、操作系统版本、App 版本、网络请求状态、接口错误信息等,用于功能适配、问题排查、服务安全和性能优化。 +- 本应用会使用网络请求、流式消息、实时通信等能力,以便完成登录、AI 回复、医生咨询、数据同步等功能。 + +### 二、我们如何使用这些信息 + +我们会将收集的信息用于以下目的: + +- 为您创建和维护账号,完成登录、退出登录、令牌刷新和账号安全验证。 +- 记录和展示您的健康指标、用药、饮食、运动、报告、咨询和随访信息。 +- 根据您输入或上传的信息,提供 AI 健康解释、报告预解读、饮食识别、用药和运动建议。 +- 通过蓝牙连接健康设备并同步血压等测量数据。 +- 向医生端展示与您咨询、签约、报告审核或随访相关的必要信息。 +- 发送用药、运动、健康指标、报告处理等站内提醒。 +- 进行系统维护、故障排查、安全审计、服务优化和合规管理。 + +本应用提供的 AI 分析、健康提醒和报告解读仅作为健康管理参考,不构成诊断、治疗或处方建议。如您出现胸痛、呼吸困难、意识异常、严重血压或血糖异常等紧急情况,请立即联系医生或前往医疗机构就诊。 + +### 三、权限调用说明 + +1. 相机权限 +- 使用场景:拍摄检查报告、饮食图片、聊天附件图片。 +- 使用目的:上传报告进行 AI 结构化解读;识别食物种类和估算份量、热量;在 AI 对话中让系统理解您主动发送的图片内容。 + +2. 相册/图片读取权限 +- 使用场景:从相册选择报告图片、饮食图片或聊天图片。 +- 使用目的:上传您主动选择的图片并完成报告管理、饮食识别或 AI 对话附件解析。 + +3. 文件读取权限 +- 使用场景:在聊天中选择 PDF 文件。 +- 使用目的:上传您主动选择的 PDF,供系统提取文字或生成 AI 对话上下文。 + +4. 蓝牙权限 +- 使用场景:扫描、绑定和连接蓝牙健康设备。 +- 使用目的:发现支持的血压计等健康设备,并同步血压、脉搏、测量时间等数据。 + +5. 定位权限 +- 使用场景:Android 蓝牙低功耗扫描。 +- 使用目的:满足系统蓝牙扫描要求。我们不会收集或上传您的精确定位经纬度。 + +### 四、信息上传和第三方服务 + +在您主动使用相关功能时,以下数据可能会上传至服务器: + +- 账号信息、个人资料、健康档案和健康指标。 +- 用药计划、服药记录、饮食记录、运动计划、日历和提醒数据。 +- 检查报告图片、聊天图片、PDF 文件及其 AI 解析结果。 +- 饮食图片识别结果、报告 AI 解读结果、AI 对话内容和医生咨询内容。 +- 蓝牙血压计同步得到的血压、脉搏和测量时间。 + +为实现 AI 对话、图片识别、报告解读、知识库检索、短信验证和实时咨询等功能,我们可能接入以下第三方或外部服务。实际启用情况以正式部署配置为准: + +- 大语言模型服务:用于 AI 健康咨询、报告预解读、文本生成和内容理解。 +- 视觉模型服务:用于饮食图片识别、报告图片或聊天图片内容识别。 +- 知识库检索服务:用于在 AI 回复时检索医学知识库或业务知识库资料。 +- 短信服务:用于向您的手机号发送登录或注册验证码。 +- 对象存储或服务器文件存储服务:用于保存您主动上传的报告、图片或 PDF 文件。 +- 实时通信服务:用于医生咨询消息的实时收发。 + +我们不会向第三方出售您的个人信息或健康数据。若第三方服务处理您的信息,我们会尽力要求其仅在实现相关功能所必需的范围内处理,并采取合理的安全保护措施。 + +### 五、信息存储和保存期限 + +- 您的账号资料、健康档案、健康记录、用药、饮食、运动、报告、咨询、随访、通知和 AI 对话等业务数据,会保存至服务器数据库,用于持续向您提供服务。 +- 您主动上传的检查报告图片会保存于服务器文件目录,并与报告记录关联;删除单份报告时,系统会删除对应报告记录和原始报告文件。 +- 您在聊天中上传的图片或 PDF 会保存于服务器文件目录,用于 AI 附件解析和会话上下文展示。 +- 饮食识别图片会临时保存至服务器用于识别处理,并提交视觉模型进行分析;识别结果会用于生成饮食记录。 +- App 本地仅保存登录 token、偏好设置、已绑定蓝牙设备信息、最近同步信息和短期去重指纹等缓存数据,不作为健康业务数据的唯一来源。 +- 我们将在实现服务目的所需期限内保存您的信息;法律法规另有要求的,从其规定。 + +### 六、您如何管理个人信息 + +您可以在 App 内查看、修改或删除部分信息: + +- 在个人资料、健康档案、健康数据、用药、饮食、运动、报告、AI 会话等页面查看或管理相关记录。 +- 在设置页查看《隐私政策》和《服务协议》。 +- 在设置页使用“删除账号”功能申请注销账号。 + +账号注销后,系统会删除您的账号及主要业务数据,包括健康记录、用药记录、饮食记录、运动计划、报告记录、AI 对话、医生咨询、随访、通知、健康档案、登录令牌等。注销后相关数据不可恢复。 + +请注意:如法律法规要求留存,或为处理争议、审计、安全风控等确有必要,我们可能在法定或合理期限内保留必要记录。 + +### 七、我们如何保护信息安全 + +- 使用登录认证和访问控制保护您的账号和接口。 +- 通过 HTTPS 等方式保护数据传输安全,正式上线时应使用有效的 HTTPS 证书。 +- 对服务器、数据库和文件存储采取权限控制、日志审计、备份和安全配置。 +- 医生端、管理端仅能访问其职责范围内必要的数据。 +- 如发生个人信息安全事件,我们将按照法律法规要求及时采取补救措施并履行通知义务。 + +### 八、未成年人保护 + +本应用主要面向具备完全民事行为能力的成人用户。如未成年人需要使用本应用,应在监护人同意和指导下使用。监护人发现未成年人信息被不当收集或使用的,可通过本政策中的联系方式与我们联系。 + +### 九、政策更新 + +我们可能根据产品功能、法律法规或合规要求更新本政策。政策更新后,我们会在 App 内或相关页面展示更新后的版本;重大变更将通过弹窗、公告或其他合理方式提示您。 + +### 十、联系我们 + +如您对本政策、个人信息保护或账号注销有任何问题,可通过以下方式联系我们: + +公司/运营主体:xxx +隐私负责人/客服邮箱:xxx +客服电话:xxx +联系地址:xxx''', + 'terms': '''## 小脉健康服务协议 + +更新日期:2026年6月29日 +生效日期:2026年6月29日 + +欢迎使用小脉健康。请您在注册、登录和使用本应用前,仔细阅读并理解本服务协议。您点击同意、注册、登录或继续使用本应用,即表示您已阅读、理解并同意本协议及《隐私政策》。 + +如您不同意本协议,请停止注册或使用本应用。 ### 一、服务内容 -小脉健康为用户提供健康数据记录、用药管理、报告管理、运动计划、健康日历、AI 健康建议及在线医生咨询等功能。具体服务内容会根据产品版本和实际开通情况进行调整。 -### 二、账号使用 -- 您应使用真实、准确、有效的信息完成注册和登录。 -- 您应妥善保管账号、验证码及设备信息,不得将账号转让、出租或出借给他人使用。 -- 因您主动泄露账号信息或操作不当造成的损失,由您自行承担。 +小脉健康为用户提供健康数据记录、健康档案管理、用药管理、饮食识别、运动计划、健康日历、检查报告管理、AI 健康解释、在线医生咨询、随访和蓝牙健康设备同步等功能。具体服务内容会根据产品版本、实际开通情况、监管要求和运营安排进行调整。 -### 三、健康服务说明 -- 本应用提供的 AI 分析、健康提醒和运动饮食建议仅作为健康管理参考,不替代医生面对面的诊断、治疗或处方。 -- 如您出现胸痛、呼吸困难、意识异常、严重头痛等紧急情况,应立即拨打急救电话或前往医疗机构就诊。 -- 医生咨询、报告解读等内容应结合您的实际病史、检查结果和线下诊疗意见综合判断。 +本应用当前可能包含以下服务: -### 四、用户行为规范 -您在使用本应用时不得: -- 提交虚假、违法、侵权或误导性信息; -- 干扰应用正常运行,或尝试未经授权访问系统、数据和接口; -- 利用本应用从事违法违规、损害他人权益或扰乱医疗服务秩序的行为。 +- 健康数据管理:记录和展示血压、心率、血糖、血氧、体重等指标。 +- 蓝牙设备同步:连接支持的健康设备,当前主要用于血压计数据同步。 +- 报告管理与预解读:上传检查报告图片,由系统进行结构化识别和 AI 辅助解读。 +- 饮食识别:上传或拍摄饮食图片,识别食物种类、估算份量和热量。 +- AI 健康咨询:根据您输入的文字、图片、PDF 或健康档案,提供健康解释和管理建议。 +- 用药、运动和日历提醒:帮助您记录计划、查看进度和接收站内提醒。 +- 在线医生咨询:在开通范围内与医生或相关服务人员进行消息沟通。 -### 五、数据与隐私 -我们会按照《隐私政策》收集、使用、存储和保护您的个人信息及健康数据。您可在 App 内「设置」中查看隐私政策,并依法行使查询、更正、删除和注销账号等权利。 +### 二、账号注册与使用 -### 六、服务变更与中止 -为提升服务质量或满足合规要求,我们可能对功能、页面、规则或服务范围进行调整。若因维护、升级、网络故障、不可抗力或法律法规要求导致服务暂时中断,我们会尽力降低对您的影响。 +- 您应使用真实、准确、有效的信息完成注册、登录和资料填写。 +- 您应妥善保管账号、验证码、登录状态及设备,不得将账号转让、出租、出借或提供给他人使用。 +- 因您主动泄露验证码、账号信息、设备信息或操作不当造成的损失,由您自行承担。 +- 如我们发现您的账号存在违法违规、异常访问、攻击系统、冒用身份、恶意上传等行为,有权依法采取限制功能、暂停服务、要求整改、注销账号或配合监管处理等措施。 -### 七、责任限制 -在法律允许范围内,小脉健康不对因第三方服务、网络故障、设备异常、用户误操作或不可抗力造成的服务中断、数据延迟或损失承担超出法定范围的责任。 +### 三、健康服务和医疗边界 + +小脉健康是健康管理辅助工具,不是医疗机构,不提供急诊服务。本应用中的 AI 分析、健康提醒、饮食运动建议、报告预解读、风险提示和自动生成内容,仅供健康管理参考,不构成诊断、治疗、处方、用药调整或医疗结论。 + +您理解并同意: + +- AI 回复可能受输入信息完整性、图片清晰度、模型能力、网络状态和第三方服务状态影响,可能存在不准确、不完整或延迟。 +- 检查报告解读仅是对报告文字、指标或图片内容的辅助说明,不能替代医生结合病史、体格检查、影像资料和线下检查作出的诊断。 +- 饮食识别、热量估算、运动建议和健康评分可能存在误差,仅作为参考。 +- 用药相关内容不应替代医生、药师或说明书建议。请勿根据 App 内容自行开始、停止、更换或调整药物。 +- 医生咨询、随访和报告审核等服务,应结合线下诊疗意见综合判断。 + +如您出现胸痛、胸闷憋气、呼吸困难、意识异常、晕厥、言语不清、一侧肢体无力、严重头痛、严重过敏、血压或血糖明显异常等紧急情况,请立即拨打急救电话或前往医疗机构就诊,不应等待或依赖本应用回复。 + +### 四、用户上传内容和行为规范 + +您在使用本应用时,可能会上传文字、图片、PDF、检查报告、健康数据、咨询内容或其他资料。您应保证上传内容来源合法、真实、准确,不侵犯他人合法权益。 + +您不得利用本应用从事以下行为: + +- 提交虚假、违法、侵权、辱骂、歧视、色情、暴力、诈骗或误导性信息。 +- 冒用他人身份,上传他人个人信息、健康数据、病历、报告或图片。 +- 干扰应用正常运行,攻击、破解、扫描、爬取或尝试未经授权访问系统、数据和接口。 +- 利用本应用从事违法违规、损害他人权益、扰乱医疗服务秩序或违反公序良俗的行为。 +- 将 AI 回复、报告解读或医生咨询内容用于违法用途,或断章取义传播造成误导。 + +因您上传内容不真实、不合法、不完整或侵犯他人权益造成的后果,由您自行承担。 + +### 五、第三方服务和外部能力 + +为实现短信登录、AI 对话、图片识别、报告解读、知识库检索、文件存储、实时通信等功能,本应用可能依赖第三方或外部服务。实际启用的服务以正式部署配置为准。 + +您理解并同意: + +- 第三方服务可能因网络、系统维护、接口限制、模型能力、政策调整等原因导致延迟、中断、识别失败或结果不准确。 +- 我们会尽力保障服务稳定性,但不承诺第三方服务始终无错误、不中断或满足您的全部预期。 +- 涉及个人信息和健康数据处理的第三方服务,我们会按照《隐私政策》进行说明,并在合理范围内要求其采取安全保护措施。 + +### 六、数据与隐私 + +我们会按照《隐私政策》收集、使用、存储、共享和保护您的个人信息及健康数据。您可在 App 内“设置”中查看《隐私政策》,并依法行使查询、更正、删除和注销账号等权利。 + +您可以在 App 内删除部分健康数据、报告、对话或其他记录;您也可以通过“删除账号”功能申请注销账号。账号注销后,系统会删除您的账号及主要业务数据,包括健康记录、用药记录、饮食记录、运动计划、报告记录、AI 对话、医生咨询、随访、通知、健康档案、登录令牌等。注销后相关数据不可恢复。 + +如法律法规要求留存,或为处理争议、审计、安全风控等确有必要,我们可能在法定或合理期限内保留必要记录。 + +### 七、服务变更、中断与终止 + +为提升服务质量、修复问题、满足合规要求或调整业务安排,我们可能对功能、页面、规则、服务范围、第三方能力或收费策略进行调整。 + +在以下情况下,服务可能发生中断、延迟或终止: + +- 系统维护、升级、迁移、故障修复或安全加固。 +- 网络、服务器、数据库、短信、AI 模型、云服务或第三方接口异常。 +- 您的设备、系统版本、权限设置、网络环境或操作方式导致服务无法正常使用。 +- 法律法规、监管要求、司法行政机关要求或不可抗力。 +- 您违反本协议、法律法规或平台规则。 + +我们会尽力降低服务中断对您的影响,但不承诺服务永久、连续、无错误运行。 + +### 八、知识产权 + +本应用的软件、页面、图标、文字、图片、交互设计、技术方案、数据结构、算法逻辑、商标、标识及相关内容,除依法属于第三方或用户自行上传的内容外,相关权利归小脉健康或其合法权利人所有。 + +未经授权,您不得复制、修改、传播、出租、出售、反向工程、反编译、抓取、镜像或以其他方式使用本应用及相关内容。 + +您上传的文字、图片、报告、PDF、健康数据等内容的合法权利仍归您或原权利人所有。为向您提供服务,您授权我们在必要范围内对相关内容进行存储、处理、识别、分析、展示和传输。 + +### 九、责任限制 + +在法律允许范围内,小脉健康不对以下情况造成的损失承担超出法定范围的责任: + +- 因您提供的信息不真实、不完整、不准确或操作不当导致的结果偏差。 +- 因您将 AI 回复、报告预解读、饮食识别、运动建议或健康提醒作为诊断、治疗、处方或急救依据造成的后果。 +- 因第三方服务、网络故障、设备异常、系统维护、不可抗力或监管要求导致的服务中断、数据延迟或功能不可用。 +- 因您泄露账号、验证码、设备或登录状态导致的信息泄露或损失。 +- 因您上传违法、侵权或不当内容引发的争议或责任。 + +本协议不排除或限制法律法规规定不得排除或限制的责任。 + +### 十、协议更新 + +我们可能根据业务发展、产品变化、法律法规或监管要求更新本协议。更新后,我们会在 App 内或相关页面展示新版本;重大变更将通过弹窗、公告或其他合理方式提示您。 + +如您在协议更新后继续使用本应用,视为您已接受更新后的协议。 + +### 十一、法律适用与争议解决 + +本协议的订立、履行、解释和争议解决适用中华人民共和国法律。因本协议或本应用服务产生争议的,双方应先友好协商;协商不成的,依法向有管辖权的人民法院解决。 + +### 十二、联系我们 -### 八、联系我们 如您对本协议或服务使用有任何疑问,请通过以下方式联系我们: -邮箱:support@healthbutler.com -电话:400-xxx-xxxx''', + +公司/运营主体:xxx +客服/支持邮箱:xxx +客服电话:xxx +联系地址:xxx''', 'about': '''## 关于小脉健康 版本:v1.0.0 (Build 20260101) diff --git a/health_app/lib/pages/report/report_pages.dart b/health_app/lib/pages/report/report_pages.dart index f3cd818..800807f 100644 --- a/health_app/lib/pages/report/report_pages.dart +++ b/health_app/lib/pages/report/report_pages.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'dart:async'; import 'dart:io'; import 'package:dio/dio.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image_picker/image_picker.dart'; @@ -404,11 +405,12 @@ class ReportListPage extends ConsumerWidget { padding: const EdgeInsets.all(16), children: [ EnterpriseHeader( - title: '报告管理', + title: '报告处理概览', subtitle: '上传检查报告后自动进行 AI 结构化解读', icon: Icons.description_outlined, color: _reportBlue, accent: _reportCyan, + showIcon: false, stats: [ EnterpriseStat( label: '报告总数', @@ -524,6 +526,25 @@ class ReportListPage extends ConsumerWidget { } }, ), + ListTile( + leading: const Icon( + Icons.picture_as_pdf_outlined, + color: _reportBlue, + ), + title: const Text('上传 PDF', style: TextStyle(fontSize: 17)), + onTap: () async { + Navigator.pop(ctx); + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['pdf'], + withData: false, + ); + if (result == null || result.files.isEmpty) return; + final path = result.files.first.path; + if (path == null || path.isEmpty) return; + ref.read(reportProvider.notifier).uploadFile(path); + }, + ), ], ), ), diff --git a/health_app/lib/providers/chat_provider.dart b/health_app/lib/providers/chat_provider.dart index 2d46fb9..3acd0c7 100644 --- a/health_app/lib/providers/chat_provider.dart +++ b/health_app/lib/providers/chat_provider.dart @@ -1,7 +1,9 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'auth_provider.dart'; +import 'conversation_history_provider.dart'; import 'data_providers.dart'; import '../utils/sse_handler.dart'; @@ -86,6 +88,15 @@ class ChatNotifier extends Notifier { void markNeedsRebuild() => state = state.copyWith(); + /// 重置整个会话:取消正在进行的 SSE,清空消息和会话 ID。 + /// 历史记录页一键清空 / 删除当前会话时调用。 + Future resetSession() async { + await _cancelActiveStream(); + _lastTriggeredAgent = null; + state = const ChatState(); + ref.read(selectedAgentProvider.notifier).select(null); + } + /// 不可变消息操作方法(供 chat_messages_view 新版代码调用) Future confirmMessage(String id) async { final msgs = state.messages.toList(); @@ -103,7 +114,9 @@ class ChatNotifier extends Notifier { try { final api = ref.read(apiClientProvider); for (final confirmationId in confirmationIds.toList()) { - final response = await api.post('/api/ai/confirm-write/$confirmationId'); + final response = await api.post( + '/api/ai/confirm-write/$confirmationId', + ); final body = response.data; if (body is! Map || body['code'] != 0) { return body is Map ? body['message']?.toString() ?? '录入失败' : '录入失败'; @@ -203,14 +216,19 @@ class ChatNotifier extends Notifier { final rawMessages = (res.data['data'] as List?) ?? []; final messages = rawMessages.map((m) { final map = m as Map; + final role = map['role']?.toString().toLowerCase() == 'user' + ? 'user' + : 'assistant'; + final metadata = _parseMetadata(map['metadataJson']); return ChatMessage( id: map['id']?.toString() ?? '', - role: map['role']?.toString() ?? 'user', + role: role, content: map['content']?.toString() ?? '', createdAt: DateTime.tryParse(map['createdAt']?.toString() ?? '') ?? DateTime.now(), - type: MessageType.text, + type: _messageTypeFromMetadata(metadata), + metadata: metadata, ); }).toList(); @@ -259,6 +277,7 @@ class ChatNotifier extends Notifier { } Future sendImage(String imagePath, String text) async { + if (state.isStreaming) return; final file = File(imagePath); if (!await file.exists()) return; _lastTriggeredAgent = null; @@ -303,16 +322,70 @@ class ChatNotifier extends Notifier { final errorMsg = ChatMessage( id: '${DateTime.now().millisecondsSinceEpoch}_upload_error', role: 'assistant', - content: uploadError == null ? '图片上传失败,请稍后重试。' : '图片上传失败,请检查文件大小或网络后重试。', + content: uploadError == null + ? '图片上传失败,请稍后重试。' + : '图片上传失败,请检查文件大小或网络后重试。', createdAt: DateTime.now(), ); state = state.copyWith(messages: [...state.messages, errorMsg]); return; } - // 将图片 URL 作为消息内容发送给 AI - final msgWithImage = text.isNotEmpty ? '$text\n[图片已上传]' : '[图片已上传]'; - await _sendToAI(msgWithImage); + // 把图片 URL 透传给后端,后端会调 VLM 识图并把描述拼到 LLM 上下文 + final userText = text.isNotEmpty ? text : '请帮我看看这张图片'; + await _sendToAI(userText, imageUrl: uploadedUrl); + } + + /// 发送 PDF 附件 + 文字(PDF 解析在后端做)。 + Future sendPdf(String pdfPath, String fileName, String text) async { + if (state.isStreaming) return; + final file = File(pdfPath); + if (!await file.exists()) return; + _lastTriggeredAgent = null; + + final userMsg = ChatMessage( + id: '${DateTime.now().millisecondsSinceEpoch}', + role: 'user', + content: text.isNotEmpty ? text : '请帮我看看这份 PDF', + createdAt: DateTime.now(), + metadata: {'pdfFileName': fileName}, + ); + state = state.copyWith(messages: [...state.messages, userMsg]); + + String? uploadedUrl; + try { + final api = ref.read(apiClientProvider); + uploadedUrl = await api.uploadFile('/api/files/upload', file); + } catch (_) { + // ignore,下方统一处理 + } + + // 更新消息附带的远程 URL + if (uploadedUrl != null) { + final updatedMsgs = state.messages.toList(); + final idx = updatedMsgs.indexWhere((m) => m.id == userMsg.id); + if (idx >= 0) { + updatedMsgs[idx] = ChatMessage( + id: userMsg.id, + role: 'user', + content: userMsg.content, + createdAt: userMsg.createdAt, + metadata: {'pdfFileName': fileName, 'pdfUrl': uploadedUrl}, + ); + state = state.copyWith(messages: updatedMsgs); + } + } else { + final errorMsg = ChatMessage( + id: '${DateTime.now().millisecondsSinceEpoch}_upload_error', + role: 'assistant', + content: 'PDF 上传失败,请检查文件大小或网络后重试。', + createdAt: DateTime.now(), + ); + state = state.copyWith(messages: [...state.messages, errorMsg]); + return; + } + + await _sendToAI(userMsg.content, pdfUrl: uploadedUrl); } Future sendMessage(String text) async { @@ -333,7 +406,11 @@ class ChatNotifier extends Notifier { await _sendToAI(text); } - Future _sendToAI(String text) async { + Future _sendToAI( + String text, { + String? imageUrl, + String? pdfUrl, + }) async { final aiMsg = ChatMessage( id: '${DateTime.now().millisecondsSinceEpoch}_ai', role: 'assistant', @@ -359,6 +436,8 @@ class ChatNotifier extends Notifier { agentType: 'unified', message: text, conversationId: state.conversationId, + imageUrl: imageUrl, + pdfUrl: pdfUrl, token: token, ); @@ -454,6 +533,26 @@ class ChatNotifier extends Notifier { } } + Map? _parseMetadata(dynamic raw) { + if (raw == null) return null; + if (raw is Map) return Map.from(raw); + if (raw is! String || raw.trim().isEmpty) return null; + try { + final decoded = jsonDecode(raw); + return decoded is Map ? Map.from(decoded) : null; + } catch (_) { + return null; + } + } + + MessageType _messageTypeFromMetadata(Map? metadata) { + if (metadata == null) return MessageType.text; + final type = metadata['messageType']?.toString(); + if (type != null && type.isNotEmpty) return _parseMessageType(type); + if (metadata['confirmationIds'] is List) return MessageType.dataConfirm; + return MessageType.text; + } + void _update(ChatMessage m) { final u = state.messages.toList(); final i = u.indexWhere((x) => x.id == m.id); @@ -478,5 +577,6 @@ class ChatNotifier extends Notifier { u.add(m); } state = state.copyWith(messages: u, isStreaming: false, thinkingText: null); + ref.invalidate(conversationHistoryProvider); } } diff --git a/health_app/lib/providers/conversation_history_provider.dart b/health_app/lib/providers/conversation_history_provider.dart new file mode 100644 index 0000000..209166b --- /dev/null +++ b/health_app/lib/providers/conversation_history_provider.dart @@ -0,0 +1,85 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../providers/auth_provider.dart'; +import '../providers/chat_provider.dart'; + +/// 对话历史列表项 +class ConversationListItem { + final String id; + final String? title; + final String? summary; + final int messageCount; + final DateTime updatedAt; + + const ConversationListItem({ + required this.id, + this.title, + this.summary, + required this.messageCount, + required this.updatedAt, + }); + + factory ConversationListItem.fromJson(Map json) => + ConversationListItem( + id: json['id']?.toString() ?? '', + title: json['title']?.toString(), + summary: json['summary']?.toString(), + messageCount: (json['messageCount'] as num?)?.toInt() ?? 0, + updatedAt: DateTime.tryParse(json['updatedAt']?.toString() ?? '') ?? + DateTime.now(), + ); +} + +/// 对话历史 Notifier:缓存列表,提供刷新/删除/清空。 +class ConversationHistoryNotifier + extends AsyncNotifier> { + @override + Future> build() => _fetch(); + + Future> _fetch() async { + final api = ref.read(apiClientProvider); + final res = await api.get('/api/ai/conversations'); + final raw = (res.data['data'] as List?) ?? const []; + return raw + .whereType() + .map((m) => ConversationListItem.fromJson(Map.from(m))) + .toList(); + } + + Future refresh() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(_fetch); + } + + /// 删除单条;先乐观更新 UI,失败回滚。 + Future deleteOne(String id) async { + final previous = state.asData?.value ?? const []; + state = AsyncValue.data(previous.where((e) => e.id != id).toList()); + try { + final api = ref.read(apiClientProvider); + await api.delete('/api/ai/conversations/$id'); + } catch (_) { + state = AsyncValue.data(previous); + rethrow; + } + // 同步清掉当前 chat state(如果删的就是当前会话) + final chat = ref.read(chatProvider); + if (chat.conversationId == id) { + ref.read(chatProvider.notifier).resetSession(); + } + } + + /// 一键清空当前用户的全部对话。 + Future clearAll() async { + final api = ref.read(apiClientProvider); + final res = await api.delete('/api/ai/conversations'); + state = const AsyncValue.data([]); + ref.read(chatProvider.notifier).resetSession(); + final deleted = res.data['data']?['deleted']; + return deleted is num ? deleted.toInt() : 0; + } +} + +final conversationHistoryProvider = AsyncNotifierProvider< + ConversationHistoryNotifier, List>( + ConversationHistoryNotifier.new, +); diff --git a/health_app/lib/utils/sse_handler.dart b/health_app/lib/utils/sse_handler.dart index 8ca498f..0f0d6b6 100644 --- a/health_app/lib/utils/sse_handler.dart +++ b/health_app/lib/utils/sse_handler.dart @@ -10,6 +10,8 @@ class SseHandler { required String agentType, required String message, String? conversationId, + String? imageUrl, + String? pdfUrl, required String token, }) { final params = { @@ -19,6 +21,12 @@ class SseHandler { if (conversationId != null) { params['conversationId'] = conversationId; } + if (imageUrl != null && imageUrl.isNotEmpty) { + params['imageUrl'] = imageUrl; + } + if (pdfUrl != null && pdfUrl.isNotEmpty) { + params['pdfUrl'] = pdfUrl; + } final query = params.entries .map((e) => '${e.key}=${Uri.encodeComponent(e.value)}') .join('&'); diff --git a/health_app/lib/widgets/drawer_shell.dart b/health_app/lib/widgets/drawer_shell.dart index 0b21876..0fc25a7 100644 --- a/health_app/lib/widgets/drawer_shell.dart +++ b/health_app/lib/widgets/drawer_shell.dart @@ -9,7 +9,7 @@ class DrawerShell extends StatelessWidget { @override Widget build(BuildContext context) { return Drawer( - width: MediaQuery.of(context).size.width * widthFactor, + width: MediaQuery.sizeOf(context).width * widthFactor, backgroundColor: Colors.transparent, elevation: 0, child: ClipRRect( diff --git a/health_app/lib/widgets/enterprise_widgets.dart b/health_app/lib/widgets/enterprise_widgets.dart index 30603cc..aa9e66e 100644 --- a/health_app/lib/widgets/enterprise_widgets.dart +++ b/health_app/lib/widgets/enterprise_widgets.dart @@ -18,6 +18,7 @@ class EnterpriseHeader extends StatelessWidget { final Color accent; final List stats; final Widget? trailing; + final bool showIcon; const EnterpriseHeader({ super.key, @@ -28,23 +29,91 @@ class EnterpriseHeader extends StatelessWidget { required this.accent, this.stats = const [], this.trailing, + this.showIcon = true, }); @override Widget build(BuildContext context) { - return Row( - children: [ - for (var i = 0; i < stats.length; i++) ...[ - Expanded( - child: _HeaderStat( - stat: stats[i], - color: i.isEven ? color : accent, - accent: i.isEven ? accent : color, - ), + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: AppColors.border, width: 1.1), + boxShadow: [AppTheme.shadowLight], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (showIcon) ...[ + Container( + width: 46, + height: 46, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [color, accent], + ), + borderRadius: BorderRadius.circular(14), + ), + child: Icon(icon, color: Colors.white, size: 25), + ), + const SizedBox(width: 12), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 20, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 3), + Text( + subtitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: AppColors.textSecondary, + fontSize: 14, + fontWeight: FontWeight.w600, + height: 1.25, + ), + ), + ], + ), + ), + if (trailing != null) ...[const SizedBox(width: 10), trailing!], + ], ), - if (i != stats.length - 1) const SizedBox(width: 8), + if (stats.isNotEmpty) ...[ + const SizedBox(height: 14), + Row( + children: [ + for (var i = 0; i < stats.length; i++) ...[ + Expanded( + child: _HeaderStat( + stat: stats[i], + color: i.isEven ? color : accent, + accent: i.isEven ? accent : color, + ), + ), + if (i != stats.length - 1) const SizedBox(width: 8), + ], + ], + ), + ], ], - ], + ), ); } } @@ -63,8 +132,8 @@ class _HeaderStat extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - constraints: const BoxConstraints(minHeight: 54), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), + constraints: const BoxConstraints(minHeight: 66), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11), decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, @@ -80,8 +149,8 @@ class _HeaderStat extends StatelessWidget { child: Row( children: [ if (stat.icon != null) ...[ - Icon(stat.icon, color: color, size: 16), - const SizedBox(width: 6), + Icon(stat.icon, color: color, size: 19), + const SizedBox(width: 8), ], Expanded( child: Column( @@ -93,8 +162,8 @@ class _HeaderStat extends StatelessWidget { overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.textPrimary, - fontSize: 14, - fontWeight: FontWeight.w800, + fontSize: 17, + fontWeight: FontWeight.w900, ), ), const SizedBox(height: 2), @@ -104,8 +173,8 @@ class _HeaderStat extends StatelessWidget { overflow: TextOverflow.ellipsis, style: const TextStyle( color: AppColors.textSecondary, - fontSize: 11, - fontWeight: FontWeight.w700, + fontSize: 12, + fontWeight: FontWeight.w800, ), ), ], diff --git a/health_app/lib/widgets/health_drawer.dart b/health_app/lib/widgets/health_drawer.dart index fb603b4..c0fb17a 100644 --- a/health_app/lib/widgets/health_drawer.dart +++ b/health_app/lib/widgets/health_drawer.dart @@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/app_colors.dart'; import '../core/navigation_provider.dart'; import '../providers/auth_provider.dart'; +import '../providers/chat_provider.dart'; +import '../providers/conversation_history_provider.dart'; import '../providers/data_providers.dart'; import 'drawer_shell.dart'; @@ -29,6 +31,8 @@ class HealthDrawer extends ConsumerWidget { _HealthDashboard(latestHealth: latestHealth, ref: ref), const SizedBox(height: 18), _NavigationSection(ref: ref), + const SizedBox(height: 18), + _HistorySection(ref: ref), ], ), ), @@ -607,3 +611,286 @@ class _NavItem { required this.colors, }); } + +/// 侧边栏底部:最近 5 条对话历史 + 查看全部 + 清空。 +class _HistorySection extends ConsumerWidget { + final WidgetRef ref; + const _HistorySection({required this.ref}); + + static const int _previewCount = 5; + + @override + Widget build(BuildContext context, WidgetRef _) { + final async = ref.watch(conversationHistoryProvider); + + return _Panel( + title: '对话记录', + trailing: _HistoryActions(ref: ref, async: async), + backgroundGradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFFFFFFF), Color(0xFFF6F1FF), Color(0xFFEFF6FF)], + ), + child: async.when( + loading: () => const Padding( + padding: EdgeInsets.symmetric(vertical: 14), + child: Center( + child: SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ), + error: (error, stackTrace) => const Padding( + padding: EdgeInsets.symmetric(vertical: 14), + child: Text( + '加载失败', + style: TextStyle(fontSize: 13, color: AppColors.textSecondary), + ), + ), + data: (list) { + if (list.isEmpty) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Text( + '暂无历史,发起对话后会显示在这里', + style: TextStyle(fontSize: 13, color: AppColors.textSecondary), + ), + ); + } + final preview = list.take(_previewCount).toList(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < preview.length; i++) + _DrawerHistoryTile( + item: preview[i], + isLast: i == preview.length - 1, + onTap: () async { + Navigator.of(context).maybePop(); // 关侧边栏 + await ref + .read(chatProvider.notifier) + .loadConversation(preview[i].id); + }, + onDelete: () async { + try { + await ref + .read(conversationHistoryProvider.notifier) + .deleteOne(preview[i].id); + } catch (_) { + // 失败时 provider 已回滚状态,UI 自然恢复 + } + }, + ), + if (list.length > _previewCount) ...[ + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: InkWell( + onTap: () { + Navigator.of(context).maybePop(); + pushRoute(ref, 'conversationHistory'); + }, + borderRadius: BorderRadius.circular(999), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Text( + '查看全部', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w800, + color: AppColors.textSecondary, + ), + ), + ), + ), + ), + ], + ], + ); + }, + ), + ); + } +} + +class _HistoryActions extends StatelessWidget { + final WidgetRef ref; + final AsyncValue> async; + const _HistoryActions({required this.ref, required this.async}); + + @override + Widget build(BuildContext context) { + final hasItems = (async.asData?.value.isNotEmpty ?? false); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: () => ref.read(conversationHistoryProvider.notifier).refresh(), + borderRadius: BorderRadius.circular(999), + child: const Padding( + padding: EdgeInsets.all(6), + child: Icon( + Icons.refresh_rounded, + size: 18, + color: Color(0xFF6D28D9), + ), + ), + ), + if (hasItems) + InkWell( + onTap: () => _confirmClearAll(context, ref), + borderRadius: BorderRadius.circular(999), + child: const Padding( + padding: EdgeInsets.all(6), + child: Icon( + Icons.delete_sweep_rounded, + size: 18, + color: AppColors.error, + ), + ), + ), + ], + ); + } + + Future _confirmClearAll(BuildContext context, WidgetRef ref) async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('清空全部历史'), + content: const Text('清空后无法恢复,确认继续?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('取消'), + ), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + style: TextButton.styleFrom(foregroundColor: AppColors.error), + child: const Text('清空'), + ), + ], + ), + ); + if (ok != true) return; + try { + await ref.read(conversationHistoryProvider.notifier).clearAll(); + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('清空失败,请稍后重试'), + backgroundColor: AppColors.error, + ), + ); + } + } + } +} + +class _DrawerHistoryTile extends StatelessWidget { + final ConversationListItem item; + final bool isLast; + final VoidCallback onTap; + final VoidCallback onDelete; + const _DrawerHistoryTile({ + required this.item, + required this.isLast, + required this.onTap, + required this.onDelete, + }); + + @override + Widget build(BuildContext context) { + return Dismissible( + key: ValueKey('drawer-${item.id}'), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 14), + margin: EdgeInsets.only(bottom: isLast ? 0 : 8), + decoration: BoxDecoration( + color: AppColors.error, + borderRadius: BorderRadius.circular(16), + ), + child: const Icon(Icons.delete_outline, color: Colors.white), + ), + confirmDismiss: (_) async { + onDelete(); + return true; + }, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: Container( + margin: EdgeInsets.only(bottom: isLast ? 0 : 6), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.72), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.white.withValues(alpha: 0.9)), + ), + child: Row( + children: [ + Expanded( + child: Text( + _displaySummary(item), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + height: 1.2, + ), + ), + ), + const SizedBox(width: 10), + Text( + _shortDate(item.updatedAt), + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textHint, + ), + ), + ], + ), + ), + ), + ); + } + + static String _displayTitle(ConversationListItem item) { + final s = item.summary?.trim(); + if (s != null && s.isNotEmpty) return s; + final t = item.title?.trim(); + if (t != null && t.isNotEmpty) return t; + return '未命名对话'; + } + + static String _displaySummary(ConversationListItem item) { + final s = item.summary?.trim(); + if (s != null && s.isNotEmpty) return s.replaceAll(RegExp(r'\s+'), ' '); + return _displayTitle(item); + } + + static String _shortDate(DateTime time) { + final now = DateTime.now(); + final local = time.toLocal(); + if (local.year == now.year && + local.month == now.month && + local.day == now.day) { + return '今天'; + } + final yesterday = now.subtract(const Duration(days: 1)); + if (local.year == yesterday.year && + local.month == yesterday.month && + local.day == yesterday.day) { + return '昨天'; + } + return '${local.month}/${local.day}'; + } +}