From e654c1e0cc0e7fc11bcc82ae5fb234e4408e7b02 Mon Sep 17 00:00:00 2001 From: MingNian <1281442923@qq.com> Date: Mon, 13 Jul 2026 10:39:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=80=9A=E7=9F=A5=E6=8E=A8=E9=80=81/?= =?UTF-8?q?=E5=85=8D=E6=89=93=E6=89=B0=E5=81=8F=E5=A5=BD=20+=20=E9=A5=AE?= =?UTF-8?q?=E9=A3=9F=E8=AE=B0=E5=BD=95=E4=BE=A7=E6=BB=91=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20+=20=E8=81=8A=E5=A4=A9=E4=BA=A4=E4=BA=92?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 通知: 新增推送开关/免打扰时段偏好持久化 + EF 迁移; 通知管线支持免打扰过滤 - 饮食: 侧滑删除 confirmDismiss 按方向放行(修复删不掉); 新增 diet_nutrition_widgets; 饮食录入页重构 - 聊天: 智能体胶囊点击锁防误触; 流式状态 select 优化; 卡片入场动画 - 主页: 消息列表提取 _HomeMessages + 侧滑手势打开抽屉 - 其他: api_client IP 适配; 通知/用药/饮食端点微调; 测试更新 --- .../Entities/support_entities.cs | 4 + ...22401_AddPushAndDndPreferences.Designer.cs | 1473 +++++++++++++++++ ...20260713022401_AddPushAndDndPreferences.cs | 62 + .../Migrations/AppDbContextModelSnapshot.cs | 12 + .../EfUserNotificationPipeline.cs | 27 +- .../health_record_reminder_service.cs | 7 +- .../Health.WebApi/Endpoints/diet_endpoints.cs | 6 +- .../Endpoints/medication_endpoints.cs | 6 +- .../Endpoints/notification_endpoints.cs | 12 + health_app/lib/core/api_client.dart | 2 +- .../lib/pages/diet/diet_capture_page.dart | 276 +-- .../pages/diet/diet_nutrition_widgets.dart | 156 ++ health_app/lib/pages/home/home_page.dart | 41 +- .../home/widgets/chat_messages_view.dart | 27 +- .../medication/medication_list_page.dart | 14 +- health_app/lib/pages/remaining_pages.dart | 178 +- .../settings/notification_prefs_page.dart | 63 +- health_app/lib/providers/chat_provider.dart | 70 +- health_app/test/chat_provider_test.dart | 47 +- 19 files changed, 2140 insertions(+), 343 deletions(-) create mode 100644 backend/src/Health.Infrastructure/Data/Migrations/20260713022401_AddPushAndDndPreferences.Designer.cs create mode 100644 backend/src/Health.Infrastructure/Data/Migrations/20260713022401_AddPushAndDndPreferences.cs create mode 100644 health_app/lib/pages/diet/diet_nutrition_widgets.dart diff --git a/backend/src/Health.Domain/Entities/support_entities.cs b/backend/src/Health.Domain/Entities/support_entities.cs index 293e98b..45abfac 100644 --- a/backend/src/Health.Domain/Entities/support_entities.cs +++ b/backend/src/Health.Domain/Entities/support_entities.cs @@ -89,6 +89,10 @@ public sealed class NotificationPreference public bool FollowUpReminder { get; set; } = true; public bool DoctorReply { get; set; } = true; public bool AbnormalAlert { get; set; } = true; + public bool PushEnabled { get; set; } = true; + public bool DndEnabled { get; set; } + public int DndStartMinutes { get; set; } = 22 * 60; + public int DndEndMinutes { get; set; } = 8 * 60; // 健康录入提醒——总开关 + 5 子项 public bool HealthRecordReminder { get; set; } = true; public bool HealthRecordReminderBloodPressure { get; set; } = true; diff --git a/backend/src/Health.Infrastructure/Data/Migrations/20260713022401_AddPushAndDndPreferences.Designer.cs b/backend/src/Health.Infrastructure/Data/Migrations/20260713022401_AddPushAndDndPreferences.Designer.cs new file mode 100644 index 0000000..e8c9162 --- /dev/null +++ b/backend/src/Health.Infrastructure/Data/Migrations/20260713022401_AddPushAndDndPreferences.Designer.cs @@ -0,0 +1,1473 @@ +// +using System; +using System.Collections.Generic; +using Health.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Health.Infrastructure.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260713022401_AddPushAndDndPreferences")] + partial class AddPushAndDndPreferences + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Health.Domain.Entities.Consultation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClosedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DoctorId") + .HasColumnType("uuid"); + + b.Property("Month") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DoctorId"); + + b.HasIndex("UserId"); + + b.ToTable("Consultations"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ConsultationMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsultationId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SenderName") + .HasColumnType("text"); + + b.Property("SenderType") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ConsultationId", "CreatedAt") + .IsDescending(false, true); + + b.ToTable("ConsultationMessages"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Conversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MessageCount") + .HasColumnType("integer"); + + b.Property("Summary") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "UpdatedAt") + .IsDescending(false, true); + + b.ToTable("Conversations"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ConversationMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Intent") + .HasColumnType("text"); + + b.Property("MetadataJson") + .HasColumnType("jsonb"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId", "CreatedAt"); + + b.ToTable("ConversationMessages"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DeviceToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Platform") + .IsRequired() + .HasColumnType("text"); + + b.Property("PushToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("DeviceTokens"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DietFoodItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Calories") + .HasColumnType("integer"); + + b.Property("CarbsGrams") + .HasColumnType("numeric"); + + b.Property("DietRecordId") + .HasColumnType("uuid"); + + b.Property("FatGrams") + .HasColumnType("numeric"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Portion") + .HasColumnType("text"); + + b.Property("ProteinGrams") + .HasColumnType("numeric"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Warning") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DietRecordId"); + + b.ToTable("DietFoodItems"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DietRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HealthScore") + .HasColumnType("integer"); + + b.Property("MealType") + .IsRequired() + .HasColumnType("text"); + + b.Property("RecordedAt") + .HasColumnType("date"); + + b.Property("TotalCalories") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "RecordedAt") + .IsDescending(false, true); + + b.ToTable("DietRecords"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Doctor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Department") + .HasColumnType("text"); + + b.Property("Introduction") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Phone") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ProfessionalDirection") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Doctors"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DoctorProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Department") + .HasColumnType("text"); + + b.Property("DoctorId") + .HasColumnType("uuid"); + + b.Property("Hospital") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsOnline") + .HasColumnType("boolean"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DoctorId"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("DoctorProfiles"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ExercisePlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("ReminderTime") + .HasColumnType("time without time zone"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "StartDate", "EndDate"); + + b.ToTable("ExercisePlans"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ExercisePlanItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DurationMinutes") + .HasColumnType("integer"); + + b.Property("ExerciseType") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsCompleted") + .HasColumnType("boolean"); + + b.Property("IsRestDay") + .HasColumnType("boolean"); + + b.Property("PlanId") + .HasColumnType("uuid"); + + b.Property("ScheduledDate") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.HasIndex("PlanId", "ScheduledDate") + .IsUnique(); + + b.ToTable("ExercisePlanItems"); + }); + + modelBuilder.Entity("Health.Domain.Entities.FollowUp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Department") + .HasColumnType("text"); + + b.Property("DoctorName") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("ScheduledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("FollowUps"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthArchive", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.PrimitiveCollection>("Allergies") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection>("ChronicDiseases") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Diagnosis") + .HasColumnType("text"); + + b.PrimitiveCollection>("DietRestrictions") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("FamilyHistory") + .HasColumnType("text"); + + b.Property("SurgeryDate") + .HasColumnType("date"); + + b.Property("SurgeryType") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("HealthArchives"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthArchiveSurgery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("HealthArchiveId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("HealthArchiveId", "SortOrder"); + + b.ToTable("HealthArchiveSurgeries", (string)null); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Diastolic") + .HasColumnType("integer"); + + b.Property("IsAbnormal") + .HasColumnType("boolean"); + + b.Property("MetricType") + .IsRequired() + .HasColumnType("text"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("Systolic") + .HasColumnType("integer"); + + b.Property("Unit") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Value") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "MetricType"); + + b.HasIndex("UserId", "RecordedAt") + .IsDescending(false, true); + + b.ToTable("HealthRecords"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Medication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Dosage") + .HasColumnType("text"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("Frequency") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.PrimitiveCollection>("TimeOfDay") + .IsRequired() + .HasColumnType("time[]"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsActive"); + + b.ToTable("Medications"); + }); + + modelBuilder.Entity("Health.Domain.Entities.MedicationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfirmedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MedicationId") + .HasColumnType("uuid"); + + b.Property("ScheduledTime") + .HasColumnType("time without time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MedicationId", "CreatedAt") + .IsDescending(false, true); + + b.ToTable("MedicationLogs"); + }); + + modelBuilder.Entity("Health.Domain.Entities.NotificationPreference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AbnormalAlert") + .HasColumnType("boolean"); + + b.Property("DndEnabled") + .HasColumnType("boolean"); + + b.Property("DndEndMinutes") + .HasColumnType("integer"); + + b.Property("DndStartMinutes") + .HasColumnType("integer"); + + b.Property("DoctorReply") + .HasColumnType("boolean"); + + b.Property("FollowUpReminder") + .HasColumnType("boolean"); + + b.Property("HealthRecordReminder") + .HasColumnType("boolean"); + + b.Property("HealthRecordReminderBloodPressure") + .HasColumnType("boolean"); + + b.Property("HealthRecordReminderGlucose") + .HasColumnType("boolean"); + + b.Property("HealthRecordReminderHeartRate") + .HasColumnType("boolean"); + + b.Property("HealthRecordReminderSpO2") + .HasColumnType("boolean"); + + b.Property("HealthRecordReminderWeight") + .HasColumnType("boolean"); + + b.Property("MedicationReminder") + .HasColumnType("boolean"); + + b.Property("PushEnabled") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("NotificationPreferences"); + }); + + modelBuilder.Entity("Health.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Report", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AiIndicators") + .HasColumnType("jsonb"); + + b.Property("AiSummary") + .HasColumnType("text"); + + b.Property("Category") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DoctorComment") + .HasColumnType("text"); + + b.Property("DoctorName") + .HasColumnType("text"); + + b.Property("DoctorRecommendation") + .HasColumnType("text"); + + b.Property("FileType") + .IsRequired() + .HasColumnType("text"); + + b.Property("FileUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Severity") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("Health.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .HasColumnType("text"); + + b.Property("BirthDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DoctorId") + .HasColumnType("uuid"); + + b.Property("Gender") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasDefaultValue("User"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DoctorId"); + + b.HasIndex("Phone") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Health.Domain.Entities.UserNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionTargetId") + .HasColumnType("text"); + + b.Property("ActionType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SourceId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SourceId") + .IsUnique(); + + b.HasIndex("UserId", "IsDeleted", "IsRead", "CreatedAt"); + + b.ToTable("UserNotifications", (string)null); + }); + + modelBuilder.Entity("Health.Domain.Entities.VerificationCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsUsed") + .HasColumnType("boolean"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Phone", "CreatedAt") + .IsDescending(false, true); + + b.ToTable("VerificationCodes"); + }); + + modelBuilder.Entity("Health.Infrastructure.Data.Records.AiWriteCommandRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Arguments") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ToolName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status", "ExpiresAt"); + + b.ToTable("AiWriteCommands", (string)null); + }); + + modelBuilder.Entity("Health.Infrastructure.Data.Records.DietImageAnalysisTaskRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("AvailableAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FilePaths") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("ResultCode") + .HasColumnType("integer"); + + b.Property("ResultData") + .HasColumnType("text"); + + b.Property("ResultMessage") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Status", "AvailableAt"); + + b.ToTable("DietImageAnalysisTasks", (string)null); + }); + + modelBuilder.Entity("Health.Infrastructure.Data.Records.MedicationReminderTaskRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("AvailableAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Dosage") + .HasColumnType("text"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("MedicationId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ScheduledDate") + .HasColumnType("date"); + + b.Property("ScheduledTime") + .HasColumnType("time without time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Status", "AvailableAt"); + + b.HasIndex("MedicationId", "ScheduledDate", "ScheduledTime") + .IsUnique(); + + b.ToTable("MedicationReminderTasks", (string)null); + }); + + modelBuilder.Entity("Health.Infrastructure.Data.Records.NotificationOutboxRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("AvailableAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SourceTaskId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SourceTaskId") + .IsUnique(); + + b.HasIndex("Status", "AvailableAt"); + + b.ToTable("NotificationOutbox", (string)null); + }); + + modelBuilder.Entity("Health.Infrastructure.Data.Records.ReportAnalysisTaskRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("AvailableAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("ReportId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ReportId"); + + b.HasIndex("Status", "AvailableAt"); + + b.ToTable("ReportAnalysisTasks", (string)null); + }); + + modelBuilder.Entity("Health.Domain.Entities.Consultation", b => + { + b.HasOne("Health.Domain.Entities.Doctor", "Doctor") + .WithMany("Consultations") + .HasForeignKey("DoctorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("Consultations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Doctor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ConsultationMessage", b => + { + b.HasOne("Health.Domain.Entities.Consultation", "Consultation") + .WithMany("Messages") + .HasForeignKey("ConsultationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Consultation"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Conversation", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("Conversations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ConversationMessage", b => + { + b.HasOne("Health.Domain.Entities.Conversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DeviceToken", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("DeviceTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DietFoodItem", b => + { + b.HasOne("Health.Domain.Entities.DietRecord", "DietRecord") + .WithMany("FoodItems") + .HasForeignKey("DietRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DietRecord"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DietRecord", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("DietRecords") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DoctorProfile", b => + { + b.HasOne("Health.Domain.Entities.Doctor", "Doctor") + .WithMany() + .HasForeignKey("DoctorId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Health.Domain.Entities.User", "User") + .WithOne("DoctorProfile") + .HasForeignKey("Health.Domain.Entities.DoctorProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Doctor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ExercisePlan", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("ExercisePlans") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ExercisePlanItem", b => + { + b.HasOne("Health.Domain.Entities.ExercisePlan", "Plan") + .WithMany("Items") + .HasForeignKey("PlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Plan"); + }); + + modelBuilder.Entity("Health.Domain.Entities.FollowUp", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("FollowUps") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthArchive", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithOne("HealthArchive") + .HasForeignKey("Health.Domain.Entities.HealthArchive", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthArchiveSurgery", b => + { + b.HasOne("Health.Domain.Entities.HealthArchive", "HealthArchive") + .WithMany("Surgeries") + .HasForeignKey("HealthArchiveId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("HealthArchive"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthRecord", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("HealthRecords") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Medication", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("Medications") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.MedicationLog", b => + { + b.HasOne("Health.Domain.Entities.Medication", "Medication") + .WithMany("Logs") + .HasForeignKey("MedicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Medication"); + }); + + modelBuilder.Entity("Health.Domain.Entities.NotificationPreference", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithOne("NotificationPreference") + .HasForeignKey("Health.Domain.Entities.NotificationPreference", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Report", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("Reports") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.User", b => + { + b.HasOne("Health.Domain.Entities.Doctor", "Doctor") + .WithMany() + .HasForeignKey("DoctorId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Doctor"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Consultation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Conversation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DietRecord", b => + { + b.Navigation("FoodItems"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Doctor", b => + { + b.Navigation("Consultations"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ExercisePlan", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthArchive", b => + { + b.Navigation("Surgeries"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Medication", b => + { + b.Navigation("Logs"); + }); + + modelBuilder.Entity("Health.Domain.Entities.User", b => + { + b.Navigation("Consultations"); + + b.Navigation("Conversations"); + + b.Navigation("DeviceTokens"); + + b.Navigation("DietRecords"); + + b.Navigation("DoctorProfile"); + + b.Navigation("ExercisePlans"); + + b.Navigation("FollowUps"); + + b.Navigation("HealthArchive"); + + b.Navigation("HealthRecords"); + + b.Navigation("Medications"); + + b.Navigation("NotificationPreference"); + + b.Navigation("Reports"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Health.Infrastructure/Data/Migrations/20260713022401_AddPushAndDndPreferences.cs b/backend/src/Health.Infrastructure/Data/Migrations/20260713022401_AddPushAndDndPreferences.cs new file mode 100644 index 0000000..57d7b0a --- /dev/null +++ b/backend/src/Health.Infrastructure/Data/Migrations/20260713022401_AddPushAndDndPreferences.cs @@ -0,0 +1,62 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Health.Infrastructure.Data.Migrations +{ + /// + public partial class AddPushAndDndPreferences : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DndEnabled", + table: "NotificationPreferences", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "DndEndMinutes", + table: "NotificationPreferences", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "DndStartMinutes", + table: "NotificationPreferences", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "PushEnabled", + table: "NotificationPreferences", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DndEnabled", + table: "NotificationPreferences"); + + migrationBuilder.DropColumn( + name: "DndEndMinutes", + table: "NotificationPreferences"); + + migrationBuilder.DropColumn( + name: "DndStartMinutes", + table: "NotificationPreferences"); + + migrationBuilder.DropColumn( + name: "PushEnabled", + table: "NotificationPreferences"); + } + } +} diff --git a/backend/src/Health.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs b/backend/src/Health.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs index 78975d2..9e6f222 100644 --- a/backend/src/Health.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/Health.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs @@ -673,6 +673,15 @@ namespace Health.Infrastructure.Data.Migrations b.Property("AbnormalAlert") .HasColumnType("boolean"); + b.Property("DndEnabled") + .HasColumnType("boolean"); + + b.Property("DndEndMinutes") + .HasColumnType("integer"); + + b.Property("DndStartMinutes") + .HasColumnType("integer"); + b.Property("DoctorReply") .HasColumnType("boolean"); @@ -700,6 +709,9 @@ namespace Health.Infrastructure.Data.Migrations b.Property("MedicationReminder") .HasColumnType("boolean"); + b.Property("PushEnabled") + .HasColumnType("boolean"); + b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); diff --git a/backend/src/Health.Infrastructure/Notifications/EfUserNotificationPipeline.cs b/backend/src/Health.Infrastructure/Notifications/EfUserNotificationPipeline.cs index eddeb0d..86b5099 100644 --- a/backend/src/Health.Infrastructure/Notifications/EfUserNotificationPipeline.cs +++ b/backend/src/Health.Infrastructure/Notifications/EfUserNotificationPipeline.cs @@ -94,7 +94,11 @@ public sealed class EfNotificationOutboxProcessor(AppDbContext db) : INotificati var message = JsonSerializer.Deserialize(candidate.Payload) ?? throw new InvalidOperationException("通知消息内容为空"); - if (!await _db.UserNotifications.AsNoTracking() + var preference = await _db.NotificationPreferences.AsNoTracking() + .FirstOrDefaultAsync(x => x.UserId == candidate.UserId, ct); + var deliver = preference == null || ShouldDeliver(preference, message.Type, DateTime.UtcNow.AddHours(8)); + + if (deliver && !await _db.UserNotifications.AsNoTracking() .AnyAsync(x => x.SourceId == candidate.SourceTaskId, ct)) { await _db.UserNotifications.AddAsync(new UserNotification @@ -136,4 +140,25 @@ public sealed class EfNotificationOutboxProcessor(AppDbContext db) : INotificati return true; } + + private static bool ShouldDeliver(NotificationPreference preference, string type, DateTime nowCst) + { + if (!preference.PushEnabled) return false; + + var minuteOfDay = nowCst.Hour * 60 + nowCst.Minute; + var inDnd = preference.DndEnabled && (preference.DndStartMinutes <= preference.DndEndMinutes + ? minuteOfDay >= preference.DndStartMinutes && minuteOfDay < preference.DndEndMinutes + : minuteOfDay >= preference.DndStartMinutes || minuteOfDay < preference.DndEndMinutes); + if (inDnd) return false; + + return type switch + { + "medication_reminder" => preference.MedicationReminder, + "follow_up_reminder" => preference.FollowUpReminder, + "doctor_reply" => preference.DoctorReply, + "abnormal_alert" => preference.AbnormalAlert, + "health_record_reminder" => preference.HealthRecordReminder, + _ => true, + }; + } } diff --git a/backend/src/Health.WebApi/BackgroundServices/health_record_reminder_service.cs b/backend/src/Health.WebApi/BackgroundServices/health_record_reminder_service.cs index aba6a1d..4b9dec7 100644 --- a/backend/src/Health.WebApi/BackgroundServices/health_record_reminder_service.cs +++ b/backend/src/Health.WebApi/BackgroundServices/health_record_reminder_service.cs @@ -54,11 +54,16 @@ public sealed class HealthRecordReminderService( // 取所有启用了健康录入提醒的用户偏好 var prefs = await db.NotificationPreferences - .Where(p => p.HealthRecordReminder) + .Where(p => p.PushEnabled && p.HealthRecordReminder) .ToListAsync(ct); foreach (var pref in prefs) { + var minuteOfDay = nowCst.Hour * 60 + nowCst.Minute; + var inDnd = pref.DndEnabled && (pref.DndStartMinutes <= pref.DndEndMinutes + ? minuteOfDay >= pref.DndStartMinutes && minuteOfDay < pref.DndEndMinutes + : minuteOfDay >= pref.DndStartMinutes || minuteOfDay < pref.DndEndMinutes); + if (inDnd) continue; // 该用户当天已录入的指标 var recordedTypes = await db.HealthRecords .Where(r => r.UserId == pref.UserId && r.RecordedAt >= todayStart && r.RecordedAt < todayEnd) diff --git a/backend/src/Health.WebApi/Endpoints/diet_endpoints.cs b/backend/src/Health.WebApi/Endpoints/diet_endpoints.cs index 4bef77e..f8a364b 100644 --- a/backend/src/Health.WebApi/Endpoints/diet_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/diet_endpoints.cs @@ -26,8 +26,10 @@ public static class DietEndpoints group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) => { var userId = GetUserId(http); - await diets.DeleteAsync(userId, id, ct); - return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); + var deleted = await diets.DeleteAsync(userId, id, ct); + return deleted + ? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }) + : Results.Ok(new { code = 40004, data = (object?)null, message = "饮食记录不存在" }); }); group.MapPut("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) => diff --git a/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs b/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs index 1f8e560..d066fc4 100644 --- a/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs @@ -36,8 +36,10 @@ public static class MedicationEndpoints group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) => { var userId = GetUserId(http); - await medications.DeleteAsync(userId, id, ct); - return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); + var deleted = await medications.DeleteAsync(userId, id, ct); + return deleted + ? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }) + : Results.Ok(new { code = 40004, data = (object?)null, message = "用药记录不存在" }); }); group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) => diff --git a/backend/src/Health.WebApi/Endpoints/notification_endpoints.cs b/backend/src/Health.WebApi/Endpoints/notification_endpoints.cs index 9ed4aa2..3115fd6 100644 --- a/backend/src/Health.WebApi/Endpoints/notification_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/notification_endpoints.cs @@ -90,6 +90,10 @@ public static class NotificationEndpoints if (body.FollowUpReminder.HasValue) pref.FollowUpReminder = body.FollowUpReminder.Value; if (body.DoctorReply.HasValue) pref.DoctorReply = body.DoctorReply.Value; if (body.AbnormalAlert.HasValue) pref.AbnormalAlert = body.AbnormalAlert.Value; + if (body.PushEnabled.HasValue) pref.PushEnabled = body.PushEnabled.Value; + if (body.DndEnabled.HasValue) pref.DndEnabled = body.DndEnabled.Value; + if (body.DndStartMinutes is >= 0 and < 1440) pref.DndStartMinutes = body.DndStartMinutes.Value; + if (body.DndEndMinutes is >= 0 and < 1440) pref.DndEndMinutes = body.DndEndMinutes.Value; if (body.HealthRecordReminder.HasValue) pref.HealthRecordReminder = body.HealthRecordReminder.Value; if (body.HealthRecordReminderBloodPressure.HasValue) pref.HealthRecordReminderBloodPressure = body.HealthRecordReminderBloodPressure.Value; if (body.HealthRecordReminderHeartRate.HasValue) pref.HealthRecordReminderHeartRate = body.HealthRecordReminderHeartRate.Value; @@ -153,6 +157,10 @@ public static class NotificationEndpoints followUpReminder = pref.FollowUpReminder, doctorReply = pref.DoctorReply, abnormalAlert = pref.AbnormalAlert, + pushEnabled = pref.PushEnabled, + dndEnabled = pref.DndEnabled, + dndStartMinutes = pref.DndStartMinutes, + dndEndMinutes = pref.DndEndMinutes, healthRecordReminder = pref.HealthRecordReminder, healthRecordReminderBloodPressure = pref.HealthRecordReminderBloodPressure, healthRecordReminderHeartRate = pref.HealthRecordReminderHeartRate, @@ -167,6 +175,10 @@ public sealed record NotificationPrefsUpdateDto( bool? FollowUpReminder, bool? DoctorReply, bool? AbnormalAlert, + bool? PushEnabled, + bool? DndEnabled, + int? DndStartMinutes, + int? DndEndMinutes, bool? HealthRecordReminder, bool? HealthRecordReminderBloodPressure, bool? HealthRecordReminderHeartRate, diff --git a/health_app/lib/core/api_client.dart b/health_app/lib/core/api_client.dart index 038710e..ea74bb0 100644 --- a/health_app/lib/core/api_client.dart +++ b/health_app/lib/core/api_client.dart @@ -6,7 +6,7 @@ import 'local_database.dart'; /// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。 const String baseUrl = String.fromEnvironment( 'API_BASE_URL', - defaultValue: 'http://10.4.251.10:5000', + defaultValue: 'http://192.168.1.34:5000', ); class ApiException implements Exception { diff --git a/health_app/lib/pages/diet/diet_capture_page.dart b/health_app/lib/pages/diet/diet_capture_page.dart index c944ef8..83f2103 100644 --- a/health_app/lib/pages/diet/diet_capture_page.dart +++ b/health_app/lib/pages/diet/diet_capture_page.dart @@ -9,6 +9,7 @@ import '../../core/app_design_tokens.dart'; import '../../core/app_theme.dart'; import '../../providers/auth_provider.dart'; import '../../widgets/app_toast.dart'; +import 'diet_nutrition_widgets.dart'; import 'diet_record_logic.dart'; final dietProvider = NotifierProvider( @@ -27,7 +28,7 @@ class DietState { DietState({ this.imagePath, this.foods = const [], - this.mealType = 'lunch', + this.mealType = '', this.isAnalyzing = false, this.healthScore, this.errorMessage, @@ -271,6 +272,9 @@ class DietNotifier extends Notifier { } Future saveRecord() async { + if (state.mealType.isEmpty) { + throw const DietFoodValidationException('请选择餐次'); + } final selectedFoods = state.foods.where((f) => f.selected).toList(); if (selectedFoods.isEmpty) { throw const DietFoodValidationException('请至少选择一种食物'); @@ -296,7 +300,7 @@ class DietNotifier extends Notifier { await api.post( '/api/diet-records', data: { - 'mealType': mealMap[state.mealType] ?? 'Lunch', + 'mealType': mealMap[state.mealType]!, 'totalCalories': totalCal, 'healthScore': state.healthScore ?? 3, 'recordedAt': DateTime.now().toIso8601String().substring(0, 10), @@ -318,9 +322,9 @@ class DietNotifier extends Notifier { } // ─────────── 饮食主题色(暖橙系,不再用紫色)─────────── -const _dietAccent = AppColors.diet; -const _dietKcalText = Color(0xFF9A3412); -const _dietGradient = AppColors.dietGradient; +const _dietAccent = DietPalette.primary; +const _dietKcalText = DietPalette.calorie; +const _dietGradient = DietPalette.gradient; class DietCapturePage extends ConsumerStatefulWidget { const DietCapturePage({super.key}); @@ -428,47 +432,47 @@ class _DietCapturePageState extends ConsumerState { children: [ Row( children: [ - Container( - width: 30, - height: 30, - decoration: BoxDecoration( - gradient: _dietGradient, - borderRadius: AppRadius.smBorder, - ), - child: const Icon( - Icons.local_fire_department_rounded, - size: 18, - color: Colors.white, - ), - ), - const SizedBox(width: 10), - const Text( - '本餐估算', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w900, - color: AppColors.textPrimary, - ), - ), + const Text('本餐估算', style: AppTextStyles.sectionTitle), const Spacer(), - _MealTypeMenu( + _MealTypeSelector( value: state.mealType, onChanged: (type) => ref.read(dietProvider.notifier).setMealType(type), ), ], ), - const SizedBox(height: 13), + const SizedBox(height: 20), Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ - _MacroStat(label: '总热量', value: '$totalCal', unit: '千卡'), - const SizedBox(width: 8), - _MacroStat(label: '蛋白质', value: '$protein', unit: 'g'), - const SizedBox(width: 8), - _MacroStat(label: '碳水', value: '$carbs', unit: 'g'), - const SizedBox(width: 8), - _MacroStat(label: '脂肪', value: '$fat', unit: 'g'), + DietCalorieRing(calories: totalCal), + const SizedBox(width: 20), + Expanded( + child: Column( + children: [ + DietMacroBar( + label: '蛋白质', + value: protein, + color: DietPalette.protein, + reference: 50, + ), + const SizedBox(height: 14), + DietMacroBar( + label: '碳水', + value: carbs, + color: DietPalette.carbs, + reference: 100, + ), + const SizedBox(height: 14), + DietMacroBar( + label: '脂肪', + value: fat, + color: DietPalette.fat, + reference: 35, + ), + ], + ), + ), ], ), ], @@ -603,7 +607,7 @@ class _DietCapturePageState extends ConsumerState { return Column( children: [ Padding( - padding: const EdgeInsets.fromLTRB(14, 11, 12, 11), + padding: const EdgeInsets.fromLTRB(14, 14, 12, 14), child: Row( children: [ GestureDetector( @@ -615,12 +619,12 @@ class _DietCapturePageState extends ConsumerState { alignment: Alignment.center, decoration: BoxDecoration( gradient: food.selected ? _dietGradient : null, - color: food.selected ? null : AppColors.dietLight, + color: food.selected ? null : DietPalette.primarySoft, borderRadius: AppRadius.smBorder, border: Border.all( color: food.selected ? Colors.transparent - : AppColors.dietBorder, + : const Color(0xFFC9D0FF), ), ), child: food.selected @@ -638,8 +642,8 @@ class _DietCapturePageState extends ConsumerState { .updateFoodName(food.id, v), fieldKey: '${food.id}_name', style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, + fontSize: 16, + fontWeight: FontWeight.w700, color: AppColors.textPrimary, ), ), @@ -655,7 +659,7 @@ class _DietCapturePageState extends ConsumerState { fieldKey: '${food.id}_portion', hint: '份量', style: const TextStyle( - fontSize: 14, + fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textSecondary, ), @@ -663,7 +667,7 @@ class _DietCapturePageState extends ConsumerState { ), const SizedBox(width: 8), SizedBox( - width: 88, + width: 96, child: Row( children: [ Expanded( @@ -677,8 +681,8 @@ class _DietCapturePageState extends ConsumerState { align: TextAlign.right, keyboardType: TextInputType.number, style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, + fontSize: 16, + fontWeight: FontWeight.w700, color: _dietKcalText, ), ), @@ -686,7 +690,7 @@ class _DietCapturePageState extends ConsumerState { const Text( ' 千卡', style: TextStyle( - fontSize: 12, + fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textHint, ), @@ -722,11 +726,18 @@ class _DietCapturePageState extends ConsumerState { textAlign: align, style: style ?? const TextStyle(fontSize: 15), decoration: InputDecoration( + filled: false, + fillColor: Colors.transparent, isDense: true, contentPadding: EdgeInsets.zero, border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + disabledBorder: InputBorder.none, + errorBorder: InputBorder.none, + focusedErrorBorder: InputBorder.none, hintText: hint, - hintStyle: const TextStyle(fontSize: 13, color: AppColors.textHint), + hintStyle: const TextStyle(fontSize: 16, color: AppColors.textHint), ), ); } @@ -853,58 +864,39 @@ class _DietCapturePageState extends ConsumerState { } } -class _MealTypeMenu extends StatelessWidget { +class _MealTypeSelector extends StatelessWidget { final String value; final ValueChanged onChanged; - const _MealTypeMenu({required this.value, required this.onChanged}); + const _MealTypeSelector({required this.value, required this.onChanged}); static const _options = [ - (label: '早餐', value: 'breakfast'), - (label: '午餐', value: 'lunch'), - (label: '晚餐', value: 'dinner'), - (label: '加餐', value: 'snack'), + (label: '早餐', value: 'breakfast', icon: Icons.wb_sunny_outlined), + (label: '午餐', value: 'lunch', icon: Icons.light_mode_outlined), + (label: '晚餐', value: 'dinner', icon: Icons.nights_stay_outlined), + (label: '加餐', value: 'snack', icon: Icons.cookie_outlined), ]; String get _label { for (final option in _options) { if (option.value == value) return option.label; } - return '午餐'; + return '餐次'; } @override Widget build(BuildContext context) { - return PopupMenuButton( - initialValue: value, - onSelected: onChanged, - color: Colors.white, - elevation: 8, - shape: RoundedRectangleBorder(borderRadius: AppRadius.mdBorder), - itemBuilder: (context) => [ - for (final option in _options) - PopupMenuItem( - value: option.value, - child: Text( - option.label, - style: TextStyle( - fontSize: 15, - fontWeight: option.value == value - ? FontWeight.w900 - : FontWeight.w700, - color: option.value == value - ? _dietKcalText - : AppColors.textPrimary, - ), - ), - ), - ], + final selected = value.isNotEmpty; + return GestureDetector( + onTap: () => _showOptions(context), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), decoration: BoxDecoration( - color: AppColors.dietLight, + color: selected ? DietPalette.primarySoft : Colors.white, borderRadius: AppRadius.mdBorder, - border: Border.all(color: AppColors.dietBorder), + border: Border.all( + color: selected ? const Color(0xFFC9D0FF) : AppColors.border, + ), ), child: Row( mainAxisSize: MainAxisSize.min, @@ -913,79 +905,93 @@ class _MealTypeMenu extends StatelessWidget { _label, style: const TextStyle( fontSize: 14, - fontWeight: FontWeight.w900, - color: _dietKcalText, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, ), ), const SizedBox(width: 5), const Icon( Icons.keyboard_arrow_down_rounded, size: 18, - color: _dietKcalText, + color: AppColors.textSecondary, ), ], ), ), ); } -} -class _MacroStat extends StatelessWidget { - final String label; - final String value; - final String unit; - - const _MacroStat({ - required this.label, - required this.value, - required this.unit, - }); - - @override - Widget build(BuildContext context) { - return Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w700, - color: AppColors.textHint, - ), - ), - const SizedBox(height: 3), - FittedBox( - fit: BoxFit.scaleDown, - alignment: Alignment.centerLeft, - child: Row( - children: [ - Text( - value, - style: const TextStyle( - fontSize: 16, - height: 1, - fontWeight: FontWeight.w900, - color: _dietKcalText, + Future _showOptions(BuildContext context) async { + final selected = await showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + showDragHandle: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), + ), + builder: (sheetContext) => SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('选择餐次', style: AppTextStyles.sectionTitle), + const SizedBox(height: 12), + for (var i = 0; i < _options.length; i++) ...[ + InkWell( + borderRadius: AppRadius.mdBorder, + onTap: () => Navigator.pop(sheetContext, _options[i].value), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 13), + child: Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: DietPalette.primarySoft, + borderRadius: AppRadius.smBorder, + ), + child: Icon( + _options[i].icon, + size: 20, + color: DietPalette.primary, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + _options[i].label, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ), + if (_options[i].value == value) + const Icon( + Icons.check_rounded, + size: 21, + color: DietPalette.primary, + ), + ], + ), ), ), - const SizedBox(width: 2), - Text( - unit, - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w800, - color: AppColors.textHint, + if (i < _options.length - 1) + const Padding( + padding: EdgeInsets.only(left: 50), + child: Divider(height: 1, color: AppColors.divider), ), - ), ], - ), + ], ), - ], + ), ), ); + if (selected != null) onChanged(selected); } } diff --git a/health_app/lib/pages/diet/diet_nutrition_widgets.dart b/health_app/lib/pages/diet/diet_nutrition_widgets.dart new file mode 100644 index 0000000..e24d934 --- /dev/null +++ b/health_app/lib/pages/diet/diet_nutrition_widgets.dart @@ -0,0 +1,156 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../core/app_colors.dart'; + +class DietPalette { + DietPalette._(); + + static const primary = Color(0xFF6476E8); + static const primarySoft = Color(0xFFEEF0FF); + static const protein = Color(0xFF38A77B); + static const carbs = Color(0xFF4E91D8); + static const fat = Color(0xFFE58A76); + static const calorie = Color(0xFF6D6FEA); + static const gradient = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF8FA7FF), primary], + ); +} + +class DietCalorieRing extends StatelessWidget { + final int calories; + final double size; + + const DietCalorieRing({super.key, required this.calories, this.size = 116}); + + @override + Widget build(BuildContext context) { + final progress = (calories / 800).clamp(0.04, 1.0); + return Semantics( + label: '总热量 $calories 千卡', + child: SizedBox.square( + dimension: size, + child: CustomPaint( + painter: _CalorieRingPainter(progress), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '$calories', + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const Text( + '千卡', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.textHint, + ), + ), + ], + ), + ), + ), + ); + } +} + +class DietMacroBar extends StatelessWidget { + final String label; + final int value; + final Color color; + final double reference; + + const DietMacroBar({ + super.key, + required this.label, + required this.value, + required this.color, + required this.reference, + }); + + @override + Widget build(BuildContext context) { + final progress = (value / reference).clamp(0.0, 1.0); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + label, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + const Spacer(), + Text( + '$value g', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(999), + child: LinearProgressIndicator( + minHeight: 5, + value: progress, + backgroundColor: color.withValues(alpha: 0.12), + valueColor: AlwaysStoppedAnimation(color), + ), + ), + ], + ); + } +} + +class _CalorieRingPainter extends CustomPainter { + final double progress; + + const _CalorieRingPainter(this.progress); + + @override + void paint(Canvas canvas, Size size) { + final center = size.center(Offset.zero); + final radius = size.shortestSide / 2 - 8; + final track = Paint() + ..color = DietPalette.primarySoft + ..style = PaintingStyle.stroke + ..strokeWidth = 9 + ..strokeCap = StrokeCap.round; + final progressPaint = Paint() + ..shader = const LinearGradient( + colors: [Color(0xFF8FA7FF), DietPalette.calorie], + ).createShader(Offset.zero & size) + ..style = PaintingStyle.stroke + ..strokeWidth = 9 + ..strokeCap = StrokeCap.round; + + canvas.drawCircle(center, radius, track); + canvas.drawArc( + Rect.fromCircle(center: center, radius: radius), + -math.pi / 2, + math.pi * 2 * progress, + false, + progressPaint, + ); + } + + @override + bool shouldRepaint(covariant _CalorieRingPainter oldDelegate) => + oldDelegate.progress != progress; +} diff --git a/health_app/lib/pages/home/home_page.dart b/health_app/lib/pages/home/home_page.dart index 820e344..6e1c0c6 100644 --- a/health_app/lib/pages/home/home_page.dart +++ b/health_app/lib/pages/home/home_page.dart @@ -30,7 +30,6 @@ class _HomePageState extends ConsumerState final _scaffoldKey = GlobalKey(); double? _drawerDragStartX; String? _pickedImagePath; - int _lastMsgCount = 0; Timer? _notificationTimer; @override @@ -53,16 +52,6 @@ class _HomePageState extends ConsumerState } } - @override - void didChangeMetrics() { - // 键盘动画期间每帧都会回调,让列表底部始终贴住输入区上沿 - if (!_focusNode.hasFocus) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || !_scrollCtrl.hasClients) return; - _scrollCtrl.jumpTo(_scrollCtrl.position.maxScrollExtent); - }); - } - @override void dispose() { WidgetsBinding.instance.removeObserver(this); @@ -89,7 +78,6 @@ class _HomePageState extends ConsumerState @override Widget build(BuildContext context) { - final chatState = ref.watch(chatProvider); final auth = ref.watch(authProvider); final user = auth.user; @@ -112,15 +100,17 @@ class _HomePageState extends ConsumerState } }); - final currentCount = chatState.messages.length; - if (currentCount > _lastMsgCount) { + ref.listen(chatProvider.select((state) => state.messages.length), ( + previous, + current, + ) { + if (current <= (previous ?? 0)) return; WidgetsBinding.instance.addPostFrameCallback((_) { - if (_scrollCtrl.hasClients) { + if (mounted && _scrollCtrl.hasClients) { _scrollCtrl.jumpTo(_scrollCtrl.position.maxScrollExtent); } }); - } - _lastMsgCount = currentCount; + }); return Scaffold( key: _scaffoldKey, @@ -136,10 +126,7 @@ class _HomePageState extends ConsumerState child: Stack( children: [ RepaintBoundary( - child: ChatMessagesView( - scrollCtrl: _scrollCtrl, - messages: chatState.messages, - ), + child: _HomeMessages(scrollCtrl: _scrollCtrl), ), Positioned( left: 0, @@ -592,6 +579,18 @@ class _HomePageState extends ConsumerState } } +class _HomeMessages extends ConsumerWidget { + final ScrollController scrollCtrl; + + const _HomeMessages({required this.scrollCtrl}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final messages = ref.watch(chatProvider.select((state) => state.messages)); + return ChatMessagesView(scrollCtrl: scrollCtrl, messages: messages); + } +} + class _HeaderIconButton extends StatelessWidget { final IconData icon; final VoidCallback onTap; 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 00417dd..bcc6aba 100644 --- a/health_app/lib/pages/home/widgets/chat_messages_view.dart +++ b/health_app/lib/pages/home/widgets/chat_messages_view.dart @@ -71,7 +71,9 @@ class ChatMessagesView extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final chatState = ref.watch(chatProvider); + final streaming = ref.watch( + chatProvider.select((state) => (state.isStreaming, state.thinkingText)), + ); if (messages.isEmpty) { return Center( @@ -107,10 +109,17 @@ class ChatMessagesView extends ConsumerWidget { controller: scrollCtrl, reverse: false, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + cacheExtent: 180, itemCount: messages.length, itemBuilder: (context, index) { final msg = messages[index]; - return _buildMessageContent(context, ref, msg, chatState); + return _buildMessageContent( + context, + ref, + msg, + streaming.$1, + streaming.$2 ?? '', + ); }, ); } @@ -121,7 +130,8 @@ class ChatMessagesView extends ConsumerWidget { BuildContext context, WidgetRef ref, ChatMessage msg, - ChatState chatState, + bool isStreaming, + String thinkingText, ) { switch (msg.type) { case MessageType.agentWelcome: @@ -134,12 +144,10 @@ class ChatMessagesView extends ConsumerWidget { case MessageType.dataConfirm: return _buildDataConfirmCard(context, ref, msg); default: - if (!msg.isUser && - chatState.isStreaming && - msg.content.trim().isEmpty) { - return _buildThinkingBubble(context, chatState.thinkingText); + if (!msg.isUser && isStreaming && msg.content.trim().isEmpty) { + return _buildThinkingBubble(context, thinkingText); } - return _buildTextBubble(context, ref, msg, chatState); + return _buildTextBubble(context, ref, msg); } } @@ -1048,7 +1056,6 @@ class ChatMessagesView extends ConsumerWidget { BuildContext context, WidgetRef ref, ChatMessage msg, - ChatState? chatState, ) { final isUser = msg.isUser; final imageUrl = msg.metadata?['imageUrl'] as String?; @@ -1088,7 +1095,7 @@ class ChatMessagesView extends ConsumerWidget { child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration( - color: isUser ? AppTheme.primary : AppColors.cardBackground, + color: isUser ? const Color(0xFF5F56FF) : AppColors.cardBackground, borderRadius: BorderRadius.only( topLeft: Radius.circular(isUser ? 18.6 : 3), topRight: Radius.circular(isUser ? 3 : 18.6), diff --git a/health_app/lib/pages/medication/medication_list_page.dart b/health_app/lib/pages/medication/medication_list_page.dart index c5e2319..b0d2fc7 100644 --- a/health_app/lib/pages/medication/medication_list_page.dart +++ b/health_app/lib/pages/medication/medication_list_page.dart @@ -11,6 +11,7 @@ import '../../widgets/app_future_view.dart'; import '../../widgets/app_status_badge.dart'; import '../../widgets/common_widgets.dart'; import '../../widgets/enterprise_widgets.dart'; +import '../../widgets/app_toast.dart'; class MedicationListPage extends ConsumerStatefulWidget { const MedicationListPage({super.key}); @@ -35,9 +36,16 @@ class _MedicationListPageState extends ConsumerState { } Future _delete(String id) async { - await ref.read(medicationServiceProvider).deleteMedication(id); - ref.invalidate(medicationListProvider); - _load(); + try { + await ref.read(medicationServiceProvider).deleteMedication(id); + ref.invalidate(medicationListProvider); + _load(); + if (mounted) AppToast.show(context, '已删除', type: AppToastType.success); + } catch (_) { + if (mounted) { + AppToast.show(context, '删除失败,请稍后重试', type: AppToastType.error); + } + } } @override diff --git a/health_app/lib/pages/remaining_pages.dart b/health_app/lib/pages/remaining_pages.dart index 0171e42..6697b8e 100644 --- a/health_app/lib/pages/remaining_pages.dart +++ b/health_app/lib/pages/remaining_pages.dart @@ -12,6 +12,7 @@ import '../widgets/common_widgets.dart'; import '../widgets/app_error_state.dart'; import '../widgets/app_future_view.dart'; import '../widgets/app_toast.dart'; +import 'diet/diet_nutrition_widgets.dart'; import 'diet/diet_record_logic.dart'; /// 饮食记录列表(趋势+日历+胶囊详情+侧滑编辑删除) @@ -44,9 +45,19 @@ class _DietRecordListPageState extends ConsumerState { } Future _delete(String id) async { - await ref.read(dietServiceProvider).deleteRecord(id); - if (mounted) { - setState(() => _data.removeWhere((d) => d['id']?.toString() == id)); + final index = _data.indexWhere((d) => d['id']?.toString() == id); + if (index < 0) return; + final removed = _data[index]; + setState(() => _data.removeAt(index)); + try { + await ref.read(dietServiceProvider).deleteRecord(id); + if (mounted) { + AppToast.show(context, '已删除', type: AppToastType.success); + } + } catch (_) { + if (!mounted) return; + setState(() => _data.insert(index.clamp(0, _data.length), removed)); + AppToast.show(context, '删除失败,请稍后重试', type: AppToastType.error); } } @@ -195,16 +206,16 @@ class _DietRecordListPageState extends ConsumerState { child: Container( padding: const EdgeInsets.symmetric(vertical: 8), decoration: BoxDecoration( - gradient: isSel ? AppColors.dietGradient : null, + gradient: isSel ? DietPalette.gradient : null, color: isSel ? null - : (isToday ? AppColors.dietLight : Colors.white), + : (isToday ? DietPalette.primarySoft : Colors.white), borderRadius: AppRadius.mdBorder, border: Border.all( color: isSel ? Colors.transparent : (isToday - ? AppColors.dietBorder + ? const Color(0xFFC9D0FF) : AppColors.borderLight), ), boxShadow: isSel ? AppColors.cardShadowLight : null, @@ -220,7 +231,7 @@ class _DietRecordListPageState extends ConsumerState { : ['一', '二', '三', '四', '五', '六', '日'][d.weekday - 1], style: TextStyle( - fontSize: 11, + fontSize: 12, fontWeight: FontWeight.w700, color: isSel ? Colors.white : AppColors.textHint, ), @@ -240,7 +251,8 @@ class _DietRecordListPageState extends ConsumerState { Text( '$cal', style: TextStyle( - fontSize: 10, + fontSize: 11, + fontWeight: FontWeight.w600, color: isSel ? Colors.white70 : AppColors.textHint, ), ), @@ -280,6 +292,7 @@ class _DietRecordListPageState extends ConsumerState { final id = record['id']?.toString() ?? ''; return _SwipeAction( + itemKey: ValueKey(id), onEdit: () => _showEditDialog(id, cal), onDelete: () => _delete(id), child: Material( @@ -296,10 +309,14 @@ class _DietRecordListPageState extends ConsumerState { width: 36, height: 36, decoration: BoxDecoration( - color: AppColors.dietLight, + color: DietPalette.primarySoft, borderRadius: AppRadius.smBorder, ), - child: Icon(mealIcon, size: 19, color: AppColors.diet), + child: Icon( + mealIcon, + size: 19, + color: DietPalette.primary, + ), ), const SizedBox(width: 12), Expanded( @@ -311,7 +328,7 @@ class _DietRecordListPageState extends ConsumerState { Text( mealLabel, style: const TextStyle( - fontSize: 15, + fontSize: 17, fontWeight: FontWeight.w700, color: AppColors.textPrimary, ), @@ -320,7 +337,8 @@ class _DietRecordListPageState extends ConsumerState { Text( '$cal 千卡', style: const TextStyle( - fontSize: 13, + fontSize: 15, + fontWeight: FontWeight.w600, color: AppColors.textSecondary, ), ), @@ -332,7 +350,8 @@ class _DietRecordListPageState extends ConsumerState { child: Text( items.map((f) => f['name']).join(' · '), style: const TextStyle( - fontSize: 13, + fontSize: 14, + fontWeight: FontWeight.w500, color: AppColors.textHint, ), maxLines: 1, @@ -412,7 +431,7 @@ class _DietDaySummary extends StatelessWidget { gradient: const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [Color(0xFFFFF8DB), Color(0xFFFFFFFF)], + colors: [Color(0xFFF0F3FF), Color(0xFFFFFFFF)], ), borderRadius: AppRadius.xlBorder, boxShadow: AppColors.cardShadowLight, @@ -447,7 +466,7 @@ class _DietDaySummary extends StatelessWidget { width: 42, height: 42, decoration: BoxDecoration( - gradient: AppColors.dietGradient, + gradient: DietPalette.gradient, borderRadius: AppRadius.mdBorder, ), child: const Icon( @@ -543,7 +562,7 @@ class _Toggle extends StatelessWidget { child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), decoration: BoxDecoration( - gradient: a ? AppColors.dietGradient : null, + gradient: a ? DietPalette.gradient : null, color: a ? null : Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: a ? Colors.transparent : AppColors.border), @@ -599,7 +618,7 @@ class _TrendChart extends StatelessWidget { Container( height: h, decoration: BoxDecoration( - gradient: data[i] > 0 ? AppColors.dietGradient : null, + gradient: data[i] > 0 ? DietPalette.gradient : null, color: data[i] > 0 ? null : AppColors.border, borderRadius: BorderRadius.circular(2), ), @@ -614,20 +633,25 @@ class _TrendChart extends StatelessWidget { } class _SwipeAction extends StatelessWidget { + final Key itemKey; final Widget child; final VoidCallback onEdit; final VoidCallback onDelete; const _SwipeAction({ + required this.itemKey, required this.child, required this.onEdit, required this.onDelete, }); @override Widget build(BuildContext c) => Dismissible( - key: UniqueKey(), + key: itemKey, direction: DismissDirection.endToStart, - confirmDismiss: (_) async { - return false; + confirmDismiss: (direction) async { + // 右滑编辑:不消除,弹回,编辑由 onUpdate 触发 + if (direction == DismissDirection.startToEnd) return false; + // 左滑删除:放行,onDismissed 会触发 onDelete + return true; }, background: Container( decoration: BoxDecoration( @@ -703,11 +727,7 @@ class DietRecordDetailPage extends ConsumerWidget { Container( padding: const EdgeInsets.fromLTRB(16, 16, 16, 15), decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFFFFF9E8), Colors.white], - ), + color: Colors.white, borderRadius: AppRadius.xlBorder, boxShadow: AppColors.cardShadowLight, ), @@ -716,20 +736,6 @@ class DietRecordDetailPage extends ConsumerWidget { children: [ Row( children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - gradient: AppColors.dietGradient, - borderRadius: AppRadius.mdBorder, - ), - child: const Icon( - Icons.restaurant_menu, - size: 21, - color: Colors.white, - ), - ), - const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -752,25 +758,39 @@ class DietRecordDetailPage extends ConsumerWidget { ], ), ), - Text( - '$totalCal 千卡', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w800, - color: Color(0xFF9A3412), - ), - ), ], ), - const SizedBox(height: 18), + const SizedBox(height: 20), Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - _DietDetailMetric(label: '蛋白质', value: '$protein g'), - _DietDetailMetric(label: '碳水', value: '$carbs g'), - _DietDetailMetric(label: '脂肪', value: '$fat g'), - _DietDetailMetric( - label: '食物', - value: '${items.length} 种', + DietCalorieRing(calories: totalCal), + const SizedBox(width: 20), + Expanded( + child: Column( + children: [ + DietMacroBar( + label: '蛋白质', + value: protein, + color: DietPalette.protein, + reference: 50, + ), + const SizedBox(height: 14), + DietMacroBar( + label: '碳水', + value: carbs, + color: DietPalette.carbs, + reference: 100, + ), + const SizedBox(height: 14), + DietMacroBar( + label: '脂肪', + value: fat, + color: DietPalette.fat, + reference: 35, + ), + ], + ), ), ], ), @@ -783,14 +803,7 @@ class DietRecordDetailPage extends ConsumerWidget { ), ), const SizedBox(height: 20), - const Text( - '识别结果', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w800, - color: AppColors.textPrimary, - ), - ), + const Text('识别结果', style: AppTextStyles.sectionTitle), const SizedBox(height: 8), ClipRRect( borderRadius: AppRadius.xlBorder, @@ -810,8 +823,8 @@ class DietRecordDetailPage extends ConsumerWidget { child: Text( items[i]['name']?.toString() ?? '', style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, + fontSize: 16, + fontWeight: FontWeight.w700, color: AppColors.textPrimary, ), ), @@ -822,7 +835,7 @@ class DietRecordDetailPage extends ConsumerWidget { items[i]['portion']?.toString() ?? '', textAlign: TextAlign.center, style: const TextStyle( - fontSize: 14, + fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textSecondary, ), @@ -834,9 +847,9 @@ class DietRecordDetailPage extends ConsumerWidget { '${items[i]['calories'] ?? 0} 千卡', textAlign: TextAlign.right, style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Color(0xFF9A3412), + fontSize: 16, + fontWeight: FontWeight.w700, + color: DietPalette.calorie, ), ), ), @@ -865,35 +878,6 @@ class DietRecordDetailPage extends ConsumerWidget { } } -class _DietDetailMetric extends StatelessWidget { - final String label; - final String value; - - const _DietDetailMetric({required this.label, required this.value}); - - @override - Widget build(BuildContext context) => Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - value, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w800, - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 2), - Text( - label, - style: const TextStyle(fontSize: 12, color: AppColors.textHint), - ), - ], - ), - ); -} - /// 运动计划列表(含打卡) class ExercisePlanPage extends ConsumerStatefulWidget { const ExercisePlanPage({super.key}); diff --git a/health_app/lib/pages/settings/notification_prefs_page.dart b/health_app/lib/pages/settings/notification_prefs_page.dart index c9720fe..899db1a 100644 --- a/health_app/lib/pages/settings/notification_prefs_page.dart +++ b/health_app/lib/pages/settings/notification_prefs_page.dart @@ -10,11 +10,11 @@ import '../../providers/auth_provider.dart'; // 持久化到后端 /api/notification-prefs,启动时自动拉取一次。 final notificationPrefsProvider = - NotifierProvider>( + NotifierProvider>( NotificationPrefsNotifier.new, ); -class NotificationPrefsNotifier extends Notifier> { +class NotificationPrefsNotifier extends Notifier> { static const _defaults = { 'pushEnabled': true, 'medication': true, @@ -22,8 +22,8 @@ class NotificationPrefsNotifier extends Notifier> { 'followUp': true, 'aiReply': false, 'dndEnabled': false, - 'dndStart': false, - 'dndEnd': false, + 'dndStartMinutes': 22 * 60, + 'dndEndMinutes': 8 * 60, // 健康录入提醒——总开关 + 5 子项 'healthRecord': true, 'healthRecord.bp': true, @@ -34,7 +34,7 @@ class NotificationPrefsNotifier extends Notifier> { }; @override - Map build() { + Map build() { Future.microtask(_loadFromBackend); return {..._defaults}; } @@ -47,6 +47,11 @@ class NotificationPrefsNotifier extends Notifier> { if (data == null) return; state = { ...state, + 'pushEnabled': (data['pushEnabled'] as bool?) ?? true, + 'dndEnabled': (data['dndEnabled'] as bool?) ?? false, + 'dndStartMinutes': + (data['dndStartMinutes'] as num?)?.toInt() ?? 22 * 60, + 'dndEndMinutes': (data['dndEndMinutes'] as num?)?.toInt() ?? 8 * 60, 'medication': (data['medicationReminder'] as bool?) ?? true, 'healthAlert': (data['abnormalAlert'] as bool?) ?? true, 'followUp': (data['followUpReminder'] as bool?) ?? true, @@ -87,6 +92,8 @@ class NotificationPrefsNotifier extends Notifier> { 'healthRecord.glucose' => 'healthRecordReminderGlucose', 'healthRecord.spo2' => 'healthRecordReminderSpO2', 'healthRecord.weight' => 'healthRecordReminderWeight', + 'pushEnabled' => 'pushEnabled', + 'dndEnabled' => 'dndEnabled', _ => null, }; if (backendField == null) return; @@ -99,12 +106,24 @@ class NotificationPrefsNotifier extends Notifier> { } } - void setDndStart(TimeOfDay time) { - state = {...state, 'dndStart': true}; + Future setDndStart(TimeOfDay time) async { + await _setTime('dndStartMinutes', time.hour * 60 + time.minute); } - void setDndEnd(TimeOfDay time) { - state = {...state, 'dndEnd': true}; + Future setDndEnd(TimeOfDay time) async { + await _setTime('dndEndMinutes', time.hour * 60 + time.minute); + } + + Future _setTime(String key, int minutes) async { + final previous = state[key]; + state = {...state, key: minutes}; + try { + await ref + .read(apiClientProvider) + .put('/api/notification-prefs', data: {key: minutes}); + } catch (_) { + state = {...state, key: previous}; + } } } @@ -116,7 +135,11 @@ class NotificationPrefsPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final prefs = ref.watch(notificationPrefsProvider); - final dndOn = prefs['dndEnabled'] ?? false; + final dndOn = prefs['dndEnabled'] as bool? ?? false; + final dndStart = prefs['dndStartMinutes'] as int? ?? 22 * 60; + final dndEnd = prefs['dndEndMinutes'] as int? ?? 8 * 60; + String formatMinutes(int value) => + '${(value ~/ 60).toString().padLeft(2, '0')}:${(value % 60).toString().padLeft(2, '0')}'; return GradientScaffold( appBar: AppBar( @@ -278,7 +301,9 @@ class NotificationPrefsPage extends ConsumerWidget { children: [ _SwitchTile( title: '开启免打扰模式', - subtitle: dndOn ? '22:00 - 08:00 期间静音' : '关闭后全天接收通知', + subtitle: dndOn + ? '${formatMinutes(dndStart)} - ${formatMinutes(dndEnd)} 期间静音' + : '关闭后全天接收通知', value: dndOn, showDivider: dndOn, onChanged: (v) => ref @@ -298,13 +323,13 @@ class NotificationPrefsPage extends ConsumerWidget { Expanded( child: _TimeButton( label: '开始', - time: '22:00', + time: formatMinutes(dndStart), onTap: () async { final picked = await showAppTimePicker( context, - initialTime: const TimeOfDay( - hour: 22, - minute: 0, + initialTime: TimeOfDay( + hour: dndStart ~/ 60, + minute: dndStart % 60, ), ); if (picked != null && context.mounted) { @@ -328,13 +353,13 @@ class NotificationPrefsPage extends ConsumerWidget { Expanded( child: _TimeButton( label: '结束', - time: '08:00', + time: formatMinutes(dndEnd), onTap: () async { final picked = await showAppTimePicker( context, - initialTime: const TimeOfDay( - hour: 8, - minute: 0, + initialTime: TimeOfDay( + hour: dndEnd ~/ 60, + minute: dndEnd % 60, ), ); if (picked != null && context.mounted) { diff --git a/health_app/lib/providers/chat_provider.dart b/health_app/lib/providers/chat_provider.dart index 7e205e0..c4bab0d 100644 --- a/health_app/lib/providers/chat_provider.dart +++ b/health_app/lib/providers/chat_provider.dart @@ -76,13 +76,13 @@ class ChatNotifier extends Notifier { StreamSubscription>? _subscription; Completer? _streamDone; ActiveAgent? _lastTriggeredAgent; - Timer? _agentWelcomeTimer; - String? _pendingAgentTriggerMessageId; + Timer? _agentTapLockTimer; /// 重置整个会话:取消正在进行的 SSE,清空消息和会话 ID。 /// 历史记录页一键清空 / 删除当前会话时调用。 Future resetSession() async { await _cancelActiveStream(); + _cancelPendingAgentWelcome(); _lastTriggeredAgent = null; state = const ChatState(); } @@ -133,7 +133,7 @@ class ChatNotifier extends Notifier { ChatState build() { ref.onDispose(() { _subscription?.cancel(); - _agentWelcomeTimer?.cancel(); + _agentTapLockTimer?.cancel(); _subscription = null; if (_streamDone != null && !_streamDone!.isCompleted) { _streamDone!.complete(); @@ -229,61 +229,41 @@ class ChatNotifier extends Notifier { } } - void insertAgentWelcome(ActiveAgent agent) { - _pendingAgentTriggerMessageId = null; - state = state.copyWith( - messages: [ - ...state.messages, - ChatMessage( - id: 'welcome_${agent.name}_${DateTime.now().millisecondsSinceEpoch}', - role: 'assistant', - content: '', - createdAt: DateTime.now(), - type: MessageType.agentWelcome, - metadata: {'agent': agent.name}, - ), - ], - ); - } - - /// 点击胶囊:先出用户标签 → 0.4 秒后出欢迎卡片,不走 AI + /// 点击胶囊:用户标签和欢迎卡片立即出现,不走 AI。 + /// 300ms 点击锁只防止误触,不延迟界面反馈。 /// 重复点击同一胶囊不重复弹卡片 void triggerAgent(ActiveAgent agent, String label) { - if (_pendingAgentTriggerMessageId != null) { - return; - } - if (_lastTriggeredAgent == agent && _pendingAgentTriggerMessageId == null) { - return; - } + if (_agentTapLockTimer != null || _lastTriggeredAgent == agent) return; _lastTriggeredAgent = agent; + final now = DateTime.now(); final userMsg = ChatMessage( - id: 'agent_trigger_${DateTime.now().millisecondsSinceEpoch}', + id: 'agent_trigger_${now.microsecondsSinceEpoch}', role: 'user', content: label, - createdAt: DateTime.now(), + createdAt: now, + ); + final welcomeMsg = ChatMessage( + id: 'welcome_${agent.name}_${now.microsecondsSinceEpoch}', + role: 'assistant', + content: '', + createdAt: now, + type: MessageType.agentWelcome, + metadata: {'agent': agent.name}, + ); + state = state.copyWith( + messages: [...state.messages, userMsg, welcomeMsg], + activeAgent: agent, ); - final messages = state.messages.toList(); - messages.add(userMsg); - _pendingAgentTriggerMessageId = userMsg.id; - state = state.copyWith(messages: messages, activeAgent: agent); - final expectedConversationId = state.conversationId; - _agentWelcomeTimer = Timer(Duration.zero, () { - if (state.conversationId != expectedConversationId || - state.activeAgent != agent || - _lastTriggeredAgent != agent) { - return; - } - _agentWelcomeTimer = null; - insertAgentWelcome(agent); + _agentTapLockTimer = Timer(const Duration(milliseconds: 300), () { + _agentTapLockTimer = null; }); } void _cancelPendingAgentWelcome() { - _agentWelcomeTimer?.cancel(); - _agentWelcomeTimer = null; - _pendingAgentTriggerMessageId = null; + _agentTapLockTimer?.cancel(); + _agentTapLockTimer = null; } Future sendImage(String imagePath, String text) async { diff --git a/health_app/test/chat_provider_test.dart b/health_app/test/chat_provider_test.dart index 0f61a59..0a93c2b 100644 --- a/health_app/test/chat_provider_test.dart +++ b/health_app/test/chat_provider_test.dart @@ -17,7 +17,7 @@ void main() { expect(messages, isEmpty); }); - test('agent capsule inserts welcome when session is unchanged', () async { + test('agent capsule inserts welcome immediately', () { final container = ProviderContainer(); addTearDown(container.dispose); @@ -25,8 +25,6 @@ void main() { .read(chatProvider.notifier) .triggerAgent(ActiveAgent.diet, '营养助手'); - await Future.delayed(const Duration(milliseconds: 450)); - final messages = container.read(chatProvider).messages; expect(messages.map((m) => m.type), contains(MessageType.agentWelcome)); expect(container.read(chatProvider).activeAgent, ActiveAgent.diet); @@ -40,11 +38,8 @@ void main() { final notifier = container.read(chatProvider.notifier); notifier.triggerAgent(ActiveAgent.diet, '营养助手'); - await Future.delayed(const Duration(milliseconds: 120)); notifier.triggerAgent(ActiveAgent.medication, '药管家'); - await Future.delayed(const Duration(milliseconds: 450)); - final welcomeAgents = container .read(chatProvider) .messages @@ -56,4 +51,44 @@ void main() { expect(container.read(chatProvider).activeAgent, ActiveAgent.diet); }, ); + + test('repeated tap on the same agent does not add another card', () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + + final notifier = container.read(chatProvider.notifier); + notifier.triggerAgent(ActiveAgent.diet, '营养助手'); + notifier.triggerAgent(ActiveAgent.diet, '营养助手'); + + expect( + container + .read(chatProvider) + .messages + .where((message) => message.type == MessageType.agentWelcome) + .length, + 1, + ); + }); + + test( + 'different agent is accepted after the previous welcome appears', + () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + + final notifier = container.read(chatProvider.notifier); + notifier.triggerAgent(ActiveAgent.diet, '营养助手'); + await Future.delayed(const Duration(milliseconds: 320)); + notifier.triggerAgent(ActiveAgent.medication, '药管家'); + + expect( + container + .read(chatProvider) + .messages + .where((message) => message.type == MessageType.agentWelcome) + .map((message) => message.metadata?['agent']), + ['diet', 'medication'], + ); + }, + ); }