From 63d2092c24f6431beec9b12305a2020381e3e452 Mon Sep 17 00:00:00 2001 From: MingNian <1281442923@qq.com> Date: Wed, 10 Jun 2026 18:27:38 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20UI=E5=85=A8=E9=9D=A2=E6=94=B9=E9=80=A0?= =?UTF-8?q?=20+=20=E5=90=8E=E7=AB=AF=E5=A4=9A=E9=A1=B9=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 【后端修复】 - 删除diet/consultation agent假工具声明 - 修复DayOfWeek实体注释 - 修复Vision API content序列化 - cleanup_service级联删除修复 - 用药提醒时区偏差修复 - 统一DateTime处理(UtcNow+8) - 新增UTC DateTime JSON转换器 【前端UI重构】 - 配色体系全面更新(#8B5CF6淡紫+#F0ECFF背景) - 登录页重设计 - 首页重设计(透明顶栏、渐变背景、胶囊输入区) - 聊天卡片加白蓝边框、渐变标题 - 侧边栏重构(渐变背景、合并顶部、删除底部设置) - 确认卡片可编辑字段恢复 - 所有子页面加返回按钮 - catch异常加日志 - 删除后refresh provider缓存 --- .../Health.Domain/Entities/exercise_plan.cs | 4 +- .../consultation_agent_handler.cs | 13 +- .../AI/AgentHandlers/diet_agent_handler.cs | 9 +- .../AgentHandlers/exercise_agent_handler.cs | 2 +- .../AgentHandlers/medication_agent_handler.cs | 6 +- .../AI/open_ai_compatible_client.cs | 2 +- .../AI/prompt_manager.cs | 2 +- .../Data/dev_data_seeder.cs | 4 +- .../BackgroundServices/cleanup_service.cs | 22 +- .../medication_reminder_service.cs | 15 +- .../Endpoints/ai_chat_endpoints.cs | 8 +- .../Health.WebApi/Endpoints/diet_endpoints.cs | 2 +- .../Endpoints/exercise_endpoints.cs | 4 +- .../Endpoints/medication_endpoints.cs | 14 +- .../Health.WebApi/Endpoints/user_endpoints.cs | 6 +- backend/src/Health.WebApi/Program.cs | 4 +- .../src/Health.WebApi/UtcDateTimeConverter.cs | 24 + docs/BUG_REVIEW.md | 438 +++++++++++++++ health_app/lib/core/api_client.dart | 2 +- health_app/lib/core/app_colors.dart | 248 ++++----- health_app/lib/core/app_theme.dart | 107 ++-- health_app/lib/main.dart | 7 + health_app/lib/pages/auth/login_page.dart | 287 +++++----- health_app/lib/pages/chart/trend_page.dart | 2 +- .../lib/pages/device/device_scan_page.dart | 22 +- .../lib/pages/diet/diet_capture_page.dart | 170 +++--- health_app/lib/pages/home/home_page.dart | 246 ++++----- .../home/widgets/chat_messages_view.dart | 69 ++- .../medication/medication_checkin_page.dart | 28 +- .../medication/medication_edit_page.dart | 15 +- .../medication/medication_list_page.dart | 94 ++-- .../lib/pages/profile/profile_page.dart | 229 +++++--- health_app/lib/pages/remaining_pages.dart | 397 +++++++------- .../lib/pages/report/ai_analysis_page.dart | 75 +-- health_app/lib/pages/report/report_pages.dart | 128 ++--- .../settings/notification_prefs_page.dart | 35 +- .../lib/pages/settings/settings_pages.dart | 31 +- health_app/lib/providers/auth_provider.dart | 6 +- health_app/lib/providers/chat_provider.dart | 25 + .../lib/providers/consultation_provider.dart | 6 +- .../lib/services/omron_ble_service.dart | 2 +- health_app/lib/widgets/common_widgets.dart | 67 ++- health_app/lib/widgets/health_drawer.dart | 505 ++++++++---------- health_app/pubspec.lock | 48 ++ health_app/pubspec.yaml | 1 + health_app/test/widget_test.dart | 3 +- 46 files changed, 2003 insertions(+), 1431 deletions(-) create mode 100644 backend/src/Health.WebApi/UtcDateTimeConverter.cs create mode 100644 docs/BUG_REVIEW.md diff --git a/backend/src/Health.Domain/Entities/exercise_plan.cs b/backend/src/Health.Domain/Entities/exercise_plan.cs index 595e204..3ffe1d0 100644 --- a/backend/src/Health.Domain/Entities/exercise_plan.cs +++ b/backend/src/Health.Domain/Entities/exercise_plan.cs @@ -7,7 +7,7 @@ public sealed class ExercisePlan { public Guid Id { get; set; } public Guid UserId { get; set; } - public DateOnly WeekStartDate { get; set; } // 本周一 + public DateOnly WeekStartDate { get; set; } // 起始日期 public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; @@ -22,7 +22,7 @@ public sealed class ExercisePlanItem { public Guid Id { get; set; } public Guid PlanId { get; set; } - public int DayOfWeek { get; set; } // 0=周一, 6=周日 + public int DayOfWeek { get; set; } // C# DayOfWeek: 0=周日, 6=周六 public string ExerciseType { get; set; } = string.Empty; // 散步/慢跑/游泳 public int DurationMinutes { get; set; } public bool IsCompleted { get; set; } diff --git a/backend/src/Health.Infrastructure/AI/AgentHandlers/consultation_agent_handler.cs b/backend/src/Health.Infrastructure/AI/AgentHandlers/consultation_agent_handler.cs index b5acb28..2f21b0d 100644 --- a/backend/src/Health.Infrastructure/AI/AgentHandlers/consultation_agent_handler.cs +++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/consultation_agent_handler.cs @@ -1,20 +1,11 @@ namespace Health.Infrastructure.AI.AgentHandlers; /// -/// AI 问诊 Agent 工具处理器——转医生 +/// 问诊 Agent 工具处理器(问诊转医生走专门界面,此处只保留档案和记录查询) /// public static class ConsultationAgentHandler { - public static readonly ToolDefinition RequestDoctorTool = new() - { - Function = new() - { - Name = "request_doctor", Description = "请求转接真人医生", - Parameters = new { type = "object", properties = new { reason = new { type = "string" }, urgency_level = new { type = "string" } } } - } - }; - - public static List Tools => [CommonAgentHandler.QueryHealthRecordsTool, CommonAgentHandler.CheckArchiveTool, RequestDoctorTool]; + public static List Tools => [CommonAgentHandler.QueryHealthRecordsTool, CommonAgentHandler.CheckArchiveTool]; public static Task Execute(string toolName, JsonElement args, AppDbContext db, Guid userId) { diff --git a/backend/src/Health.Infrastructure/AI/AgentHandlers/diet_agent_handler.cs b/backend/src/Health.Infrastructure/AI/AgentHandlers/diet_agent_handler.cs index 9a503ad..b13c6f6 100644 --- a/backend/src/Health.Infrastructure/AI/AgentHandlers/diet_agent_handler.cs +++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/diet_agent_handler.cs @@ -1,16 +1,11 @@ namespace Health.Infrastructure.AI.AgentHandlers; /// -/// 拍饮食 Agent 工具处理器——食物估算 +/// 饮食 Agent 工具处理器(饮食拍照走专门界面,此处只保留档案查询) /// public static class DietAgentHandler { - public static readonly ToolDefinition EstimateFoodTool = new() - { - Function = new() { Name = "estimate_food_text", Description = "根据文字描述估算食物份量和热量", Parameters = new { type = "object", properties = new { text = new { type = "string" } }, required = new[] { "text" } } } - }; - - public static List Tools => [EstimateFoodTool, CommonAgentHandler.CheckArchiveTool]; + public static List Tools => [CommonAgentHandler.CheckArchiveTool]; public static Task Execute(string toolName, JsonElement args, AppDbContext db, Guid userId) { diff --git a/backend/src/Health.Infrastructure/AI/AgentHandlers/exercise_agent_handler.cs b/backend/src/Health.Infrastructure/AI/AgentHandlers/exercise_agent_handler.cs index 03cee8d..03524d5 100644 --- a/backend/src/Health.Infrastructure/AI/AgentHandlers/exercise_agent_handler.cs +++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/exercise_agent_handler.cs @@ -40,7 +40,7 @@ public static class ExerciseAgentHandler switch (action) { case "create": - var weekStart = args.TryGetProperty("week_start_date", out var wsd) ? DateOnly.Parse(wsd.GetString()!) : DateOnly.FromDateTime(DateTime.Now); + var weekStart = args.TryGetProperty("week_start_date", out var wsd) ? DateOnly.Parse(wsd.GetString()!) : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = weekStart }; if (args.TryGetProperty("items", out var items)) { diff --git a/backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs b/backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs index 1268f4f..9c998cf 100644 --- a/backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs +++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs @@ -71,8 +71,8 @@ public static class MedicationAgentHandler Name = name, Dosage = dosage, Frequency = frequency, TimeOfDay = times.Count > 0 ? times : [new TimeOnly(8, 0)], Source = MedicationSource.AiEntry, IsActive = true, - StartDate = DateOnly.TryParse(startDateStr, out var parsedDate) ? parsedDate : DateOnly.FromDateTime(DateTime.Now), - EndDate = durationDays > 0 ? DateOnly.FromDateTime(DateTime.Now).AddDays(durationDays) : null, + StartDate = DateOnly.TryParse(startDateStr, out var parsedDate) ? parsedDate : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), + EndDate = durationDays > 0 ? DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)).AddDays(durationDays) : null, }; db.Medications.Add(med); await db.SaveChangesAsync(); @@ -100,7 +100,7 @@ public static class MedicationAgentHandler db.MedicationLogs.Add(new MedicationLog { Id = Guid.NewGuid(), MedicationId = medId, UserId = userId, - Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.Now), ConfirmedAt = DateTime.UtcNow, + Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), ConfirmedAt = DateTime.UtcNow, }); await db.SaveChangesAsync(); return new { success = true }; diff --git a/backend/src/Health.Infrastructure/AI/open_ai_compatible_client.cs b/backend/src/Health.Infrastructure/AI/open_ai_compatible_client.cs index 493ecc3..2f80299 100644 --- a/backend/src/Health.Infrastructure/AI/open_ai_compatible_client.cs +++ b/backend/src/Health.Infrastructure/AI/open_ai_compatible_client.cs @@ -133,7 +133,7 @@ public sealed class OpenAiCompatibleClient(string baseUrl, string apiKey, string var userMessage = new ChatMessage { Role = "user", - Content = JsonSerializer.Serialize(contentParts, _jsonOptions) + Content = contentParts // 数组格式,让 JSON 序列化时保持为数组而非字符串 }; messages.Add(userMessage); diff --git a/backend/src/Health.Infrastructure/AI/prompt_manager.cs b/backend/src/Health.Infrastructure/AI/prompt_manager.cs index e378adb..cb160e0 100644 --- a/backend/src/Health.Infrastructure/AI/prompt_manager.cs +++ b/backend/src/Health.Infrastructure/AI/prompt_manager.cs @@ -126,7 +126,7 @@ public sealed class PromptManager 领域判断与可用工具: 1. 健康数据(血压/心率/血糖/血氧/体重)→ 调用 record_health_data - 2. 饮食分析 → 调用 estimate_food_text + 2. 饮食分析 → 引导用户使用「拍饮食」功能拍照识别 3. 用药管理 → 调用 manage_medication 4. 运动计划 → 调用 manage_exercise 5. 健康档案查询/修改 → 调用 check_archive / manage_archive diff --git a/backend/src/Health.Infrastructure/Data/dev_data_seeder.cs b/backend/src/Health.Infrastructure/Data/dev_data_seeder.cs index 463ee11..535ec5b 100644 --- a/backend/src/Health.Infrastructure/Data/dev_data_seeder.cs +++ b/backend/src/Health.Infrastructure/Data/dev_data_seeder.cs @@ -104,7 +104,7 @@ public static class DevDataSeeder }); // ---- 运动计划 ---- - var monday = DateOnly.FromDateTime(DateTime.Now.AddDays(-(int)DateTime.Now.DayOfWeek + 1)); + var monday = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8).AddDays(-(int)DateTime.UtcNow.AddHours(8).DayOfWeek + 1)); var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = user.Id, WeekStartDate = monday }; plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 0, ExerciseType = "散步", DurationMinutes = 30, IsCompleted = true, CompletedAt = DateTime.UtcNow.AddDays(-1) }); plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 1, ExerciseType = "慢跑", DurationMinutes = 20 }); @@ -119,7 +119,7 @@ public static class DevDataSeeder var lunch = new DietRecord { Id = Guid.NewGuid(), UserId = user.Id, MealType = MealType.Lunch, - TotalCalories = 644, HealthScore = 3, RecordedAt = DateOnly.FromDateTime(DateTime.Now), + TotalCalories = 644, HealthScore = 3, RecordedAt = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), }; lunch.FoodItems.Add(new DietFoodItem { Id = Guid.NewGuid(), Name = "米饭", Portion = "约1碗", Calories = 174, SortOrder = 1 }); lunch.FoodItems.Add(new DietFoodItem { Id = Guid.NewGuid(), Name = "红烧肉", Portion = "约5块", Calories = 470, Warning = "脂肪含量偏高", SortOrder = 2 }); diff --git a/backend/src/Health.WebApi/BackgroundServices/cleanup_service.cs b/backend/src/Health.WebApi/BackgroundServices/cleanup_service.cs index 4694329..ea1512a 100644 --- a/backend/src/Health.WebApi/BackgroundServices/cleanup_service.cs +++ b/backend/src/Health.WebApi/BackgroundServices/cleanup_service.cs @@ -17,17 +17,29 @@ public sealed class CleanupService(IServiceScopeFactory scopeFactory, ILogger(); - // 清理 30 天前的对话记录 + // 清理 30 天前的对话记录(先删消息再删对话,避免 FK 冲突) var cutoff = DateTime.UtcNow.AddDays(-30); - var oldConversations = await db.Conversations - .Where(c => c.CreatedAt < cutoff) + var oldConvIds = await db.Conversations + .Where(c => c.UpdatedAt < cutoff) + .Select(c => c.Id) .ToListAsync(stoppingToken); - if (oldConversations.Count > 0) + if (oldConvIds.Count > 0) { + // 先批量删消息 + var oldMessages = await db.ConversationMessages + .Where(m => oldConvIds.Contains(m.ConversationId)) + .ToListAsync(stoppingToken); + db.ConversationMessages.RemoveRange(oldMessages); + + // 再删对话 + var oldConversations = await db.Conversations + .Where(c => oldConvIds.Contains(c.Id)) + .ToListAsync(stoppingToken); db.Conversations.RemoveRange(oldConversations); + await db.SaveChangesAsync(stoppingToken); - _logger.LogInformation("清理 {Count} 条过期对话", oldConversations.Count); + _logger.LogInformation("清理 {Count} 条过期对话", oldConvIds.Count); } // 清理过期验证码 diff --git a/backend/src/Health.WebApi/BackgroundServices/medication_reminder_service.cs b/backend/src/Health.WebApi/BackgroundServices/medication_reminder_service.cs index 026c1f1..e1cd085 100644 --- a/backend/src/Health.WebApi/BackgroundServices/medication_reminder_service.cs +++ b/backend/src/Health.WebApi/BackgroundServices/medication_reminder_service.cs @@ -31,13 +31,12 @@ public sealed class MedicationReminderService(IServiceScopeFactory scopeFactory, { using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - // 使用北京时间(UTC+8) - var beijingNow = DateTime.UtcNow.AddHours(8); - var beijingTime = TimeOnly.FromDateTime(beijingNow); - var todayStart = DateTime.SpecifyKind(beijingNow.Date, DateTimeKind.Utc); + // 北京时间今天0点 → 转为UTC + var beijingToday = DateTime.UtcNow.AddHours(8).Date; + var todayStartUtc = beijingToday.AddHours(-8); - // 查询:启用的用药计划 AND 服药时间在当前时间前后5分钟窗口内(防止服务重启错过提醒) - // 处理跨午夜情况 + // 查询:启用的用药计划 AND 服药时间在当前时间前后5分钟窗口内 + var beijingTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); var windowStart = beijingTime.AddMinutes(-5); var medications = await db.Medications .Where(m => m.IsActive) @@ -47,10 +46,10 @@ public sealed class MedicationReminderService(IServiceScopeFactory scopeFactory, foreach (var med in medications) { - // 检查今天是否已打卡 + // 检查今天是否已打卡(用正确的北京时间区间) var alreadyLogged = await db.MedicationLogs .AnyAsync(l => l.MedicationId == med.Id - && l.CreatedAt >= todayStart + && l.CreatedAt >= todayStartUtc && l.Status == MedicationLogStatus.Taken, ct); if (alreadyLogged) continue; diff --git a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs index d51cfd2..e6a59a4 100644 --- a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs @@ -291,7 +291,7 @@ public static class AiChatEndpoints var result = response.Choices?.FirstOrDefault()?.Message?.Content ?? "{}"; // 记录VLM原始返回用于排查 var uploadsDir2 = Path.Combine(Directory.GetCurrentDirectory(), "uploads"); - var logPath = Path.Combine(uploadsDir2, $"vlm_log_{DateTime.Now:HHmmss}.txt"); + var logPath = Path.Combine(uploadsDir2, $"vlm_log_{DateTime.UtcNow:HHmmss}.txt"); await File.WriteAllTextAsync(logPath, $"MODEL: {Environment.GetEnvironmentVariable("VLM_MODEL")}\nIMAGE_SIZE: {imageUrls.FirstOrDefault()?.Length ?? 0}\nRESPONSE:\n{result}", ct); return Results.Ok(new { code = 0, data = result, message = (string?)null }); } @@ -340,7 +340,6 @@ public static class AiChatEndpoints AgentType.Unified => [ HealthDataAgentHandler.RecordHealthDataTool, CommonAgentHandler.QueryHealthRecordsTool, - DietAgentHandler.EstimateFoodTool, MedicationAgentHandler.ManageMedicationTool, ExerciseAgentHandler.ManageExerciseTool, CommonAgentHandler.CheckArchiveTool, @@ -359,13 +358,11 @@ public static class AiChatEndpoints "query_health_records" => CommonAgentHandler.Execute(toolName, root, db, userId), "check_archive" => CommonAgentHandler.Execute(toolName, root, db, userId), "manage_medication" => MedicationAgentHandler.Execute(toolName, root, db, userId), - "estimate_food_text" => DietAgentHandler.Execute(toolName, root, db, userId), "analyze_report" => visionClient != null ? ReportAgentHandler.AnalyzeReport(db, userId, root, visionClient) : Task.FromResult(new { success = false, message = "VLM服务未配置" }), "manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, db, userId), "manage_archive" => CommonAgentHandler.ExecuteManageArchive(db, userId, root), - "request_doctor" => ConsultationAgentHandler.Execute(toolName, root, db, userId), _ => Task.FromResult(new { success = false, message = $"未知工具: {toolName}" }) }; } @@ -470,9 +467,6 @@ public static class AiChatEndpoints metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm"); } break; - case "estimate_food_text": - messageType = "diet_analysis"; - break; case "analyze_report": messageType = "report_analysis"; break; diff --git a/backend/src/Health.WebApi/Endpoints/diet_endpoints.cs b/backend/src/Health.WebApi/Endpoints/diet_endpoints.cs index dff6672..a28f6e5 100644 --- a/backend/src/Health.WebApi/Endpoints/diet_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/diet_endpoints.cs @@ -35,7 +35,7 @@ public static class DietEndpoints MealType = Enum.TryParse(root.TryGetProperty("mealType", out var mt) ? mt.GetString() : "Lunch", out var meal) ? meal : MealType.Lunch, TotalCalories = root.TryGetProperty("totalCalories", out var tc) ? tc.GetInt32() : null, HealthScore = root.TryGetProperty("healthScore", out var hs) ? hs.GetInt32() : null, - RecordedAt = root.TryGetProperty("recordedAt", out var ra) && DateOnly.TryParse(ra.GetString(), out var d) ? d : DateOnly.FromDateTime(DateTime.Now), + RecordedAt = root.TryGetProperty("recordedAt", out var ra) && DateOnly.TryParse(ra.GetString(), out var d) ? d : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), }; if (root.TryGetProperty("foodItems", out var items)) { diff --git a/backend/src/Health.WebApi/Endpoints/exercise_endpoints.cs b/backend/src/Health.WebApi/Endpoints/exercise_endpoints.cs index ce8111e..77b3576 100644 --- a/backend/src/Health.WebApi/Endpoints/exercise_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/exercise_endpoints.cs @@ -45,8 +45,8 @@ public static class ExerciseEndpoints var root = json.RootElement; var exerciseType = root.TryGetProperty("exerciseType", out var et) ? et.GetString()! : "运动"; var duration = root.TryGetProperty("durationMinutes", out var dm) ? dm.GetInt32() : 30; - var startDate = DateOnly.Parse((root.TryGetProperty("startDate", out var sd) ? sd.GetString()! : DateTime.Now.ToString("yyyy-MM-dd"))); - var endDate = DateOnly.Parse((root.TryGetProperty("endDate", out var ed) ? ed.GetString()! : DateTime.Now.AddDays(6).ToString("yyyy-MM-dd"))); + var startDate = DateOnly.Parse((root.TryGetProperty("startDate", out var sd) ? sd.GetString()! : DateTime.UtcNow.AddHours(8).ToString("yyyy-MM-dd"))); + var endDate = DateOnly.Parse((root.TryGetProperty("endDate", out var ed) ? ed.GetString()! : DateTime.UtcNow.AddHours(8).AddDays(6).ToString("yyyy-MM-dd"))); var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = startDate }; for (var d = startDate; d <= endDate; d = d.AddDays(1)) { diff --git a/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs b/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs index 78cf002..e64f753 100644 --- a/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs @@ -12,12 +12,16 @@ public static class MedicationEndpoints var query = db.Medications.Where(m => m.UserId == userId); if (filter == "active") query = query.Where(m => m.IsActive); else if (filter == "inactive") query = query.Where(m => !m.IsActive); + // 北京时间今天对应的 UTC 区间 + var beijingToday = DateTime.UtcNow.AddHours(8).Date; + var todayStartUtc = beijingToday.AddHours(-8); + var todayEndUtc = todayStartUtc.AddDays(1); var meds = await query.OrderByDescending(m => m.CreatedAt).Select(m => new { m.Id, m.Name, m.Dosage, Frequency = m.Frequency.ToString(), m.TimeOfDay, m.StartDate, m.EndDate, m.IsActive, m.Notes, Source = m.Source.ToString(), m.CreatedAt, m.UpdatedAt, - todayTaken = m.Logs.Any(l => l.CreatedAt.Date == DateTime.UtcNow.Date && l.Status == MedicationLogStatus.Taken) + todayTaken = m.Logs.Any(l => l.CreatedAt >= todayStartUtc && l.CreatedAt < todayEndUtc && l.Status == MedicationLogStatus.Taken) }).ToListAsync(ct); return Results.Ok(new { code = 0, data = meds, message = (string?)null }); }); @@ -86,8 +90,10 @@ public static class MedicationEndpoints group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) => { var userId = GetUserId(http); - var today = DateOnly.FromDateTime(DateTime.UtcNow); - var existing = await db.MedicationLogs.FirstOrDefaultAsync(l => l.MedicationId == id && l.CreatedAt.Date == DateTime.UtcNow.Date && l.Status == MedicationLogStatus.Taken, ct); + var beijingToday = DateTime.UtcNow.AddHours(8).Date; + var todayStartUtc = beijingToday.AddHours(-8); + var todayEndUtc = todayStartUtc.AddDays(1); + var existing = await db.MedicationLogs.FirstOrDefaultAsync(l => l.MedicationId == id && l.CreatedAt >= todayStartUtc && l.CreatedAt < todayEndUtc && l.Status == MedicationLogStatus.Taken, ct); if (existing != null) { db.MedicationLogs.Remove(existing); await db.SaveChangesAsync(ct); @@ -96,7 +102,7 @@ public static class MedicationEndpoints var log = new MedicationLog { Id = Guid.NewGuid(), MedicationId = id, UserId = userId, - Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.Now), ConfirmedAt = DateTime.UtcNow, + Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), ConfirmedAt = DateTime.UtcNow, }; db.MedicationLogs.Add(log); await db.SaveChangesAsync(ct); diff --git a/backend/src/Health.WebApi/Endpoints/user_endpoints.cs b/backend/src/Health.WebApi/Endpoints/user_endpoints.cs index 4c09227..c45d600 100644 --- a/backend/src/Health.WebApi/Endpoints/user_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/user_endpoints.cs @@ -50,7 +50,11 @@ public static class UserEndpoints { var userId = GetUserId(http); var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId, ct); - if (archive == null) return Results.Ok(new { code = 40004, message = "档案不存在" }); + if (archive == null) + { + archive = new HealthArchive { Id = Guid.NewGuid(), UserId = userId }; + db.HealthArchives.Add(archive); + } archive.Diagnosis = req.Diagnosis ?? archive.Diagnosis; archive.SurgeryType = req.SurgeryType ?? archive.SurgeryType; diff --git a/backend/src/Health.WebApi/Program.cs b/backend/src/Health.WebApi/Program.cs index 9f45dc1..baed7fb 100644 --- a/backend/src/Health.WebApi/Program.cs +++ b/backend/src/Health.WebApi/Program.cs @@ -3,6 +3,7 @@ using Health.Infrastructure.AI; using Health.Infrastructure.Data; using Health.Infrastructure.Services; using Health.WebApi.BackgroundServices; +using Health.WebApi.Converters; using Health.WebApi.Endpoints; using Health.WebApi.Middleware; using Microsoft.AspNetCore.Authentication.JwtBearer; @@ -27,10 +28,11 @@ if (File.Exists(envPath)) var builder = WebApplication.CreateBuilder(args); -// ---- JSON 配置(枚举字符串、camelCase)---- +// ---- JSON 配置(枚举字符串、camelCase、UTC时间统一带Z)---- builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()); + options.SerializerOptions.Converters.Add(new UtcDateTimeConverter()); options.SerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase; options.SerializerOptions.PropertyNameCaseInsensitive = true; }); diff --git a/backend/src/Health.WebApi/UtcDateTimeConverter.cs b/backend/src/Health.WebApi/UtcDateTimeConverter.cs new file mode 100644 index 0000000..4146a5f --- /dev/null +++ b/backend/src/Health.WebApi/UtcDateTimeConverter.cs @@ -0,0 +1,24 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Health.WebApi.Converters; + +/// +/// 统一 DateTime 序列化为 ISO 8601 UTC 格式(始终带 Z 后缀), +/// 前端解析时可通过 Z 识别为 UTC 时间并自动转本地时间显示。 +/// +public sealed class UtcDateTimeConverter : JsonConverter +{ + public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => reader.GetDateTime(); + + public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) + { + // 始终按 UTC 写出,带 Z 后缀 + writer.WriteStringValue(value.Kind == DateTimeKind.Local + ? value.ToUniversalTime() + : value.Kind == DateTimeKind.Unspecified + ? DateTime.SpecifyKind(value, DateTimeKind.Utc) + : value); + } +} diff --git a/docs/BUG_REVIEW.md b/docs/BUG_REVIEW.md new file mode 100644 index 0000000..1642726 --- /dev/null +++ b/docs/BUG_REVIEW.md @@ -0,0 +1,438 @@ +# 健康管家 — 全面代码审查与Bug文档 + +> 审查日期:2026-06-10 | 范围:全栈(后端 .NET 10 + 前端 Flutter) + +--- + +## 测试结果汇总 + +| 测试集 | 通过 | 失败 | 说明 | +|--------|------|------|------| +| 后端单元测试 (entity_tests) | 10 | 0 | 实体/数据库操作全通过 | +| 后端单元测试 (auth_tests) | 4 | 0 | 认证流程全通过 | +| 后端单元测试 (ai_agent_tests - PromptManager) | 8 | 0 | AI提示词全通过 | +| 后端集成测试 (ai_agent_tests - AI对话) | 0 | 5 | 需要后端运行中 | +| Flutter 测试 (widget_test) | - | - | sqlite3 原生库下载失败,无法运行 | + +**总计:22/27 通过(5个集成测试需要后端在线)** + +--- + +## 一、后端 Bug + +### B1. 缺少 consultation DELETE 端点 +- **位置**:`consultation_endpoints.cs` +- **问题**:问诊创建后无删除/取消接口。患者无法取消发起的问诊 +- **影响**:患者发起错误问诊后无法撤销 +- **建议**:添加 `MapDelete("/consultations/{id:guid}", ...)` + +### B2. ai_chat_endpoints 中 unified agent 缺少部分工具 +- **位置**:`ai_chat_endpoints.cs:340-347` +- **问题**:unified agent 虽聚合了 7 种工具,但缺少 `manage_archive` 和 `request_doctor` +- **影响**:用户在 unified 模式无法通过对话修改档案或请求医生 + +### B3. CompressImage 未释放 GDI 资源 +- **位置**:`ai_chat_endpoints.cs:484-503` +- **问题**:`Image.FromFile`、`Bitmap`、`Graphics` 均 `using` 包裹,但 `EncoderParameters` 未释放 +- **影响**:每次食物识别可能泄漏少量非托管内存 +- **修复**:`using var parameters = new EncoderParameters(1);` + +### B4. 用药提醒服务可能定时查询过于频繁 +- **位置**:`BackgroundServices/medication_reminder_service.cs` +- **问题**:需要检查轮询间隔,每分钟查一次可能对数据库压力大 + +### B5. 开发数据种子硬编码 API Key 检查 +- **位置**:`dev_data_seeder.cs` +- **问题**:`DEVDATA_ENABLED=true` 创建测试用户,生产环境可能误开启 + +--- + +## 二、前端 Bug + +### C1. ~~聊天列表从顶部跳到底部~~ ✅ 已修复 +- **修复**:`chat_messages_view.dart` 改为 `reverse:true` +- **修复**:`home_page.dart` 滚动逻辑简化 + +### C2. ~~SwipeDeleteTile 内层手势冲突~~ ✅ 已修复 +- **修复**:`common_widgets.dart` 滑动状态下用 `AbsorbPointer` 屏蔽内部按钮 + +### C3. ~~SwipeDeleteTile 红色溢出~~ ✅ 已修复 +- **修复**:margin 参数从 Stack 内部移到外部 Padding + +### C4. ~~运动打卡 dayOfWeek 索引偏差~~ ✅ 已修复 +- **修复**:`remaining_pages.dart` 改为 `weekday % 7` 与 C# DayOfWeek 对齐 + +### C5. ~~用药管理 Dismissible 一步删除~~ ✅ 已修复 +- **修复**:统一使用 `SwipeDeleteTile` + +### C6. Flutter 测试断言错误 +- **位置**:`health_app/test/widget_test.dart:9` +- **代码**:`expect(AppTheme.primary, AppTheme.primaryLight);` +- **问题**:`primary` (0xFF6366F1) ≠ `primaryLight` (0xFFEEF2FF),这个断言必然失败 +- **修复**:改为 `expect(AppTheme.primary, const Color(0xFF6366F1));` + +### C7. home_page.dart onTap 手误写了 `?.()` +- **位置**:`home_page.dart:115` +- **代码**:`onTap: () => pushRoute(ref, 'notificationPrefs')` 实际没有 `?.()` 问题(之前看错了),此处无误 + +### C8. 报告详情返回按钮:popRoute 在 setState 后 +- **位置**:`report_pages.dart:449-454` +- **问题**:`clearAnalysis()` 调用 `state.copyWith(...)` 然后立即 `popRoute(ref)`,在 pop 过程中可能访问已 dispose 的 provider +- **风险**:中等 + +### C9. DietCapturePage TextField 内存泄漏 +- **位置**:`diet_capture_page.dart:465,479,495` +- **问题**:每个食物项创建 `TextEditingController(text: food.name)` 但从不 dispose +- **修复**:用 `TextFormField` + `initialValue` 或缓存 controller + +### C10. 报告上传错误吞没 +- **位置**:`report_pages.dart:199` +- **代码**:`} catch (_) {}` — 上传失败无任何反馈 +- **修复**:至少显示 snackbar 提示 + +--- + +## 三、前端缺失功能 + +### M1. 饮食记录无编辑/修改功能 +- 当前只能查看(`DietRecordDetailPage`)和删除,无法修改已有记录 + +### M2. 运动计划详情页缺失 +- `ExercisePlanPage` 的 `onTap: () {}` 为空,点卡片无反应 +- 没有类似 `ExercisePlanDetailPage` 的页面 + +### M3. 问诊列表无前端入口 +- 后端有 `/api/consultations` GET,但前端没有问诊历史列表页 + +### M4. 对话无删除/清空功能 +- 后端有 `DELETE /api/ai/conversations/{id}`,但前端无对应UI + +### M5. 无数据导出功能 +- 用户无法导出健康数据(血压记录、饮食记录等) + +--- + +## 四、安全与代码质量 + +### S1. JWT Secret 开发默认值 +- **位置**:`Program.cs:46` +- **问题**:`jwtSecret ??= "dev-secret-key-change-in-production-min-32-chars!!";` +- **风险**:若忘记设环境变量,生产环境用弱密钥 + +### S2. token 通过 query string 传输(SSE) +- **位置**:`ai_chat_endpoints.cs:25`, `sse_handler.dart:17` +- **问题**:`token` 放在 URL query string,会被服务器日志、代理缓存 +- **风险**:中等(含过期时间的 token,但仍有泄露风险) + +### S3. CORS 全开 +- **位置**:`Program.cs:96-98` +- **代码**:`policy.SetIsOriginAllowed(_ => true)` +- **风险**:生产环境应限定具体 origin + +### S4. 全局异常中间件可能泄露内部错误 +- **位置**:`exception_middleware.cs` +- **需要检查**:是否在生产环境返回了调用栈 + +### S5. Flutter 硬编码后端 IP +- **位置**:`api_client.dart:6` +- **代码**:`const String baseUrl = 'http://10.4.164.158:5000';` +- **问题**:每次换网络都需改代码 + +--- + +## 五、未使用代码(可清理) + +- `chat_messages_view.dart`: `_cardFilledBtn`, `_cardOutlineBtn`, `_agentColors`, `_taskRow` 未使用 +- `chat_provider.dart`: `_parseAgent` 未使用 +- `remaining_pages.dart`: `shadcn_ui` 导入未使用 +- `report_pages.dart`: `shadcn_ui` 导入未使用,`reportId` 变量未使用 +- `service_package_detail_page.dart`: `navigation_provider` 导入未使用 + +--- + +## 六、Agent 自动审查新增发现 + +### A1. app_router.dart 无防御 null 断言(🔴严重) +- **位置**:`app_router.dart:41-73` +- **代码示例**:`ReportDetailPage(id: params['id']!)` 等多处 +- **问题**:`params['id']!` 若 params 中无 'id' 键,会抛出 null 断言异常导致崩溃 +- **影响**:任何路由参数拼写错误或缺少都会 crash +- **修复**:使用 `params['id'] ?? ''` 并提供 fallback + +### A2. device_scan_page.dart BLE 流订阅未取消(🔴严重) +- **位置**:`device_scan_page.dart` +- **问题**:BLE stream subscription 在 dispose 时未 cancel,导致蓝牙连接泄漏 +- **影响**:多次进出扫描页会积累未释放的 BLE 连接 + +### A3. 静默吞错误(🟡中) +- **范围**:全前端 28 处 `catch (_) {}` +- **问题**:所有 API 错误、解析错误均无日志或用户提示 +- **修复**:至少加上 `debugPrint('Error: $e')` 或显示 snackbar + +### A4. 删除后未刷新 Provider(🟡中) +- **范围**:多个页面 +- **问题**:删除操作后调用了 `_load()` 或 `_refresh()`(setState),但未调用 `ref.invalidate(someProvider)`,导致 Riverpod 缓存未更新 +- **影响**:切换到其他页面再回来,旧数据可能仍显示 + +### A5. FutureProvider + setState 双重模式(🟢低) +- **范围**:`DietRecordListPage`, `ExercisePlanPage` 等 +- **问题**:同时使用 Riverpod FutureProvider 和本地 setState,导致 provider 失效无效 + +--- + +## 七、全部问题汇总(含Agent发现) + +| # | 优先级 | 位置 | 问题描述 | +|---|--------|------|----------| +| 1 | 🔴 | `app_router.dart` | null 断言无防御,参数缺失即崩溃 | +| 2 | 🔴 | `device_scan_page.dart` | BLE 流订阅未取消 | +| 3 | 🔴 | `B1` consultation | 缺少 DELETE 端点 | +| 4 | 🔴 | `S1` Program.cs | JWT 开发默认弱密钥 | +| 5 | 🔴 | `C9` diet_capture | TextEditingController 泄漏 | +| 6 | 🟡 | 全局 28处 | catch(_){} 静默吞错 | +| 7 | 🟡 | 多页面 | 删除后无 ref.invalidate | +| 8 | 🟡 | `C6` widget_test | 断言 primary == primaryLight 错误 | +| 9 | 🟡 | `C10` report | 上传失败无提示 | +| 10 | 🟡 | `M2` exercise | 运动详情页缺失,onTap 空 | +| 11 | 🟡 | `C8` report | pop 时序问题 | +| 12 | 🟡 | `A5` 多页面 | FutureProvider+setState 双重模式 | +| 13 | 🟢 | `B3` ai_chat | EncoderParameters 未释放 | +| 14 | 🟢 | `B4` bg_service | 用药提醒轮询频率需审视 | +| 15 | 🟢 | `B5` dev_data | 生产环境可能误开启测试数据 | +| 16 | 🟢 | `S2` SSE | token 走 query string | +| 17 | 🟢 | `S3` CORS | 全开 AllowCredentials | +| 18 | 🟢 | `S5` api_client | 硬编码 IP | +| 19 | 🟢 | 多处 | 未使用代码可清理 | +| 20 | 🟢 | `B2` unified | unified agent 缺少 manage_archive 工具 | + +--- + +## 八、后端 Agent 审查新增发现(关键) + +### D1. 运动计划 DayOfWeek 跨层不一致(🔴严重) +- **位置**:`prompt_manager.cs` vs `exercise_plan.cs` 实体注释 +- **问题**:Prompt 告诉 AI `day_of_week: 0-6(周日=0)`,但实体注释 `// 0=周一, 6=周日`。AI 按周日=0 生成数据,后端按周一=0 解析,运动计划星期全偏一天 +- **修复**:统一为 C# DayOfWeek 枚举(0=周日) + +### D2. SMS 验证码使用伪随机数(🔴安全漏洞) +- **位置**:`sms_service.cs` +- **问题**:`Random.Shared.Next(100000, 1000000)` 使用 PRNG,攻击者可预测验证码 +- **修复**:改用 `RandomNumberGenerator.GetInt32(100000, 999999)` + +### D3. checkin 无所有权验证(🔴越权漏洞) +- **位置**:`medication_agent_handler.cs:99`、`exercise_agent_handler.cs:69` +- **问题**:`confirm_medication` 和 `exercise checkin` 直接通过 itemId 操作,不验证是否属于当前用户。任何认证用户可操作他人数据 +- **修复**:添加 `&& item.Plan.UserId == userId` 检查 + +### D4. VisionAsync content 序列化错误(🔴严重) +- **位置**:`open_ai_compatible_client.cs:136` +- **问题**:将图片 contentParts 先序列化为 JSON 字符串再赋值给 Content,但 OpenAI 兼容 API 期望 Content 为数组格式 +- **影响**:食物识别 VLM 调用可能失败 + +### D5. diet/consultation agent 工具声明但未实现(🔴严重) +- **位置**:`diet_agent_handler.cs`、`consultation_agent_handler.cs` +- **问题**:`EstimateFoodTool` 和 `RequestDoctorTool` 在 Tools 列表中声明,但 Execute 方法无对应 case,调用返回"未知工具" +- **影响**:饮食识别和请求医生功能不可用 + +### D6. CleanupService 删除未级联(🔴严重) +- **位置**:`cleanup_service.cs:28` +- **问题**:`db.Conversations.RemoveRange(oldConversations)` 未先删除关联的 ConversationMessage,可能因 FK 约束抛异常 +- **修复**:先删除 Messages 再删 Conversations,或使用 ExecuteDeleteAsync + +### D7. 用药提醒时区计算 bug(🔴严重) +- **位置**:`medication_reminder_service.cs:37` +- **问题**:`DateTime.SpecifyKind(beijingNow.Date, DateTimeKind.Utc)` — 北京时间 00:00 被标记为 UTC 00:00,导致打卡检测有 8 小时偏差 +- **影响**:提醒时间错位、重复提醒或漏提醒 + +### D8. DbContext 未配置外键和级联删除(🟡中等) +- **位置**:`app_db_context.cs` +- **问题**:`OnModelCreating` 没有任何 `HasOne/WithMany/HasForeignKey/OnDelete` 配置,全凭约定 +- **影响**:数据库无 FK 约束、级联行为不明确、RefreshToken 全表扫描 + +### D9. 缺少多个数据库索引(🟡中等) +- RefreshToken: 无索引,认证查询全表扫描 +- FollowUp: 无 (UserId, ScheduledAt) 索引 +- DeviceToken: 无 UserId 索引 +- Report: 无 (UserId, CreatedAt) 索引 + +### D10. ExceptionMiddleware 统一返回500(🟡中等) +- **位置**:`exception_middleware.cs` +- **问题**:所有异常都返回 500,不区分 400/401/404 +- **影响**:客户端无法根据状态码处理不同类型的错误 + +### D11. 用药提醒未实际推送(🟡中等) +- **位置**:`medication_reminder_service.cs` +- **问题**:TODO 注释表明推送尚未实现,只记录日志 + +### D12. Prompt 文本硬编码(🟢低) +- 不支持热更新,修改需重新编译 + +--- + +## 九、最终汇总(所有发现) + +| # | 优先级 | 类别 | 位置 | 问题 | +|---|--------|------|------|------| +| 1 | 🔴 | 安全 | sms_service | 验证码用 PRNG 可预测 | +| 2 | 🔴 | 安全 | agent handlers | checkin 越权漏洞 | +| 3 | 🔴 | 逻辑 | prompt/entity | DayOfWeek 跨层不一致 | +| 4 | 🔴 | 功能 | diet/consult agent | 工具声明但未实现 | +| 5 | 🔴 | 功能 | open_ai_client | Vision content 序列化错误 | +| 6 | 🔴 | 稳定性 | cleanup_service | 删除未级联 FK 冲突 | +| 7 | 🔴 | 功能 | reminder_service | 时区计算 8h 偏差 | +| 8 | 🔴 | 崩溃 | app_router.dart | null 断言无防御 | +| 9 | 🔴 | 泄漏 | device_scan_page | BLE 流未取消 | +| 10 | 🔴 | 安全 | Program.cs | CORS 全开+AllowCredentials | +| 11 | 🔴 | 安全 | Program.cs | JWT 默认弱密钥 | +| 12 | 🔴 | 泄漏 | diet_capture | TextEditingController 未释放 | +| 13 | 🟡 | 体验 | 28处 | catch(_){} 静默吞错 | +| 14 | 🟡 | 逻辑 | 多页面 | 删除后未 invalidate provider | +| 15 | 🟡 | 配置 | app_db_context | 无 FK/级联/索引 | +| 16 | 🟡 | 体验 | exception_middleware | 统一返回 500 | +| 17 | 🟡 | 测试 | widget_test | 断言永远失败 | +| 18 | 🟡 | 功能 | 用药提醒 | 推送未实现 | +| 19 | 🟡 | 架构 | 多页面 | FutureProvider+setState 混用 | +| 20 | 🟡 | 缺失 | exercise | 计划详情页缺失 | +| 21 | 🟢 | 代码 | 多处 | 未使用代码/本地时间/冗余 | + +--- + +## 十、后端端点 Agent 审查新增发现(关键) + +### E1. 医生端点零授权(🔴阻断级) +- **位置**:`doctor_endpoints.cs:19` +- **问题**:`MapGroup("/api/doctor")` **没有 `.RequireAuthorization()`**,所有医生端点(患者详情、健康数据、问诊、报告、随访)对公网开放 +- **影响**:任何人可查看所有患者隐私数据、修改报告、创建/删除随访 + +### E2. consultation POST 消息无所有权检查(🔴阻断级) +- **位置**:`consultation_endpoints.cs:48-75` +- **问题**:发消息只检查 consultation 存在,不验证是否属于当前用户。用户A可向用户B的问诊发消息 + +### E3. exercise checkin 无所有权检查(🔴阻断级) +- **位置**:`exercise_endpoints.cs:119` +- **问题**:`FindAsync([itemId])` 只按ID查,不验证 `item.Plan.UserId == userId`。用户可操作他人运动计划 + +### E4. SMS验证码在响应中暴露(🔴严重) +- **位置**:`auth_endpoints.cs:34` +- **问题**:`devCode = code` 将6位验证码直接返回在JSON中,无 `#if DEBUG` 守卫 + +### E5. Task.Run 火后不理模式(🔴严重) +- **位置**:`report_endpoints.cs:67` +- **问题**:`_ = Task.Run(async () => { ... }, CancellationToken.None)` 在请求结束后 scope 可能已释放,后台任务崩溃 + +### E6. System.Drawing 仅Windows(🔴严重) +- **位置**:`ai_chat_endpoints.cs:486-494` +- **问题**:`Image.FromFile`/`Bitmap`/`Graphics` 在Linux容器中崩溃 + +### E7. 健康数据 N+1 查询(🟡中等) +- **位置**:`health_endpoints.cs:78-89` +- **问题**:`/latest` 对5种指标类型分别发一次SQL查询 + +### E8. 日历用药事件显示错误(🟡中等) +- **位置**:`calendar_endpoints.cs:49` +- **问题**:用户有任意活跃用药就在每月每天标记"用药",不区分具体哪天该吃药 + +### E9. 手动JSON解析绕过模型绑定(🟡中等) +- **范围**:diet、medication、exercise、doctor endpoints +- **问题**:`JsonDocument.Parse` 手动解析导致 Swagger 无法文档化、无自动验证、拼写错误抛500 + +### E10. 缺失端点 +- health:缺 DELETE +- diet:缺 PUT +- exercise:缺 PUT +- report:缺 DELETE +- file:缺 GET/DELETE/list +- followup:缺 detail/confirm + +--- + +## 十一、完整问题排名 + +| # | 等级 | 文件 | 问题 | +|---|------|------|------| +| 1 | 🔴🔴 | doctor_endpoints | **零授权** — 所有患者数据公开 | +| 2 | 🔴🔴 | consultation | 发消息无所有权检查 | +| 3 | 🔴🔴 | exercise | checkin无所有权检查 | +| 4 | 🔴 | auth | SMS验证码响应暴露 | +| 5 | 🔴 | report | Task.Run火后不理 | +| 6 | 🔴 | ai_chat | System.Drawing仅Windows | +| 7 | 🔴 | sms_service | PRNG可预测验证码 | +| 8 | 🔴 | agent handlers | checkin越权 | +| 9 | 🔴 | prompt_manager | DayOfWeek不一致 | +| 10 | 🔴 | diet/consult agent | 工具声明未实现 | +| 11 | 🔴 | open_ai_client | Vision序列化错误 | +| 12 | 🔴 | cleanup_service | 删除未级联 | +| 13 | 🔴 | reminder_service | 时区8h偏差 | +| 14 | 🔴 | app_router | null断言崩溃 | +| 15 | 🔴 | device_scan | BLE泄漏 | +| 16 | 🔴 | Program.cs | CORS全开 | +| 17 | 🔴 | Program.cs | JWT弱密钥 | +| 18 | 🔴 | diet_capture | Controller泄漏 | +| 19 | 🟡 | 28处 | catch(_){}吞错 | +| 20 | 🟡 | 多页面 | 删除未invalidate | +| 21 | 🟡 | health | N+1查询 | +| 22 | 🟡 | calendar | 用药事件逻辑错误 | +| 23 | 🟡 | app_db_context | 无FK/索引配置 | +| 24 | 🟡 | exception_mw | 统一500 | +| 25 | 🟡 | 提醒服务 | 推送未实现 | +| 26 | 🟡 | 多端点 | 缺PUT/DELETE | +| 27 | 🟡 | 多端点 | 手动JSON无验证 | + +--- + +## 优先级排序 + +| 优先级 | Bug | 影响范围 | +|--------|-----|----------| +| 🔴 高 | C9: TextEditingController 内存泄漏 | 饮食识别页面 | +| 🔴 高 | B1: 缺少 consultation DELETE | 问诊功能 | +| 🔴 高 | S1: JWT 弱密钥默认值 | 安全 | +| 🟡 中 | C6: Flutter 测试断言错误 | CI/CD | +| 🟡 中 | C10: 报告上传错误吞没 | 用户体验 | +| 🟡 中 | M2: 运动计划详情页缺失 | 用户体验 | +| 🟡 中 | C8: report pop 时序问题 | 潜在崩溃 | +| 🟢 低 | B3: EncoderParameters 未释放 | 极少量泄漏 | +| 🟢 低 | S5: 硬编码 IP | 开发体验 | +| 🟢 低 | 未使用代码 | 代码质量 | + +--- + +*共发现 15 个问题,5 个缺失功能。其中 5 个 Bug 已在本轮修复。* + +--- + +## 第二轮换角度审查(新增 10 个问题) + +### F1. uploadFile 响应解析崩溃 🔴 +`api_client.dart:60-69` — 后端返回 `data: [{id, name, size}]`(List),前端做 `data['data']?['url']` 对List用字符串索引,运行时异常。聊天图片上传全部无声失败。 + +### F2. consultationChatProvider SignalR 永停不了 🔴 +`consultation_provider.dart` — `stop()` 定义了但无 Widget dispose 调用。离开问诊页后 SignalR 连接和 5秒轮询永续运行。 + +### F3. chatProvider 流订阅无 dispose 🔴 +`chat_provider.dart` — `_subscription`/`_streamTimer` 无 dispose,provider 销毁即泄漏。 + +### F4. ChatMessage 原地修改破坏不可变性 🔴 +`msg.confirmed = true` 直接修改 state 中的对象 → 违反 Riverpod 不可变契约。 + +### F5. 所有 FutureProvider 无 autoDispose 🟡 +6 个 FutureProvider 缓存永不过期,页面级数据不释放。 + +### F6. authProvider Token 刷新窗口期 id/phone 为空 🟡 +`auth_provider.dart:59` — `UserInfo(id: '', phone: '')` 然后等 `_loadProfile()` 异步补全。 + +### F7. 医生 Web 零认证 🔴🔴 +`doctor_web/src/services/api-client.ts` — 不发送 Authorization 头。叠加后端 doctor_endpoints 零授权(E1),任何人可访问所有患者数据。 + +### F8. 医生 Web SignalR handler 泄漏 🔴 +`ChatPage.tsx:28-65` — 组件卸载时若在 Reconnecting 状态,跳过 `off('ReceiveMessage')`,重复挂载累积 handler。 + +### F9. 6个后端端点前端从未调用 🟢 +`GET/DELETE /api/ai/conversations`、`GET /api/consultations`、`PUT /api/health-records/{id}`、全部 `/api/doctor/*` + +### F10. 37 对 API 契约中 1 对崩溃 + 多端手动JSON脆弱 🟡 +`uploadFile` 是唯一崩溃的。其余 36 对匹配但 `TryGetProperty` 区分大小写,依赖前端恰好用 camelCase。 + +--- + +*两轮共发现 37 个问题,其中 5 个阻断级、19 个严重、10 个中等、3 个低。* diff --git a/health_app/lib/core/api_client.dart b/health_app/lib/core/api_client.dart index 93b2b4b..c1f6812 100644 --- a/health_app/lib/core/api_client.dart +++ b/health_app/lib/core/api_client.dart @@ -104,7 +104,7 @@ class _AuthInterceptor extends Interceptor { final retryResponse = await Dio(BaseOptions(baseUrl: baseUrl)).fetch(opts); return handler.resolve(retryResponse); } - } catch (_) {} + } catch (e) { print('[ApiClient] token刷新失败: $e'); } } await _client.clearTokens(); } diff --git a/health_app/lib/core/app_colors.dart b/health_app/lib/core/app_colors.dart index fb3267f..d0fefc2 100644 --- a/health_app/lib/core/app_colors.dart +++ b/health_app/lib/core/app_colors.dart @@ -1,197 +1,147 @@ import 'package:flutter/material.dart'; -/// 统一配色方案 - 蚂蚁阿福风格:紫蓝渐变 + 淡紫背景 + 圆角卡片 +/// 健康管家 — 蚂蚁阿福风格 v2.0 +/// 柔和淡紫 + 大量留白 + 紫色仅作点缀 class AppColors { AppColors._(); // ═══════════════════════════════════════════════════════════ - // 主色调 - 紫蓝渐变系 + // 主色 — 柔和淡紫(非浓烈深紫) // ═══════════════════════════════════════════════════════════ - /// 主紫色(鲜艳紫蓝,阿福风主按钮色) - static const Color primary = Color(0xFF6366F1); + static const Color primary = Color(0xFF8B5CF6); // 主紫(柔和不刺眼) + static const Color primaryLight = Color(0xFFA78BFA); // 浅紫(装饰/渐变) + static const Color primaryDark = Color(0xFF7C3AED); // 深紫(按压态) + static const Color primaryMid = Color(0xFFA78BFA); // 兼容旧名 - /// 主紫色深色 - static const Color primaryDark = Color(0xFF4F46E5); - - /// 浅紫(用于背景、图标底) - static const Color primaryLight = Color(0xFFEEF2FF); - - /// 中紫(渐变过渡) - static const Color primaryMid = Color(0xFFA78BFA); - - /// 青色点缀(渐变右侧,标签色) - static const Color accentCyan = Color(0xFF22D3EE); - - /// 青色深色 - static const Color accentCyanDark = Color(0xFF06B6D4); + // 渐变 — 柔和淡紫,仅垂直 + static const LinearGradient primaryGradient = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFF8B5CF6), Color(0xFFA78BFA)], + ); // ═══════════════════════════════════════════════════════════ // 背景色 // ═══════════════════════════════════════════════════════════ - /// 页面背景(极淡紫蓝,营造漂浮感) - static const Color background = Color(0xFFF8FAFF); + static const Color background = Color(0xFFF0ECFF); // 页面全局背景(淡紫) + static const Color backgroundSoft = Color(0xFFF0ECFF); // 输入框底色(淡紫,同页面背景) + static const Color cardBackground = Color(0xFFFFFFFF); // 卡片白色 + static const Color cardInner = Color(0xFFF5F2FF); // 卡片内区域底色 - /// 更淡的紫背景(最底层,带渐变感) - static const Color backgroundSoft = Color(0xFFF5F3FF); + // 主背景渐变(紫→蓝,4:6比例) + static const LinearGradient bgGradient = LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [Color(0xFFF0ECFF), Color(0xFFEBF4FF)], + stops: [0.0, 0.4], + ); - /// 卡片背景 - 纯白 - static const Color cardBackground = Color(0xFFFFFFFF); - - /// 卡片内小卡片背景(极淡紫灰) - static const Color cardInner = Color(0xFFF8FAFF); - - // ═══════════════════════════════════════════════════════════ - // 图标渐变配色 - // ═══════════════════════════════════════════════════════════ - - /// 报告/数据图标:蓝紫渐变 - static const Color iconBlueStart = Color(0xFF818CF8); - static const Color iconBlueEnd = Color(0xFF6366F1); - - /// 药管家图标:青蓝渐变 - static const Color iconCyanStart = Color(0xFF67E8F9); - static const Color iconCyanEnd = Color(0xFF22D3EE); - - /// 拍皮肤图标:紫蓝渐变 - static const Color iconPurpleStart = Color(0xFFA78BFA); - static const Color iconPurpleEnd = Color(0xFF7C3AED); - - /// 通用图标底色 - static const Color iconBg = Color(0xFFEEF2FF); + // 导航栏背景(极淡蓝紫) + static const LinearGradient navGradient = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFE0E7FF), Color(0xFFC7D2FE)], + ); // ═══════════════════════════════════════════════════════════ // 文字颜色 // ═══════════════════════════════════════════════════════════ - /// 主要文字(深紫黑) - static const Color textPrimary = Color(0xFF1E1B4B); - - /// 次要文字(深灰) - static const Color textSecondary = Color(0xFF64748B); - - /// 辅助文字(浅灰) - static const Color textHint = Color(0xFF94A3B8); - - /// 渐变上的白色文字 + static const Color textPrimary = Color(0xFF1F2937); + static const Color textSecondary = Color(0xFF6B7280); + static const Color textHint = Color(0xFF9CA3AF); static const Color textOnGradient = Color(0xFFFFFFFF); // ═══════════════════════════════════════════════════════════ - // 边框和分割线 + // 边框/分割线 // ═══════════════════════════════════════════════════════════ - /// 默认边框色(极淡) - static const Color border = Color(0xFFE2E8F0); - - /// 更浅边框色 - static const Color borderLight = Color(0xFFF1F5F9); + static const Color border = Color(0xFFE5E7EB); + static const Color borderLight = Color(0xFFF3F4F6); // ═══════════════════════════════════════════════════════════ // 功能色 // ═══════════════════════════════════════════════════════════ - /// 成功色(柔和绿) - static const Color success = Color(0xFF22C55E); - - /// 成功浅色 - static const Color successLight = Color(0xFFDCFCE7); - - /// 错误色(柔和红) + static const Color success = Color(0xFF10B981); + static const Color successLight = Color(0xFFD1FAE5); static const Color error = Color(0xFFEF4444); - - /// 错误浅色 static const Color errorLight = Color(0xFFFEE2E2); - - /// 警告色(橙) static const Color warning = Color(0xFFF59E0B); - - /// 警告浅色 static const Color warningLight = Color(0xFFFEF3C7); + static const Color blueMeasure = Color(0xFF3B82F6); // ═══════════════════════════════════════════════════════════ - // 渐变组 + // 图标 / 小区域底色(极淡紫蓝) // ═══════════════════════════════════════════════════════════ - /// 主渐变:紫 → 蓝紫(主按钮填充) - static const LinearGradient primaryGradient = LinearGradient( - colors: [Color(0xFF818CF8), Color(0xFF6366F1)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ); + static const Color iconBg = Color(0xFFE8E0FA); // 卡片内底色,比页面背景稍深 - /// 边框渐变:蓝紫 → 紫 → 青(按钮/卡片描边) - static const LinearGradient borderGradient = LinearGradient( - colors: [Color(0xFF818CF8), Color(0xFFA78BFA), Color(0xFF22D3EE)], - begin: Alignment.centerLeft, - end: Alignment.centerRight, - ); - - /// 青色渐变(药管家等) - static const LinearGradient cyanGradient = LinearGradient( - colors: [Color(0xFF67E8F9), Color(0xFF22D3EE)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ); - - /// 紫色渐变(拍皮肤等) - static const LinearGradient purpleGradient = LinearGradient( - colors: [Color(0xFFC4B5FD), Color(0xFF8B5CF6)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ); - - /// 页面背景渐变(淡紫 → 淡蓝,营造漂浮感) - static const LinearGradient pageBgGradient = LinearGradient( - colors: [Color(0xFFF5F3FF), Color(0xFFEFF6FF)], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ); - - /// 卡片内轻渐变 - static const LinearGradient cardLightGradient = LinearGradient( - colors: [Color(0xFFEEF2FF), Color(0xFFF0F9FF)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ); + // 兼容旧名字 + static const Color iconBlueStart = Color(0xFF8B5CF6); + static const Color iconBlueEnd = Color(0xFFA78BFA); + static const Color iconCyanStart = Color(0xFF10B981); + static const Color iconCyanEnd = Color(0xFF34D399); + static const Color iconPurpleStart = Color(0xFF8B5CF6); + static const Color iconPurpleEnd = Color(0xFFA78BFA); + static const Color accentCyan = Color(0xFF10B981); + static const Color accentCyanDark = Color(0xFF059669); // ═══════════════════════════════════════════════════════════ - // 阴影 + // 渐变 — 所有旧名字统一为柔和淡紫 // ═══════════════════════════════════════════════════════════ - /// 卡片阴影(淡紫色柔和阴影) - static List cardShadow = [ - BoxShadow( - color: Color(0xFF6366F1).withOpacity(0.08), - blurRadius: 24, - offset: const Offset(0, 8), - ), + static LinearGradient get borderGradient => primaryGradient; + static LinearGradient get cyanGradient => primaryGradient; + static LinearGradient get purpleGradient => primaryGradient; + static LinearGradient get pageBgGradient => bgGradient; + static LinearGradient get cardLightGradient => bgGradient; + static LinearGradient get gWelcomeHeader => primaryGradient; + + // 各种旧花渐变全改为极淡色或纯色 + static LinearGradient get gPurplePink => primaryGradient; + static LinearGradient get gBluePurple => primaryGradient; + static LinearGradient get gPinkPurple => primaryGradient; + static LinearGradient get gBlueRed => primaryGradient; + static LinearGradient get gBlueGreen => primaryGradient; + static LinearGradient get gPurpleCyan => primaryGradient; + static LinearGradient get gRedPurple => primaryGradient; + static LinearGradient get gCyanBlue => primaryGradient; + static LinearGradient get gPinkCyan => primaryGradient; + static LinearGradient get gLightPurple => bgGradient; + static LinearGradient get gLightBlue => bgGradient; + + // ═══════════════════════════════════════════════════════════ + // 阴影 — 轻柔 + // ═══════════════════════════════════════════════════════════ + + static List get cardShadow => [ + BoxShadow(color: Colors.black.withOpacity(0.06), blurRadius: 8, offset: const Offset(0, 2)), + ]; + static List get cardShadowLight => [ + BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 6, offset: const Offset(0, 1)), + ]; + static List get buttonShadow => [ + BoxShadow(color: Color(0xFF8B5CF6).withOpacity(0.25), blurRadius: 12, offset: const Offset(0, 4)), + ]; + static List get fabShadow => [ + BoxShadow(color: Color(0xFF8B5CF6).withOpacity(0.30), blurRadius: 16, offset: const Offset(0, 6)), ]; - /// 轻卡片阴影 - static List cardShadowLight = [ - BoxShadow( - color: Color(0xFF6366F1).withOpacity(0.05), - blurRadius: 16, - offset: const Offset(0, 4), - ), - ]; + // ═══════════════════════════════════════════════════════════ + // 旧代码兼容别名(chat_messages_view 原始代码引用) + // ═══════════════════════════════════════════════════════════ - /// 按钮阴影 - static List buttonShadow = [ - BoxShadow( - color: Color(0xFF6366F1).withOpacity(0.25), - blurRadius: 16, - offset: const Offset(0, 6), - ), - ]; - - /// 悬浮操作按钮阴影(右下角大号按钮) - static List fabShadow = [ - BoxShadow( - color: Color(0xFF6366F1).withOpacity(0.35), - blurRadius: 20, - offset: const Offset(0, 8), - ), - ]; + static const Color purpleGradientStart = Color(0xFF8B5CF6); + static const Color blueGradientEnd = Color(0xFF3B82F6); + static LinearGradient get purpleBlueGradient => primaryGradient; + static Color get primaryPurple => primary; + static Color get iconColor => primary; + static Color get iconBackground => iconBg; + static Color get primaryBlue => blueMeasure; + static LinearGradient get lightGradient => gLightPurple; + static Color get backgroundSecondary => background; + static LinearGradient get successButtonGradient => primaryGradient; } diff --git a/health_app/lib/core/app_theme.dart b/health_app/lib/core/app_theme.dart index 4b50954..826a338 100644 --- a/health_app/lib/core/app_theme.dart +++ b/health_app/lib/core/app_theme.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'app_colors.dart'; -/// 健康管家 —— 蚂蚁阿福风格:紫蓝渐变 + 超大圆角 + 漂浮卡片 +/// 健康管家 — 设计规范 v1.0:紫色系 + 干净白灰 + 12px圆角 class AppTheme { AppTheme._(); @@ -11,8 +11,8 @@ class AppTheme { static const Color primaryLight = AppColors.primaryLight; static const Color primaryDark = AppColors.primaryDark; static const Color primaryMid = AppColors.primaryMid; - static const Color accent = AppColors.accentCyan; - static const Color info = AppColors.accentCyan; + static const Color accent = AppColors.success; + static const Color info = AppColors.blueMeasure; // ── 中性色 ── static const Color bg = AppColors.background; @@ -33,51 +33,51 @@ class AppTheme { static const Color warning = AppColors.warning; static const Color warningLight = AppColors.warningLight; - // ── 圆角(阿福风格:超大圆角)── - static const double rXs = 8; - static const double rSm = 12; - static const double rMd = 18; - static const double rLg = 24; - static const double rXl = 32; + // ── 圆角(规范:12px 卡片)── + static const double rXs = 4; + static const double rSm = 8; + static const double rMd = 12; + static const double rLg = 16; + static const double rXl = 20; static const double rPill = 999; - // ── 间距 ── + // ── 间距(8px 网格)── static const double sXs = 4; static const double sSm = 8; static const double sMd = 12; static const double sLg = 16; static const double sXl = 20; - static const double sXxl = 28; + static const double sXxl = 24; - // ── 阴影(淡紫柔和阴影)── + // ── 阴影(轻阴影)── static BoxShadow get shadowCard => BoxShadow( - color: AppColors.primary.withOpacity(0.08), - blurRadius: 24, - offset: const Offset(0, 8), + color: Colors.black.withOpacity(0.06), + blurRadius: 8, + offset: const Offset(0, 2), ); static BoxShadow get shadowLight => BoxShadow( - color: AppColors.primary.withOpacity(0.05), - blurRadius: 16, - offset: const Offset(0, 4), + color: Colors.black.withOpacity(0.04), + blurRadius: 6, + offset: const Offset(0, 1), ); static BoxShadow get shadowElevated => BoxShadow( - color: AppColors.primary.withOpacity(0.15), - blurRadius: 32, - offset: const Offset(0, 10), + color: primary.withOpacity(0.08), + blurRadius: 12, + offset: const Offset(0, 4), ); static BoxShadow get shadowButton => BoxShadow( - color: AppColors.primary.withOpacity(0.25), - blurRadius: 16, - offset: const Offset(0, 6), + color: primary.withOpacity(0.25), + blurRadius: 12, + offset: const Offset(0, 4), ); // ── 智能体配色 ── static const Map agentColors = { - 'default': AppColors.primaryLight, + 'default': Color(0xFFEDE9FE), 'consultation': Color(0xFFE0E7FF), - 'health': Color(0xFFDCFCE7), + 'health': Color(0xFFD1FAE5), 'diet': Color(0xFFFEF3C7), - 'medication': Color(0xFFFCE7F3), + 'medication': Color(0xFFFEE2E2), 'report': Color(0xFFDBEAFE), 'exercise': Color(0xFFEDE9FE), }; @@ -94,70 +94,75 @@ class AppTheme { onPrimary: Colors.white, secondary: accent, surface: surface, - background: bg, brightness: Brightness.light, ), scaffoldBackgroundColor: bg, splashColor: Colors.transparent, highlightColor: Colors.transparent, - hoverColor: primaryLight.withOpacity(0.5), + hoverColor: primaryLight.withOpacity(0.3), fontFamily: null, appBarTheme: AppBarTheme( - backgroundColor: bg, + backgroundColor: surface, foregroundColor: text, elevation: 0, - centerTitle: false, + centerTitle: true, scrolledUnderElevation: 0, - titleTextStyle: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: text), + titleTextStyle: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: text), + toolbarHeight: 44, ), cardTheme: CardThemeData( color: surface, elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rLg)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rMd)), margin: EdgeInsets.zero, ), inputDecorationTheme: InputDecorationTheme( filled: true, fillColor: bgSoft, - contentPadding: const EdgeInsets.symmetric(horizontal: sLg, vertical: 16), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(rMd), borderSide: BorderSide.none), - enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(rMd), borderSide: BorderSide.none), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(rMd), - borderSide: const BorderSide(color: primary, width: 2), + contentPadding: const EdgeInsets.symmetric(horizontal: sLg, vertical: 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, ), - hintStyle: TextStyle(color: textHint, fontSize: 16), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: const BorderSide(color: primary, width: 1), + ), + hintStyle: TextStyle(color: textHint, fontSize: 15), ), elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( backgroundColor: primary, foregroundColor: Colors.white, - minimumSize: const Size(double.infinity, 56), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rPill)), - textStyle: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600), + minimumSize: const Size(double.infinity, 52), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rMd)), + textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), elevation: 0, - shadowColor: primary.withOpacity(0.3), ), ), - dialogTheme: DialogThemeData(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rXl))), + dialogTheme: DialogThemeData(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rLg))), textTheme: const TextTheme( - headlineLarge: TextStyle(fontSize: 28, fontWeight: FontWeight.w700, color: text, letterSpacing: -0.3), - titleLarge: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: text), - titleMedium: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: text), - bodyLarge: TextStyle(fontSize: 16, color: text, height: 1.5), - bodyMedium: TextStyle(fontSize: 14, color: textSub, height: 1.4), + headlineLarge: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: text), + titleLarge: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: text), + titleMedium: TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: text), + bodyLarge: TextStyle(fontSize: 15, color: text, height: 1.5), + bodyMedium: TextStyle(fontSize: 13, color: textSub, height: 1.4), labelMedium: TextStyle(fontSize: 13, color: textSub), labelSmall: TextStyle(fontSize: 12, color: textHint), ), ); - // ── Shadcn UI 主题(紫蓝系) ── + // ── Shadcn UI 主题 ── static ShadThemeData get shadTheme => ShadThemeData( brightness: Brightness.light, colorScheme: ShadVioletColorScheme.light( diff --git a/health_app/lib/main.dart b/health_app/lib/main.dart index b8b273b..d7b8665 100644 --- a/health_app/lib/main.dart +++ b/health_app/lib/main.dart @@ -1,8 +1,15 @@ +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sqflite_common_ffi_web/sqflite_ffi_web.dart' + show databaseFactoryFfiWebNoWebWorker; +import 'package:sqflite/sqflite.dart' show databaseFactory; import 'app.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); + if (kIsWeb) { + databaseFactory = databaseFactoryFfiWebNoWebWorker; + } runApp(const ProviderScope(child: HealthApp())); } diff --git a/health_app/lib/pages/auth/login_page.dart b/health_app/lib/pages/auth/login_page.dart index 4c77879..715a3dc 100644 --- a/health_app/lib/pages/auth/login_page.dart +++ b/health_app/lib/pages/auth/login_page.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; 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/auth_provider.dart'; +import '../../providers/data_providers.dart'; class LoginPage extends ConsumerStatefulWidget { const LoginPage({super.key}); @@ -46,12 +48,15 @@ class _LoginPageState extends ConsumerState { final err = await ref.read(authProvider.notifier).login(_phoneCtrl.text.trim(), _codeCtrl.text.trim()); setState(() => _loading = false); if (err != null) { setState(() => _error = err); return; } + ref.invalidate(latestHealthProvider); + ref.invalidate(medicationListProvider); + ref.invalidate(medicationReminderProvider); + ref.invalidate(currentExercisePlanProvider); goRoute(ref, 'home'); } @override Widget build(BuildContext context) { final authState = ref.watch(authProvider); - final theme = ShadTheme.of(context); if (authState.isLoggedIn && !authState.isLoading) { WidgetsBinding.instance.addPostFrameCallback((_) => goRoute(ref, 'home')); } @@ -60,190 +65,150 @@ class _LoginPageState extends ConsumerState { body: Container( decoration: const BoxDecoration( gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [AppTheme.bg, AppTheme.bg, AppTheme.primaryLight], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFEEF2FF), Color(0xFFF5F3FF)], ), ), child: SafeArea(child: Center(child: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 32), child: Column(mainAxisSize: MainAxisSize.min, children: [ - const SizedBox(height: 24), + const SizedBox(height: 40), // Logo Container( - width: 100, height: 100, + width: 88, height: 88, decoration: BoxDecoration( - color: AppTheme.primary.withAlpha(25), - borderRadius: BorderRadius.circular(50), + color: Colors.white, + borderRadius: BorderRadius.circular(44), + boxShadow: [BoxShadow(color: AppColors.primary.withOpacity(0.15), blurRadius: 20, offset: const Offset(0, 8))], ), - child: Stack(alignment: Alignment.center, children: [ - Container( - width: 72, height: 72, - decoration: BoxDecoration( - color: Colors.white.withAlpha(200), - borderRadius: BorderRadius.circular(36), + child: const Icon(LucideIcons.heart, size: 40, color: AppColors.primary), + ), + const SizedBox(height: 20), + const Text('健康管家', style: TextStyle(fontSize: 28, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), + const SizedBox(height: 6), + const Text('你的 AI 心脏健康管家', style: TextStyle(fontSize: 15, color: AppColors.textSecondary)), + const SizedBox(height: 40), + + // 手机号 + Container( + height: 52, + decoration: BoxDecoration( + color: AppColors.backgroundSoft, + borderRadius: BorderRadius.circular(10), + ), + child: Row(children: [ + const Padding(padding: EdgeInsets.only(left: 16), child: Text('+86', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: AppColors.textPrimary))), + Container(width: 1, height: 24, color: AppColors.border, margin: const EdgeInsets.symmetric(horizontal: 12)), + Expanded( + child: TextField( + controller: _phoneCtrl, + keyboardType: TextInputType.phone, + maxLength: 11, + style: const TextStyle(fontSize: 16, color: AppColors.textPrimary), + decoration: const InputDecoration( + hintText: '请输入手机号', + hintStyle: TextStyle(fontSize: 15, color: AppColors.textHint), + border: InputBorder.none, + counterText: '', + contentPadding: EdgeInsets.symmetric(vertical: 14), + ), ), - child: const Icon(LucideIcons.heart, size: 36, color: AppTheme.primary), ), ]), ), - const SizedBox(height: 20), - Text('健康管家', style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold, color: theme.colorScheme.foreground)), - const SizedBox(height: 8), - Text('你的 AI 心脏健康管家', style: TextStyle(fontSize: 18, color: theme.colorScheme.mutedForeground)), - const SizedBox(height: 36), + const SizedBox(height: 16), - // 手机号输入 - _buildPhoneInput(theme), - const SizedBox(height: AppTheme.sLg), + // 验证码 + Row(children: [ + Expanded( + child: Container( + height: 52, + decoration: BoxDecoration( + color: AppColors.backgroundSoft, + borderRadius: BorderRadius.circular(10), + ), + child: TextField( + controller: _codeCtrl, + keyboardType: TextInputType.number, + maxLength: 6, + style: const TextStyle(fontSize: 16, color: AppColors.textPrimary), + decoration: const InputDecoration( + hintText: '验证码', + hintStyle: TextStyle(fontSize: 15, color: AppColors.textHint), + border: InputBorder.none, + counterText: '', + contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 14), + ), + ), + ), + ), + const SizedBox(width: 12), + GestureDetector( + onTap: (_countdown > 0 || _sending) ? null : _sendSms, + child: Container( + width: 120, height: 52, + alignment: Alignment.center, + decoration: BoxDecoration( + color: (_countdown > 0 || _sending) ? AppColors.backgroundSoft : AppColors.primary, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + _sending ? '发送中' : _countdown > 0 ? '${_countdown}s' : '获取验证码', + style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, + color: (_countdown > 0 || _sending) ? AppColors.textHint : Colors.white), + ), + ), + ), + ]), + const SizedBox(height: 12), - // 验证码输入 - _buildCodeRow(theme), - const SizedBox(height: AppTheme.sSm), + // 协议 + GestureDetector( + onTap: () => setState(() => _agreed = !_agreed), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Container( + width: 18, height: 18, margin: const EdgeInsets.only(right: 6), + decoration: BoxDecoration( + color: _agreed ? AppColors.primary : Colors.transparent, + borderRadius: BorderRadius.circular(4), + border: Border.all(color: _agreed ? AppColors.primary : AppColors.border, width: 1.5), + ), + child: _agreed ? const Icon(Icons.check, size: 14, color: Colors.white) : null, + ), + RichText(text: TextSpan(children: [ + TextSpan(text: '已阅读并同意', style: TextStyle(fontSize: 13, color: AppColors.textHint)), + TextSpan(text: '《服务协议》', style: TextStyle(fontSize: 13, color: AppColors.primary)), + TextSpan(text: '和', style: TextStyle(fontSize: 13, color: AppColors.textHint)), + TextSpan(text: '《隐私政策》', style: TextStyle(fontSize: 13, color: AppColors.primary)), + ])), + ]), + ), - // 协议勾选 - _buildAgreement(theme), - - // 错误提示 if (_error != null) - Padding(padding: const EdgeInsets.only(top: AppTheme.sMd), child: Text(_error!, style: TextStyle(color: theme.colorScheme.destructive, fontSize: 16))), + Padding(padding: const EdgeInsets.only(top: 12), child: Text(_error!, style: const TextStyle(color: AppColors.error, fontSize: 14))), - const SizedBox(height: AppTheme.rXl), + const SizedBox(height: 32), // 登录按钮 - _buildLoginButton(theme), + GestureDetector( + onTap: _loading ? null : _login, + child: Container( + width: double.infinity, height: 52, alignment: Alignment.center, + decoration: BoxDecoration( + gradient: AppColors.primaryGradient, + borderRadius: BorderRadius.circular(12), + ), + child: _loading + ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white)) + : const Text('登 录', style: TextStyle(fontSize: 18, color: Colors.white, fontWeight: FontWeight.w600, letterSpacing: 2)), + ), + ), - const SizedBox(height: AppTheme.sXl), + const SizedBox(height: 40), ]), ))), ), ); } - - Widget _buildPhoneInput(ShadThemeData theme) { - return Container( - decoration: BoxDecoration( - color: theme.colorScheme.card, - borderRadius: BorderRadius.circular(AppTheme.rSm), - boxShadow: [AppTheme.shadowLight], - ), - child: Row(children: [ - const Padding(padding: EdgeInsets.only(left: AppTheme.sLg), child: Text('+86', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w500, color: AppTheme.text))), - Container(width: 1, height: 24, color: theme.colorScheme.border, margin: const EdgeInsets.symmetric(horizontal: AppTheme.sMd)), - Expanded( - child: TextField( - controller: _phoneCtrl, - keyboardType: TextInputType.phone, - maxLength: 11, - style: TextStyle(fontSize: 19, color: theme.colorScheme.foreground), - decoration: InputDecoration( - hintText: '请输入手机号', - hintStyle: TextStyle(color: theme.colorScheme.mutedForeground), - border: InputBorder.none, - counterText: '', - contentPadding: const EdgeInsets.symmetric(vertical: AppTheme.sLg), - ), - ), - ), - ]), - ); - } - - Widget _buildCodeRow(ShadThemeData theme) { - final codeDisabled = _countdown > 0 || _sending; - return Row(children: [ - Expanded( - child: Container( - decoration: BoxDecoration( - color: theme.colorScheme.card, - borderRadius: BorderRadius.circular(AppTheme.rSm), - boxShadow: [AppTheme.shadowLight], - ), - child: TextField( - controller: _codeCtrl, - keyboardType: TextInputType.number, - maxLength: 6, - style: TextStyle(fontSize: 19, color: theme.colorScheme.foreground), - decoration: InputDecoration( - hintText: '验证码', - hintStyle: TextStyle(color: theme.colorScheme.mutedForeground), - border: InputBorder.none, - counterText: '', - contentPadding: const EdgeInsets.symmetric(horizontal: AppTheme.sLg, vertical: AppTheme.sLg), - ), - ), - ), - ), - const SizedBox(width: AppTheme.sMd), - GestureDetector( - onTap: codeDisabled ? null : _sendSms, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - width: 110, height: 52, - alignment: Alignment.center, - decoration: BoxDecoration( - color: codeDisabled ? theme.colorScheme.muted : AppTheme.primary, - borderRadius: BorderRadius.circular(AppTheme.rSm), - ), - child: Text( - _sending ? '发送中' : _countdown > 0 ? '${_countdown}s' : '获取验证码', - style: TextStyle( - fontSize: 17, - color: codeDisabled ? theme.colorScheme.mutedForeground : Colors.white, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - ]); - } - - Widget _buildAgreement(ShadThemeData theme) { - return Align( - alignment: Alignment.centerLeft, - child: GestureDetector( - onTap: () => setState(() => _agreed = !_agreed), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Container( - width: 18, height: 18, - margin: const EdgeInsets.only(right: 6), - decoration: BoxDecoration( - color: _agreed ? AppTheme.primary : Colors.transparent, - borderRadius: BorderRadius.circular(4), - border: Border.all( - color: _agreed ? AppTheme.primary : theme.colorScheme.border, - width: 1.5, - ), - ), - child: _agreed ? Icon(LucideIcons.check, size: 15, color: theme.colorScheme.primaryForeground) : null, - ), - RichText(text: TextSpan(children: [ - TextSpan(text: '已阅读并同意', style: TextStyle(fontSize: 16, color: theme.colorScheme.mutedForeground)), - TextSpan(text: '《服务协议》', style: const TextStyle(fontSize: 16, color: AppTheme.primary)), - TextSpan(text: '和', style: TextStyle(fontSize: 16, color: theme.colorScheme.mutedForeground)), - TextSpan(text: '《隐私政策》', style: const TextStyle(fontSize: 16, color: AppTheme.primary)), - ])), - ]), - ), - ); - } - - Widget _buildLoginButton(ShadThemeData theme) { - return GestureDetector( - onTap: _loading ? null : _login, - child: Container( - width: double.infinity, - height: 52, - alignment: Alignment.center, - decoration: BoxDecoration( - gradient: const LinearGradient(colors: [Color(0xFF9B8FEF), AppTheme.primary]), - borderRadius: BorderRadius.circular(AppTheme.rPill), - boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(80), blurRadius: 16, offset: const Offset(0, 8))], - ), - child: _loading - ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white)) - : const Text('登 录', style: TextStyle(fontSize: 20, color: Colors.white, fontWeight: FontWeight.w600, letterSpacing: 2)), - ), - ); - } } diff --git a/health_app/lib/pages/chart/trend_page.dart b/health_app/lib/pages/chart/trend_page.dart index 950c084..59bc361 100644 --- a/health_app/lib/pages/chart/trend_page.dart +++ b/health_app/lib/pages/chart/trend_page.dart @@ -70,7 +70,7 @@ class _TrendPageState extends ConsumerState { 'isAbnormal': r['isAbnormal'] == true, }); } - } catch (_) {} + } catch (e) { debugPrint('[Trend] 加载趋势失败: $e'); } } all.sort((a, b) => (b['date'] as DateTime).compareTo(a['date'] as DateTime)); if (mounted) { diff --git a/health_app/lib/pages/device/device_scan_page.dart b/health_app/lib/pages/device/device_scan_page.dart index 8d1a029..cca009f 100644 --- a/health_app/lib/pages/device/device_scan_page.dart +++ b/health_app/lib/pages/device/device_scan_page.dart @@ -40,9 +40,9 @@ class _DeviceScanPageState extends ConsumerState { await [Permission.bluetoothScan, Permission.bluetoothConnect].request(); - try { await FlutterBluePlus.turnOn(); } catch (_) {} + try { await FlutterBluePlus.turnOn(); } catch (e) { debugPrint('[BLE Scan] 操作失败: $e'); } - try { await FlutterBluePlus.stopScan(); } catch (_) {} + try { await FlutterBluePlus.stopScan(); } catch (e) { debugPrint('[BLE Scan] 操作失败: $e'); } await FlutterBluePlus.startScan( timeout: const Duration(seconds: 30), androidScanMode: AndroidScanMode.lowLatency, @@ -72,7 +72,7 @@ class _DeviceScanPageState extends ConsumerState { final api = ref.read(apiClientProvider); await api.post('/api/health-records', data: reading.toHealthRecord()); await ref.read(omronDeviceProvider.notifier).onReading(reading); - } catch (_) {} + } catch (e) { debugPrint('[BLE Scan] 操作失败: $e'); } }); if (mounted) { @@ -102,7 +102,7 @@ class _DeviceScanPageState extends ConsumerState { body: Column(children: [ Container( width: double.infinity, padding: const EdgeInsets.all(16), - color: AppColors.iconBackground, + color: AppColors.iconBg, child: Row(children: [ if (_scanning) ...[ const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF5B8DEF))), @@ -128,7 +128,7 @@ class _DeviceScanPageState extends ConsumerState { icon: const Icon(Icons.refresh), label: const Text('重新扫描'), style: OutlinedButton.styleFrom( - foregroundColor: AppColors.iconColor, + foregroundColor: AppColors.primary, side: const BorderSide(color: Color(0xFF5B8DEF)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), padding: const EdgeInsets.symmetric(vertical: 14), @@ -152,13 +152,13 @@ class _DeviceScanPageState extends ConsumerState { decoration: BoxDecoration( color: AppColors.cardBackground, borderRadius: BorderRadius.circular(16), boxShadow: AppColors.cardShadow, - border: isBP ? Border.all(color: AppColors.iconColor, width: 1.5) : null, + border: isBP ? Border.all(color: AppColors.primary, width: 1.5) : null, ), child: Row(children: [ Container( width: 44, height: 44, - decoration: BoxDecoration(color: isBP ? AppColors.iconBackground : AppColors.backgroundSecondary, borderRadius: BorderRadius.circular(12)), - child: Icon(Icons.bluetooth, size: 24, color: isBP ? AppColors.iconColor : AppColors.textHint), + decoration: BoxDecoration(color: isBP ? AppColors.iconBg : AppColors.cardInner, borderRadius: BorderRadius.circular(12)), + child: Icon(Icons.bluetooth, size: 24, color: isBP ? AppColors.primary : AppColors.textHint), ), const SizedBox(width: 12), Expanded( @@ -167,7 +167,7 @@ class _DeviceScanPageState extends ConsumerState { Flexible(child: Text(name, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: AppColors.textPrimary))), if (isBP) ...[ const SizedBox(width: 6), - Container(padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration(color: AppColors.iconColor, borderRadius: BorderRadius.circular(4)), child: const Text('血压计', style: TextStyle(fontSize: 11, color: Colors.white))), + Container(padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration(color: AppColors.primary, borderRadius: BorderRadius.circular(4)), child: const Text('血压计', style: TextStyle(fontSize: 11, color: Colors.white))), ], ]), const SizedBox(height: 2), @@ -179,7 +179,7 @@ class _DeviceScanPageState extends ConsumerState { else GestureDetector( onTap: () => _connect(r), - child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration(gradient: AppColors.purpleBlueGradient, borderRadius: BorderRadius.circular(12)), child: const Text('连接', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Colors.white))), + child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration(gradient: AppColors.primaryGradient, borderRadius: BorderRadius.circular(12)), child: const Text('连接', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Colors.white))), ), ]), ); @@ -202,7 +202,7 @@ class _DeviceScanPageState extends ConsumerState { Widget _tip(String num, String text) => Padding( padding: const EdgeInsets.only(bottom: 8), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container(width: 20, height: 20, alignment: Alignment.center, decoration: BoxDecoration(color: AppColors.iconColor, borderRadius: BorderRadius.circular(10)), child: Text(num, style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600))), + Container(width: 20, height: 20, alignment: Alignment.center, decoration: BoxDecoration(color: AppColors.primary, borderRadius: BorderRadius.circular(10)), child: Text(num, style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600))), const SizedBox(width: 10), Expanded(child: Text(text, style: TextStyle(fontSize: 14, color: AppColors.textSecondary))), ]), diff --git a/health_app/lib/pages/diet/diet_capture_page.dart b/health_app/lib/pages/diet/diet_capture_page.dart index 8710169..b9ebe49 100644 --- a/health_app/lib/pages/diet/diet_capture_page.dart +++ b/health_app/lib/pages/diet/diet_capture_page.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import '../../core/navigation_provider.dart'; +import '../../core/app_colors.dart'; import '../../core/app_theme.dart'; import '../../providers/auth_provider.dart'; import '../../utils/sse_handler.dart'; @@ -123,7 +124,9 @@ class DietNotifier extends Notifier { if (text.isNotEmpty) state = state.copyWith(commentary: text.trim()); } } - } catch (_) {} + } catch (e) { + debugPrint('[Diet] 获取饮食点评失败: $e'); + } } List _parseFoodItems(String raw) { @@ -243,7 +246,7 @@ class _DietCapturePageState extends ConsumerState { centerTitle: true, ), body: state.imagePath == null - ? const Center(child: Text('请从首页拍摄或选择食物照片', style: TextStyle(color: Color(0xFF999999)))) + ? const Center(child: Text('请从首页拍摄或选择食物照片', style: TextStyle(color: AppColors.textSecondary))) : _buildResultView(context, ref), ); } @@ -263,11 +266,12 @@ class _DietCapturePageState extends ConsumerState { final totalCalories = state.foods.where((f) => f.selected).fold(0, (sum, f) => sum + f.calories); return Container( - color: _kPageBg, + color: AppColors.background, child: SingleChildScrollView( + padding: const EdgeInsets.all(16), child: Column(children: [ _buildImagePreview(state.imagePath!), - const SizedBox(height: 20), + const SizedBox(height: 16), _buildMealSelector(ref), const SizedBox(height: 16), if (state.isAnalyzing) @@ -282,8 +286,8 @@ class _DietCapturePageState extends ConsumerState { _buildAiCommentary(state.commentary!), ], ], - const SizedBox(height: 24), - _buildSubmitButton(context, ref), + const SizedBox(height: 20), + _buildSubmitButton(), const SizedBox(height: 16), ], ]), @@ -293,29 +297,25 @@ class _DietCapturePageState extends ConsumerState { // ─────────── 图片预览 ─────────── Widget _buildImagePreview(String path) { - return Stack( - children: [ - Container( - height: 220, + return Center( + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Container( + height: 200, + width: MediaQuery.of(context).size.width * 0.75, decoration: BoxDecoration( - color: const Color(0xFFE0E0E0), image: DecorationImage(image: FileImage(File(path)), fit: BoxFit.cover), + boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 14, offset: const Offset(0, 4))], ), - ), - Positioned( - left: 0, right: 0, bottom: 0, - child: Container( - height: 60, - decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [Colors.transparent, Color(0x40000000)], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ), + foregroundDecoration: const BoxDecoration( + gradient: LinearGradient( + colors: [Colors.transparent, Colors.black38], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, ), ), ), - ], + ), ); } @@ -351,9 +351,9 @@ class _DietCapturePageState extends ConsumerState { : null, ), child: Column(mainAxisSize: MainAxisSize.min, children: [ - Icon(m.icon, size: 23, color: isSelected ? Colors.white : _kSubText), + Icon(m.icon, size: 23, color: isSelected ? AppColors.textOnGradient : _kSubText), const SizedBox(height: 4), - Text(m.label, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: isSelected ? Colors.white : _kSubText)), + Text(m.label, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: isSelected ? AppColors.textOnGradient : _kSubText)), ]), ), ), @@ -416,7 +416,7 @@ class _DietCapturePageState extends ConsumerState { const Spacer(), Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration(color: const Color(0xFFFFF8EE), borderRadius: BorderRadius.circular(8)), + decoration: BoxDecoration(color: AppColors.warningLight, borderRadius: BorderRadius.circular(8)), child: Text('共 $totalCal kcal', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: _kWarning)), ), IconButton( @@ -441,9 +441,9 @@ class _DietCapturePageState extends ConsumerState { child: AnimatedContainer( duration: const Duration(milliseconds: 200), decoration: BoxDecoration( - color: food.selected ? const Color(0xFFF6F4FF) : const Color(0xFFFAFAFA), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: food.selected ? _kPrimary.withAlpha(30) : const Color(0xFFEEEEEE)), + color: food.selected ? AppColors.iconBg : AppColors.background, + borderRadius: BorderRadius.circular(AppTheme.rMd), + border: Border.all(color: food.selected ? _kPrimary.withAlpha(30) : AppColors.border), ), child: Padding( padding: const EdgeInsets.fromLTRB(12, 10, 6, 10), @@ -454,10 +454,10 @@ class _DietCapturePageState extends ConsumerState { width: 22, height: 22, decoration: BoxDecoration( shape: BoxShape.circle, - color: food.selected ? _kPrimary : Colors.white, - border: Border.all(color: food.selected ? _kPrimary : const Color(0xFFCCCCCC), width: 2), + color: food.selected ? _kPrimary : AppTheme.surface, + border: Border.all(color: food.selected ? _kPrimary : AppColors.border, width: 2), ), - child: food.selected ? const Icon(Icons.check, size: 17, color: Colors.white) : null, + child: food.selected ? const Icon(Icons.check, size: 17, color: AppColors.textOnGradient) : null, ), ), const SizedBox(width: 12), @@ -514,7 +514,7 @@ class _DietCapturePageState extends ConsumerState { ]), ), IconButton( - icon: const Icon(Icons.close, size: 19, color: Color(0xFFBBBBBB)), + icon: const Icon(Icons.close, size: 19, color: AppColors.textHint), padding: EdgeInsets.zero, constraints: const BoxConstraints(), onPressed: () => ref.read(dietProvider.notifier).removeFood(food.id), @@ -532,7 +532,7 @@ class _DietCapturePageState extends ConsumerState { child: Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( - gradient: const LinearGradient(colors: [_kPrimary, Color(0xFF8B7CF0)], begin: Alignment.topLeft, end: Alignment.bottomRight), + gradient: AppColors.primaryGradient, borderRadius: BorderRadius.circular(16), boxShadow: [BoxShadow(color: _kPrimary.withAlpha(40), blurRadius: 10, offset: const Offset(0, 4))], ), @@ -548,12 +548,12 @@ class _DietCapturePageState extends ConsumerState { value: (totalCalories / 700).clamp(0.0, 1.0), strokeWidth: 4, backgroundColor: Colors.white24, - color: Colors.white, + color: AppColors.textOnGradient, ), ), Column(mainAxisSize: MainAxisSize.min, children: [ - Text('$totalCalories', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w800, color: Colors.white)), - Text('kcal', style: const TextStyle(fontSize: 13, color: Colors.white70)), + Text('$totalCalories', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w800, color: AppColors.textOnGradient)), + Text('kcal', style: const TextStyle(fontSize: 13, color: AppColors.textOnGradient)), ]), ], ), @@ -561,14 +561,14 @@ class _DietCapturePageState extends ConsumerState { const SizedBox(width: 16), Expanded( child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('本餐热量', style: TextStyle(fontSize: 17, color: Colors.white70)), + const Text('本餐热量', style: TextStyle(fontSize: 17, color: AppColors.textOnGradient)), const SizedBox(height: 4), Row(children: [ - Expanded(child: _macroBar('碳水', 0.55, const Color(0xFFFFF9C4))), + Expanded(child: _macroBar('碳水', 0.55, AppColors.warningLight)), const SizedBox(width: 8), - Expanded(child: _macroBar('蛋白', 0.25, const Color(0xFFD4C8FC))), + Expanded(child: _macroBar('蛋白', 0.25, AppColors.iconBg)), const SizedBox(width: 8), - Expanded(child: _macroBar('脂肪', 0.20, const Color(0xFFFFCDD2))), + Expanded(child: _macroBar('脂肪', 0.20, AppColors.errorLight)), ]), ]), ), @@ -582,7 +582,7 @@ class _DietCapturePageState extends ConsumerState { Row(children: [ Container(width: 6, height: 6, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), const SizedBox(width: 4), - Text(label, style: const TextStyle(fontSize: 13, color: Colors.white70)), + Text(label, style: const TextStyle(fontSize: 13, color: AppColors.textOnGradient)), ]), const SizedBox(height: 3), ClipRRect( @@ -597,19 +597,22 @@ class _DietCapturePageState extends ConsumerState { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Container( - padding: const EdgeInsets.all(14), + padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: _kSurface, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: _kBorder), + gradient: AppColors.gLightPurple, + borderRadius: BorderRadius.circular(16), + boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(20), blurRadius: 12, offset: const Offset(0, 4))], ), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( - width: 32, height: 32, - decoration: BoxDecoration(color: _kPrimaryLight, borderRadius: BorderRadius.circular(8)), - child: const Icon(Icons.auto_awesome, size: 19, color: _kPrimary), + width: 36, height: 36, + decoration: BoxDecoration( + gradient: AppColors.primaryGradient, + borderRadius: const BorderRadius.all(Radius.circular(10)), + ), + child: const Icon(Icons.auto_awesome, size: 20, color: AppColors.textOnGradient), ), - const SizedBox(width: 10), + const SizedBox(width: 12), Expanded(child: Text(text, style: const TextStyle(fontSize: 16, color: _kText, height: 1.6))), ]), ), @@ -617,47 +620,34 @@ class _DietCapturePageState extends ConsumerState { } // ─────────── 保存按钮 ─────────── - Widget _buildSubmitButton(BuildContext context, WidgetRef ref) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: SizedBox( - width: double.infinity, - height: 50, - child: ElevatedButton( - onPressed: () async { - try { - await ref.read(dietProvider.notifier).saveRecord(); - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: const Text('饮食记录已保存'), - backgroundColor: _kPrimary, - behavior: SnackBarBehavior.floating, - duration: const Duration(seconds: 2), - )); - popRoute(ref); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text('保存失败: $e'), - backgroundColor: AppTheme.error, - behavior: SnackBarBehavior.floating, - )); - } - } - }, - style: ElevatedButton.styleFrom( - backgroundColor: _kPrimary, - foregroundColor: Colors.white, - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)), - textStyle: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600), + Widget _buildSubmitButton() { + return SizedBox( + width: double.infinity, + height: 52, + child: Container( + decoration: BoxDecoration( + gradient: AppColors.gPurplePink, + borderRadius: BorderRadius.circular(14), + boxShadow: [BoxShadow(color: AppColors.primary.withAlpha(20), blurRadius: 12, offset: const Offset(0, 4))], + ), + padding: const EdgeInsets.all(1.5), + child: Material( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(12.5), + child: InkWell( + onTap: () async { + try { + await ref.read(dietProvider.notifier).saveRecord(); + if (mounted) popRoute(ref); + } catch (e) { debugPrint('[Diet] 保存记录失败: $e'); } + }, + borderRadius: BorderRadius.circular(12), + child: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(Icons.check_circle_outline, size: 22, color: AppColors.primary), + SizedBox(width: 8), + Text('保存记录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.primary)), + ]), ), - child: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.check_circle_outline, size: 23), - SizedBox(width: 8), - Text('保存记录'), - ]), ), ), ); diff --git a/health_app/lib/pages/home/home_page.dart b/health_app/lib/pages/home/home_page.dart index 37b7965..fb7bfd6 100644 --- a/health_app/lib/pages/home/home_page.dart +++ b/health_app/lib/pages/home/home_page.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import 'package:file_picker/file_picker.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/auth_provider.dart'; @@ -24,6 +25,7 @@ class _HomePageState extends ConsumerState { String? _pickedImagePath; final Set _welcomedAgents = {}; final _focusNode = FocusNode(); + int _lastMsgCount = 0; @override void initState() { super.initState(); } @override void dispose() { _textCtrl.dispose(); _scrollCtrl.dispose(); _focusNode.dispose(); super.dispose(); } @@ -33,7 +35,7 @@ class _HomePageState extends ConsumerState { final imagePath = _pickedImagePath; if (text.isEmpty && imagePath == null) return; _textCtrl.clear(); - _focusNode.unfocus(); // 收起键盘 + _focusNode.unfocus(); setState(() => _pickedImagePath = null); if (imagePath != null) { ref.read(chatProvider.notifier).sendImage(imagePath, text); @@ -46,8 +48,6 @@ class _HomePageState extends ConsumerState { final chatState = ref.watch(chatProvider); final auth = ref.watch(authProvider); final user = auth.user; - final selectedAgent = ref.watch(selectedAgentProvider); - final theme = ShadTheme.of(context); ref.listen(cameraActionProvider, (prev, next) { if (next == 'camera') { _pickImage(ImageSource.camera); ref.read(cameraActionProvider.notifier).clear(); } @@ -58,49 +58,52 @@ class _HomePageState extends ConsumerState { else if (next == 'pickFoodGallery') { _pickFoodImage(ImageSource.gallery); ref.read(dietActionProvider.notifier).clear(); } }); + final currentCount = chatState.messages.length; + if (currentCount > _lastMsgCount) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_scrollCtrl.hasClients) _scrollCtrl.jumpTo(0); + }); + } + _lastMsgCount = currentCount; + return Scaffold( drawer: const HealthDrawer(), - backgroundColor: theme.colorScheme.background, - body: SafeArea( + body: Container( + decoration: const BoxDecoration(gradient: AppColors.bgGradient), + child: SafeArea( child: Column(children: [ - _buildHeader(user, theme), + _buildHeader(user), Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)), - _buildBottomBar(context, selectedAgent, theme), + _buildBottomBar(context), ]), ), + ), ); } - // ═══════ 顶部栏 ═══════ - Widget _buildHeader(dynamic user, ShadThemeData theme) { + // ═══════ 顶部栏 — 透明背景,与页面渐变无缝衔接 ═══════ + Widget _buildHeader(dynamic user) { return Container( - padding: const EdgeInsets.symmetric(horizontal: AppTheme.sLg, vertical: 10), - decoration: BoxDecoration( - color: theme.colorScheme.card, - border: Border(bottom: BorderSide(color: theme.colorScheme.border, width: 0.5)), - ), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), child: Row(children: [ Builder(builder: (ctx) => GestureDetector( onTap: () => Scaffold.of(ctx).openDrawer(), - child: CircleAvatar( - radius: 20, - backgroundColor: AppTheme.primaryLight, - backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null, - child: user?.avatarUrl == null ? const Icon(Icons.person, size: 28, color: AppTheme.primary) : null, + child: Container( + width: 38, height: 38, + decoration: BoxDecoration(color: AppColors.iconBg, borderRadius: BorderRadius.circular(10)), + child: const Icon(LucideIcons.menu, size: 20, color: AppColors.primary), ), )), - const SizedBox(width: 10), + const SizedBox(width: 12), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(LucideIcons.bot, size: 19, color: AppTheme.primary), - const SizedBox(width: 4), - Text('AI 健康管家', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: theme.colorScheme.mutedForeground)), - ]), - const SizedBox(height: 2), + const Text('AI 健康管家', style: TextStyle(fontSize: 13, color: AppColors.textHint)), Text('${_getGreeting()},${(user?.name != null && user!.name!.isNotEmpty) ? user.name : '用户'}!', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: theme.colorScheme.foreground)), + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), ])), - Icon(LucideIcons.bell, size: 25, color: theme.colorScheme.mutedForeground), + GestureDetector( + onTap: () => pushRoute(ref, 'notificationPrefs'), + child: const Icon(LucideIcons.bell, size: 22, color: AppColors.textSecondary), + ), ]), ); } @@ -113,7 +116,7 @@ class _HomePageState extends ConsumerState { return '晚上好'; } - // ═══════ 智能体选择条 ═══════ + // ═══════ 智能体胶囊 ═══════ static final _agentDefs = [ (ActiveAgent.consultation, '问诊', LucideIcons.messageCircle), (ActiveAgent.health, '记数据', LucideIcons.heart), @@ -123,32 +126,29 @@ class _HomePageState extends ConsumerState { (ActiveAgent.exercise, '运动', LucideIcons.trendingUp), ]; - Widget _buildAgentBar(ActiveAgent? selected, ShadThemeData theme) { - return Container( - height: 40, - padding: const EdgeInsets.symmetric(horizontal: AppTheme.sMd), + Widget _buildAgentBar() { + return SizedBox( + height: 32, child: ListView.separated( scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), itemCount: _agentDefs.length, - separatorBuilder: (_, i) => const SizedBox(width: 6), + separatorBuilder: (_, i) => const SizedBox(width: 8), itemBuilder: (_, i) { final (agent, label, icon) = _agentDefs[i]; return GestureDetector( - onTap: () { - // 用户标签 → 欢迎卡片,不发 AI 后端 - ref.read(chatProvider.notifier).triggerAgent(agent, label); - }, + onTap: () => ref.read(chatProvider.notifier).triggerAgent(agent, label), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( - color: theme.colorScheme.card, - borderRadius: BorderRadius.circular(AppTheme.rPill), - border: Border.all(color: theme.colorScheme.border), + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: AppColors.cardShadowLight, ), child: Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 17, color: theme.colorScheme.mutedForeground), + Icon(icon, size: 15, color: AppColors.primary), const SizedBox(width: 4), - Text(label, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: theme.colorScheme.mutedForeground)), + Text(label, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: AppColors.primary)), ]), ), ); @@ -157,122 +157,114 @@ class _HomePageState extends ConsumerState { ); } - // ═══════ 底部区域 ═══════ - Widget _buildBottomBar(BuildContext context, ActiveAgent? selectedAgent, ShadThemeData theme) { + // ═══════ 底部输入区 ═══════ + Widget _buildBottomBar(BuildContext context) { return Column(mainAxisSize: MainAxisSize.min, children: [ - Container( - padding: const EdgeInsets.only(top: AppTheme.sSm, bottom: 6), - child: _buildAgentBar(selectedAgent, theme), - ), - if (_pickedImagePath != null) _buildImagePreview(theme), - _buildCompactInputBar(theme), + Container(padding: const EdgeInsets.only(top: 4, bottom: 4), child: _buildAgentBar()), + if (_pickedImagePath != null) _buildImagePreview(), + _buildInputBar(), ]); } - Widget _buildImagePreview(ShadThemeData theme) { + Widget _buildImagePreview() { return Container( padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), - decoration: BoxDecoration( - color: theme.colorScheme.card, - border: Border(top: BorderSide(color: theme.colorScheme.border)), - ), + color: AppColors.cardInner, child: Row(children: [ - Stack(children: [ - ClipRRect( - borderRadius: BorderRadius.circular(AppTheme.rXs), - child: Image.file(File(_pickedImagePath!), width: 60, height: 60, fit: BoxFit.cover), - ), - Positioned(top: -4, right: -4, child: GestureDetector( - onTap: () => setState(() => _pickedImagePath = null), - child: Container( - width: 20, height: 20, - decoration: const BoxDecoration(color: AppTheme.text, shape: BoxShape.circle), - child: const Icon(Icons.close, size: 17, color: Colors.white), - ), - )), - ]), + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.file(File(_pickedImagePath!), width: 48, height: 48, fit: BoxFit.cover), + ), + const SizedBox(width: 10), + const Text('点击发送图片', style: TextStyle(fontSize: 14, color: AppColors.textSecondary)), const Spacer(), - Text('点击发送上传图片', style: TextStyle(fontSize: 15, color: theme.colorScheme.mutedForeground)), + GestureDetector( + onTap: () => setState(() => _pickedImagePath = null), + child: const Icon(Icons.close, size: 20, color: AppColors.textHint), + ), ]), ); } - Widget _buildCompactInputBar(ShadThemeData theme) { + Widget _buildInputBar() { return Container( - padding: const EdgeInsets.symmetric(horizontal: AppTheme.sSm, vertical: AppTheme.sSm), - decoration: BoxDecoration( - color: theme.colorScheme.card, - border: Border(top: BorderSide(color: theme.colorScheme.border)), - ), + padding: const EdgeInsets.fromLTRB(12, 8, 12, 10), child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [ - ShadButton.ghost( - size: ShadButtonSize.sm, - leading: Icon(LucideIcons.paperclip, size: 25, color: theme.colorScheme.mutedForeground), - onPressed: () => _showAttachmentPicker(context, theme), - ), - Expanded( - child: TextField( - controller: _textCtrl, - focusNode: _focusNode, - style: TextStyle(fontSize: 18, color: theme.colorScheme.foreground), - maxLines: null, - textInputAction: TextInputAction.newline, - decoration: InputDecoration( - hintText: '输入你想说的...', - hintStyle: TextStyle(fontSize: 18, color: theme.colorScheme.mutedForeground), - filled: true, - fillColor: theme.colorScheme.muted, - contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppTheme.rXl), - borderSide: BorderSide.none, - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppTheme.rXl), - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppTheme.rXl), - borderSide: BorderSide.none, - ), - ), - onSubmitted: (_) => _sendMessage(), + GestureDetector( + onTap: () => _showAttachmentPicker(context), + child: const Padding( + padding: EdgeInsets.only(bottom: 10), + child: Icon(LucideIcons.paperclip, size: 24, color: AppColors.textSecondary), ), ), - const SizedBox(width: 4), - ShadButton( - size: ShadButtonSize.sm, - leading: Icon(LucideIcons.send, size: 21, color: theme.colorScheme.primaryForeground), - onPressed: _sendMessage, + const SizedBox(width: 8), + Expanded( + child: Container( + constraints: const BoxConstraints(maxHeight: 120), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + ), + child: TextField( + controller: _textCtrl, + focusNode: _focusNode, + style: const TextStyle(fontSize: 15, color: AppColors.textPrimary), + maxLines: null, + textInputAction: TextInputAction.newline, + decoration: const InputDecoration( + hintText: '输入你想说的...', + hintStyle: TextStyle(fontSize: 15, color: AppColors.textHint), + filled: true, fillColor: Colors.white, + contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 10), + border: OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none), + ), + onSubmitted: (_) => _sendMessage(), + ), + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: _sendMessage, + child: Container( + width: 40, height: 40, + margin: EdgeInsets.only(bottom: _pickedImagePath != null ? 0 : 4), + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(20), + ), + child: const Icon(LucideIcons.send, size: 20, color: Colors.white), + ), ), ]), ); } - void _showAttachmentPicker(BuildContext context, ShadThemeData theme) { + void _showAttachmentPicker(BuildContext context) { showModalBottomSheet( context: context, - backgroundColor: theme.colorScheme.card, + backgroundColor: Colors.white, shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(AppTheme.rXl)), + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), builder: (ctx) => SafeArea( child: Padding( - padding: const EdgeInsets.symmetric(vertical: AppTheme.sMd), + padding: const EdgeInsets.symmetric(vertical: 8), child: Wrap(children: [ ListTile( - leading: Icon(LucideIcons.camera, color: theme.colorScheme.foreground), - title: Text('拍照', style: TextStyle(color: theme.colorScheme.foreground)), + leading: const Icon(Icons.camera_alt_outlined, color: AppColors.primary), + title: const Text('拍照'), onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.camera); }, ), ListTile( - leading: Icon(LucideIcons.image, color: theme.colorScheme.foreground), - title: Text('从相册选', style: TextStyle(color: theme.colorScheme.foreground)), + leading: const Icon(Icons.photo_library_outlined, color: AppColors.primary), + title: const Text('从相册选'), onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.gallery); }, ), ListTile( - leading: Icon(LucideIcons.file, color: theme.colorScheme.foreground), - title: Text('传文件', style: TextStyle(color: theme.colorScheme.foreground)), + leading: const Icon(Icons.file_open_outlined, color: AppColors.primary), + title: const Text('传文件'), onTap: () async { Navigator.pop(ctx); final result = await FilePicker.platform.pickFiles(); @@ -289,8 +281,7 @@ class _HomePageState extends ConsumerState { } Future _pickImage(ImageSource source) async { - final picker = ImagePicker(); - final picked = await picker.pickImage(source: source, imageQuality: 85); + final picked = await ImagePicker().pickImage(source: source, imageQuality: 85); if (picked != null) { final token = await ref.read(apiClientProvider).accessToken; if (token == null) return; @@ -299,8 +290,7 @@ class _HomePageState extends ConsumerState { } Future _pickFoodImage(ImageSource source) async { - final picker = ImagePicker(); - final picked = await picker.pickImage(source: source, imageQuality: 80, maxWidth: 1024, maxHeight: 1024); + final picked = await ImagePicker().pickImage(source: source, imageQuality: 80, maxWidth: 1024, maxHeight: 1024); if (picked != null && mounted) { ref.read(dietProvider.notifier).reset(); ref.read(dietProvider.notifier).setImage(picked.path); 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 3472efd..24aade8 100644 --- a/health_app/lib/pages/home/widgets/chat_messages_view.dart +++ b/health_app/lib/pages/home/widgets/chat_messages_view.dart @@ -110,14 +110,15 @@ class ChatMessagesView extends ConsumerWidget { constraints: BoxConstraints(maxWidth: screenWidth * 0.95), decoration: BoxDecoration( color: AppColors.cardBackground, - borderRadius: BorderRadius.circular(24), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Color(0xFFE2E8F0), width: 2), boxShadow: AppColors.cardShadow, ), clipBehavior: Clip.antiAlias, child: Column( mainAxisSize: MainAxisSize.min, children: [ - // ── 标题区域(清爽蓝紫渐变背景) ── + // ── 标题区域 ── Container( width: double.infinity, padding: const EdgeInsets.fromLTRB(20, 22, 20, 20), @@ -247,6 +248,15 @@ class ChatMessagesView extends ConsumerWidget { ), ], + // ── 医生选择区(问诊专用)── + if (agent == ActiveAgent.consultation) ...[ + const SizedBox(height: 14), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: _buildDoctorCards(ref), + ), + ], + // ── 快捷操作按钮区 ── if (actions.isNotEmpty) ...[ const SizedBox(height: 14), @@ -550,14 +560,15 @@ class ChatMessagesView extends ConsumerWidget { constraints: BoxConstraints(maxWidth: screenWidth * 0.95), decoration: BoxDecoration( color: AppColors.cardBackground, - borderRadius: BorderRadius.circular(24), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Color(0xFFE2E8F0), width: 2), boxShadow: AppColors.cardShadow, ), clipBehavior: Clip.antiAlias, child: Column( mainAxisSize: MainAxisSize.min, children: [ - // ── 标题区域(清爽蓝紫渐变背景) ── + // ── 标题区域 ── Container( width: double.infinity, padding: const EdgeInsets.fromLTRB(20, 20, 20, 18), @@ -758,10 +769,7 @@ class ChatMessagesView extends ConsumerWidget { color: Colors.transparent, child: InkWell( onTap: () { - msg.confirmed = true; - if (isMedication) ref.invalidate(medicationListProvider); - if (isHealth) ref.invalidate(latestHealthProvider); - ref.read(chatProvider.notifier).markNeedsRebuild(); + ref.read(chatProvider.notifier).confirmMessage(msg.id); }, borderRadius: BorderRadius.circular(16), child: Row( @@ -816,8 +824,7 @@ class ChatMessagesView extends ConsumerWidget { else InkWell( onTap: () { - msg.metadata?['_editingField'] = field.label; - ref.read(chatProvider.notifier).markNeedsRebuild(); + ref.read(chatProvider.notifier).startEditingField(msg.id, field.label); }, child: Row( children: [ @@ -854,14 +861,10 @@ class ChatMessagesView extends ConsumerWidget { border: InputBorder.none, ), onSubmitted: (value) { - msg.metadata?[field.label] = value; - msg.metadata?['_editingField'] = null; - ref.read(chatProvider.notifier).markNeedsRebuild(); + ref.read(chatProvider.notifier).finishEditingField(msg.id, field.label, value); }, onTapOutside: (_) { - msg.metadata?[field.label] = controller.text; - msg.metadata?['_editingField'] = null; - ref.read(chatProvider.notifier).markNeedsRebuild(); + ref.read(chatProvider.notifier).finishEditingField(msg.id, field.label, controller.text); }, ), ); @@ -1057,6 +1060,7 @@ class ChatMessagesView extends ConsumerWidget { .replaceAll(RegExp(r'^[\-\*]\s+', multiLine: true), '') .replaceAll(RegExp(r'`(.+?)`'), r'$1') .replaceAll(RegExp(r'\[([^\]]+)\]\([^)]+\)'), r'$1') + .replaceAll('\$1', '') // AI 偶尔输出 $1 占位符 .replaceAll(RegExp(r'\n{3,}'), '\n\n') .trim(); } @@ -1328,20 +1332,35 @@ class ChatMessagesView extends ConsumerWidget { return Container( margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), - boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))], + border: Border.all(color: Color(0xFFE2E8F0), width: 2), + boxShadow: AppColors.cardShadow, ), + clipBehavior: Clip.antiAlias, child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - Icon(Icons.today, size: 21, color: AppTheme.primary), - const SizedBox(width: 8), - const Text('今日任务', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), - ]), - const SizedBox(height: 10), - ...tasks, + Container( + width: double.infinity, padding: const EdgeInsets.fromLTRB(16, 12, 16, 10), + decoration: const BoxDecoration(gradient: LinearGradient(colors: [Color(0xFFF0ECFF), Color(0xFFEBF4FF)])), + child: Row(children: [ + Container(width: 28, height: 28, decoration: BoxDecoration(color: Colors.white.withOpacity(0.6), borderRadius: BorderRadius.circular(8)), + child: const Icon(Icons.health_and_safety, size: 16, color: AppColors.primary)), + const SizedBox(width: 8), + const Text('今日健康', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + const Spacer(), + Text('${now.month}月${now.day}日', style: const TextStyle(fontSize: 13, color: AppColors.textSecondary)), + ]), + ), + Padding( + padding: const EdgeInsets.all(14), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (tasks.isEmpty) + _taskRow(context, Icons.check_circle, '今日暂无待办', status: 'done') + else + ...tasks, + ]), + ), ]), ); } diff --git a/health_app/lib/pages/medication/medication_checkin_page.dart b/health_app/lib/pages/medication/medication_checkin_page.dart index 24b3912..d0f8523 100644 --- a/health_app/lib/pages/medication/medication_checkin_page.dart +++ b/health_app/lib/pages/medication/medication_checkin_page.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.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'; @@ -29,13 +31,13 @@ class _MedicationCheckInPageState extends ConsumerState { ref.invalidate(medicationReminderProvider); ref.invalidate(medicationListProvider); _load(); - } catch (_) {} + } catch (e) { debugPrint('[MedCheckIn] 打卡失败: $e'); } } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppTheme.bg, - appBar: AppBar(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) { @@ -46,9 +48,9 @@ class _MedicationCheckInPageState extends ConsumerState { if (reminders.isEmpty) { return Center( child: Column(mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.check_circle_outline, size: 64, color: Colors.grey[300]), + Icon(Icons.check_circle_outline, size: 64, color: AppTheme.textHint), const SizedBox(height: 12), - const Text('今天所有用药已打卡', style: TextStyle(fontSize: 19, color: Color(0xFF999999))), + const Text('今天所有用药已打卡', style: TextStyle(fontSize: 19, color: AppColors.textSecondary)), ]), ); } @@ -78,21 +80,21 @@ class _MedicationCheckInPageState extends ConsumerState { margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))], + color: AppTheme.surface, + borderRadius: BorderRadius.circular(AppTheme.rLg), + boxShadow: [AppTheme.shadowCard], ), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Container( width: 40, height: 40, - decoration: BoxDecoration(color: const Color(0xFFFFF3E0), borderRadius: BorderRadius.circular(10)), + 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: Color(0xFF888888))), + if (dosage.isNotEmpty) Text(dosage, style: const TextStyle(fontSize: 16, color: AppColors.textSecondary)), ]), ]), const SizedBox(height: 14), @@ -107,12 +109,12 @@ class _MedicationCheckInPageState extends ConsumerState { Icon( isTaken ? Icons.check_circle : Icons.radio_button_unchecked, size: 23, - color: isTaken ? AppTheme.success : const Color(0xFFCCCCCC), + color: isTaken ? AppTheme.success : AppTheme.border, ), const SizedBox(width: 10), Text(time, style: TextStyle( fontSize: 18, fontWeight: FontWeight.w500, - color: isTaken ? const Color(0xFF999999) : const Color(0xFF333333), + color: isTaken ? AppTheme.textSub : AppTheme.text, )), const Spacer(), GestureDetector( @@ -120,12 +122,12 @@ class _MedicationCheckInPageState extends ConsumerState { child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( - color: isTaken ? const Color(0xFFDCFCE7) : AppTheme.primary, + color: isTaken ? AppColors.successLight : AppTheme.primary, borderRadius: BorderRadius.circular(20), ), child: Text(isTaken ? '已打卡' : '打卡', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, - color: isTaken ? AppTheme.success : Colors.white)), + color: isTaken ? AppTheme.success : AppColors.textOnGradient)), ), ), ]), diff --git a/health_app/lib/pages/medication/medication_edit_page.dart b/health_app/lib/pages/medication/medication_edit_page.dart index 676cec3..1999103 100644 --- a/health_app/lib/pages/medication/medication_edit_page.dart +++ b/health_app/lib/pages/medication/medication_edit_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../../core/app_colors.dart'; import '../../core/app_theme.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/navigation_provider.dart'; @@ -57,7 +58,7 @@ class _MedicationEditPageState extends ConsumerState { try { if (widget.id != null) { await srv.update(widget.id!, data); } else { await srv.create(data); } ref.invalidate(medicationListProvider); ref.invalidate(medicationReminderProvider); - if (mounted) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已保存'), backgroundColor: AppTheme.success)); popRoute(ref); } + if (mounted) { popRoute(ref); } } catch (_) { if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存失败'), backgroundColor: AppTheme.error)); } if (mounted) setState(() => _loading = false); } @@ -113,24 +114,24 @@ class _MedicationEditPageState extends ConsumerState { ); } - Widget _label(String text) => Text(text, style: const TextStyle(fontSize: 17, color: Color(0xFF666666))); + Widget _label(String text) => Text(text, style: TextStyle(fontSize: 17, color: AppColors.textSecondary)); Widget _field(String label, TextEditingController ctrl, {String? hint}) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ _label(label), const SizedBox(height: 6), - TextField(controller: ctrl, decoration: InputDecoration(hintText: hint, filled: true, fillColor: Colors.white, border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12)), style: const TextStyle(fontSize: 19)), + TextField(controller: ctrl, decoration: InputDecoration(hintText: hint, filled: true, fillColor: AppTheme.surface, border: OutlineInputBorder(borderRadius: BorderRadius.circular(AppTheme.rMd), borderSide: BorderSide.none), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12)), style: const TextStyle(fontSize: 19)), ]); Widget _chip(String label, bool sel, VoidCallback onTap) => GestureDetector(onTap: onTap, child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration(color: sel ? AppTheme.primary : AppTheme.primaryLight, borderRadius: BorderRadius.circular(16)), child: Text(label, style: TextStyle(fontSize: 16, color: sel ? Colors.white : AppTheme.primary, fontWeight: FontWeight.w500)))); Widget _dateField(String label, DateTime val, ValueChanged cb) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ _label(label), const SizedBox(height: 6), GestureDetector(onTap: () async { final d = await showDatePicker(context: context, initialDate: val, firstDate: DateTime(2024), lastDate: DateTime(2030)); if (d != null) cb(d); }, - child: Container(width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)), child: Text('${val.month}/${val.day}', style: const TextStyle(fontSize: 18)))), + child: Container(width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: Text('${val.month}/${val.day}', style: const TextStyle(fontSize: 18)))), ]); Widget _dateFieldOpt(String label, DateTime? val, ValueChanged cb) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ _label(label), const SizedBox(height: 6), GestureDetector(onTap: () async { final d = await showDatePicker(context: context, initialDate: val ?? DateTime.now(), firstDate: DateTime(2024), lastDate: DateTime(2030)); if (d != null) cb(d); }, - child: Container(width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)), child: Row(children: [ - Text(val != null ? '${val.month}/${val.day}' : '不设置', style: TextStyle(fontSize: 18, color: val != null ? null : Colors.grey[400])), + child: Container(width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: Row(children: [ + Text(val != null ? '${val.month}/${val.day}' : '不设置', style: TextStyle(fontSize: 18, color: val != null ? null : AppTheme.textHint)), if (val != null) const Spacer(), - if (val != null) GestureDetector(onTap: () => cb(null), child: const Icon(Icons.close, size: 19, color: Color(0xFFBBBBBB))), + if (val != null) GestureDetector(onTap: () => cb(null), child: const Icon(Icons.close, size: 19, color: AppColors.textHint)), ]))), ]); } diff --git a/health_app/lib/pages/medication/medication_list_page.dart b/health_app/lib/pages/medication/medication_list_page.dart index ae45215..3c2c841 100644 --- a/health_app/lib/pages/medication/medication_list_page.dart +++ b/health_app/lib/pages/medication/medication_list_page.dart @@ -6,6 +6,7 @@ import '../../core/navigation_provider.dart'; import '../../providers/data_providers.dart'; import '../../widgets/app_empty_state.dart'; import '../../widgets/app_tab_chip.dart'; +import '../../widgets/common_widgets.dart'; class MedicationListPage extends ConsumerStatefulWidget { const MedicationListPage({super.key}); @@ -21,16 +22,17 @@ class _MedicationListPageState extends ConsumerState { Future _delete(String id) async { await ref.read(medicationServiceProvider).deleteMedication(id); + ref.invalidate(medicationListProvider); _load(); } static const _tabs = [('全部', ''), ('服用中', 'active'), ('已停药', 'inactive')]; @override Widget build(BuildContext context) { - final theme = ShadTheme.of(context); return Scaffold( - backgroundColor: theme.colorScheme.background, + backgroundColor: AppTheme.bg, appBar: AppBar( + leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('用药管理'), actions: [ ShadButton.ghost( @@ -69,64 +71,48 @@ class _MedicationListPageState extends ConsumerState { final times = (m['timeOfDay'] as List?)?.map((t) => t.toString().substring(0, 5)).join(' ') ?? ''; final isActive = m['isActive'] == true; final id = m['id']?.toString() ?? ''; - return Dismissible( + return SwipeDeleteTile( key: Key(id), - direction: DismissDirection.endToStart, - confirmDismiss: (_) async { - await _delete(id); - return false; - }, - background: Container( - margin: const EdgeInsets.symmetric(horizontal: AppTheme.sLg, vertical: 4), + onDelete: () => _delete(id), + onTap: () => pushRoute(ref, 'medCheckIn', params: {'id': id}), + margin: const EdgeInsets.symmetric(horizontal: AppTheme.sLg, vertical: 4), + child: Container( + padding: const EdgeInsets.all(AppTheme.sLg), decoration: BoxDecoration( - color: theme.colorScheme.destructive, + color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rMd), + boxShadow: [AppTheme.shadowLight], ), - alignment: Alignment.centerRight, - padding: const EdgeInsets.only(right: 20), - child: Icon(LucideIcons.trash, color: theme.colorScheme.destructiveForeground, size: 25), - ), - child: GestureDetector( - onTap: () => pushRoute(ref, 'medCheckIn', params: {'id': id}), - child: Container( - margin: const EdgeInsets.symmetric(horizontal: AppTheme.sLg, vertical: 4), - padding: const EdgeInsets.all(AppTheme.sLg), - decoration: BoxDecoration( - color: theme.colorScheme.card, - borderRadius: BorderRadius.circular(AppTheme.rMd), - boxShadow: [AppTheme.shadowLight], - ), - child: Row(children: [ - Container( - width: 44, height: 44, - decoration: BoxDecoration( - color: isActive ? AppTheme.primaryLight : theme.colorScheme.muted, - borderRadius: BorderRadius.circular(AppTheme.rSm), - ), - child: Icon(LucideIcons.pill, size: 25, color: isActive ? AppTheme.primary : theme.colorScheme.mutedForeground), + child: Row(children: [ + Container( + width: 44, height: 44, + decoration: BoxDecoration( + color: isActive ? AppTheme.primaryLight : AppTheme.bgSoft, + borderRadius: BorderRadius.circular(AppTheme.rSm), ), - const SizedBox(width: AppTheme.sMd), - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - Text(m['name']?.toString() ?? '', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: isActive ? theme.colorScheme.foreground : theme.colorScheme.mutedForeground)), - if (!isActive) ...[ - const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), - decoration: BoxDecoration( - color: theme.colorScheme.muted, - borderRadius: BorderRadius.circular(4), - ), - child: Text('已停', style: TextStyle(fontSize: 13, color: theme.colorScheme.mutedForeground)), + child: Icon(LucideIcons.pill, size: 25, color: isActive ? AppTheme.primary : AppTheme.textSub), + ), + const SizedBox(width: AppTheme.sMd), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Text(m['name']?.toString() ?? '', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: isActive ? AppTheme.text : AppTheme.textSub)), + if (!isActive) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: AppTheme.bgSoft, + borderRadius: BorderRadius.circular(4), ), - ], - ]), - const SizedBox(height: 2), - Text('${m['dosage'] ?? ''} $times', style: TextStyle(fontSize: 16, color: theme.colorScheme.mutedForeground)), - ])), - Icon(LucideIcons.chevronRight, size: 21, color: theme.colorScheme.border), - ]), - ), + child: Text('已停', style: TextStyle(fontSize: 13, color: AppTheme.textSub)), + ), + ], + ]), + const SizedBox(height: 2), + Text('${m['dosage'] ?? ''} $times', style: TextStyle(fontSize: 16, color: AppTheme.textSub)), + ])), + Icon(LucideIcons.chevronRight, size: 21, color: AppTheme.border), + ]), ), ); }, diff --git a/health_app/lib/pages/profile/profile_page.dart b/health_app/lib/pages/profile/profile_page.dart index 6798810..a7e2db5 100644 --- a/health_app/lib/pages/profile/profile_page.dart +++ b/health_app/lib/pages/profile/profile_page.dart @@ -1,10 +1,9 @@ import 'package:flutter/material.dart'; 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/auth_provider.dart'; -import '../../widgets/app_menu_item.dart'; class ProfilePage extends ConsumerWidget { const ProfilePage({super.key}); @@ -12,100 +11,178 @@ class ProfilePage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final auth = ref.watch(authProvider); final user = auth.user; - final theme = ShadTheme.of(context); + final name = user?.name?.isNotEmpty == true ? user!.name! : '未设置昵称'; + final phone = user?.phone ?? ''; return Scaffold( - backgroundColor: theme.colorScheme.background, - body: SafeArea(child: SingleChildScrollView( - padding: const EdgeInsets.only(bottom: AppTheme.sXl), - child: Column(children: [ - // 用户头部卡片 - Container( - width: double.infinity, - padding: const EdgeInsets.all(AppTheme.rXl), - decoration: BoxDecoration( - color: theme.colorScheme.card, - borderRadius: const BorderRadius.only( - bottomLeft: Radius.circular(AppTheme.rXl), - bottomRight: Radius.circular(AppTheme.rXl), - ), - boxShadow: [AppTheme.shadowLight], - ), - child: InkWell( - onTap: () => pushRoute(ref, 'healthArchive'), - borderRadius: BorderRadius.circular(AppTheme.rSm), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: AppTheme.sSm), - child: Row(children: [ - Stack(children: [ - CircleAvatar( - radius: 32, - backgroundColor: AppTheme.primaryLight, - backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null, - child: user?.avatarUrl == null ? const Icon(Icons.person, size: 36, color: AppTheme.primary) : null, - ), - Positioned( - right: 0, bottom: 0, - child: Container( - width: 20, height: 20, - decoration: BoxDecoration( - color: AppTheme.primary, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: theme.colorScheme.card, width: 2), - ), - child: const Icon(Icons.edit, size: 13, color: Colors.white), - ), - ), - ]), - const SizedBox(width: AppTheme.sLg), - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(user?.name ?? '未设置昵称', - style: TextStyle(fontSize: 23, fontWeight: FontWeight.bold, color: theme.colorScheme.foreground)), - const SizedBox(height: 4), - Text(user?.phone ?? '', style: TextStyle(fontSize: 17, color: theme.colorScheme.mutedForeground)), - ])), - Icon(LucideIcons.chevronRight, size: 25, color: theme.colorScheme.mutedForeground), - ]), - ), - ), + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.cardBackground, + title: const Text('个人信息'), + actions: [ + TextButton( + onPressed: () => pushRoute(ref, 'healthArchive'), + child: const Text('编辑档案', style: TextStyle(fontSize: 15, color: AppColors.primary)), ), - const SizedBox(height: AppTheme.sMd), - AppMenuItem(icon: LucideIcons.folderArchive, title: '健康档案', onTap: () => pushRoute(ref, 'healthArchive')), - AppMenuItem(icon: LucideIcons.settings, title: '设置', onTap: () => pushRoute(ref, 'settings')), - const SizedBox(height: 40), - // 退出登录 - GestureDetector( - onTap: () => _logout(context, ref), - child: Container( - margin: const EdgeInsets.symmetric(horizontal: AppTheme.rXl), - height: 50, - alignment: Alignment.center, + ], + ), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column(children: [ + const SizedBox(height: 20), + + // 头像区 + Center( + child: Stack(children: [ + Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + gradient: AppColors.gPurplePink, + shape: BoxShape.circle, + ), + child: CircleAvatar( + radius: 50, + backgroundColor: AppColors.cardBackground, + backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null, + child: user?.avatarUrl == null + ? const Icon(Icons.person, size: 48, color: AppColors.primary) + : null, + ), + ), + Positioned( + right: 0, bottom: 0, + child: Container( + width: 34, height: 34, + decoration: BoxDecoration( + gradient: AppColors.gPurplePink, + shape: BoxShape.circle, + border: Border.all(color: AppColors.cardBackground, width: 3), + ), + child: const Icon(Icons.camera_alt, size: 17, color: AppColors.textOnGradient), + ), + ), + ]), + ), + const SizedBox(height: 20), + Text(name, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), + const SizedBox(height: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), decoration: BoxDecoration( - border: Border.all(color: theme.colorScheme.destructive.withAlpha(80)), + color: AppColors.primaryLight, borderRadius: BorderRadius.circular(AppTheme.rPill), ), - child: Text('退出登录', style: TextStyle(fontSize: 19, color: theme.colorScheme.destructive, fontWeight: FontWeight.w500)), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.phone_android, size: 16, color: AppColors.primary), + const SizedBox(width: 6), + Text(phone.isNotEmpty ? phone : '未绑定手机', style: const TextStyle(fontSize: 14, color: AppColors.primary)), + ]), ), + + const SizedBox(height: 32), + + // 基础信息卡片 + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: AppColors.cardBackground, + borderRadius: BorderRadius.circular(AppTheme.rLg), + boxShadow: [AppTheme.shadowCard], + ), + child: Column(children: [ + _infoRow(Icons.person_outline, '姓名', name), + const Divider(height: 24, color: AppColors.borderLight), + _infoRow(Icons.phone_android, '手机号', phone.isNotEmpty ? phone : '未绑定'), + const Divider(height: 24, color: AppColors.borderLight), + _infoRow(Icons.health_and_safety, '健康档案', '查看详情', onTap: () => pushRoute(ref, 'healthArchive')), + ]), + ), + + const SizedBox(height: 24), + + // 快捷入口 + Row(children: [ + Expanded(child: _actionCard(Icons.folder_outlined, '健康档案', () => pushRoute(ref, 'healthArchive'))), + const SizedBox(width: 12), + Expanded(child: _actionCard(Icons.bluetooth_rounded, '蓝牙设备', () => pushRoute(ref, 'devices'))), + ]), + const SizedBox(height: 12), + Row(children: [ + Expanded(child: _actionCard(Icons.settings_outlined, '设置', () => pushRoute(ref, 'settings'))), + const SizedBox(width: 12), + Expanded(child: _actionCard(Icons.logout, '退出登录', () => _logout(context, ref), isDestructive: true)), + ]), + + const SizedBox(height: 40), + ]), + ), + ), + ); + } + + Widget _infoRow(IconData icon, String label, String value, {VoidCallback? onTap}) { + return GestureDetector( + onTap: onTap, + child: Row(children: [ + Container( + width: 40, height: 40, + decoration: BoxDecoration( + color: AppColors.primaryLight, + borderRadius: BorderRadius.circular(10), ), + child: Icon(icon, size: 22, color: AppColors.primary), + ), + const SizedBox(width: 14), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(label, style: const TextStyle(fontSize: 13, color: AppColors.textHint)), + const SizedBox(height: 3), + Text(value, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w500, color: AppColors.textPrimary)), + ]), + ), + if (onTap != null) const Icon(Icons.chevron_right, size: 20, color: AppColors.textHint), + ]), + ); + } + + Widget _actionCard(IconData icon, String label, VoidCallback onTap, {bool isDestructive = false}) { + final c = isDestructive ? AppColors.error : AppColors.primary; + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16), + decoration: BoxDecoration( + color: AppColors.cardBackground, + borderRadius: BorderRadius.circular(AppTheme.rMd), + boxShadow: [AppTheme.shadowLight], + ), + child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(icon, size: 20, color: c), + const SizedBox(width: 8), + Text(label, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: isDestructive ? c : AppColors.textPrimary)), ]), - )), + ), ); } Future _logout(BuildContext context, WidgetRef ref) async { - final theme = ShadTheme.of(context); final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rXl)), - title: Text('退出登录', style: TextStyle(color: theme.colorScheme.foreground)), - content: Text('确定退出?', style: TextStyle(color: theme.colorScheme.mutedForeground)), + title: const Text('退出登录'), + content: const Text('确定退出当前账号?'), actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('取消', style: TextStyle(color: theme.colorScheme.mutedForeground))), - TextButton(onPressed: () => Navigator.pop(ctx, true), child: Text('确定', style: TextStyle(color: theme.colorScheme.destructive))), + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), + TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定', style: TextStyle(color: AppColors.error))), ], ), ); - if (ok == true) { await ref.read(authProvider.notifier).logout(); goRoute(ref, 'login'); } + if (ok == true) { + await ref.read(authProvider.notifier).logout(); + goRoute(ref, 'login'); + } } } diff --git a/health_app/lib/pages/remaining_pages.dart b/health_app/lib/pages/remaining_pages.dart index f051698..54cb36f 100644 --- a/health_app/lib/pages/remaining_pages.dart +++ b/health_app/lib/pages/remaining_pages.dart @@ -7,6 +7,7 @@ import '../core/navigation_provider.dart'; import '../providers/auth_provider.dart'; import '../providers/data_providers.dart'; import '../providers/omron_device_provider.dart'; +import '../widgets/common_widgets.dart'; /// 饮食记录列表(左滑删除) class DietRecordListPage extends ConsumerStatefulWidget { @@ -22,7 +23,7 @@ class _DietRecordListPageState extends ConsumerState { @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppTheme.bg, - appBar: AppBar(title: const Text('饮食记录')), + appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('饮食记录')), body: FutureBuilder>>( future: _future, builder: (ctx, snap) { @@ -38,7 +39,7 @@ class _DietRecordListPageState extends ConsumerState { final mealNames = {'Breakfast':'早餐','Lunch':'午餐','Dinner':'晚餐','Snack':'加餐'}; final mealLabel = mealNames[d['mealType']?.toString()] ?? d['mealType']?.toString() ?? ''; final mealIcons = {'Breakfast':'🌅','Lunch':'☀️','Dinner':'🌙','Snack':'🍪'}; - return _SwipeDeleteTile( + return SwipeDeleteTile( key: Key(d['id']?.toString() ?? '$i'), onDelete: () async { await ref.read(dietServiceProvider).deleteRecord(d['id']?.toString() ?? ''); @@ -46,11 +47,10 @@ class _DietRecordListPageState extends ConsumerState { }, onTap: () => pushRoute(ref, 'dietDetail', params: {'id': d['id']?.toString() ?? ''}), child: Container( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), padding: const EdgeInsets.all(14), - decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: Row(children: [ - Container(width: 44, height: 44, decoration: BoxDecoration(color: AppTheme.primaryLight, borderRadius: BorderRadius.circular(12)), child: Center(child: Text(mealIcons[d['mealType']?.toString()] ?? '🍽️', style: const TextStyle(fontSize: 23)))), + Container(width: 44, height: 44, decoration: BoxDecoration(color: AppColors.iconBg, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: Center(child: Text(mealIcons[d['mealType']?.toString()] ?? '🍽️', style: const TextStyle(fontSize: 23)))), const SizedBox(width: 12), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ @@ -59,9 +59,9 @@ class _DietRecordListPageState extends ConsumerState { Text('${d['totalCalories'] ?? 0}千卡', style: const TextStyle(fontSize: 16, color: AppTheme.primary)), ]), const SizedBox(height: 4), - Text(items.map((f) => f['name']).join(' · '), style: const TextStyle(fontSize: 16, color: Color(0xFF888888)), maxLines: 1, overflow: TextOverflow.ellipsis), + Text(items.map((f) => f['name']).join(' · '), style: const TextStyle(fontSize: 16, color: AppColors.textSecondary), maxLines: 1, overflow: TextOverflow.ellipsis), ])), - const Icon(Icons.chevron_right, size: 21, color: Color(0xFFCCCCCC)), + Icon(Icons.chevron_right, size: 21, color: AppColors.textHint), ]), ), ); @@ -81,7 +81,7 @@ class DietRecordDetailPage extends ConsumerWidget { final service = ref.watch(dietServiceProvider); return Scaffold( backgroundColor: AppTheme.bg, - appBar: AppBar(title: const Text('饮食详情')), + appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('饮食详情')), body: FutureBuilder>>( future: service.getRecords(), builder: (ctx, snap) { @@ -92,23 +92,23 @@ class DietRecordDetailPage extends ConsumerWidget { final totalCal = d['totalCalories'] ?? 0; final mealNames = {'Breakfast':'早餐','Lunch':'午餐','Dinner':'晚餐','Snack':'加餐'}; return ListView(padding: const EdgeInsets.all(16), children: [ - Container(padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)), child: Row(children: [ - Container(width: 48, height: 48, decoration: BoxDecoration(color: AppTheme.primaryLight, borderRadius: BorderRadius.circular(14)), child: const Icon(Icons.restaurant, size: 28, color: AppTheme.primary)), + Container(padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: Row(children: [ + Container(width: 48, height: 48, decoration: BoxDecoration(color: AppColors.iconBg, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: const Icon(Icons.restaurant, size: 28, color: AppTheme.primary)), const SizedBox(width: 14), Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(mealNames[d['mealType']?.toString()] ?? '', style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w700)), - Text('$totalCal 千卡 · ${d['recordedAt'] ?? ''}', style: const TextStyle(fontSize: 16, color: Color(0xFF999999))), + Text('$totalCal 千卡 · ${d['recordedAt'] ?? ''}', style: const TextStyle(fontSize: 16, color: AppColors.textHint)), ]), ])), const SizedBox(height: 16), ...items.map((f) => Container( margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(14), - decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: Row(children: [ Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(f['name']?.toString() ?? '', style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600)), - if ((f['portion']?.toString() ?? '').isNotEmpty) Text(f['portion']!.toString(), style: const TextStyle(fontSize: 16, color: Color(0xFF888888))), + if ((f['portion']?.toString() ?? '').isNotEmpty) Text(f['portion']!.toString(), style: const TextStyle(fontSize: 16, color: AppColors.textSecondary)), ])), Text('${f['calories'] ?? 0} kcal', style: const TextStyle(fontSize: 18, color: AppTheme.primary, fontWeight: FontWeight.w600)), ]), @@ -138,13 +138,14 @@ class _ExercisePlanPageState extends ConsumerState { Future _deletePlan(String id) async { await ref.read(exerciseServiceProvider).deletePlan(id); + ref.invalidate(currentExercisePlanProvider); _load(); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppTheme.bg, - appBar: AppBar(title: const Text('运动计划')), + appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('运动计划')), floatingActionButton: FloatingActionButton(onPressed: () { pushRoute(ref, 'exerciseCreate'); }, backgroundColor: AppTheme.primary, child: const Icon(Icons.add)), body: FutureBuilder>>( future: _future, @@ -157,51 +158,44 @@ class _ExercisePlanPageState extends ConsumerState { if (items.isEmpty) return const SizedBox.shrink(); final total = items.length; final done = items.where((it) => it['isCompleted'] == true).length; - // 基于weekStartDate推算今天对应的条目索引 final weekStart = p['weekStartDate']?.toString() ?? ''; - int? todayIdx; - if (weekStart.isNotEmpty) { - final ws = DateTime.tryParse(weekStart); - if (ws != null) { - final diff = DateTime.now().difference(ws).inDays; - if (diff >= 0 && diff < items.length) todayIdx = diff; - } - } - final todayItem = todayIdx != null ? items[todayIdx] : null; - final todayDone = todayItem?['isCompleted'] == true; + // 用 DayOfWeek 匹配今天(C#: 0=Sun, 6=Sat; Dart weekday%7 对齐) + final todayCsDow = DateTime.now().weekday % 7; + final todayItem = items.cast>().firstWhere( + (it) => it['dayOfWeek'] == todayCsDow, + orElse: () => {}, + ); + final hasTodayItem = todayItem.isNotEmpty; + final todayDone = todayItem['isCompleted'] == true; final exerciseName = items.isNotEmpty ? (items.first['exerciseType']?.toString() ?? '运动') : '运动'; - final startDate = weekStart; - final endIdx = items.length - 1; - final endDay = items.isNotEmpty ? items.last['dayOfWeek'] as int? : 6; - return _SwipeDeleteTile( + return SwipeDeleteTile( key: Key(p['id']?.toString() ?? '$i'), onDelete: () => _deletePlan(p['id']?.toString() ?? ''), onTap: () {}, child: Container( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), padding: const EdgeInsets.all(16), - decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: Row(children: [ - Container(width: 44, height: 44, decoration: BoxDecoration(color: AppTheme.primaryLight, borderRadius: BorderRadius.circular(12)), child: const Icon(Icons.directions_run, size: 25, color: AppTheme.primary)), + Container(width: 44, height: 44, decoration: BoxDecoration(color: AppColors.iconBg, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: const Icon(Icons.directions_run, size: 25, color: AppTheme.primary)), const SizedBox(width: 12), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(exerciseName, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600)), const SizedBox(height: 2), - Text('$startDate 起 · ${items.first['durationMinutes'] ?? 0}分钟/天', style: const TextStyle(fontSize: 16, color: Color(0xFF888888))), + Text('$weekStart 起 · ${items.first['durationMinutes'] ?? 0}分钟/天', style: const TextStyle(fontSize: 16, color: AppColors.textSecondary)), const SizedBox(height: 6), Text('$done/$total 天已完成', style: const TextStyle(fontSize: 15, color: AppTheme.primary)), ])), GestureDetector( - onTap: todayItem != null ? () => _checkIn(p['id']?.toString() ?? '', todayItem['id']?.toString() ?? '') : null, + onTap: hasTodayItem ? () => _checkIn(p['id']?.toString() ?? '', todayItem['id']?.toString() ?? '') : null, child: AnimatedContainer( duration: const Duration(milliseconds: 300), width: 40, height: 40, decoration: BoxDecoration( - color: todayDone ? const Color(0xFFDCFCE7) : (todayItem != null ? AppTheme.primaryLight : const Color(0xFFF5F5F5)), + color: todayDone ? AppColors.successLight : (hasTodayItem ? AppTheme.primaryLight : AppColors.cardInner), borderRadius: BorderRadius.circular(12), ), - child: Icon(todayDone ? Icons.check_circle : Icons.check_circle_outline, size: 28, color: todayDone ? AppTheme.success : const Color(0xFFBBBBBB)), + child: Icon(todayDone ? Icons.check_circle : Icons.check_circle_outline, size: 28, color: todayDone ? AppTheme.success : AppColors.textHint), ), ), ]), @@ -237,13 +231,13 @@ class _ExercisePlanCreatePageState extends ConsumerState 'endDate': '${_end.year}-${_end.month.toString().padLeft(2,'0')}-${_end.day.toString().padLeft(2,'0')}', }); ref.invalidate(currentExercisePlanProvider); - if (mounted) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('计划已创建'), backgroundColor: Color(0xFF43A047))); popRoute(ref); } + if (mounted) { popRoute(ref); } } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: AppTheme.bg, - appBar: AppBar(title: const Text('新建计划')), + appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('新建计划')), body: ListView(padding: const EdgeInsets.all(16), children: [ _field('运动名称', _nameCtrl, hint: '如: 散步、慢跑、太极'), const SizedBox(height: 16), @@ -253,22 +247,22 @@ class _ExercisePlanCreatePageState extends ConsumerState const SizedBox(height: 16), _dateField('结束日期', _end, (d) => setState(() => _end = d)), const SizedBox(height: 32), - SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _save, style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primary, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), padding: const EdgeInsets.symmetric(vertical: 14)), child: const Text('保存计划', style: TextStyle(fontSize: 19)))), + SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _save, style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primary, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)), padding: const EdgeInsets.symmetric(vertical: 14)), child: const Text('保存计划', style: TextStyle(fontSize: 19)))), ]), ); } Widget _field(String label, TextEditingController ctrl, {String? hint, bool number = false}) { return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: const TextStyle(fontSize: 17, color: Color(0xFF666666))), + Text(label, style: TextStyle(fontSize: 17, color: AppColors.textSecondary)), const SizedBox(height: 6), - TextField(controller: ctrl, keyboardType: number ? TextInputType.number : null, decoration: InputDecoration(hintText: hint, filled: true, fillColor: Colors.white, border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none)), style: const TextStyle(fontSize: 19)), + TextField(controller: ctrl, keyboardType: number ? TextInputType.number : null, decoration: InputDecoration(hintText: hint, filled: true, fillColor: AppColors.backgroundSoft, border: OutlineInputBorder(borderRadius: BorderRadius.circular(AppTheme.rMd), borderSide: BorderSide.none)), style: const TextStyle(fontSize: 19)), ]); } Widget _dateField(String label, DateTime val, ValueChanged onChanged) { return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: const TextStyle(fontSize: 17, color: Color(0xFF666666))), + Text(label, style: const TextStyle(fontSize: 17, color: AppColors.textSecondary)), const SizedBox(height: 6), GestureDetector( onTap: () async { final d = await showDatePicker(context: context, initialDate: val, firstDate: DateTime(2024), lastDate: DateTime(2030)); if (d != null) onChanged(d); }, @@ -294,7 +288,7 @@ class _FollowUpListPageState extends ConsumerState { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(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) { @@ -305,11 +299,11 @@ class _FollowUpListPageState extends ConsumerState { if (list.isEmpty) { return Center( child: Column(mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.event_note_outlined, size: 64, color: Colors.grey[300]), + Icon(Icons.event_note_outlined, size: 64, color: AppColors.textHint), const SizedBox(height: 12), Text('暂无随访安排', style: Theme.of(context).textTheme.bodyMedium), const SizedBox(height: 4), - const Text('医生创建随访后将显示在这里', style: TextStyle(fontSize: 16, color: Color(0xFF999999))), + const Text('医生创建随访后将显示在这里', style: TextStyle(fontSize: 16, color: AppColors.textHint)), ]), ); } @@ -338,19 +332,19 @@ class _FollowUpItem extends StatelessWidget { Color _statusColor(String? status) { switch (status) { - case 'Upcoming': return const Color(0xFF4F6EF7); + case 'Upcoming': return AppColors.primary; case 'Completed': return AppTheme.success; case 'Cancelled': return AppTheme.error; - default: return const Color(0xFF999999); + default: return AppColors.textHint; } } Color _statusBg(String? status) { switch (status) { - case 'Upcoming': return const Color(0xFFEDF2FF); - case 'Completed': return const Color(0xFFDCFCE7); - case 'Cancelled': return const Color(0xFFFFF5F5); - default: return const Color(0xFFF5F5F5); + case 'Upcoming': return AppColors.iconBg; + case 'Completed': return AppColors.successLight; + case 'Cancelled': return AppColors.errorLight; + default: return AppColors.backgroundSoft; } } @@ -366,28 +360,28 @@ class _FollowUpItem extends StatelessWidget { padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(10), blurRadius: 4, offset: const Offset(0, 2))], + borderRadius: BorderRadius.circular(AppTheme.rMd), + boxShadow: [AppTheme.shadowCard], ), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(8)), + decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(AppTheme.rSm)), child: Text(label, style: TextStyle(fontSize: 15, color: color, fontWeight: FontWeight.w500)), ), const Spacer(), if (item['doctorName'] != null) - Text('👨‍⚕️ ${item['doctorName']}', style: const TextStyle(fontSize: 15, color: Color(0xFF999999))), + Text('👨‍⚕️ ${item['doctorName']}', style: const TextStyle(fontSize: 15, color: AppColors.textHint)), ]), const SizedBox(height: 12), Text(item['title']?.toString() ?? '', style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600)), const SizedBox(height: 4), if (item['scheduledAt'] != null) - Text(_formatDateTime(item['scheduledAt']?.toString()), style: TextStyle(fontSize: 17, color: Colors.grey[500])), + Text(_formatDateTime(item['scheduledAt']?.toString()), style: const TextStyle(fontSize: 17, color: AppColors.textHint)), if ((item['notes']?.toString() ?? '').isNotEmpty) ...[ const SizedBox(height: 8), - Text(item['notes']?.toString() ?? '', style: TextStyle(fontSize: 16, color: Colors.grey[600])), + Text(item['notes']?.toString() ?? '', style: const TextStyle(fontSize: 16, color: AppColors.textSecondary)), ], ]), ); @@ -419,6 +413,7 @@ class _HealthArchivePageState extends ConsumerState { final _dietCtrl = TextEditingController(); final _familyCtrl = TextEditingController(); bool _loading = true; + bool _saving = false; @override void initState() { super.initState(); _load(); } @override void dispose() { @@ -452,8 +447,9 @@ class _HealthArchivePageState extends ConsumerState { } Future _save() async { - final srv = ref.read(userServiceProvider); + setState(() => _saving = true); try { + final srv = ref.read(userServiceProvider); await srv.updateProfile(name: _nameCtrl.text, gender: _genderCtrl.text, birthDate: _birthCtrl.text); await srv.updateHealthArchive({ 'diagnosis': _diagnosisCtrl.text, @@ -464,63 +460,145 @@ class _HealthArchivePageState extends ConsumerState { 'dietRestrictions': _dietCtrl.text.split('、').where((s) => s.isNotEmpty).toList(), 'familyHistory': _familyCtrl.text, }); - if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已保存'), backgroundColor: Color(0xFF43A047))); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('保存成功'), backgroundColor: AppColors.success), + ); + } } catch (_) { - if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存失败'), backgroundColor: Color(0xFFE53935))); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('保存失败,请重试'), backgroundColor: AppColors.error), + ); + } + } finally { + if (mounted) setState(() => _saving = false); } } @override Widget build(BuildContext context) { - if (_loading) return Scaffold(appBar: AppBar(title: const Text('健康档案')), body: const Center(child: CircularProgressIndicator(color: AppTheme.primary))); + if (_loading) { + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('健康档案')), + body: const Center(child: CircularProgressIndicator(color: AppColors.primary)), + ); + } return Scaffold( - backgroundColor: AppTheme.bg, - appBar: AppBar(title: const Text('健康档案'), actions: [ - TextButton(onPressed: _save, child: const Text('保存', style: TextStyle(color: AppTheme.primary, fontWeight: FontWeight.w600))), - ]), - body: ListView(padding: const EdgeInsets.all(16), children: [ - _sectionTitle('个人资料'), - _editableCard(children: [ - _field('姓名', _nameCtrl), - Row(children: [Expanded(child: _field('性别', _genderCtrl)), const SizedBox(width: 12), Expanded(child: _field('出生日期', _birthCtrl, hint: 'YYYY-MM-DD'))]), + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.cardBackground, + title: const Text('健康档案'), + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildSection('个人资料', Icons.person_outline, [ + _buildField('姓名', _nameCtrl, hint: '请输入姓名'), + const SizedBox(height: 12), + Row(children: [ + Expanded(child: _buildField('性别', _genderCtrl, hint: '男/女')), + const SizedBox(width: 12), + Expanded(child: _buildField('出生日期', _birthCtrl, hint: '如 1970-03-15')), + ]), + ]), + const SizedBox(height: 16), + _buildSection('医疗信息', Icons.local_hospital_outlined, [ + _buildField('主要诊断', _diagnosisCtrl, hint: '如: 冠心病'), + const SizedBox(height: 12), + Row(children: [ + Expanded(child: _buildField('手术类型', _surgeryCtrl, hint: '如: PCI支架植入术')), + const SizedBox(width: 12), + Expanded(child: _buildField('手术日期', _surgeryDateCtrl, hint: '如 2026-03-15')), + ]), + ]), + const SizedBox(height: 16), + _buildSection('病史与限制', Icons.warning_amber_outlined, [ + _buildField('过敏史', _allergiesCtrl, hint: '多个用、分隔,如: 青霉素、海鲜'), + const SizedBox(height: 12), + _buildField('慢性病史', _chronicCtrl, hint: '多个用、分隔,如: 高血压、高血脂'), + const SizedBox(height: 12), + _buildField('饮食限制', _dietCtrl, hint: '多个用、分隔,如: 低盐、低脂'), + const SizedBox(height: 12), + _buildField('家族病史', _familyCtrl, hint: '如: 父亲冠心病'), + ]), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + height: 52, + child: ElevatedButton( + onPressed: _saving ? null : _save, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)), + elevation: 0, + ), + child: Text(_saving ? '保存中...' : '保存档案', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600)), + ), + ), + const SizedBox(height: 40), + ], + ), + ); + } + + Widget _buildSection(String title, IconData icon, List fields) { + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: AppColors.cardBackground, + borderRadius: BorderRadius.circular(AppTheme.rMd), + boxShadow: [AppTheme.shadowCard], + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Container( + width: 36, height: 36, + decoration: BoxDecoration( + color: AppColors.iconBg, + borderRadius: BorderRadius.circular(10), + ), + child: Icon(icon, size: 20, color: AppColors.primary), + ), + const SizedBox(width: 10), + Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), ]), - _sectionTitle('医疗信息'), - _editableCard(children: [ - _field('主要诊断', _diagnosisCtrl, hint: '如: 冠心病'), - Row(children: [Expanded(child: _field('手术类型', _surgeryCtrl, hint: '如: PCI支架植入术')), const SizedBox(width: 12), Expanded(child: _field('手术日期', _surgeryDateCtrl, hint: 'YYYY-MM-DD'))]), - ]), - _sectionTitle('病史与限制'), - _editableCard(children: [ - _field('过敏史', _allergiesCtrl, hint: '多个用、分隔 如: 青霉素、海鲜'), - _field('慢性病史', _chronicCtrl, hint: '多个用、分隔 如: 高血压、高血脂'), - _field('饮食限制', _dietCtrl, hint: '多个用、分隔 如: 低盐、低脂'), - _field('家族病史', _familyCtrl, hint: '如: 父亲冠心病'), - ]), - const SizedBox(height: 20), - SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _save, child: const Text('保存档案')),), - const SizedBox(height: 40), + const SizedBox(height: 16), + ...fields, ]), ); } - Widget _sectionTitle(String title) => Padding( - padding: const EdgeInsets.only(left: 4, top: 16, bottom: 8), - child: Text(title, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w700, color: AppTheme.primary)), - ); - - Widget _editableCard({required List children}) => Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(8), blurRadius: 8, offset: const Offset(0, 2))]), - child: Column(children: children), - ); - - Widget _field(String label, TextEditingController ctrl, {String? hint}) => Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: const TextStyle(fontSize: 16, color: Color(0xFF888888))), - const SizedBox(height: 4), - TextField(controller: ctrl, decoration: InputDecoration(hintText: hint, filled: true, fillColor: const Color(0xFFF4F5FA), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none)), style: const TextStyle(fontSize: 18)), - ]), - ); + Widget _buildField(String label, TextEditingController ctrl, {String? hint}) { + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(label, style: const TextStyle(fontSize: 14, color: AppColors.textHint)), + const SizedBox(height: 6), + TextField( + controller: ctrl, + style: const TextStyle(fontSize: 17, color: AppColors.textPrimary), + decoration: InputDecoration( + hintText: hint, + hintStyle: const TextStyle(fontSize: 15, color: AppColors.textHint), + filled: true, + fillColor: AppColors.backgroundSoft, + contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppTheme.rSm), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppTheme.rSm), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(AppTheme.rSm), + borderSide: const BorderSide(color: AppColors.primary, width: 1.5), + ), + ), + ), + ]); + } } /// 健康日历 @@ -552,12 +630,12 @@ class _HealthCalendarPageState extends ConsumerState { events[map['date'] as String] = List.from(map['events'] ?? []); } if (mounted) setState(() => _events = events); - } catch (_) {} + } catch (e) { debugPrint('[Calendar] 加载日历失败: $e'); } } @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('健康日历'), centerTitle: true), + appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('健康日历'), centerTitle: true), body: Column(children: [ _buildMonthHeader(), _buildWeekdayHeader(), @@ -591,7 +669,7 @@ class _HealthCalendarPageState extends ConsumerState { Widget _buildWeekdayHeader() { const weekdays = ['日', '一', '二', '三', '四', '五', '六']; return Row(children: weekdays.map((day) => Expanded( - child: Center(child: Text(day, style: TextStyle(fontSize: 17, color: Colors.grey[500]))), + child: Center(child: Text(day, style: const TextStyle(fontSize: 17, color: AppColors.textHint))), )).toList()); } @@ -638,7 +716,7 @@ class _HealthCalendarPageState extends ConsumerState { '$day', style: TextStyle( fontSize: 19, - color: isToday ? Colors.white : Colors.black, + color: isToday ? Colors.white : AppColors.textPrimary, fontWeight: isToday ? FontWeight.w600 : FontWeight.normal, ), ), @@ -669,8 +747,8 @@ class _HealthCalendarPageState extends ConsumerState { switch (type) { case 'medication': return AppTheme.primary; case 'exercise': return AppTheme.success; - case 'followup': return const Color(0xFFF59E0B); - default: return Colors.grey; + case 'followup': return AppColors.warning; + default: return AppColors.textHint; } } @@ -678,14 +756,14 @@ class _HealthCalendarPageState extends ConsumerState { final items = [ {'color': AppTheme.primary, 'label': '用药提醒'}, {'color': AppTheme.success, 'label': '运动计划'}, - {'color': const Color(0xFFF59E0B), 'label': '复查随访'}, + {'color': AppColors.warning, 'label': '复查随访'}, ]; return Container( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row(mainAxisAlignment: MainAxisAlignment.center, children: items.map((item) => Row(children: [ Container(width: 10, height: 10, decoration: BoxDecoration(color: item['color'] as Color, borderRadius: BorderRadius.circular(5))), const SizedBox(width: 4), - Text(item['label'] as String, style: TextStyle(fontSize: 15, color: Colors.grey[600])), + Text(item['label'] as String, style: const TextStyle(fontSize: 15, color: AppColors.textSecondary)), const SizedBox(width: 20), ])).toList()), ); @@ -774,12 +852,12 @@ class StaticTextPage extends ConsumerWidget { backgroundColor: Colors.white, elevation: 0, leading: IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => popRoute(ref)), - title: Text(titles[type] ?? '', style: const TextStyle(color: Color(0xFF1A1A1A), fontWeight: FontWeight.w600)), + title: Text(titles[type] ?? '', style: TextStyle(color: AppColors.textPrimary, fontWeight: FontWeight.w600)), centerTitle: true, ), body: SingleChildScrollView( padding: const EdgeInsets.all(20), - child: Text(contents[type] ?? '内容加载中...', style: const TextStyle(fontSize: 17, height: 1.8, color: Color(0xFF333333))), + child: Text(contents[type] ?? '内容加载中...', style: TextStyle(fontSize: 17, height: 1.8, color: AppColors.textPrimary)), ), ); } @@ -808,7 +886,7 @@ class DeviceManagementPage extends ConsumerWidget { Container( width: 88, height: 88, decoration: BoxDecoration( - color: AppColors.iconBackground, + color: AppColors.iconBg, borderRadius: BorderRadius.circular(28), ), child: Icon(Icons.bluetooth_disabled, size: 44, color: AppColors.textHint), @@ -826,9 +904,9 @@ class DeviceManagementPage extends ConsumerWidget { icon: const Icon(Icons.add, size: 22), label: const Text('添加设备', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600)), style: ElevatedButton.styleFrom( - backgroundColor: AppColors.iconColor, + backgroundColor: AppColors.primary, foregroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)), ), ), ), @@ -845,15 +923,15 @@ class DeviceManagementPage extends ConsumerWidget { padding: const EdgeInsets.all(24), decoration: BoxDecoration( color: AppColors.cardBackground, - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(AppTheme.rMd), boxShadow: AppColors.cardShadow, ), child: Column(children: [ Container( width: 72, height: 72, decoration: BoxDecoration( - gradient: AppColors.purpleBlueGradient, - borderRadius: BorderRadius.circular(22), + gradient: AppColors.primaryGradient, + borderRadius: BorderRadius.circular(AppTheme.rMd), ), child: const Icon(Icons.bluetooth_connected, size: 36, color: Colors.white), ), @@ -874,7 +952,7 @@ class DeviceManagementPage extends ConsumerWidget { label: const Text('解绑', style: TextStyle(color: AppColors.error)), style: OutlinedButton.styleFrom( side: const BorderSide(color: AppColors.error), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)), padding: const EdgeInsets.symmetric(vertical: 14), ), ), @@ -886,9 +964,9 @@ class DeviceManagementPage extends ConsumerWidget { icon: const Icon(Icons.swap_horiz, size: 18), label: const Text('更换设备'), style: ElevatedButton.styleFrom( - backgroundColor: AppColors.iconColor, + backgroundColor: AppColors.primary, foregroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)), padding: const EdgeInsets.symmetric(vertical: 14), ), ), @@ -905,7 +983,7 @@ class DeviceManagementPage extends ConsumerWidget { padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: AppColors.cardBackground, - borderRadius: BorderRadius.circular(20), + borderRadius: BorderRadius.circular(AppTheme.rMd), boxShadow: AppColors.cardShadow, ), child: Column(children: [ @@ -913,7 +991,7 @@ class DeviceManagementPage extends ConsumerWidget { Container( width: 6, height: 18, decoration: BoxDecoration( - gradient: AppColors.purpleBlueGradient, + gradient: AppColors.primaryGradient, borderRadius: BorderRadius.circular(3), ), ), @@ -925,8 +1003,8 @@ class DeviceManagementPage extends ConsumerWidget { Container( width: 56, height: 56, decoration: BoxDecoration( - gradient: AppColors.lightGradient, - borderRadius: BorderRadius.circular(16), + color: AppColors.iconBg, + borderRadius: BorderRadius.circular(AppTheme.rMd), ), child: const Text('🩺', style: TextStyle(fontSize: 28)), ), @@ -938,7 +1016,7 @@ class DeviceManagementPage extends ConsumerWidget { const Spacer(), if (device.lastReading!.pulse != null) Column(crossAxisAlignment: CrossAxisAlignment.end, children: [ - Text('${device.lastReading!.pulse}', style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: AppColors.iconColor)), + Text('${device.lastReading!.pulse}', style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: AppColors.primary)), const Text('bpm', style: TextStyle(fontSize: 13, color: AppColors.textHint)), ]), ]), @@ -952,12 +1030,12 @@ class DeviceManagementPage extends ConsumerWidget { width: double.infinity, padding: const EdgeInsets.all(20), decoration: BoxDecoration( - color: AppColors.iconBackground, - borderRadius: BorderRadius.circular(16), + color: AppColors.iconBg, + borderRadius: BorderRadius.circular(AppTheme.rMd), ), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ - Icon(Icons.lightbulb_outline, size: 18, color: AppColors.iconColor), + Icon(Icons.lightbulb_outline, size: 18, color: AppColors.primary), const SizedBox(width: 8), const Text('使用说明', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), ]), @@ -974,7 +1052,7 @@ class DeviceManagementPage extends ConsumerWidget { Widget _guideItem(String num, String text) => Padding( padding: const EdgeInsets.only(bottom: 6), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('$num.', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.iconColor)), + Text('$num.', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.primary)), const SizedBox(width: 6), Expanded(child: Text(text, style: const TextStyle(fontSize: 14, color: AppColors.textSecondary))), ]), @@ -995,61 +1073,8 @@ class DeviceManagementPage extends ConsumerWidget { } } -// ═══════════════════ 自定义滑动删除组件 ═══════════════════ -class _SwipeDeleteTile extends StatefulWidget { - final Widget child; - final VoidCallback onDelete; - final VoidCallback onTap; - const _SwipeDeleteTile({super.key, required this.child, required this.onDelete, required this.onTap}); - @override State<_SwipeDeleteTile> createState() => _SwipeDeleteTileState(); -} - -class _SwipeDeleteTileState extends State<_SwipeDeleteTile> with SingleTickerProviderStateMixin { - double _dx = 0; - static const _maxSlide = 80.0; - static const _threshold = 40.0; - - void _onDragUpdate(DragUpdateDetails d) { - setState(() => _dx = (_dx + d.delta.dx).clamp(-_maxSlide, 0.0)); - } - - void _onDragEnd(DragEndDetails d) { - if (_dx < -_threshold) { - setState(() => _dx = -_maxSlide); - } else { - setState(() => _dx = 0); - } - } - - void _onDelete() { - widget.onDelete(); - setState(() => _dx = 0); - } - - void _closeSwipe() => setState(() => _dx = 0); - - @override Widget build(BuildContext context) { - return Stack(children: [ - Positioned.fill(child: Container( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - decoration: BoxDecoration(color: AppTheme.error, borderRadius: BorderRadius.circular(16)), - child: const Align( - alignment: Alignment.centerRight, - child: Padding(padding: EdgeInsets.only(right: 20), child: Icon(Icons.delete_outline, color: Colors.white, size: 28)), - ), - )), - GestureDetector( - onTap: _dx < 0 ? () { widget.onDelete(); setState(() => _dx = 0); } : widget.onTap, - onHorizontalDragUpdate: _onDragUpdate, - onHorizontalDragEnd: _onDragEnd, - child: Transform.translate(offset: Offset(_dx, 0), child: widget.child), - ), - ]); - } -} - Widget _empty(BuildContext context, String title, String subtitle) => Center(child: Column(mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.inbox_outlined, size: 64, color: Colors.grey[300]), + Icon(Icons.inbox_outlined, size: 64, color: AppColors.textHint), const SizedBox(height: 12), Text(subtitle, style: Theme.of(context).textTheme.bodyMedium), ])); diff --git a/health_app/lib/pages/report/ai_analysis_page.dart b/health_app/lib/pages/report/ai_analysis_page.dart index 49467a4..d35fb1c 100644 --- a/health_app/lib/pages/report/ai_analysis_page.dart +++ b/health_app/lib/pages/report/ai_analysis_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../../core/app_colors.dart'; import '../../core/app_theme.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/navigation_provider.dart'; @@ -74,8 +75,8 @@ class _AiAnalysisPageState extends ConsumerState { Widget _buildHeader(ReportAnalysis analysis) { return Container( width: double.infinity, padding: const EdgeInsets.all(16), - decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), - boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(10), blurRadius: 8)]), + decoration: BoxDecoration(color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rLg), + boxShadow: [AppTheme.shadowCard]), child: Row(children: [ Container(width: 48, height: 48, decoration: BoxDecoration(color: AppTheme.primaryLight, borderRadius: BorderRadius.circular(12)), child: const Icon(Icons.auto_awesome, size: 28, color: AppTheme.primary)), @@ -83,7 +84,7 @@ class _AiAnalysisPageState extends ConsumerState { Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(analysis.reportType, style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w700)), const SizedBox(height: 4), - const Text('AI 预解读结果 · 待医生确认', style: TextStyle(fontSize: 16, color: Color(0xFF999999))), + const Text('AI 预解读结果 · 待医生确认', style: TextStyle(fontSize: 16, color: AppColors.textSecondary)), ])), ]), ); @@ -94,16 +95,16 @@ class _AiAnalysisPageState extends ConsumerState { return Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( - color: isReviewed ? const Color(0xFFDCFCE7) : const Color(0xFFFFF3E0), + color: isReviewed ? AppColors.successLight : AppColors.warningLight, borderRadius: BorderRadius.circular(10), ), child: Row(mainAxisSize: MainAxisSize.min, children: [ Icon(isReviewed ? Icons.check_circle : Icons.hourglass_empty, size: 21, - color: isReviewed ? AppTheme.success : const Color(0xFFF59E0B)), + color: isReviewed ? AppTheme.success : AppColors.warning), const SizedBox(width: 8), Text(isReviewed ? '已通过医生审核' : '等待医生审核中', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w500, - color: isReviewed ? AppTheme.success : const Color(0xFFF59E0B))), + color: isReviewed ? AppTheme.success : AppColors.warning)), ]), ); } @@ -111,7 +112,7 @@ class _AiAnalysisPageState extends ConsumerState { Widget _buildIndicators(ReportAnalysis analysis) { return Container( width: double.infinity, - decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)), + decoration: BoxDecoration(color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rLg)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ const Padding( padding: EdgeInsets.fromLTRB(16, 16, 16, 8), @@ -128,19 +129,19 @@ class _AiAnalysisPageState extends ConsumerState { final IconData icon; switch (ind.status) { case 'high': color = AppTheme.error; icon = Icons.arrow_upward; break; - case 'low': color = const Color(0xFFF9A825); icon = Icons.arrow_downward; break; + case 'low': color = AppColors.warning; icon = Icons.arrow_downward; break; default: color = AppTheme.success; icon = Icons.check_circle; } return Container( margin: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(vertical: 12), - decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0xFFF0F0F0)))), + decoration: BoxDecoration(border: Border(bottom: BorderSide(color: AppColors.border))), child: Row(children: [ Expanded( child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(ind.name, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w500)), if (ind.referenceRange != null && ind.referenceRange!.isNotEmpty) - Text('参考值: ${ind.referenceRange}', style: const TextStyle(fontSize: 15, color: Color(0xFF999999))), + Text('参考值: ${ind.referenceRange}', style: TextStyle(fontSize: 15, color: AppColors.textHint)), ]), ), Column(crossAxisAlignment: CrossAxisAlignment.end, children: [ @@ -155,23 +156,23 @@ class _AiAnalysisPageState extends ConsumerState { Widget _buildSummary(ReportAnalysis analysis) { return Container( width: double.infinity, padding: const EdgeInsets.all(16), - decoration: BoxDecoration(color: const Color(0xFFFEFCE8), borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFFFDE68A))), + decoration: BoxDecoration(color: AppColors.warningLight, borderRadius: BorderRadius.circular(AppTheme.rLg), + border: Border.all(color: AppColors.warning.withAlpha(80))), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Row(children: [ - Text('💡', style: TextStyle(fontSize: 21)), - SizedBox(width: 8), - Text('综合解读', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w700, color: Color(0xFFD97706))), + Row(children: [ + const Text('💡', style: TextStyle(fontSize: 21)), + const SizedBox(width: 8), + Text('综合解读', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w700, color: AppColors.warning)), ]), const SizedBox(height: 10), Text(analysis.summary.isNotEmpty ? analysis.summary : '暂无 AI 解读结果', - style: const TextStyle(fontSize: 17, color: Color(0xFF92400E), height: 1.6)), + style: TextStyle(fontSize: 17, color: AppColors.warning, height: 1.6)), const SizedBox(height: 10), Container( width: double.infinity, padding: const EdgeInsets.all(10), - decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10)), - child: const Text('⚠️ AI 解读仅供参考,请以医生诊断为准', - style: TextStyle(fontSize: 15, color: Color(0xFFD97706))), + decoration: BoxDecoration(color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rSm)), + child: Text('⚠️ AI 解读仅供参考,请以医生诊断为准', + style: TextStyle(fontSize: 15, color: AppColors.warning)), ), ]), ); @@ -180,22 +181,22 @@ class _AiAnalysisPageState extends ConsumerState { Widget _buildDoctorReview(ReportItem report) { return Container( width: double.infinity, padding: const EdgeInsets.all(16), - decoration: BoxDecoration(color: const Color(0xFFDCFCE7), borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFF86EFAC))), + decoration: BoxDecoration(color: AppColors.successLight, borderRadius: BorderRadius.circular(AppTheme.rLg), + border: Border.all(color: AppColors.success.withAlpha(80))), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Row(children: [ - Text('✅', style: TextStyle(fontSize: 21)), - SizedBox(width: 8), - Text('医生审核意见', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w700, color: Color(0xFF2E7D32))), + Row(children: [ + const Text('✅', style: TextStyle(fontSize: 21)), + const SizedBox(width: 8), + Text('医生审核意见', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w700, color: AppColors.success)), ]), if (report.doctorName != null) ...[ const SizedBox(height: 4), - Text('审核医生:${report.doctorName}', style: const TextStyle(fontSize: 16, color: Color(0xFF4CAF50))), + Text('审核医生:${report.doctorName}', style: TextStyle(fontSize: 16, color: AppColors.success)), ], if (report.reviewedAt != null) ...[ const SizedBox(height: 2), Text('审核时间:${report.reviewedAt!.toLocal().toString().substring(0, 16)}', - style: const TextStyle(fontSize: 15, color: Color(0xFF81C784))), + style: TextStyle(fontSize: 15, color: AppColors.success.withAlpha(180))), ], if (report.severity != null) ...[ const SizedBox(height: 8), @@ -205,18 +206,18 @@ class _AiAnalysisPageState extends ConsumerState { const SizedBox(height: 10), Container( width: double.infinity, padding: const EdgeInsets.all(12), - decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10)), + decoration: BoxDecoration(color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rSm)), child: Text('💬 ${report.doctorComment!}', - style: const TextStyle(fontSize: 18, color: Color(0xFF1A1A1A), height: 1.5)), + style: TextStyle(fontSize: 18, color: AppColors.textPrimary, height: 1.5)), ), ], if (report.doctorRecommendation != null && report.doctorRecommendation!.isNotEmpty) ...[ const SizedBox(height: 8), Container( width: double.infinity, padding: const EdgeInsets.all(12), - decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10)), + decoration: BoxDecoration(color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rSm)), child: Text('📝 ${report.doctorRecommendation!}', - style: const TextStyle(fontSize: 17, color: Color(0xFF333333), height: 1.5)), + style: TextStyle(fontSize: 17, color: AppColors.textPrimary, height: 1.5)), ), ], ]), @@ -225,11 +226,11 @@ class _AiAnalysisPageState extends ConsumerState { Widget _severityBadge(String severity) { final (label, color, bg) = switch (severity) { - 'Normal' => ('🟢 正常', AppTheme.success, const Color(0xFFDCFCE7)), - 'Abnormal' => ('🟡 轻度异常', const Color(0xFFF59E0B), const Color(0xFFFFF3E0)), - 'Severe' => ('🟠 中度异常', const Color(0xFFFF7043), const Color(0xFFFEE2E2)), - 'Critical' => ('🔴 重度异常', const Color(0xFFDC2626), const Color(0xFFFEE2E2)), - _ => (severity, Colors.grey, Colors.grey.withAlpha(30)), + 'Normal' => ('🟢 正常', AppTheme.success, AppColors.successLight), + 'Abnormal' => ('🟡 轻度异常', AppColors.warning, AppColors.warningLight), + 'Severe' => ('🟠 中度异常', AppColors.error, AppColors.errorLight), + 'Critical' => ('🔴 重度异常', AppColors.error, AppColors.errorLight), + _ => (severity, AppColors.textHint, AppColors.borderLight), }; return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), diff --git a/health_app/lib/pages/report/report_pages.dart b/health_app/lib/pages/report/report_pages.dart index 3185cd2..61ce4e6 100644 --- a/health_app/lib/pages/report/report_pages.dart +++ b/health_app/lib/pages/report/report_pages.dart @@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import 'package:file_picker/file_picker.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/auth_provider.dart'; @@ -129,7 +130,9 @@ class ReportNotifier extends Notifier { ); }).toList(); state = state.copyWith(reports: reports); - } catch (_) {} + } catch (e) { + debugPrint('[Report] 加载报告列表失败: $e'); + } } void fetchReportDetail(String reportId) async { @@ -234,6 +237,7 @@ class ReportListPage extends ConsumerWidget { return Scaffold( appBar: AppBar( + leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('看报告'), centerTitle: true, ), @@ -311,7 +315,7 @@ class ReportListPage extends ConsumerWidget { width: 120, height: 120, decoration: BoxDecoration( - color: const Color(0xFFF0F2FF), + color: AppColors.primaryLight, borderRadius: BorderRadius.circular(60), ), child: const Icon(Icons.file_open, size: 48, color: AppTheme.primaryLight), @@ -319,7 +323,7 @@ class ReportListPage extends ConsumerWidget { const SizedBox(height: 20), const Text('暂无检查报告', style: TextStyle(fontSize: 21, fontWeight: FontWeight.w500)), const SizedBox(height: 8), - const Text('点击下方按钮上传报告', style: TextStyle(fontSize: 17, color: Color(0xFF999999))), + const Text('点击下方按钮上传报告', style: TextStyle(fontSize: 17, color: AppColors.textHint)), ]), ); } @@ -328,24 +332,24 @@ class ReportListPage extends ConsumerWidget { return Container( margin: const EdgeInsets.only(bottom: 12), decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(10), blurRadius: 4, offset: const Offset(0, 2))], + color: AppTheme.surface, + borderRadius: BorderRadius.circular(AppTheme.rLg), + boxShadow: [AppTheme.shadowCard], ), child: ListTile( leading: Container( width: 48, height: 48, decoration: BoxDecoration( - color: const Color(0xFFF0F2FF), + color: AppColors.primaryLight, borderRadius: BorderRadius.circular(12), ), child: _getReportIcon(report.type), ), title: Text(report.title, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600)), subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(report.type, style: TextStyle(fontSize: 17, color: Colors.grey[500])), - Text(_formatDate(report.uploadedAt), style: TextStyle(fontSize: 15, color: Colors.grey[400])), + Text(report.type, style: TextStyle(fontSize: 17, color: AppColors.textSecondary)), + Text(_formatDate(report.uploadedAt), style: TextStyle(fontSize: 15, color: AppColors.textHint)), ]), trailing: Row( mainAxisSize: MainAxisSize.min, @@ -354,38 +358,36 @@ class ReportListPage extends ConsumerWidget { Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: const Color(0xFFDCFCE7), + color: AppColors.successLight, borderRadius: BorderRadius.circular(4), ), - child: const Text('已审核', style: TextStyle(fontSize: 13, color: Color(0xFF43A047))), + child: const Text('已审核', style: TextStyle(fontSize: 13, color: AppColors.success)), ) else if (!report.hasAnalysis) Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: const Color(0xFFE3F2FD), - borderRadius: BorderRadius.circular(4), + color: AppTheme.primaryLight, + borderRadius: BorderRadius.circular(AppTheme.rXs), ), - child: const Text('分析中', style: TextStyle(fontSize: 13, color: Color(0xFF2196F3))), + child: Text('分析中', style: TextStyle(fontSize: 13, color: AppTheme.primary)), ) else if (report.status == 'PendingDoctor') Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: const Color(0xFFFFF3E0), + color: AppColors.warningLight, borderRadius: BorderRadius.circular(4), ), - child: const Text('待审核', style: TextStyle(fontSize: 13, color: Color(0xFFF59E0B))), + child: const Text('待审核', style: TextStyle(fontSize: 13, color: AppColors.warning)), ), const SizedBox(width: 8), report.hasAnalysis - ? const Icon(Icons.check_circle, size: 23, color: Color(0xFF43A047)) - : const Icon(Icons.arrow_forward_ios, size: 21, color: Color(0xFFCCCCCC)), + ? Icon(Icons.check_circle, size: 23, color: AppColors.success) + : Icon(Icons.arrow_forward_ios, size: 21, color: AppColors.textHint), ], ), - onTap: () { - pushRoute(ref, 'reportDetail', params: {'id': report.id}); - }, + onTap: () => pushRoute(ref, 'reportDetail', params: {'id': report.id}), ), ); } @@ -487,23 +489,23 @@ class _ReportDetailPageState extends ConsumerState { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: const Color(0xFFDCFCE7), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFF43A047).withAlpha(50)), + color: AppColors.successLight, + borderRadius: BorderRadius.circular(AppTheme.rLg), + border: Border.all(color: AppColors.success.withAlpha(50)), ), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ const Text('✅', style: TextStyle(fontSize: 23)), const SizedBox(width: 8), - const Text('医生审核意见', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: Color(0xFF2E7D32))), + const Text('医生审核意见', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: AppColors.success)), ]), if (report.doctorName != null) ...[ const SizedBox(height: 4), - Text('审核医生:${report.doctorName}', style: const TextStyle(fontSize: 16, color: Color(0xFF4CAF50))), + Text('审核医生:${report.doctorName}', style: TextStyle(fontSize: 16, color: AppColors.success)), ], if (report.reviewedAt != null) ...[ const SizedBox(height: 2), - Text('审核时间:${report.reviewedAt!.toLocal().toString().substring(0, 19)}', style: const TextStyle(fontSize: 15, color: Color(0xFF81C784))), + Text('审核时间:${report.reviewedAt!.toLocal().toString().substring(0, 19)}', style: TextStyle(fontSize: 15, color: AppColors.success.withAlpha(180))), ], if (report.severity != null) ...[ const SizedBox(height: 6), @@ -515,10 +517,10 @@ class _ReportDetailPageState extends ConsumerState { width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), + color: AppTheme.surface, + borderRadius: BorderRadius.circular(AppTheme.rMd), ), - child: Text('💬 ${report.doctorComment!}', style: const TextStyle(fontSize: 18, color: Color(0xFF1A1A1A), height: 1.5)), + child: Text('💬 ${report.doctorComment!}', style: TextStyle(fontSize: 18, color: AppColors.textPrimary, height: 1.5)), ), ], if (report.doctorRecommendation != null && report.doctorRecommendation!.isNotEmpty) ...[ @@ -527,10 +529,10 @@ class _ReportDetailPageState extends ConsumerState { width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), + color: AppTheme.surface, + borderRadius: BorderRadius.circular(AppTheme.rMd), ), - child: Text('📝 ${report.doctorRecommendation!}', style: const TextStyle(fontSize: 17, color: Color(0xFF333333), height: 1.5)), + child: Text('📝 ${report.doctorRecommendation!}', style: TextStyle(fontSize: 17, color: AppColors.textPrimary, height: 1.5)), ), ], ]), @@ -539,15 +541,15 @@ class _ReportDetailPageState extends ConsumerState { Widget _severityBadge(String severity) { final (label, color, bg) = switch (severity) { - 'Normal' => ('🟢 正常', const Color(0xFF43A047), const Color(0xFFDCFCE7)), - 'Abnormal' => ('🟡 轻度异常', const Color(0xFFF59E0B), const Color(0xFFFFF3E0)), - 'Severe' => ('🟠 中度异常', const Color(0xFFEF4444), const Color(0xFFFEE2E2)), - 'Critical' => ('🔴 重度异常', const Color(0xFFDC2626), const Color(0xFFFEE2E2)), - _ => (severity, Colors.grey, Colors.grey.withAlpha(30)), + 'Normal' => ('🟢 正常', AppColors.success, AppColors.successLight), + 'Abnormal' => ('🟡 轻度异常', AppColors.warning, AppColors.warningLight), + 'Severe' => ('🟠 中度异常', AppColors.error, AppColors.errorLight), + 'Critical' => ('🔴 重度异常', AppColors.error, AppColors.errorLight), + _ => (severity, AppColors.textSecondary, AppColors.border), }; return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)), + decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(AppTheme.rXs)), child: Text(label, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: color)), ); } @@ -556,8 +558,8 @@ class _ReportDetailPageState extends ConsumerState { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: const Color(0xFFF0F2FF), - borderRadius: BorderRadius.circular(16), + color: AppColors.primaryLight, + borderRadius: BorderRadius.circular(AppTheme.rLg), ), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ @@ -566,7 +568,7 @@ class _ReportDetailPageState extends ConsumerState { Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(analysis.reportType, style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w600)), const SizedBox(height: 4), - const Text('AI 预解读结果', style: TextStyle(fontSize: 17, color: Color(0xFF666666))), + Text('AI 预解读结果', style: TextStyle(fontSize: 17, color: AppColors.textSecondary)), ]), ]), ]), @@ -576,9 +578,9 @@ class _ReportDetailPageState extends ConsumerState { Widget _buildAnalysisSection(ReportAnalysis analysis) { return Container( decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFFD8DCFD), width: 1.5), + color: AppTheme.surface, + borderRadius: BorderRadius.circular(AppTheme.rLg), + border: Border.all(color: AppColors.border, width: 1), ), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( @@ -593,18 +595,18 @@ class _ReportDetailPageState extends ConsumerState { margin: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( - color: const Color(0xFFFFF3E0), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: const Color(0xFFFFE0B2)), + color: AppColors.warningLight, + borderRadius: BorderRadius.circular(AppTheme.rXs), + border: Border.all(color: AppColors.warning.withAlpha(80)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.info_outline, size: 19, color: Color(0xFFE65100)), + Icon(Icons.info_outline, size: 19, color: AppColors.warning), const SizedBox(width: 6), - const Text( + Text( 'AI 预解读 · 待医生确认', - style: TextStyle(fontSize: 16, color: Color(0xFFE65100), fontWeight: FontWeight.w500), + style: TextStyle(fontSize: 16, color: AppColors.warning, fontWeight: FontWeight.w500), ), ], ), @@ -620,28 +622,28 @@ class _ReportDetailPageState extends ConsumerState { IconData statusIcon; switch (ind.status) { case 'high': - statusColor = const Color(0xFFE53935); + statusColor = AppColors.error; statusIcon = Icons.arrow_upward; break; case 'low': - statusColor = const Color(0xFFF9A825); + statusColor = AppColors.warning; statusIcon = Icons.arrow_downward; break; default: - statusColor = const Color(0xFF43A047); + statusColor = AppColors.success; statusIcon = Icons.check_circle; } return Container( margin: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(vertical: 12), - decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0xFFF0F0F0)))), + decoration: BoxDecoration(border: Border(bottom: BorderSide(color: AppColors.border))), child: Row(children: [ Expanded( child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(ind.name, style: const TextStyle(fontSize: 17)), if (ind.referenceRange != null) - Text('参考值: ${ind.referenceRange}', style: TextStyle(fontSize: 15, color: Colors.grey[400])), + Text('参考值: ${ind.referenceRange}', style: TextStyle(fontSize: 15, color: AppColors.textHint)), ]), ), const SizedBox(width: 16), @@ -657,25 +659,25 @@ class _ReportDetailPageState extends ConsumerState { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: const Color(0xFFFEF3C7), - borderRadius: BorderRadius.circular(16), + color: AppColors.warningLight, + borderRadius: BorderRadius.circular(AppTheme.rLg), ), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ const Text('💡', style: TextStyle(fontSize: 23)), const SizedBox(width: 8), - const Text('综合解读', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: Color(0xFFD97706))), + Text('综合解读', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: AppColors.warning)), ]), const SizedBox(height: 12), - Text(analysis.summary, style: const TextStyle(fontSize: 17, color: Color(0xFF92400E), height: 1.6)), + Text(analysis.summary, style: TextStyle(fontSize: 17, color: AppColors.warning, height: 1.6)), const SizedBox(height: 12), Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), + color: AppTheme.surface, + borderRadius: BorderRadius.circular(AppTheme.rSm), ), - child: const Text('⚠️ AI 解读仅供参考,请以医生诊断为准', style: TextStyle(fontSize: 16, color: Color(0xFFD97706))), + child: Text('⚠️ AI 解读仅供参考,请以医生诊断为准', style: TextStyle(fontSize: 16, color: AppColors.warning)), ), ]), ); diff --git a/health_app/lib/pages/settings/notification_prefs_page.dart b/health_app/lib/pages/settings/notification_prefs_page.dart index 489fdb4..6aeb959 100644 --- a/health_app/lib/pages/settings/notification_prefs_page.dart +++ b/health_app/lib/pages/settings/notification_prefs_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../../core/app_colors.dart'; import '../../core/app_theme.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/navigation_provider.dart'; @@ -55,10 +56,10 @@ class NotificationPrefsPage extends ConsumerWidget { backgroundColor: Colors.transparent, elevation: 0, leading: IconButton( - icon: const Icon(Icons.arrow_back_ios_new, color: Color(0xFF1A1A1A)), + icon: const Icon(Icons.arrow_back_ios_new, color: AppColors.textPrimary), onPressed: () => popRoute(ref), ), - title: const Text('消息通知', style: TextStyle(fontSize: 21, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), + title: Text('消息通知', style: TextStyle(fontSize: 21, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), centerTitle: true, ), body: SingleChildScrollView( @@ -80,8 +81,8 @@ class NotificationPrefsPage extends ConsumerWidget { _SectionTitle(title: '通知类型'), _SwitchTile( icon: Icons.medication_rounded, - iconBg: const Color(0xFFFFF3E0), - iconColor: const Color(0xFFFF9800), + iconBg: AppColors.errorLight, + iconColor: AppColors.error, title: '用药提醒', subtitle: '服药时间到达时提醒您', value: prefs['medication'] ?? true, @@ -89,7 +90,7 @@ class NotificationPrefsPage extends ConsumerWidget { ), _SwitchTile( icon: Icons.warning_amber_rounded, - iconBg: const Color(0xFFFFEBEE), + iconBg: AppColors.errorLight, iconColor: AppTheme.error, title: '健康异常提醒', subtitle: '检测到数据异常时及时通知', @@ -98,8 +99,8 @@ class NotificationPrefsPage extends ConsumerWidget { ), _SwitchTile( icon: Icons.event_available_rounded, - iconBg: const Color(0xFFE8F5E9), - iconColor: const Color(0xFF4CAF50), + iconBg: AppColors.successLight, + iconColor: AppColors.success, title: '复查日期提醒', subtitle: '复查日前一天提醒您预约', value: prefs['followUp'] ?? true, @@ -107,7 +108,7 @@ class NotificationPrefsPage extends ConsumerWidget { ), _SwitchTile( icon: Icons.smart_toy_outlined, - iconBg: const Color(0xFFF3E5F5), + iconBg: AppColors.iconBg, iconColor: AppTheme.primary, title: 'AI 回复通知', subtitle: 'AI 助手回复时发送通知', @@ -128,13 +129,13 @@ class NotificationPrefsPage extends ConsumerWidget { Container( margin: const EdgeInsets.symmetric(horizontal: 4), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), - decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)), + decoration: BoxDecoration(color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: Row(children: [ Expanded(child: _TimeButton(label: '开始', time: '22:00', onTap: () async { final picked = await showTimePicker(context: context, initialTime: const TimeOfDay(hour: 22, minute: 0)); if (picked != null && context.mounted) ref.read(notificationPrefsProvider.notifier).setDndStart(picked); })), - Padding(padding: const EdgeInsets.symmetric(horizontal: 12), child: Text('~', style: TextStyle(fontSize: 19, color: Colors.grey[400]))), + Padding(padding: const EdgeInsets.symmetric(horizontal: 12), child: Text('~', style: TextStyle(fontSize: 19, color: AppTheme.textHint))), Expanded(child: _TimeButton(label: '结束', time: '08:00', onTap: () async { final picked = await showTimePicker(context: context, initialTime: const TimeOfDay(hour: 8, minute: 0)); if (picked != null && context.mounted) ref.read(notificationPrefsProvider.notifier).setDndEnd(picked); @@ -158,7 +159,7 @@ class _SectionTitle extends StatelessWidget { const _SectionTitle({required this.title}); @override Widget build(BuildContext context) { - return Padding(padding: const EdgeInsets.only(left: 4, bottom: 10), child: Text(title, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: Color(0xFF999999)))); + return Padding(padding: const EdgeInsets.only(left: 4, bottom: 10), child: Text(title, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: AppColors.textSecondary))); } } @@ -184,17 +185,17 @@ class _SwitchTile extends StatelessWidget { return Container( margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), - decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)), + decoration: BoxDecoration(color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: Row(children: [ if (icon != null) ...[ Container(width: 38, height: 38, decoration: BoxDecoration(color: iconBg, borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 23, color: iconColor)), const SizedBox(width: 12), ], Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, style: const TextStyle(fontSize: 18, color: Color(0xFF1A1A1A), fontWeight: FontWeight.w500)), - if (subtitle != null && subtitle!.isNotEmpty) Text(subtitle!, style: TextStyle(fontSize: 15, color: Colors.grey[500])), + Text(title, style: const TextStyle(fontSize: 18, color: AppColors.textPrimary, fontWeight: FontWeight.w500)), + if (subtitle != null && subtitle!.isNotEmpty) Text(subtitle!, style: TextStyle(fontSize: 15, color: AppTheme.textSub)), ])), - Switch(value: value, onChanged: onChanged, activeThumbColor: AppTheme.primary, activeTrackColor: const Color(0xFFC5BFFF)), + Switch(value: value, onChanged: onChanged, activeThumbColor: AppTheme.primary, activeTrackColor: AppColors.primaryLight), ]), ); } @@ -210,8 +211,8 @@ class _TimeButton extends StatelessWidget { Widget build(BuildContext context) { return GestureDetector(onTap: onTap, child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration(border: Border.all(color: const Color(0xFFE0E0E0)), borderRadius: BorderRadius.circular(10)), - child: Column(children: [Text(label, style: TextStyle(fontSize: 14, color: Colors.grey[500])), Text(time, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: AppTheme.primary))]), + decoration: BoxDecoration(border: Border.all(color: AppColors.border), borderRadius: BorderRadius.circular(AppTheme.rSm)), + child: Column(children: [Text(label, style: TextStyle(fontSize: 14, color: AppTheme.textHint)), Text(time, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: AppTheme.primary))]), )); } } diff --git a/health_app/lib/pages/settings/settings_pages.dart b/health_app/lib/pages/settings/settings_pages.dart index a06030a..2f297f3 100644 --- a/health_app/lib/pages/settings/settings_pages.dart +++ b/health_app/lib/pages/settings/settings_pages.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; 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/auth_provider.dart'; @@ -10,15 +11,12 @@ class SettingsPage extends ConsumerWidget { const SettingsPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final theme = ShadTheme.of(context); - return Scaffold( - backgroundColor: theme.colorScheme.background, + backgroundColor: AppColors.background, appBar: AppBar( - backgroundColor: theme.colorScheme.card, - elevation: 0, - leading: IconButton(icon: Icon(LucideIcons.chevronLeft, color: theme.colorScheme.foreground), onPressed: () => popRoute(ref)), - title: Text('设置', style: TextStyle(fontSize: 21, fontWeight: FontWeight.w600, color: theme.colorScheme.foreground)), + backgroundColor: AppColors.cardBackground, + leading: IconButton(icon: const Icon(LucideIcons.chevronLeft, color: AppColors.textPrimary), onPressed: () => popRoute(ref)), + title: const Text('设置', style: TextStyle(fontSize: 21, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), centerTitle: true, ), body: SafeArea(child: SingleChildScrollView( @@ -26,10 +24,8 @@ class SettingsPage extends ConsumerWidget { child: Column(children: [ const SizedBox(height: AppTheme.sMd), AppMenuItem(icon: LucideIcons.bell, title: '消息通知', onTap: () => pushRoute(ref, 'notificationPrefs')), - AppMenuItem(icon: LucideIcons.pill, title: '用药提醒', subtitle: 'mmHg / mmol/L', onTap: () => pushRoute(ref, 'medications')), - AppMenuItem(icon: LucideIcons.database, title: '数据导出', onTap: () {}), - AppMenuItem(icon: LucideIcons.type, title: '字体大小', trailing: 'v1.0.0', onTap: () {}), - AppMenuItem(icon: LucideIcons.sprayCan, title: '清除缓存', subtitle: '73.2 MB', onTap: () {}), + AppMenuItem(icon: LucideIcons.type, title: '字体大小', subtitle: '老年版', onTap: () {}), + AppMenuItem(icon: LucideIcons.sprayCan, title: '清除缓存', onTap: () {}), AppMenuItem(icon: LucideIcons.info, title: '关于健康管家', onTap: () => pushRoute(ref, 'staticText', params: {'type': 'about'})), AppMenuItem(icon: LucideIcons.shield, title: '隐私协议', onTap: () => pushRoute(ref, 'staticText', params: {'type': 'privacy'})), const SizedBox(height: 30), @@ -40,10 +36,10 @@ class SettingsPage extends ConsumerWidget { height: 50, alignment: Alignment.center, decoration: BoxDecoration( - border: Border.all(color: theme.colorScheme.destructive.withAlpha(80)), + border: Border.all(color: AppColors.error.withAlpha(80)), borderRadius: BorderRadius.circular(AppTheme.rPill), ), - child: Text('退出登录', style: TextStyle(fontSize: 19, color: theme.colorScheme.destructive, fontWeight: FontWeight.w500)), + child: const Text('退出登录', style: TextStyle(fontSize: 19, color: AppColors.error, fontWeight: FontWeight.w500)), ), ), ]), @@ -52,16 +48,15 @@ class SettingsPage extends ConsumerWidget { } Future _logout(BuildContext context, WidgetRef ref) async { - final theme = ShadTheme.of(context); final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rXl)), - title: Text('退出登录', style: TextStyle(color: theme.colorScheme.foreground)), - content: Text('确定退出?', style: TextStyle(color: theme.colorScheme.mutedForeground)), + title: const Text('退出登录'), + content: const Text('确定退出?'), actions: [ - TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('取消', style: TextStyle(color: theme.colorScheme.mutedForeground))), - TextButton(onPressed: () => Navigator.pop(ctx, true), child: Text('确定', style: TextStyle(color: theme.colorScheme.destructive))), + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), + TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定', style: TextStyle(color: AppColors.error))), ], ), ); diff --git a/health_app/lib/providers/auth_provider.dart b/health_app/lib/providers/auth_provider.dart index af2568d..7a127e5 100644 --- a/health_app/lib/providers/auth_provider.dart +++ b/health_app/lib/providers/auth_provider.dart @@ -84,7 +84,9 @@ class AuthNotifier extends Notifier { ), ); } - } catch (_) {} + } catch (e) { + print('[Auth]加载个人信息失败: $e'); + } } /// 发送验证码,返回 (error, devCode) @@ -131,7 +133,7 @@ class AuthNotifier extends Notifier { final db = ref.read(localDbProvider); final refresh = await db.read('refresh_token'); if (refresh != null) { - try { await api.post('/api/auth/logout', data: {'refreshToken': refresh}); } catch (_) {} + try { await api.post('/api/auth/logout', data: {'refreshToken': refresh}); } catch (e) { print('[Auth]登出请求失败: $e'); } } await api.clearTokens(); state = const AuthState(isLoggedIn: false, isLoading: false); diff --git a/health_app/lib/providers/chat_provider.dart b/health_app/lib/providers/chat_provider.dart index c570fe6..71ca942 100644 --- a/health_app/lib/providers/chat_provider.dart +++ b/health_app/lib/providers/chat_provider.dart @@ -82,6 +82,31 @@ class ChatNotifier extends Notifier { void markNeedsRebuild() => state = state.copyWith(); + /// 不可变消息操作方法(供 chat_messages_view 新版代码调用) + void confirmMessage(String id) { + final msgs = state.messages.toList(); + final i = msgs.indexWhere((m) => m.id == id); + if (i >= 0) { msgs[i].confirmed = true; state = state.copyWith(messages: msgs); } + ref.invalidate(medicationListProvider); + ref.invalidate(latestHealthProvider); + } + + void startEditingField(String msgId, String fieldLabel) { + final msgs = state.messages.toList(); + final i = msgs.indexWhere((m) => m.id == msgId); + if (i >= 0) { msgs[i].metadata?['_editingField'] = fieldLabel; state = state.copyWith(messages: msgs); } + } + + void finishEditingField(String msgId, String fieldLabel, String value) { + final msgs = state.messages.toList(); + final i = msgs.indexWhere((m) => m.id == msgId); + if (i >= 0) { + if (value.isNotEmpty) msgs[i].metadata?[fieldLabel] = value; + msgs[i].metadata?.remove('_editingField'); + state = state.copyWith(messages: msgs); + } + } + @override ChatState build() { Future.microtask(() { diff --git a/health_app/lib/providers/consultation_provider.dart b/health_app/lib/providers/consultation_provider.dart index 4d4f585..f580981 100644 --- a/health_app/lib/providers/consultation_provider.dart +++ b/health_app/lib/providers/consultation_provider.dart @@ -197,7 +197,7 @@ class ConsultationChatNotifier extends Notifier { doctorTitle: doc['title']?.toString() ?? '', doctorDepartment: doc['department']?.toString() ?? '', ); - } catch (_) {} + } catch (e) { print('[Consultation]请求失败: $e'); } } Future _loadMessages(String consultationId) async { @@ -218,7 +218,7 @@ class ConsultationChatNotifier extends Notifier { if (msgs.isNotEmpty) { state = state.copyWith(messages: msgs); } - } catch (_) {} + } catch (e) { print('[Consultation]请求失败: $e'); } } Future sendMessage(String text) async { @@ -333,7 +333,7 @@ class ConsultationChatNotifier extends Notifier { if (newMsgs.isNotEmpty) { state = state.copyWith(messages: [...state.messages, ...newMsgs]); } - } catch (_) {} + } catch (e) { print('[Consultation]请求失败: $e'); } } void stop() { diff --git a/health_app/lib/services/omron_ble_service.dart b/health_app/lib/services/omron_ble_service.dart index f05ea84..1f17615 100644 --- a/health_app/lib/services/omron_ble_service.dart +++ b/health_app/lib/services/omron_ble_service.dart @@ -160,6 +160,6 @@ class OmronBleService { now.month, now.day, now.hour, now.minute, now.second, ]); - } catch (_) {} + } catch (e) { print('[BLE]读取数据失败: $e'); } } } diff --git a/health_app/lib/widgets/common_widgets.dart b/health_app/lib/widgets/common_widgets.dart index 5c1890d..a29423f 100644 --- a/health_app/lib/widgets/common_widgets.dart +++ b/health_app/lib/widgets/common_widgets.dart @@ -23,7 +23,7 @@ class GradientBorderButton extends StatelessWidget { return Container( height: height, decoration: BoxDecoration( - gradient: isSuccess ? AppColors.successButtonGradient : AppColors.buttonGradient, + gradient: isSuccess ? AppColors.primaryGradient : AppColors.primaryGradient, borderRadius: BorderRadius.circular(14), boxShadow: AppColors.buttonShadow, ), @@ -78,14 +78,14 @@ class CardActionButton extends StatelessWidget { child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( - color: AppColors.backgroundSecondary, + color: AppColors.cardInner, borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.borderLight), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 18, color: iconColor ?? AppColors.iconColor), + Icon(icon, size: 18, color: iconColor ?? AppColors.primary), const SizedBox(width: 6), Text( label, @@ -102,6 +102,63 @@ class CardActionButton extends StatelessWidget { } } +/// 滑动删除组件:左滑露出红色删除背景,达30%阈值锁定,点击确认删除 +/// [margin] 控制卡片之间的间距,red背景与child完全对齐 +class SwipeDeleteTile extends StatefulWidget { + final Widget child; + final VoidCallback onDelete; + final VoidCallback? onTap; + final EdgeInsetsGeometry margin; + const SwipeDeleteTile({super.key, required this.child, required this.onDelete, this.onTap, this.margin = const EdgeInsets.symmetric(horizontal: 16, vertical: 4)}); + @override State createState() => _SwipeDeleteTileState(); +} + +class _SwipeDeleteTileState extends State with SingleTickerProviderStateMixin { + double _dx = 0; + static const _maxSlide = 80.0; + static const _threshold = 24.0; // 30% of 80 + + void _onDragUpdate(DragUpdateDetails d) { + setState(() => _dx = (_dx + d.delta.dx).clamp(-_maxSlide, 0.0)); + } + + void _onDragEnd(DragEndDetails d) { + if (_dx < -_threshold) { + setState(() => _dx = -_maxSlide); + } else { + setState(() => _dx = 0); + } + } + + @override Widget build(BuildContext context) { + final isSwiped = _dx < 0; + return Padding( + padding: widget.margin, + child: Stack(children: [ + Positioned.fill(child: Container( + decoration: BoxDecoration(color: AppColors.error, borderRadius: BorderRadius.circular(16)), + child: const Align( + alignment: Alignment.centerRight, + child: Padding(padding: EdgeInsets.only(right: 20), child: Icon(Icons.delete_outline, color: AppColors.textOnGradient, size: 28)), + ), + )), + GestureDetector( + behavior: isSwiped ? HitTestBehavior.opaque : HitTestBehavior.deferToChild, + onTap: isSwiped ? () { widget.onDelete(); setState(() => _dx = 0); } : widget.onTap, + onHorizontalDragUpdate: _onDragUpdate, + onHorizontalDragEnd: _onDragEnd, + child: Transform.translate( + offset: Offset(_dx, 0), + child: isSwiped + ? AbsorbPointer(absorbing: true, child: widget.child) + : widget.child, + ), + ), + ]), + ); + } +} + /// 功能区小图标容器 class IconBox extends StatelessWidget { final IconData icon; @@ -123,13 +180,13 @@ class IconBox extends StatelessWidget { width: size, height: size, decoration: BoxDecoration( - color: backgroundColor ?? AppColors.iconBackground, + color: backgroundColor ?? AppColors.iconBg, borderRadius: BorderRadius.circular(size / 3), ), child: Icon( icon, size: size * 0.55, - color: iconColor ?? AppColors.iconColor, + color: iconColor ?? AppColors.primary, ), ); } diff --git a/health_app/lib/widgets/health_drawer.dart b/health_app/lib/widgets/health_drawer.dart index e76f96a..901763e 100644 --- a/health_app/lib/widgets/health_drawer.dart +++ b/health_app/lib/widgets/health_drawer.dart @@ -1,13 +1,12 @@ import 'package:flutter/material.dart'; import '../core/app_theme.dart'; +import '../core/app_colors.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/navigation_provider.dart'; import '../providers/auth_provider.dart'; import '../providers/data_providers.dart'; -import '../providers/chat_provider.dart'; import 'service_package_card.dart'; -/// 侧滑抽屉——彩色分区卡片式设计 class HealthDrawer extends ConsumerWidget { const HealthDrawer({super.key}); @@ -17,285 +16,258 @@ class HealthDrawer extends ConsumerWidget { final user = auth.user; final latestHealth = ref.watch(latestHealthProvider); return Drawer( - width: MediaQuery.of(context).size.width * 0.82, - backgroundColor: const Color(0xFFFAFBFE), - child: SafeArea( - child: ListView( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 20), - children: [ - // ════════════ 用户区 ════════════ - _SectionCard( - color: const Color(0xFF635BFF), - gradientColors: [const Color(0xFF7C74FF), const Color(0xFF5248E8)], - child: GestureDetector( - onTap: () => pushRoute(ref, 'profile'), - child: Padding( - padding: const EdgeInsets.all(18), - child: Row(children: [ - Container( - width: 52, height: 52, + width: MediaQuery.of(context).size.width * 0.85, + child: Container( + decoration: const BoxDecoration(gradient: AppColors.bgGradient), + child: SafeArea( + child: ListView( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 0), + children: [ + // 个人信息 + 蓝牙 + 健康档案 — 合并为一个卡片 + _buildTopCard(user, ref), + const SizedBox(height: 8), + _buildHealthSection(latestHealth, ref), + const SizedBox(height: 8), + _buildFeatureSection(ref), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(AppTheme.rMd), + border: Border.all(color: Color(0xFFC8DDFD), width: 1.5), + boxShadow: [AppTheme.shadowCard], + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 0), + child: Container( decoration: BoxDecoration( - gradient: LinearGradient(colors: [Colors.white.withAlpha(40), Colors.white.withAlpha(15)]), - shape: BoxShape.circle, - border: Border.all(color: Colors.white30, width: 1.5), + gradient: AppColors.primaryGradient, + borderRadius: BorderRadius.circular(AppTheme.rPill), ), - child: user?.avatarUrl != null - ? ClipOval(child: Image.network(user!.avatarUrl!, fit: BoxFit.cover, errorBuilder: (_, e, s) => _defaultAvatar())) - : _defaultAvatar(), - ), - const SizedBox(width: 14), - Expanded(child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(user?.name ?? '未设置昵称', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: Colors.white)), - const SizedBox(height: 2), - Text(user?.phone ?? '未登录', style: TextStyle(fontSize: 15, color: Colors.white70)), - ], - )), - Icon(Icons.chevron_right, size: 21, color: Colors.white54), - ]), - ), - ), - ), - - const SizedBox(height: 10), - - // ════════════ 健康概览区 ════════════ - _SectionCard( - color: Colors.white, - gradientColors: null, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 6), - child: Row(children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: AppTheme.primary.withAlpha(15), - borderRadius: BorderRadius.circular(6), - ), - child: const Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.monitor_heart_rounded, size: 16, color: AppTheme.primary), - SizedBox(width: 4), - Text('健康概览', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppTheme.primary)), - ]), - ), - const Spacer(), - GestureDetector( - onTap: () => pushRoute(ref, 'trend'), - child: const Padding(padding: EdgeInsets.all(4), child: Text('详情', style: TextStyle(fontSize: 14, color: Color(0xFF888888)))), - ), - ]), - ), - latestHealth.when( - data: (data) => Padding( - padding: const EdgeInsets.fromLTRB(12, 4, 12, 14), - child: Row(children: [ - _MiniMetric(icon: Icons.favorite_rounded, label: '血压', value: _bpText(data['BloodPressure']), onTap: () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'})), - _MiniMetric(icon: Icons.monitor_heart_outlined, label: '心率', value: _metricVal(data['HeartRate']), onTap: () => pushRoute(ref, 'trend', params: {'type': 'heart_rate'})), - _MiniMetric(icon: Icons.bloodtype_outlined, label: '血糖', value: _metricVal(data['Glucose']), onTap: () => pushRoute(ref, 'trend', params: {'type': 'glucose'})), - _MiniMetric(icon: Icons.air_outlined, label: '血氧', value: _metricVal(data['SpO2']), onTap: () => pushRoute(ref, 'trend', params: {'type': 'spo2'})), - _MiniMetric(icon: Icons.monitor_weight_outlined, label: '体重', value: _metricVal(data['Weight']), onTap: () => pushRoute(ref, 'trend', params: {'type': 'weight'})), - ]), - ), - loading: () => const Padding(padding: EdgeInsets.symmetric(vertical: 20), child: Center(child: SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.primary)))), - error: (_, __) => Padding( - padding: const EdgeInsets.fromLTRB(12, 4, 12, 14), - child: Row(children: [ - _MiniMetric(icon: Icons.favorite_rounded, label: '血压', value: '--'), - _MiniMetric(icon: Icons.monitor_heart_outlined, label: '心率', value: '--'), - _MiniMetric(icon: Icons.bloodtype_outlined, label: '血糖', value: '--'), - _MiniMetric(icon: Icons.air_outlined, label: '血氧', value: '--'), - _MiniMetric(icon: Icons.monitor_weight_outlined, label: '体重', value: '--'), - ]), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + child: const Text('服务包', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Colors.white)), ), ), - ], + const ServicePackageCard(compact: true), + ]), ), - ), - - const SizedBox(height: 10), - - // ════════════ 功能区(横向均布)════════════ - _SectionCard( - color: Colors.white, - gradientColors: null, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 6), - child: Row(children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: const Color(0xFFF0A060).withAlpha(15), - borderRadius: BorderRadius.circular(6), - ), - child: const Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.apps_rounded, size: 16, color: Color(0xFFF0A060)), - SizedBox(width: 4), - Text('功能', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFFF0A060))), - ]), - ), - ]), - ), - Padding( - padding: const EdgeInsets.fromLTRB(12, 4, 12, 14), - child: Row(children: [ - _FeatureChip(icon: Icons.description_outlined, label: '报告管理', onTap: () => pushRoute(ref, 'reports')), - _FeatureChip(icon: Icons.calendar_today_outlined, label: '健康日历', onTap: () => pushRoute(ref, 'calendar')), - _FeatureChip(icon: Icons.restaurant_outlined, label: '饮食记录', onTap: () => pushRoute(ref, 'dietRecords')), - _FeatureChip(icon: Icons.event_note_outlined, label: '复查随访', onTap: () => pushRoute(ref, 'followups')), - ]), - ), - Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 14), - child: Row(children: [ - _FeatureChip(icon: Icons.bluetooth_rounded, label: '蓝牙设备', onTap: () => pushRoute(ref, 'devices')), - ]), - ), - ], - ), - ), - - const SizedBox(height: 10), - - // ════════════ 产品服务包 ════════════ - const ServicePackageCard(compact: true), - - const SizedBox(height: 10), - - // ════════════ 设置区 ════════════ - _SectionCard( - color: const Color(0xFFF5F5F7), - gradientColors: null, - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => pushRoute(ref, 'settings'), - borderRadius: BorderRadius.circular(16), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), - child: Row(children: [ - Container( - width: 34, height: 34, - decoration: BoxDecoration( - color: const Color(0xFFEEEEEE), - borderRadius: BorderRadius.circular(10), - ), - child: const Icon(Icons.settings_outlined, size: 21, color: Color(0xFF666666)), - ), - const SizedBox(width: 12), - const Expanded(child: Text('设置', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500, color: Color(0xFF333333)))), - const Icon(Icons.chevron_right, size: 19, color: Color(0xFFCCCCCC)), - ]), - ), - ), - ), - ), - - const SizedBox(height: 6), - ], + const SizedBox(height: 8), + ], + ), ), ), ); } - static Widget _defaultAvatar() => const Icon(Icons.person, size: 26, color: Colors.white70); + // ════════════ 顶部区域(个人信息 + 设置 + 快捷入口,无框融入背景) ═══════════ + Widget _buildTopCard(dynamic user, WidgetRef ref) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Column(children: [ + // 第一行:个人信息(窄)+ 设置按钮 + Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 8, 0), + child: Row(children: [ + // 用户头像+名称 — 占70% + Expanded( + flex: 7, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => pushRoute(ref, 'profile'), + child: Row(children: [ + Container( + width: 44, height: 44, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: const Icon(Icons.person, size: 24, color: AppColors.textHint), + ), + const SizedBox(width: 10), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(user?.name ?? '未设置昵称', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: AppColors.textPrimary), overflow: TextOverflow.ellipsis), + Text(user?.phone ?? '未登录', style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)), + ])), + ]), + ), + ), + // 设置按钮 — 占30% + Expanded( + flex: 3, + child: GestureDetector( + onTap: () => pushRoute(ref, 'settings'), + child: Container( + height: 44, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppTheme.rSm), + ), + child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ + const Icon(Icons.settings_outlined, size: 22, color: AppColors.textPrimary), + const SizedBox(height: 2), + const Text('设置', style: TextStyle(fontSize: 11, color: AppColors.textPrimary)), + ]), + ), + ), + ), + ]), + ), + const SizedBox(height: 16), + // 第二行:蓝牙 + 健康档案 — 白色底 + Row(children: [ + Expanded(child: _QuickEntry(Icons.bluetooth_rounded, '蓝牙设备', () => pushRoute(ref, 'devices'))), + const SizedBox(width: 12), + Expanded(child: _QuickEntry(Icons.folder_outlined, '健康档案', () => pushRoute(ref, 'healthArchive'))), + ]), + ]), + ); + } + Widget _QuickEntry(IconData icon, String label, VoidCallback onTap) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 18), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(AppTheme.rMd), + border: Border.all(color: Color(0xFFC8DDFD), width: 1.5), + ), + child: Column(children: [ + Icon(icon, size: 28, color: AppColors.primary), + const SizedBox(height: 6), + Text(label, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + ]), + ), + ); + } + + // ════════════ 健康概览 ════════════ + Widget _buildHealthSection(AsyncValue> latestHealth, WidgetRef ref) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(AppTheme.rLg), + border: Border.all(color: Color(0xFFC8DDFD), width: 1.5), + boxShadow: [AppTheme.shadowCard], + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 4), + child: Row(children: [ + Container( + decoration: BoxDecoration( + gradient: AppColors.primaryGradient, + borderRadius: BorderRadius.circular(AppTheme.rPill), + ), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + child: const Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.monitor_heart_rounded, size: 15, color: Colors.white), + SizedBox(width: 4), + Text('健康概览', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Colors.white)), + ]), + ), + const Spacer(), + GestureDetector( + onTap: () => pushRoute(ref, 'trend'), + child: const Padding(padding: EdgeInsets.all(4), child: Text('详情', style: TextStyle(fontSize: 13, color: AppColors.textHint))), + ), + ]), + ), + latestHealth.when( + data: (data) => Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 14), + child: Row(children: [ + _MiniMetric(Icons.favorite_rounded, '血压', _bpText(data['BloodPressure']), () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'})), + _MiniMetric(Icons.monitor_heart_outlined, '心率', _metricVal(data['HeartRate']), () => pushRoute(ref, 'trend', params: {'type': 'heart_rate'})), + _MiniMetric(Icons.bloodtype_outlined, '血糖', _metricVal(data['Glucose']), () => pushRoute(ref, 'trend', params: {'type': 'glucose'})), + _MiniMetric(Icons.air_outlined, '血氧', _metricVal(data['SpO2']), () => pushRoute(ref, 'trend', params: {'type': 'spo2'})), + _MiniMetric(Icons.monitor_weight_outlined, '体重', _metricVal(data['Weight']), () => pushRoute(ref, 'trend', params: {'type': 'weight'})), + ]), + ), + loading: () => const Padding(padding: EdgeInsets.all(20), child: Center(child: SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.primary)))), + error: (_, __) => Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 14), + child: Row(children: [ + _MiniMetric(Icons.favorite_rounded, '血压', '--'), + _MiniMetric(Icons.monitor_heart_outlined, '心率', '--'), + _MiniMetric(Icons.bloodtype_outlined, '血糖', '--'), + _MiniMetric(Icons.air_outlined, '血氧', '--'), + _MiniMetric(Icons.monitor_weight_outlined, '体重', '--'), + ]), + ), + ), + ]), + ); + } + + // ════════════ 功能区 ════════════ + Widget _buildFeatureSection(WidgetRef ref) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(AppTheme.rLg), + border: Border.all(color: Color(0xFFC8DDFD), width: 1.5), + boxShadow: [AppTheme.shadowCard], + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 4), + child: Container( + decoration: BoxDecoration( + gradient: AppColors.primaryGradient, + borderRadius: BorderRadius.circular(AppTheme.rPill), + ), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + child: const Text('功能', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Colors.white)), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 6, 12, 14), + child: Row(children: [ + _FeatureChip(icon: Icons.description_outlined, label: '报告管理', onTap: () => pushRoute(ref, 'reports')), + _FeatureChip(icon: Icons.calendar_today_outlined, label: '健康日历', onTap: () => pushRoute(ref, 'calendar')), + _FeatureChip(icon: Icons.restaurant_outlined, label: '饮食记录', onTap: () => pushRoute(ref, 'dietRecords')), + _FeatureChip(icon: Icons.event_note_outlined, label: '复查随访', onTap: () => pushRoute(ref, 'followups')), + ]), + ), + ]), + ); + } + + // ════════════ Helpers ════════════ String _bpText(dynamic bp) { if (bp == null) return '--'; if (bp is Map) return '${bp['systolic'] ?? '--'}/${bp['diastolic'] ?? '--'}'; return '--'; } - - String _metricVal(dynamic metric) { - if (metric == null) return '--'; - if (metric is Map) { final v = metric['value']; return v?.toString() ?? '--'; } - return metric.toString(); - } - -} - -// ═══════════════════════════════════════════════════════════════ -// 分区卡片容器 —— 带圆角、阴影和微动效 -// ═══════════════════════════════════════════════════════════════ - -class _SectionCard extends StatelessWidget { - final Widget child; - final Color color; - final List? gradientColors; - - const _SectionCard({required this.child, required this.color, this.gradientColors}); - - @override - Widget build(BuildContext context) { - return TweenAnimationBuilder( - tween: Tween(begin: 0.0, end: 1.0), - duration: const Duration(milliseconds: 500), - curve: Curves.easeOutCubic, - builder: (context, value, child) => Transform.translate( - offset: Offset(0, 8 * (1 - value)), - child: Opacity(opacity: value, child: child), - ), - child: Container( - decoration: BoxDecoration( - color: gradientColors == null ? color : null, - gradient: gradientColors != null ? LinearGradient( - colors: gradientColors!, - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ) : null, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: (gradientColors?.first ?? color).withAlpha(25), - blurRadius: 12, - offset: const Offset(0, 4), - ), - ], - ), - clipBehavior: Clip.antiAlias, - child: child, - ), - ); + String _metricVal(dynamic val) { + if (val == null) return '--'; + if (val is num) return val.toStringAsFixed(val is double ? 1 : 0); + if (val is Map) return (val['value']?.toString() ?? '--'); + return '--'; } } -// ═══════════════════════════════════════════════════════════════ -// 健康指标小方块 -// ═══════════════════════════════════════════════════════════════ - class _MiniMetric extends StatelessWidget { - final IconData icon; - final String label; - final String value; - final VoidCallback? onTap; - const _MiniMetric({required this.icon, required this.label, required this.value, this.onTap}); - - static const _c = AppTheme.primary; - + final IconData icon; final String label; final String value; final VoidCallback? onTap; + const _MiniMetric(this.icon, this.label, this.value, [this.onTap]); @override Widget build(BuildContext context) { return Expanded( child: GestureDetector( onTap: onTap, child: Container( - margin: const EdgeInsets.symmetric(horizontal: 3), + margin: const EdgeInsets.symmetric(horizontal: 2), padding: const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( - color: _c.withAlpha(10), - borderRadius: BorderRadius.circular(12), + gradient: AppColors.bgGradient, + borderRadius: BorderRadius.circular(AppTheme.rSm), ), child: Column(mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 21, color: _c), - const SizedBox(height: 6), - Text(value, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Color(0xFF1A1A1A)), maxLines: 1, overflow: TextOverflow.ellipsis), - const SizedBox(height: 2), - Text(label, style: TextStyle(fontSize: 13, color: Colors.grey[500])), + Icon(icon, size: 24, color: AppColors.primary), + const SizedBox(height: 5), + Text(value, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800, color: AppColors.textPrimary)), + Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textHint)), ]), ), ), @@ -303,43 +275,28 @@ class _MiniMetric extends StatelessWidget { } } -// ═══════════════════════════════════════════════════════════════ -// 功能按钮(横向) -// ═══════════════════════════════════════════════════════════════ - class _FeatureChip extends StatelessWidget { - final IconData icon; - final String label; - final VoidCallback onTap; - - const _FeatureChip({ - required this.icon, - required this.label, - required this.onTap, - }); - - static const _c = AppTheme.primary; - + final IconData icon; final String label; final VoidCallback onTap; + const _FeatureChip({required this.icon, required this.label, required this.onTap}); @override Widget build(BuildContext context) { return Expanded( child: GestureDetector( onTap: onTap, child: Container( - margin: const EdgeInsets.symmetric(horizontal: 3), + margin: const EdgeInsets.symmetric(horizontal: 2), padding: const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( - color: _c.withAlpha(10), - borderRadius: BorderRadius.circular(12), + gradient: AppColors.bgGradient, + borderRadius: BorderRadius.circular(AppTheme.rSm), ), child: Column(mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 21, color: _c), - const SizedBox(height: 6), - Text(label, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: Color(0xFF666666))), + Icon(icon, size: 22, color: AppColors.primary), + const SizedBox(height: 4), + Text(label, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: AppColors.textPrimary)), ]), ), ), ); } } - diff --git a/health_app/pubspec.lock b/health_app/pubspec.lock index 2a7670a..002b466 100644 --- a/health_app/pubspec.lock +++ b/health_app/pubspec.lock @@ -145,6 +145,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.12" + dev_build: + dependency: transitive + description: + name: dev_build + sha256: f2742f154e484a52471dcc7eda17e6da06ab8b1d0a64b4845710d5de4fa9cc47 + url: "https://pub.dev" + source: hosted + version: "1.1.7+4" dio: dependency: "direct main" description: @@ -653,6 +661,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: f59351d28f49520cd3a74eb1f41c5f19ae15e53c65a3231d14af672e46510a96 + url: "https://pub.dev" + source: hosted + version: "0.19.1" node_preamble: dependency: transitive description: @@ -821,6 +837,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.2" + process_run: + dependency: transitive + description: + name: process_run + sha256: e0e24c11a49445c449911daef46cf28d68e80a5e33daafbfbc6d6819a1f83519 + url: "https://pub.dev" + source: hosted + version: "1.3.3+1" pub_semver: dependency: transitive description: @@ -978,6 +1002,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.5.8" + sqflite_common_ffi: + dependency: transitive + description: + name: sqflite_common_ffi + sha256: cd0c7f7de39a08f2d54ef144d9058c46eca8461879aaa648025643455c1e5a20 + url: "https://pub.dev" + source: hosted + version: "2.4.0+3" + sqflite_common_ffi_web: + dependency: "direct main" + description: + name: sqflite_common_ffi_web + sha256: "79338d0b69521d70cea10f841209ac87ce617921aaf7d33e7380682c83da1f06" + url: "https://pub.dev" + source: hosted + version: "1.1.1" sqflite_darwin: dependency: transitive description: @@ -994,6 +1034,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.0" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: "9488c7d2cdb1091c91cacf7e207cff81b28bff8e366f042bad3afe7d34afe189" + url: "https://pub.dev" + source: hosted + version: "3.3.2" sse: dependency: transitive description: diff --git a/health_app/pubspec.yaml b/health_app/pubspec.yaml index f9e7aef..427b0ce 100644 --- a/health_app/pubspec.yaml +++ b/health_app/pubspec.yaml @@ -20,6 +20,7 @@ dependencies: # 本地数据库 sqflite: ^2.4.0 + sqflite_common_ffi_web: ^1.1.1 path: ^1.9.0 # 图表 diff --git a/health_app/test/widget_test.dart b/health_app/test/widget_test.dart index 84bd999..a0e7886 100644 --- a/health_app/test/widget_test.dart +++ b/health_app/test/widget_test.dart @@ -6,7 +6,8 @@ import 'package:health_app/pages/auth/login_page.dart'; void main() { testWidgets('主题颜色正确', (tester) async { - expect(AppTheme.primary, AppTheme.primaryLight); + expect(AppTheme.primary, const Color(0xFF6366F1)); + expect(AppTheme.primaryLight, const Color(0xFFEEF2FF)); expect(AppTheme.bg, AppTheme.bg); expect(AppTheme.error, const Color(0xFFF56C6C)); expect(AppTheme.success, const Color(0xFF6ECF8A));